Listing Detail — Photo Gallery, Contact Form and Seller Profile

The listing detail page is the conversion page — where a browsing buyer decides to contact the seller. Every design decision should support this goal: high-quality photos presented well, clear price and location, easy contact form, and social proof (seller rating, member since, response time). The photo gallery is the most technically complex component — it needs to work smoothly on mobile (swipe), desktop (keyboard), and support accessibility (keyboard navigation, alt text).

Listing Detail and Contact Form

// ── features/listings/listing-detail/listing-detail.component.ts ──────────
@Component({
  selector:   'app-listing-detail',
  standalone:  true,
  imports:    [ContactFormComponent, SellerProfileCardComponent,
               RelatedListingsComponent, MatIconModule, DatePipe,
               CurrencyPipe, CdnUrlPipe, MatChipsModule],
  template: `
    @if (loading()) {
      <app-listing-detail-skeleton />
    } @else if (listing()) {
      <div class="detail-layout">
        <!-- Left: Gallery + Details ────────────────────────────────────── -->
        <div class="detail-main">
          <!-- Photo gallery ─────────────────────────────────────────────── -->
          <div class="gallery" data-cy="photo-gallery">
            <!-- Main photo ────────────────────────────────────────────── -->
            <div class="main-photo"
                 (click)="openFullscreen()"
                 (keydown.ArrowRight)="nextPhoto()"
                 (keydown.ArrowLeft)="prevPhoto()"
                 tabindex="0" role="img"
                 [attr.aria-label]="listing()!.title + ' photo ' + (activePhotoIdx() + 1) + ' of ' + listing()!.photoUrls.length">
              <img [src]="activePhotoUrl() | cdnUrl"
                   [alt]="listing()!.title"
                   class="hero-image"
                   loading="eager"
                   width="800" height="600">
              <!-- Navigation arrows ──────────────────────────────────── -->
              @if (listing()!.photoUrls.length > 1) {
                <button class="nav-btn prev" (click)="prevPhoto(); $event.stopPropagation()"
                        aria-label="Previous photo">
                  <mat-icon>chevron_left</mat-icon>
                </button>
                <button class="nav-btn next" (click)="nextPhoto(); $event.stopPropagation()"
                        aria-label="Next photo">
                  <mat-icon>chevron_right</mat-icon>
                </button>
              }
            </div>
            <!-- Thumbnail strip ────────────────────────────────────────── -->
            @if (listing()!.photoUrls.length > 1) {
              <div class="thumbnails" role="list">
                @for (url of listing()!.photoUrls; track url; let i = $index) {
                  <button class="thumb"
                          [class.active]="activePhotoIdx() === i"
                          (click)="activePhotoIdx.set(i)"
                          [attr.aria-label]="'View photo ' + (i + 1)"
                          role="listitem">
                    <img [src]="url | cdnUrl" alt="" width="80" height="60" loading="lazy">
                  </button>
                }
              </div>
            }
          </div>

          <!-- Price and title ─────────────────────────────────────────── -->
          <div class="listing-header">
            <h1 data-cy="listing-title">{{ listing()!.title }}</h1>
            <div class="price" data-cy="listing-price">
              {{ listing()!.price | currency:listing()!.currency:'symbol':'1.0-0' }}
            </div>
          </div>

          <!-- Meta: location, category, posted ───────────────────────── -->
          <div class="listing-meta">
            <mat-chip>{{ listing()!.category | titlecase }}</mat-chip>
            <span><mat-icon>place</mat-icon>{{ listing()!.city }}, {{ listing()!.postcode }}</span>
            <span>Posted {{ listing()!.publishedAt | date:'mediumDate' }}</span>
          </div>

          <!-- Description ─────────────────────────────────────────────── -->
          <section class="description">
            <h2>Description</h2>
            <p data-cy="listing-description">{{ listing()!.description }}</p>
          </section>
        </div>

        <!-- Right: Seller card + Contact form ──────────────────────────── -->
        <aside class="detail-sidebar">
          <app-seller-profile-card [seller]="listing()!.seller" />
          <app-contact-form
            [listingId]="listing()!.id"
            [listingTitle]="listing()!.title" />
        </aside>
      </div>
    }
  `,
})
export class ListingDetailComponent implements OnInit {
  @Input() id!: string;  // from route parameter

  private listingsApi = inject(ListingsApiService);
  private titleSvc    = inject(Title);

  listing        = signal<ListingDto | null>(null);
  loading        = signal(true);
  activePhotoIdx = signal(0);
  activePhotoUrl = computed(() =>
    this.listing()?.photoUrls[this.activePhotoIdx()] ?? null);

  ngOnInit() {
    this.listingsApi.getById(this.id).subscribe({
      next: l => {
        this.listing.set(l);
        this.loading.set(false);
        this.titleSvc.setTitle(`${l.title} — ${l.city} | ClassifiedApp`);
        // Fire-and-forget view count increment
        this.listingsApi.incrementViewCount(l.id).subscribe();
      },
      error: () => this.loading.set(false),
    });
  }

  nextPhoto(): void {
    const count = this.listing()!.photoUrls.length;
    this.activePhotoIdx.update(i => (i + 1) % count);
  }

  prevPhoto(): void {
    const count = this.listing()!.photoUrls.length;
    this.activePhotoIdx.update(i => (i - 1 + count) % count);
  }
}
Note: The photo gallery navigation is accessible — it has tabindex="0" so it receives keyboard focus, role="img" to identify it to screen readers, an aria-label that describes which photo is active, and keyboard event handlers for arrow keys. The thumbnail buttons have explicit aria-label attributes. Accessible photo galleries require these ARIA attributes — a visually impaired user navigating with a screen reader and keyboard should be able to fully interact with all listing photos.
Tip: The main/hero image has loading="eager" (not lazy) because it is above the fold — the Largest Contentful Paint (LCP) element on the listing detail page. Lazy-loading the LCP element delays it from starting to download, hurting the LCP Core Web Vitals score. Thumbnail images below the main photo use loading="lazy". Always set loading="eager" (or omit the attribute, which defaults to eager) on the first visible image in the viewport.
Warning: The contact form for unauthenticated users should not silently fail — it should prompt them to log in first. Wrap the contact form in an auth check: if the user is not logged in, show a “Sign in to contact the seller” message with a login button that includes the current listing URL as a returnUrl parameter. This is better UX than letting them fill in the form and then getting a 401 error after submission.

Common Mistakes

Mistake 1 — loading=”lazy” on the hero/LCP image (hurts Core Web Vitals)

❌ Wrong — hero image has loading="lazy"; browser defers it; LCP score degrades; page feels slow.

✅ Correct — hero image uses loading="eager"; below-fold thumbnails use loading="lazy".

❌ Wrong — gallery only responds to click/swipe; keyboard users cannot navigate between photos.

✅ Correct — (keydown.ArrowRight) and (keydown.ArrowLeft) handlers; tabindex="0" for focus; gallery is keyboard-accessible.

🧠 Test Yourself

A listing has 3 photos. The user is on photo 3 (index 2) and presses the right arrow key. What index does nextPhoto() set?