Browse and Search — Listings Grid, Filters and Map View

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';
  }
}
Note: The 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.
Tip: Use Angular’s 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.
Warning: The listing card links to ['/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.

🧠 Test Yourself

The search component uses switchMap and the user types “bike” (4 keystrokes in 200ms) with 350ms debounce on the search input. How many API requests are made?