Search state persistence and mobile UX are the details that separate a polished classified website from a rough prototype. Users on mobile expect a fast, thumb-friendly experience — large touch targets, filters in a bottom sheet, swipe-friendly cards. The SearchStateService two-way URL synchronisation means every search is shareable and bookmarkable. The RelativeTimePipe (“3 days ago”) communicates recency instantly, which matters in classifieds where fresh listings are more likely to still be available.
Search State and Mobile Optimisations
// ── shared/pipes/relative-time.pipe.ts ────────────────────────────────────
@Pipe({ name: 'relativeTime', standalone: true, pure: false })
export class RelativeTimePipe implements PipeTransform {
private static readonly INTERVALS = [
{ label: 'year', seconds: 31536000 },
{ label: 'month', seconds: 2592000 },
{ label: 'week', seconds: 604800 },
{ label: 'day', seconds: 86400 },
{ label: 'hour', seconds: 3600 },
{ label: 'minute', seconds: 60 },
] as const;
transform(value: string | Date | null): string {
if (!value) return '';
const seconds = Math.floor(
(Date.now() - new Date(value).getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 0) return 'in the future';
for (const { label, seconds: threshold } of RelativeTimePipe.INTERVALS) {
const count = Math.floor(seconds / threshold);
if (count >= 1)
return `${count} ${label}${count !== 1 ? 's' : ''} ago`;
}
return 'a long time ago';
}
}
// ── shared/services/saved-searches.service.ts ────────────────────────────
@Injectable({ providedIn: 'root' })
export class SavedSearchesService {
private readonly STORAGE_KEY = 'classifiedapp_recent_searches';
private readonly MAX_SAVES = 5;
private _searches = signal<SavedSearch[]>(this.load());
readonly searches = this._searches.asReadonly();
save(params: SearchParams): void {
if (!params.keyword && !params.city && !params.category) return;
const label = [params.keyword, params.category, params.city]
.filter(Boolean).join(' · ');
const updated = [
{ label, params, savedAt: new Date().toISOString() },
...this._searches().filter(s => s.label !== label),
].slice(0, this.MAX_SAVES);
this._searches.set(updated);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(updated));
}
remove(label: string): void {
const updated = this._searches().filter(s => s.label !== label);
this._searches.set(updated);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(updated));
}
private load(): SavedSearch[] {
try {
return JSON.parse(localStorage.getItem(this.STORAGE_KEY) ?? '[]');
} catch { return []; }
}
}
// ── Browse page: recent searches as quick chips ───────────────────────────
// <div class="recent-searches" *ngIf="savedSearches.searches().length > 0">
// <span class="label">Recent:</span>
// @for (search of savedSearches.searches(); track search.label) {
// <mat-chip-row (click)="applySearch(search.params)"
// (removed)="savedSearches.remove(search.label)">
// {{ search.label }}
// <button matChipRemove aria-label="Remove recent search">
// <mat-icon>cancel</mat-icon>
// </button>
// </mat-chip-row>
// }
// </div>
// ── Mobile filters bottom sheet ───────────────────────────────────────────
// BrowseComponent — mobile: open filters in a MatBottomSheet
// In the filters toggle button:
// (click)="breakpointObserver.isMatched('(max-width: 768px)')
// ? openFiltersSheet()
// : filtersOpen.update(v => !v)"
// openFiltersSheet() {
// const ref = this.bottomSheet.open(SearchFiltersBottomSheetComponent);
// ref.afterDismissed().subscribe(filters => {
// if (filters) this.onFiltersChanged(filters);
// });
// }
RelativeTimePipe is marked pure: false because its output changes over time even if the input (the date string) hasn’t changed. A pure pipe only recomputes when its input changes — “2 days ago” is correct at 11pm but wrong at midnight when it becomes “3 days ago.” The pure: false setting re-runs the pipe on every change detection cycle, keeping it accurate. The performance cost is acceptable for a pipe used in listing cards — change detection runs infrequently on a mostly static list.BreakpointObserver (from @angular/cdk/layout) to adapt the UI for mobile vs desktop without CSS-only media queries. breakpointObserver.isMatched('(max-width: 768px)') in component code allows Angular to render different template blocks or open different overlays (bottom sheet on mobile, sidebar on desktop) based on the viewport width. Combine with @if in templates for progressive disclosure patterns.SavedSearchesService reads from localStorage synchronously in the constructor (via this.load()). In SSR (Angular Universal/Server-Side Rendering) environments, localStorage is not available on the server — accessing it throws a ReferenceError. If the classified website uses SSR for SEO, guard all localStorage accesses with isPlatformBrowser(this.platformId) from @angular/common. For most client-only SPAs (no SSR), this is not an issue.Frontend Feature Summary
| Feature | Component | Key Pattern |
|---|---|---|
| Browse/Search | BrowseComponent | URL-persisted signals + switchMap |
| Listing card | ListingCardComponent | CurrencyPipe, RelativeTimePipe, CDN URL |
| Create listing | CreateListingWizardComponent | MatStepper + step-level FormGroups |
| Photo gallery | ListingDetailComponent | Computed signal + keyboard nav |
| Seller dashboard | MyListingsComponent | MatTable + SelectionModel + status @switch |
| Recent searches | SavedSearchesService | Signals + localStorage persistence |
Common Mistakes
Mistake 1 — pure: true on a time-dependent pipe (stale “X days ago” display)
❌ Wrong — RelativeTimePipe with pure: true; “just now” shown for a 3-day-old listing after initial render; never updates.
✅ Correct — pure: false; pipe re-evaluates on every change detection; display stays current.
Mistake 2 — Desktop-only filter sidebar on mobile (filters inaccessible on small screens)
❌ Wrong — sidebar always rendered; on mobile takes entire viewport; no way to see listings.
✅ Correct — BreakpointObserver-driven conditional: sidebar on desktop, bottom sheet on mobile.