From c3a2addd472bf97e26622924b76717affb05f638 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 14:37:37 +0200 Subject: [PATCH] feat: add frontend show detail page --- .../show-detail-placeholder-page.component.ts | 278 ++++++++++++++++-- .../src/app/services/shows-api.service.ts | 32 ++ 2 files changed, 285 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/pages/show-detail-placeholder-page.component.ts b/frontend/src/app/pages/show-detail-placeholder-page.component.ts index 1542fd9..a32635e 100644 --- a/frontend/src/app/pages/show-detail-placeholder-page.component.ts +++ b/frontend/src/app/pages/show-detail-placeholder-page.component.ts @@ -1,41 +1,121 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { TitleCasePipe } from '@angular/common'; +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { switchMap, map, of } from 'rxjs'; + +import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-api.service'; @Component({ standalone: true, - imports: [TitleCasePipe, RouterLink, MatButtonModule, MatCardModule], + imports: [ + DatePipe, + RouterLink, + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + ], template: `
- + @if (isLoading()) { +
+ +

Loading show details...

+
+ } @else if (errorMessage()) { + + +
+ error +
+

Could not load this show

+

{{ errorMessage() }}

+
+
+
+ + + Back to shows + +
+ } @else if (show()) { + - - Next UI step - -

Wire show copy, upcoming performances, and booking entry points from the backend contract.

-
- - Open booking placeholder - -
+
+
+
+

Upcoming performances

+

Choose a performance to continue to the booking placeholder.

+
+ Back to show list +
+ + @if (performances().length === 0) { + + +
+ theaters +
+

No performances published yet

+

This show is online, but there are no upcoming performances available right now.

+
+
+
+
+ } @else { +
+ @for (performance of performances(); track performance.id) { + + {{ performance.starts_at | date: 'EEEE d MMMM, HH:mm' }} + {{ performance.venue.name }}, {{ performance.venue.city }} + +
+
+
Venue
+
{{ performance.venue.name }}
+
+
+
City
+
{{ performance.venue.city }}
+
+
+
Available seats
+
{{ performance.available_seats }}
+
+
+
+ + @if (performance.booking_enabled) { + Book this performance + } @else { + + } + +
+ } +
+ } +
+ }
`, styles: [` .page { - max-width: 960px; + max-width: 1080px; margin: 0 auto; } .page-header { - margin-bottom: 22px; + margin-bottom: 28px; } .eyebrow { @@ -52,22 +132,170 @@ import { MatCardModule } from '@angular/material/card'; } .supporting { + margin: 14px 0 0; color: var(--azionelab-muted); line-height: 1.6; - max-width: 52ch; + max-width: 64ch; } - .content-card { + .section { + display: grid; + gap: 20px; + } + + .section-heading { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: end; + } + + .section-heading h2 { + margin: 0 0 6px; + font-size: 1.4rem; + } + + .section-heading p { + margin: 0; + color: var(--azionelab-muted); + } + + .performance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + } + + .performance-card, + .status-card { border-radius: 8px; border: 1px solid var(--azionelab-border); background: var(--azionelab-surface); box-shadow: var(--azionelab-shadow); } + + .performance-card { + min-height: 260px; + } + + .performance-meta { + display: grid; + gap: 14px; + margin: 0; + } + + .performance-meta div { + display: grid; + gap: 2px; + } + + .performance-meta dt { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + color: var(--azionelab-muted); + } + + .performance-meta dd { + margin: 0; + font-size: 0.98rem; + } + + .status-panel, + .status-copy { + display: flex; + align-items: center; + gap: 16px; + } + + .status-panel { + min-height: 240px; + justify-content: center; + color: var(--azionelab-muted); + } + + .status-copy { + align-items: flex-start; + } + + .status-copy h1, + .status-copy h2 { + margin: 0 0 8px; + font-size: 1.2rem; + } + + .status-copy p { + margin: 0; + color: var(--azionelab-muted); + line-height: 1.6; + } + + @media (max-width: 860px) { + .section-heading { + align-items: flex-start; + flex-direction: column; + } + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ShowDetailPlaceholderPageComponent { - protected readonly slug = this.route.snapshot.paramMap.get('slug') ?? 'show'; + private readonly destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + private readonly showsApi = inject(ShowsApiService); - constructor(private readonly route: ActivatedRoute) {} + protected readonly show = signal(null); + protected readonly performances = signal([]); + protected readonly isLoading = signal(true); + protected readonly errorMessage = signal(''); + + constructor() { + this.loadShow(); + } + + protected reload(): void { + this.loadShow(); + } + + private loadShow(): void { + const slug = this.route.snapshot.paramMap.get('slug'); + + if (!slug) { + this.errorMessage.set('The requested show is missing a valid identifier.'); + this.show.set(null); + this.performances.set([]); + this.isLoading.set(false); + return; + } + + this.isLoading.set(true); + this.errorMessage.set(''); + + this.showsApi.getShow(slug) + .pipe( + switchMap((show) => { + if (show.performances) { + return of({ show, performances: show.performances }); + } + + return this.showsApi.listPerformancesForShow(slug).pipe( + map(({ results }) => ({ show, performances: results })), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: ({ show, performances }) => { + this.show.set(show); + this.performances.set(performances); + this.isLoading.set(false); + }, + error: () => { + this.show.set(null); + this.performances.set([]); + this.errorMessage.set('Please try again in a moment.'); + this.isLoading.set(false); + }, + }); + } } diff --git a/frontend/src/app/services/shows-api.service.ts b/frontend/src/app/services/shows-api.service.ts index 86e4b45..b756894 100644 --- a/frontend/src/app/services/shows-api.service.ts +++ b/frontend/src/app/services/shows-api.service.ts @@ -12,10 +12,32 @@ export type ShowListItem = { poster_image: string; }; +export type VenueSummary = { + name: string; + city: string; +}; + +export type ShowPerformance = { + id: number; + starts_at: string; + venue: VenueSummary; + booking_enabled: boolean; + available_seats: number; +}; + +export type ShowDetail = ShowListItem & { + description: string; + performances?: ShowPerformance[]; +}; + type ShowListResponse = { results: ShowListItem[]; }; +type PerformanceListResponse = { + results: ShowPerformance[]; +}; + @Injectable({ providedIn: 'root', }) @@ -26,4 +48,14 @@ export class ShowsApiService { listShows(): Observable { return this.http.get(`${this.apiBaseUrl}/shows/`); } + + getShow(slug: string): Observable { + return this.http.get(`${this.apiBaseUrl}/shows/${slug}/`); + } + + listPerformancesForShow(slug: string): Observable { + return this.http.get(`${this.apiBaseUrl}/performances/`, { + params: { show: slug }, + }); + } }