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);
}
}
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.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.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".
Mistake 2 — No keyboard navigation for photo gallery (accessibility failure)
❌ 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.