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: `
+
+ `,
+ 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: `
+
+ `,
+ 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