The browse experience is the classified website’s most important page — it’s where buyers discover listings and sellers get seen. The grid layout with filtering sidebar must work on both desktop (side-by-side) and mobile (filters in a bottom sheet or collapsible panel). The listing card communicates the essential information at a glance: thumbnail, price, title, location, and how recently it was posted — the same information a buyer scans in a newspaper classifieds section.
Browse and Search Components
// ── features/browse/browse.component.ts ──────────────────────────────────
@Component({
selector: 'app-browse',
standalone: true,
imports: [ListingCardComponent, SearchFiltersComponent,
MatPaginatorModule, MatProgressBarModule, AsyncPipe],
template: `
<!-- Global progress bar at top of page ─────────────────────────────── -->
@if (loading()) {
<mat-progress-bar mode="indeterminate" class="page-loader" />
}
<div class="browse-layout">
<!-- Filters sidebar ─────────────────────────────────────────────── -->
<aside class="filters-panel" [class.open]="filtersOpen()">
<app-search-filters
(filtersChanged)="onFiltersChanged($event)"
(closed)="filtersOpen.set(false)" />
</aside>
<!-- Results area ────────────────────────────────────────────────── -->
<main class="results-area">
<!-- Result count and sort ──────────────────────────────────────── -->
<div class="results-header">
<button mat-icon-button class="filter-toggle"
(click)="filtersOpen.update(v => !v)">
<mat-icon>filter_list</mat-icon>
</button>
@if (!loading()) {
<span data-cy="result-count">
{{ total() | number }} listing{{ total() !== 1 ? 's' : '' }}
@if (searchState.city()) { in {{ searchState.city() }} }
</span>
}
</div>
<!-- Listing grid ────────────────────────────────────────────────── -->
@if (loading()) {
<div class="listing-grid">
@for (_ of skeletons; track $index) {
<app-listing-card-skeleton />
}
</div>
} @else if (listings().length === 0) {
<app-empty-search-state
[keyword]="searchState.keyword()"
[city]="searchState.city()"
(clearFilters)="searchState.clearAll()" />
} @else {
<div class="listing-grid" data-cy="listing-grid">
@for (listing of listings(); track listing.id) {
<app-listing-card [listing]="listing" />
}
</div>
<mat-paginator
[length]="total()"
[pageSize]="20"
[pageIndex]="searchState.page() - 1"
(page)="onPageChange($event)" />
}
</main>
</div>
`,
})
export class BrowseComponent implements OnInit {
protected searchState = inject(SearchStateService);
private listingsApi = inject(ListingsApiService);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
listings = signal<ListingSummaryDto[]>([]);
total = signal(0);
loading = signal(true);
filtersOpen = signal(false);
skeletons = Array(12); // 12 skeleton cards
ngOnInit() {
// URL → signals → API call pipeline
this.route.queryParamMap.pipe(
takeUntilDestroyed(this.destroyRef),
tap(params => {
this.searchState.syncFromUrl(params);
this.loading.set(true);
}),
switchMap(() => this.listingsApi.search(this.searchState.params())
.pipe(catchError(() => of({ items: [], total: 0 } as any)))
),
).subscribe(result => {
this.listings.set(result.items);
this.total.set(result.total);
this.loading.set(false);
});
}
onFiltersChanged(filters: Partial<SearchParams>): void {
this.searchState.updateUrl({ ...filters, page: 1 });
}
onPageChange(event: PageEvent): void {
this.searchState.updateUrl({ page: event.pageIndex + 1 });
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
// ── features/browse/listing-card.component.ts ─────────────────────────────
@Component({
selector: 'app-listing-card',
standalone: true,
imports: [RouterLink, CdnUrlPipe, DatePipe, CurrencyPipe, MatChipsModule],
template: `
<a [routerLink]="['/listings', listing.id]"
class="listing-card" data-cy="listing-card">
<div class="card-image">
<img [src]="listing.thumbnailUrl | cdnUrl"
[alt]="listing.title"
loading="lazy" width="400" height="300"
(error)="onImageError($event)">
@if (listing.photoCount > 1) {
<span class="photo-count">
<mat-icon>photo_library</mat-icon>
{{ listing.photoCount }}
</span>
}
</div>
<div class="card-body">
<div class="price" data-cy="listing-price">
{{ listing.price | currency:listing.currency:'symbol':'1.0-0' }}
</div>
<h3 class="title" data-cy="listing-title">{{ listing.title }}</h3>
<div class="meta">
<mat-chip [color]="categoryColour(listing.category)">
{{ listing.category | titlecase }}
</mat-chip>
<span class="location">
<mat-icon>place</mat-icon>
{{ listing.city }}
</span>
<time [dateTime]="listing.publishedAt" class="age">
{{ listing.publishedAt | relativeTime }}
</time>
</div>
</div>
</a>
`,
})
export class ListingCardComponent {
@Input({ required: true }) listing!: ListingSummaryDto;
categoryColour = (cat: string): string => ({
Electronics: 'primary',
Vehicles: 'accent',
SportsLeisure: 'primary',
HomeGarden: 'accent',
}[cat] ?? '');
onImageError(e: Event): void {
(e.target as HTMLImageElement).src = '/assets/images/listing-placeholder.webp';
}
}
switchMap on route.queryParamMap automatically cancels the previous API call when filters change quickly — if a user types in the search box with debounce, each keystroke triggers a URL change and a new API call, but only the latest response is processed. Previous in-flight requests are cancelled. This prevents stale search results from overwriting newer ones when the user changes filters quickly.CurrencyPipe with 'symbol':'1.0-0' for clean price display: {{ listing.price | currency:listing.currency:'symbol':'1.0-0' }} renders “£1,234” not “£1,234.00” — the trailing zeros add visual noise for classifieds prices. The dynamic currency code (listing.currency) means the same pipe works correctly for GBP (£), EUR (€), USD ($), and any other supported currency without conditional logic.['/listings', listing.id] using the listing’s GUID ID. Consider using the listing’s slug (title-derived URL) instead: ['/listings', listing.slug]. Slug-based URLs are more SEO-friendly, more human-readable (users see what the listing is about in the URL), and more shareable. The backend should ensure slug uniqueness with a suffix if needed (e.g., “mountain-bike-trek-1” if “mountain-bike-trek” is taken).Common Mistakes
Mistake 1 — Loading all listings on page load (no filters, no pagination)
❌ Wrong — initial load fetches all active listings; database returns 50,000 rows; page hangs for 30+ seconds.
✅ Correct — always send page and pageSize params; default page=1, pageSize=20; server-side pagination from the start.
Mistake 2 — Hiding the results count while loading (disorienting UX)
❌ Wrong — result count disappears while loading; user doesn’t know if new results are coming or filters cleared everything.
✅ Correct — show the previous count while loading; replace with new count when results arrive; skeleton cards fill the space.