diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..3efaeee --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,65 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "azionelab": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/azionelab", + "browser": "src/main.ts", + "index": "src/index.html", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", + "src/styles.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "azionelab:build:production" + }, + "development": { + "buildTarget": "azionelab:build:development" + } + }, + "defaultConfiguration": "development" + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..52a2fd3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "azionelab-frontend", + "version": "0.0.1", + "private": true, + "scripts": { + "start": "ng serve", + "build": "ng build", + "test": "ng test" + }, + "dependencies": { + "@angular/animations": "^19.2.0", + "@angular/cdk": "^19.2.0", + "@angular/common": "^19.2.0", + "@angular/compiler": "^19.2.0", + "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", + "@angular/material": "^19.2.0", + "@angular/platform-browser": "^19.2.0", + "@angular/platform-browser-dynamic": "^19.2.0", + "@angular/router": "^19.2.0", + "rxjs": "^7.8.1", + "tslib": "^2.8.1", + "zone.js": "^0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^19.2.0", + "@angular/cli": "^19.2.0", + "@angular/compiler-cli": "^19.2.0", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/public/favicon.ico @@ -0,0 +1 @@ + diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 0000000..9355b39 --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,119 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatToolbarModule } from '@angular/material/toolbar'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ + RouterOutlet, + RouterLink, + RouterLinkActive, + MatToolbarModule, + MatButtonModule, + MatIconModule, + ], + template: ` +
+ + + A + + AzioneLab + Theatre and reservations + + + + + + +
+ +
+
+ `, + styles: [` + .app-shell { + min-height: 100vh; + } + + .app-toolbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + justify-content: space-between; + gap: 16px; + min-height: 72px; + padding: 0 24px; + background: rgba(251, 247, 242, 0.88); + backdrop-filter: blur(18px); + border-bottom: 1px solid var(--azionelab-border); + } + + .brand { + display: inline-flex; + align-items: center; + gap: 12px; + color: inherit; + text-decoration: none; + } + + .brand-mark { + display: inline-grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--azionelab-accent), #ca6d3b); + color: white; + font-weight: 700; + } + + .brand-text { + display: flex; + flex-direction: column; + line-height: 1.1; + } + + .brand-text small { + color: var(--azionelab-muted); + font-size: 0.74rem; + } + + .main-nav { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + } + + .main-nav .active { + background: rgba(159, 47, 40, 0.08); + } + + .page-shell { + padding: 32px 20px 56px; + } + + @media (max-width: 800px) { + .app-toolbar { + align-items: flex-start; + flex-direction: column; + padding: 16px 16px 14px; + } + + .main-nav { + width: 100%; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppComponent {} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..61e8a7d --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,16 @@ +import { Routes } from '@angular/router'; + +import { BookingPlaceholderPageComponent } from './pages/booking-placeholder-page.component'; +import { CheckInPlaceholderPageComponent } from './pages/check-in-placeholder-page.component'; +import { HomePageComponent } from './pages/home-page.component'; +import { ShowDetailPlaceholderPageComponent } from './pages/show-detail-placeholder-page.component'; +import { ShowListPageComponent } from './pages/show-list-page.component'; + +export const appRoutes: Routes = [ + { path: '', component: HomePageComponent, title: 'AzioneLab' }, + { path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' }, + { path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Show detail | AzioneLab' }, + { path: 'book/:performanceId', component: BookingPlaceholderPageComponent, title: 'Book | AzioneLab' }, + { path: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' }, + { path: '**', redirectTo: '' }, +]; diff --git a/frontend/src/app/environments/environment.ts b/frontend/src/app/environments/environment.ts new file mode 100644 index 0000000..9934449 --- /dev/null +++ b/frontend/src/app/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiBaseUrl: '/api', +}; diff --git a/frontend/src/app/pages/booking-placeholder-page.component.ts b/frontend/src/app/pages/booking-placeholder-page.component.ts new file mode 100644 index 0000000..272755a --- /dev/null +++ b/frontend/src/app/pages/booking-placeholder-page.component.ts @@ -0,0 +1,74 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatListModule } from '@angular/material/list'; + +@Component({ + standalone: true, + imports: [MatCardModule, MatDividerModule, MatListModule], + template: ` +
+ + + + Planned interactions + + + Load performance detail and availability + Submit pending reservation + Show email confirmation guidance + + + +
+ `, + styles: [` + .page { + max-width: 900px; + margin: 0 auto; + } + + .page-header { + margin-bottom: 22px; + } + + .eyebrow { + margin: 0 0 10px; + color: var(--azionelab-accent); + text-transform: uppercase; + font-size: 0.78rem; + font-weight: 700; + } + + h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); + } + + .supporting { + color: var(--azionelab-muted); + line-height: 1.6; + max-width: 50ch; + } + + .content-card { + border-radius: 8px; + border: 1px solid var(--azionelab-border); + background: var(--azionelab-surface); + box-shadow: var(--azionelab-shadow); + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BookingPlaceholderPageComponent { + protected readonly performanceId = this.route.snapshot.paramMap.get('performanceId') ?? '0'; + + constructor(private readonly route: ActivatedRoute) {} +} diff --git a/frontend/src/app/pages/check-in-placeholder-page.component.ts b/frontend/src/app/pages/check-in-placeholder-page.component.ts new file mode 100644 index 0000000..4a8264f --- /dev/null +++ b/frontend/src/app/pages/check-in-placeholder-page.component.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +@Component({ + standalone: true, + imports: [MatCardModule, MatFormFieldModule, MatInputModule], + template: ` +
+ + + + Future scan/lookup input + + + Opaque QR token + + + + +
+ `, + styles: [` + .page { + max-width: 760px; + margin: 0 auto; + } + + .page-header { + margin-bottom: 22px; + } + + .eyebrow { + margin: 0 0 10px; + color: var(--azionelab-accent); + text-transform: uppercase; + font-size: 0.78rem; + font-weight: 700; + } + + h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); + } + + .supporting { + color: var(--azionelab-muted); + line-height: 1.6; + max-width: 50ch; + } + + .content-card { + border-radius: 8px; + border: 1px solid var(--azionelab-border); + background: var(--azionelab-surface); + box-shadow: var(--azionelab-shadow); + } + + .full-width { + width: 100%; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckInPlaceholderPageComponent {} diff --git a/frontend/src/app/pages/home-page.component.ts b/frontend/src/app/pages/home-page.component.ts new file mode 100644 index 0000000..3911ddd --- /dev/null +++ b/frontend/src/app/pages/home-page.component.ts @@ -0,0 +1,113 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; + +import { API_BASE_URL } from '../services/api-config.token'; + +@Component({ + standalone: true, + imports: [RouterLink, MatButtonModule, MatCardModule], + template: ` +
+
+

AzioneLab Theatre Company

+

Public website and booking UI foundations.

+

+ This Angular shell is wired for the existing Django APIs and ready for the next booking-focused iterations. +

+ +
+ +
+ + Frontend wiring + +

API base URL

+ {{ apiBaseUrl }} +

Placeholders are in place for public content, booking, and staff check-in flows.

+
+
+
+
+ `, + styles: [` + .hero { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); + gap: 28px; + align-items: stretch; + max-width: 1180px; + margin: 0 auto; + } + + .hero-copy { + padding: 36px 0; + } + + .eyebrow { + margin: 0 0 12px; + color: var(--azionelab-accent); + text-transform: uppercase; + font-size: 0.78rem; + font-weight: 700; + } + + h1 { + margin: 0; + max-width: 10ch; + font-size: clamp(2.5rem, 5vw, 4.75rem); + line-height: 0.95; + } + + .supporting { + max-width: 52ch; + color: var(--azionelab-muted); + font-size: 1.08rem; + line-height: 1.65; + margin: 20px 0 0; + } + + .hero-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 28px; + } + + .hero-panel mat-card { + height: 100%; + border-radius: 8px; + border: 1px solid var(--azionelab-border); + background: var(--azionelab-surface); + box-shadow: var(--azionelab-shadow); + } + + code { + display: inline-block; + margin-top: 8px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(30, 27, 24, 0.06); + } + + .panel-note { + margin-top: 20px; + color: var(--azionelab-muted); + line-height: 1.5; + } + + @media (max-width: 900px) { + .hero { + grid-template-columns: 1fr; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HomePageComponent { + protected readonly apiBaseUrl = inject(API_BASE_URL); +} diff --git a/frontend/src/app/pages/show-detail-placeholder-page.component.ts b/frontend/src/app/pages/show-detail-placeholder-page.component.ts new file mode 100644 index 0000000..1542fd9 --- /dev/null +++ b/frontend/src/app/pages/show-detail-placeholder-page.component.ts @@ -0,0 +1,73 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { TitleCasePipe } from '@angular/common'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; + +@Component({ + standalone: true, + imports: [TitleCasePipe, RouterLink, MatButtonModule, MatCardModule], + template: ` +
+ + + + Next UI step + +

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

+
+ + Open booking placeholder + +
+
+ `, + styles: [` + .page { + max-width: 960px; + margin: 0 auto; + } + + .page-header { + margin-bottom: 22px; + } + + .eyebrow { + margin: 0 0 10px; + color: var(--azionelab-accent); + text-transform: uppercase; + font-size: 0.78rem; + font-weight: 700; + } + + h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.2rem); + } + + .supporting { + color: var(--azionelab-muted); + line-height: 1.6; + max-width: 52ch; + } + + .content-card { + border-radius: 8px; + border: 1px solid var(--azionelab-border); + background: var(--azionelab-surface); + box-shadow: var(--azionelab-shadow); + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ShowDetailPlaceholderPageComponent { + protected readonly slug = this.route.snapshot.paramMap.get('slug') ?? 'show'; + + constructor(private readonly route: ActivatedRoute) {} +} diff --git a/frontend/src/app/pages/show-list-page.component.ts b/frontend/src/app/pages/show-list-page.component.ts new file mode 100644 index 0000000..1b85733 --- /dev/null +++ b/frontend/src/app/pages/show-list-page.component.ts @@ -0,0 +1,125 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; + +type DemoShow = { + slug: string; + title: string; + summary: string; + venue: string; + startsAt: string; +}; + +@Component({ + standalone: true, + imports: [RouterLink, MatButtonModule, MatCardModule, MatChipsModule], + template: ` +
+ + +
+ @for (show of demoShows; track show.slug) { + + {{ show.title }} + {{ show.venue }} + +

{{ show.summary }}

+ + {{ show.startsAt }} + +
+ + Open detail + +
+ } +
+
+ `, + styles: [` + .page { + max-width: 1180px; + margin: 0 auto; + } + + .page-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 380px); + gap: 24px; + align-items: end; + margin-bottom: 24px; + } + + .eyebrow { + margin: 0 0 10px; + color: var(--azionelab-accent); + text-transform: uppercase; + font-size: 0.78rem; + font-weight: 700; + } + + h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); + } + + .supporting { + margin: 0; + color: var(--azionelab-muted); + line-height: 1.6; + } + + .show-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 20px; + } + + .show-card { + border-radius: 8px; + border: 1px solid var(--azionelab-border); + background: var(--azionelab-surface); + box-shadow: var(--azionelab-shadow); + } + + .show-card p { + color: var(--azionelab-muted); + line-height: 1.6; + } + + @media (max-width: 860px) { + .page-header { + grid-template-columns: 1fr; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ShowListPageComponent { + protected readonly demoShows: DemoShow[] = [ + { + slug: 'open-stage', + title: 'Open Stage', + summary: 'Placeholder list item for the first published production.', + venue: 'AzioneLab Theatre', + startsAt: 'May 15, 20:30', + }, + { + slug: 'city-echoes', + title: 'City Echoes', + summary: 'Second sample entry showing how cards will map to live API data.', + venue: 'Studio Nuovo', + startsAt: 'May 22, 18:00', + }, + ]; +} diff --git a/frontend/src/app/services/api-config.token.ts b/frontend/src/app/services/api-config.token.ts new file mode 100644 index 0000000..2be5b95 --- /dev/null +++ b/frontend/src/app/services/api-config.token.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core'; + +import { environment } from '../environments/environment'; + +export const API_BASE_URL = new InjectionToken('API_BASE_URL', { + factory: () => environment.apiBaseUrl, +}); diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..46bc0db --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,12 @@ + + + + + AzioneLab + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..e7c087c --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,15 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter } from '@angular/router'; + +import { AppComponent } from './app/app.component'; +import { appRoutes } from './app/app.routes'; + +bootstrapApplication(AppComponent, { + providers: [ + provideAnimations(), + provideHttpClient(), + provideRouter(appRoutes), + ], +}).catch((err) => console.error(err)); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..87eff7e --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,33 @@ +:root { + --azionelab-bg: #f3eee6; + --azionelab-surface: rgba(255, 255, 255, 0.78); + --azionelab-ink: #1e1b18; + --azionelab-muted: #645b53; + --azionelab-accent: #9f2f28; + --azionelab-accent-strong: #7f211c; + --azionelab-border: rgba(30, 27, 24, 0.12); + --azionelab-shadow: 0 18px 48px rgba(46, 28, 18, 0.12); +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + min-height: 100%; + font-family: "Segoe UI", "Helvetica Neue", sans-serif; + color: var(--azionelab-ink); + background: + radial-gradient(circle at top right, rgba(159, 47, 40, 0.12), transparent 28%), + radial-gradient(circle at left center, rgba(140, 116, 86, 0.14), transparent 35%), + linear-gradient(180deg, #fbf7f2 0%, var(--azionelab-bg) 100%); +} + +body { + min-height: 100vh; +} + +button, input, textarea { + font: inherit; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..5b9d3c5 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..413577a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index 390d10c..37b0f52 100644 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -28,7 +28,8 @@ services: frontend: build: - context: ./frontend + context: ../.. + dockerfile: infra/docker/frontend/Dockerfile image: azionelab-frontend:local expose: - "${FRONTEND_PORT:-8080}" diff --git a/infra/docker/frontend/Dockerfile b/infra/docker/frontend/Dockerfile index 0f494ea..db68748 100644 --- a/infra/docker/frontend/Dockerfile +++ b/infra/docker/frontend/Dockerfile @@ -1,6 +1,18 @@ +FROM node:22.12.0-alpine AS build + +WORKDIR /app + +COPY frontend/package.json /app/package.json + +RUN npm install + +COPY frontend/ /app/ + +RUN npm run build + FROM nginx:1.27.0-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY html/ /usr/share/nginx/html/ +COPY infra/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/azionelab/browser/ /usr/share/nginx/html/ EXPOSE 8080