Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.8.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "10kB",
|
||||
"maximumError": "16kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+8069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.17.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.1.0",
|
||||
"@angular/compiler": "^22.1.0",
|
||||
"@angular/core": "^22.1.0",
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "warn"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,16 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
|
||||
import { authInterceptor } from './core/auth.interceptor';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
// withComponentInputBinding lets route params arrive as component inputs,
|
||||
// which is how the booking and confirmation screens receive their ids.
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard, guestGuard } from './core/auth.guard';
|
||||
|
||||
// Every feature is lazily loaded, so the sign-in screen ships on its own and
|
||||
// the console bundle only arrives once someone is actually signed in.
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [guestGuard],
|
||||
title: 'Sign in | TravelHub',
|
||||
loadComponent: () => import('./features/login/login').then((m) => m.LoginPage),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./layout/shell').then((m) => m.Shell),
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
title: 'Dashboard | TravelHub',
|
||||
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.DashboardPage),
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
title: 'Search flights | TravelHub',
|
||||
loadComponent: () => import('./features/search/search').then((m) => m.SearchPage),
|
||||
},
|
||||
{
|
||||
path: 'book/:flightId',
|
||||
title: 'Complete booking | TravelHub',
|
||||
loadComponent: () => import('./features/checkout/checkout').then((m) => m.CheckoutPage),
|
||||
},
|
||||
{
|
||||
path: 'bookings',
|
||||
title: 'Bookings | TravelHub',
|
||||
loadComponent: () => import('./features/bookings/bookings').then((m) => m.BookingsPage),
|
||||
},
|
||||
{
|
||||
path: 'bookings/:bookingId',
|
||||
title: 'Booking detail | TravelHub',
|
||||
loadComponent: () =>
|
||||
import('./features/bookings/booking-detail').then((m) => m.BookingDetailPage),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
/** Root component: routing decides between the sign-in screen and the shell. */
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
describe('ApiService', () => {
|
||||
let api: ApiService;
|
||||
let http: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
api = TestBed.inject(ApiService);
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
|
||||
describe('searchFlights', () => {
|
||||
it('sends only the filters that were actually set', () => {
|
||||
api.searchFlights({ origin: 'JFK', destination: 'LHR', limit: 10 }).subscribe();
|
||||
|
||||
const req = http.expectOne((r) => r.url === '/api/flights');
|
||||
expect(req.request.params.get('origin')).toBe('JFK');
|
||||
expect(req.request.params.get('destination')).toBe('LHR');
|
||||
expect(req.request.params.get('limit')).toBe('10');
|
||||
expect(req.request.params.has('date')).toBe(false);
|
||||
expect(req.request.params.has('cabin')).toBe(false);
|
||||
req.flush({ items: [], total: 0, limit: 10, offset: 0 });
|
||||
});
|
||||
|
||||
it('drops empty strings so a cleared filter does not narrow the search', () => {
|
||||
api.searchFlights({ origin: '', destination: '', cabin: '', date: '' }).subscribe();
|
||||
|
||||
const req = http.expectOne((r) => r.url === '/api/flights');
|
||||
expect(req.request.params.keys()).toEqual([]);
|
||||
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
|
||||
});
|
||||
|
||||
it('keeps a zero offset, which is a real value rather than an absent one', () => {
|
||||
api.searchFlights({ offset: 0, passengers: 2 }).subscribe();
|
||||
|
||||
const req = http.expectOne((r) => r.url === '/api/flights');
|
||||
expect(req.request.params.get('offset')).toBe('0');
|
||||
expect(req.request.params.get('passengers')).toBe('2');
|
||||
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it('requests one flight by id', () => {
|
||||
api.getFlight('flight-1').subscribe();
|
||||
|
||||
const req = http.expectOne('/api/flights/flight-1');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
describe('listBookings', () => {
|
||||
it('defaults to the first page of every status', () => {
|
||||
api.listBookings().subscribe();
|
||||
|
||||
const req = http.expectOne((r) => r.url === '/api/bookings');
|
||||
expect(req.request.params.get('limit')).toBe('25');
|
||||
expect(req.request.params.get('offset')).toBe('0');
|
||||
expect(req.request.params.has('status')).toBe(false);
|
||||
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
|
||||
});
|
||||
|
||||
it('passes a status filter through when given', () => {
|
||||
api.listBookings('confirmed', 10, 20).subscribe();
|
||||
|
||||
const req = http.expectOne((r) => r.url === '/api/bookings');
|
||||
expect(req.request.params.get('status')).toBe('confirmed');
|
||||
expect(req.request.params.get('limit')).toBe('10');
|
||||
expect(req.request.params.get('offset')).toBe('20');
|
||||
req.flush({ items: [], total: 0, limit: 10, offset: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
it('posts a booking', () => {
|
||||
const body = {
|
||||
flightId: 'f-1',
|
||||
contactEmail: 'ops@example.com',
|
||||
contactPhone: '',
|
||||
passengers: [{ firstName: 'Ada', lastName: 'Lovelace', dateOfBirth: '', passportNumber: '' }],
|
||||
};
|
||||
|
||||
api.createBooking(body).subscribe();
|
||||
|
||||
const req = http.expectOne('/api/bookings');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(body);
|
||||
// The client never names a tenant; the server derives it from credentials.
|
||||
expect(JSON.stringify(req.request.body)).not.toContain('tenant');
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('cancels a booking with an empty body', () => {
|
||||
api.cancelBooking('b-1').subscribe();
|
||||
|
||||
const req = http.expectOne('/api/bookings/b-1/cancel');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({});
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('reads the dashboard', () => {
|
||||
api.dashboard().subscribe();
|
||||
|
||||
const req = http.expectOne('/api/dashboard');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('reads the tenant reference lists', () => {
|
||||
api.listAirports().subscribe();
|
||||
http.expectOne('/api/airports').flush([]);
|
||||
|
||||
api.listAirlines().subscribe();
|
||||
http.expectOne('/api/airlines').flush([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import {
|
||||
Airline,
|
||||
Airport,
|
||||
Booking,
|
||||
CreateBookingRequest,
|
||||
DashboardStats,
|
||||
Flight,
|
||||
FlightQuery,
|
||||
Page,
|
||||
} from './models';
|
||||
|
||||
/**
|
||||
* Every call here is implicitly tenant-scoped: the interceptor attaches the
|
||||
* tenant slug, and the server derives the tenant id from the credentials. The
|
||||
* client never sends a tenant id, and could not usefully forge one.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
searchFlights(query: FlightQuery): Observable<Page<Flight>> {
|
||||
let params = new HttpParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return this.http.get<Page<Flight>>('/api/flights', { params });
|
||||
}
|
||||
|
||||
getFlight(id: string): Observable<Flight> {
|
||||
return this.http.get<Flight>(`/api/flights/${id}`);
|
||||
}
|
||||
|
||||
listAirports(): Observable<Airport[]> {
|
||||
return this.http.get<Airport[]>('/api/airports');
|
||||
}
|
||||
|
||||
listAirlines(): Observable<Airline[]> {
|
||||
return this.http.get<Airline[]>('/api/airlines');
|
||||
}
|
||||
|
||||
listBookings(status = '', limit = 25, offset = 0): Observable<Page<Booking>> {
|
||||
let params = new HttpParams().set('limit', limit).set('offset', offset);
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
return this.http.get<Page<Booking>>('/api/bookings', { params });
|
||||
}
|
||||
|
||||
getBooking(id: string): Observable<Booking> {
|
||||
return this.http.get<Booking>(`/api/bookings/${id}`);
|
||||
}
|
||||
|
||||
createBooking(request: CreateBookingRequest): Observable<Booking> {
|
||||
return this.http.post<Booking>('/api/bookings', request);
|
||||
}
|
||||
|
||||
cancelBooking(id: string): Observable<Booking> {
|
||||
return this.http.post<Booking>(`/api/bookings/${id}/cancel`, {});
|
||||
}
|
||||
|
||||
dashboard(): Observable<DashboardStats> {
|
||||
return this.http.get<DashboardStats>('/api/dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRouteSnapshot, provideRouter, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||
|
||||
import { authGuard, guestGuard } from './auth.guard';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/** Minimal stand-in: the guards only ever call restore(). */
|
||||
class FakeAuth {
|
||||
constructor(private readonly restorable: boolean) {}
|
||||
restore = () => Promise.resolve(this.restorable);
|
||||
}
|
||||
|
||||
function setup(signedIn: boolean) {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: AuthService, useValue: new FakeAuth(signedIn) }],
|
||||
});
|
||||
}
|
||||
|
||||
function stateFor(url: string): RouterStateSnapshot {
|
||||
return { url } as RouterStateSnapshot;
|
||||
}
|
||||
|
||||
const route = {} as ActivatedRouteSnapshot;
|
||||
|
||||
describe('authGuard', () => {
|
||||
it('lets a signed-in user through', async () => {
|
||||
setup(true);
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() =>
|
||||
authGuard(route, stateFor('/dashboard')),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects to sign-in and remembers where the user was heading', async () => {
|
||||
setup(false);
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() =>
|
||||
authGuard(route, stateFor('/bookings/abc-123')),
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toBe('/login?returnTo=%2Fbookings%2Fabc-123');
|
||||
});
|
||||
|
||||
it('omits returnTo when the destination was the root', async () => {
|
||||
setup(false);
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() => authGuard(route, stateFor('/')));
|
||||
|
||||
expect((result as UrlTree).toString()).toBe('/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guestGuard', () => {
|
||||
it('lets a signed-out visitor reach the sign-in screen', async () => {
|
||||
setup(false);
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() => guestGuard(route, stateFor('/login')));
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('sends an already signed-in user to the dashboard', async () => {
|
||||
setup(true);
|
||||
|
||||
const result = await TestBed.runInInjectionContext(() => guestGuard(route, stateFor('/login')));
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/**
|
||||
* Blocks the application shell until a session is confirmed.
|
||||
*
|
||||
* On a fresh page load there are credentials in storage but no session object
|
||||
* yet, so the guard re-validates them against the server before letting the
|
||||
* route through. That way a revoked account cannot linger on screen.
|
||||
*/
|
||||
export const authGuard: CanActivateFn = async (_route, state) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (await auth.restore()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/login'], {
|
||||
queryParams: state.url === '/' ? {} : { returnTo: state.url },
|
||||
});
|
||||
};
|
||||
|
||||
/** Sends an already-signed-in user away from the sign-in screen. */
|
||||
export const guestGuard: CanActivateFn = async () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (await auth.restore()) {
|
||||
return router.createUrlTree(['/dashboard']);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const STORAGE_KEY = 'travelhub.credentials';
|
||||
const BASIC = btoa('agent@skyline.test:demo1234');
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let backend: HttpTestingController;
|
||||
let auth: AuthService;
|
||||
let navigated: string[][];
|
||||
|
||||
function create(withCredentials = true): void {
|
||||
if (withCredentials) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ tenant: 'skyline', basic: BASIC }));
|
||||
}
|
||||
|
||||
navigated = [];
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
navigate: (commands: string[]) => {
|
||||
navigated.push(commands);
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
backend = TestBed.inject(HttpTestingController);
|
||||
auth = TestBed.inject(AuthService);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backend.verify();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stamps API calls with the credentials and tenant', () => {
|
||||
create();
|
||||
|
||||
http.get('/api/flights').subscribe();
|
||||
|
||||
const req = backend.expectOne('/api/flights');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Basic ${BASIC}`);
|
||||
expect(req.request.headers.get('X-Tenant')).toBe('skyline');
|
||||
req.flush({ items: [], total: 0, limit: 25, offset: 0 });
|
||||
});
|
||||
|
||||
it('leaves non-API requests alone', () => {
|
||||
create();
|
||||
|
||||
http.get('/assets/config.json').subscribe();
|
||||
|
||||
const req = backend.expectOne('/assets/config.json');
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
expect(req.request.headers.has('X-Tenant')).toBe(false);
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('does not overwrite credentials a caller set explicitly', () => {
|
||||
create();
|
||||
const probe = btoa('someone@else.test:trying');
|
||||
|
||||
http.get('/api/session', { headers: { Authorization: `Basic ${probe}` } }).subscribe();
|
||||
|
||||
// This is how signIn() tests candidate credentials before storing them.
|
||||
const req = backend.expectOne('/api/session');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Basic ${probe}`);
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('adds nothing when signed out', () => {
|
||||
create(false);
|
||||
|
||||
http.get('/api/tenants').subscribe();
|
||||
|
||||
const req = backend.expectOne('/api/tenants');
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('signs out and redirects when stored credentials go stale', () => {
|
||||
create();
|
||||
expect(auth.hasCredentials()).toBe(true);
|
||||
|
||||
http.get('/api/bookings').subscribe({ error: () => undefined });
|
||||
backend
|
||||
.expectOne('/api/bookings')
|
||||
.flush({ error: 'invalid' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(navigated).toEqual([['/login']]);
|
||||
});
|
||||
|
||||
it('does not sign out on a 401 from an unstamped request', () => {
|
||||
create(false);
|
||||
|
||||
http
|
||||
.get('/api/session', { headers: { Authorization: 'Basic bogus' } })
|
||||
.subscribe({ error: () => undefined });
|
||||
backend
|
||||
.expectOne('/api/session')
|
||||
.flush({ error: 'invalid' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
// A failed sign-in attempt must not trigger a redirect loop.
|
||||
expect(navigated).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves the session intact for other error statuses', () => {
|
||||
create();
|
||||
|
||||
http.get('/api/flights').subscribe({ error: () => undefined });
|
||||
backend
|
||||
.expectOne('/api/flights')
|
||||
.flush({ error: 'boom' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(auth.hasCredentials()).toBe(true);
|
||||
expect(navigated).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes the error through to the caller', async () => {
|
||||
create();
|
||||
|
||||
const failure = new Promise<number>((resolve) => {
|
||||
http.get('/api/flights').subscribe({ error: (err) => resolve(err.status) });
|
||||
});
|
||||
backend.expectOne('/api/flights').flush({}, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(await failure).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/**
|
||||
* Stamps every API call with the Basic credentials and the tenant slug.
|
||||
*
|
||||
* Requests that already carry an Authorization header are left alone: that is
|
||||
* how signIn() probes a candidate set of credentials before storing them.
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const credentials = auth.currentCredentials();
|
||||
const shouldStamp =
|
||||
credentials !== null && req.url.startsWith('/api/') && !req.headers.has('Authorization');
|
||||
|
||||
const outgoing = shouldStamp
|
||||
? req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Basic ${credentials.basic}`,
|
||||
'X-Tenant': credentials.tenant,
|
||||
},
|
||||
})
|
||||
: req;
|
||||
|
||||
return next(outgoing).pipe(
|
||||
catchError((error: unknown) => {
|
||||
// A 401 on a stamped request means the stored credentials went stale --
|
||||
// the account was removed, or its password changed elsewhere. Drop them
|
||||
// and send the user back to sign in rather than looping on failures.
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && shouldStamp) {
|
||||
auth.signOut();
|
||||
void router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { Session } from './models';
|
||||
|
||||
const STORAGE_KEY = 'travelhub.credentials';
|
||||
|
||||
function sessionFor(slug: string, email: string): Session {
|
||||
return {
|
||||
user: {
|
||||
id: 'u-1',
|
||||
tenantId: 't-1',
|
||||
email,
|
||||
fullName: 'Marcus Reid',
|
||||
role: 'agent',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
tenant: {
|
||||
id: 't-1',
|
||||
slug,
|
||||
name: 'Skyline Travel',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
let auth: AuthService;
|
||||
let http: HttpTestingController;
|
||||
|
||||
/** Builds the service after any localStorage priming a test needs. */
|
||||
function create(): void {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
auth = TestBed.inject(AuthService);
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (http) {
|
||||
http.verify();
|
||||
}
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('starts signed out with nothing in storage', () => {
|
||||
create();
|
||||
|
||||
expect(auth.session()).toBeNull();
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
expect(auth.currentCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches the public tenant list without credentials', () => {
|
||||
create();
|
||||
|
||||
auth.listTenants().subscribe();
|
||||
|
||||
const req = http.expectOne('/api/tenants');
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush([{ slug: 'skyline', name: 'Skyline Travel' }]);
|
||||
});
|
||||
|
||||
it('verifies credentials against the chosen tenant before storing them', async () => {
|
||||
create();
|
||||
|
||||
const pending = auth.signIn('skyline', 'agent@skyline.test', 'demo1234');
|
||||
|
||||
const req = http.expectOne('/api/session');
|
||||
// The candidate credentials must ride on this very request -- the
|
||||
// interceptor cannot stamp them, because they are not stored yet.
|
||||
expect(req.request.headers.get('Authorization')).toBe(
|
||||
'Basic ' + btoa('agent@skyline.test:demo1234'),
|
||||
);
|
||||
expect(req.request.headers.get('X-Tenant')).toBe('skyline');
|
||||
|
||||
req.flush(sessionFor('skyline', 'agent@skyline.test'));
|
||||
await pending;
|
||||
|
||||
expect(auth.session()?.tenant.slug).toBe('skyline');
|
||||
expect(auth.user()?.email).toBe('agent@skyline.test');
|
||||
expect(auth.isAdmin()).toBe(false);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toContain('skyline');
|
||||
});
|
||||
|
||||
it('leaves no trace when the server rejects the credentials', async () => {
|
||||
create();
|
||||
|
||||
const pending = auth.signIn('skyline', 'agent@skyline.test', 'wrong');
|
||||
http.expectOne('/api/session').flush(
|
||||
{ error: 'invalid credentials for this tenant' },
|
||||
{ status: 401, statusText: 'Unauthorized' },
|
||||
);
|
||||
|
||||
await expect(pending).rejects.toBeTruthy();
|
||||
|
||||
expect(auth.session()).toBeNull();
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the admin role', async () => {
|
||||
create();
|
||||
|
||||
const pending = auth.signIn('skyline', 'admin@skyline.test', 'demo1234');
|
||||
const session = sessionFor('skyline', 'admin@skyline.test');
|
||||
session.user.role = 'admin';
|
||||
http.expectOne('/api/session').flush(session);
|
||||
await pending;
|
||||
|
||||
expect(auth.isAdmin()).toBe(true);
|
||||
});
|
||||
|
||||
it('revalidates stored credentials on restore', async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ tenant: 'skyline', basic: btoa('agent@skyline.test:demo1234') }),
|
||||
);
|
||||
create();
|
||||
|
||||
// Credentials exist, but the session is not trusted until the server says so.
|
||||
expect(auth.hasCredentials()).toBe(true);
|
||||
expect(auth.session()).toBeNull();
|
||||
|
||||
const pending = auth.restore();
|
||||
http.expectOne('/api/session').flush(sessionFor('skyline', 'agent@skyline.test'));
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(auth.session()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('discards stored credentials the server no longer accepts', async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ tenant: 'skyline', basic: btoa('ghost@skyline.test:stale') }),
|
||||
);
|
||||
create();
|
||||
|
||||
const pending = auth.restore();
|
||||
http
|
||||
.expectOne('/api/session')
|
||||
.flush({ error: 'invalid' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(await pending).toBe(false);
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('restores without a second round trip once the session is known', async () => {
|
||||
create();
|
||||
|
||||
const signingIn = auth.signIn('skyline', 'agent@skyline.test', 'demo1234');
|
||||
http.expectOne('/api/session').flush(sessionFor('skyline', 'agent@skyline.test'));
|
||||
await signingIn;
|
||||
|
||||
expect(await auth.restore()).toBe(true);
|
||||
http.expectNone('/api/session');
|
||||
});
|
||||
|
||||
it('short-circuits restore when there are no credentials', async () => {
|
||||
create();
|
||||
|
||||
expect(await auth.restore()).toBe(false);
|
||||
http.expectNone('/api/session');
|
||||
});
|
||||
|
||||
it('clears everything on sign out', async () => {
|
||||
create();
|
||||
|
||||
const pending = auth.signIn('skyline', 'agent@skyline.test', 'demo1234');
|
||||
http.expectOne('/api/session').flush(sessionFor('skyline', 'agent@skyline.test'));
|
||||
await pending;
|
||||
|
||||
auth.signOut();
|
||||
|
||||
expect(auth.session()).toBeNull();
|
||||
expect(auth.tenant()).toBeNull();
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats unreadable storage as signed out rather than crashing', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-json-at-all');
|
||||
create();
|
||||
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a stored value of the wrong shape', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ tenant: 'skyline' }));
|
||||
create();
|
||||
|
||||
expect(auth.hasCredentials()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { PublicTenant, Session } from './models';
|
||||
|
||||
/** Shape persisted to localStorage so a reload keeps the user signed in. */
|
||||
interface StoredCredentials {
|
||||
tenant: string;
|
||||
basic: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'travelhub.credentials';
|
||||
|
||||
/**
|
||||
* Holds the Basic auth credentials and the resolved session.
|
||||
*
|
||||
* Basic auth means the credentials themselves must be replayed on every
|
||||
* request, so they are kept in localStorage rather than exchanged for a token.
|
||||
* That is a real trade-off of the scheme, not an oversight -- see the
|
||||
* authentication section of the README for what to swap in for production.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private readonly credentials = signal<StoredCredentials | null>(readStored());
|
||||
private readonly sessionSignal = signal<Session | null>(null);
|
||||
|
||||
/** The signed-in user and tenant, or null when signed out. */
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
readonly user = computed(() => this.sessionSignal()?.user ?? null);
|
||||
readonly tenant = computed(() => this.sessionSignal()?.tenant ?? null);
|
||||
readonly isAdmin = computed(() => this.sessionSignal()?.user.role === 'admin');
|
||||
|
||||
/** True while credentials exist, even before the session is confirmed. */
|
||||
readonly hasCredentials = computed(() => this.credentials() !== null);
|
||||
|
||||
/** Read by the HTTP interceptor to stamp outgoing requests. */
|
||||
currentCredentials(): StoredCredentials | null {
|
||||
return this.credentials();
|
||||
}
|
||||
|
||||
/** The agency list shown on the sign-in screen. Needs no credentials. */
|
||||
listTenants() {
|
||||
return this.http.get<PublicTenant[]>('/api/tenants');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a set of credentials by calling /api/session with them, and keeps
|
||||
* them only if the server accepts. Nothing is written to storage until the
|
||||
* server has confirmed the login, so a failed attempt leaves no trace.
|
||||
*/
|
||||
async signIn(tenant: string, email: string, password: string): Promise<Session> {
|
||||
const candidate: StoredCredentials = {
|
||||
tenant,
|
||||
basic: btoa(`${email}:${password}`),
|
||||
};
|
||||
|
||||
const session = await firstValueFrom(
|
||||
this.http.get<Session>('/api/session', {
|
||||
headers: {
|
||||
Authorization: `Basic ${candidate.basic}`,
|
||||
'X-Tenant': candidate.tenant,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
this.credentials.set(candidate);
|
||||
this.sessionSignal.set(session);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(candidate));
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validates credentials found in storage on app start. Returns false if
|
||||
* there are none or the server rejects them, in which case the guard sends
|
||||
* the user to the sign-in screen.
|
||||
*/
|
||||
async restore(): Promise<boolean> {
|
||||
if (this.sessionSignal() !== null) {
|
||||
return true;
|
||||
}
|
||||
if (this.credentials() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await firstValueFrom(this.http.get<Session>('/api/session'));
|
||||
this.sessionSignal.set(session);
|
||||
return true;
|
||||
} catch {
|
||||
this.signOut();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
signOut(): void {
|
||||
this.credentials.set(null);
|
||||
this.sessionSignal.set(null);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function readStored(): StoredCredentials | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<StoredCredentials>;
|
||||
if (typeof parsed.tenant === 'string' && typeof parsed.basic === 'string') {
|
||||
return { tenant: parsed.tenant, basic: parsed.basic };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// Private-mode browsers and cleared site data both land here; signed out
|
||||
// is the correct fallback.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// TypeScript mirrors of the Go API types in backend/internal/models.
|
||||
// Field names match the JSON tags exactly.
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** What GET /api/tenants returns: enough to populate the sign-in picker. */
|
||||
export interface PublicTenant {
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: 'admin' | 'agent';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user: User;
|
||||
tenant: Tenant;
|
||||
}
|
||||
|
||||
export interface Airport {
|
||||
id: string;
|
||||
iataCode: string;
|
||||
name: string;
|
||||
city: string;
|
||||
country: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface Airline {
|
||||
id: string;
|
||||
iataCode: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export type CabinClass = 'economy' | 'premium_economy' | 'business' | 'first';
|
||||
|
||||
export interface Flight {
|
||||
id: string;
|
||||
flightNumber: string;
|
||||
airline: Airline;
|
||||
origin: Airport;
|
||||
destination: Airport;
|
||||
departureTime: string;
|
||||
arrivalTime: string;
|
||||
durationMinutes: number;
|
||||
aircraft: string;
|
||||
cabinClass: CabinClass;
|
||||
basePrice: number;
|
||||
currency: string;
|
||||
seatsTotal: number;
|
||||
seatsAvailable: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Passenger {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth?: string;
|
||||
passportNumber?: string;
|
||||
seatNumber?: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: string;
|
||||
bookingReference: string;
|
||||
flight: Flight;
|
||||
passengers: Passenger[];
|
||||
passengerCount: number;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: 'confirmed' | 'cancelled';
|
||||
contactEmail: string;
|
||||
contactPhone?: string;
|
||||
bookedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Envelope shared by every list endpoint. */
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface FlightQuery {
|
||||
origin?: string;
|
||||
destination?: string;
|
||||
date?: string;
|
||||
cabin?: CabinClass | '';
|
||||
passengers?: number;
|
||||
sort?: 'price' | 'duration' | 'departure';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface NewPassenger {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
passportNumber: string;
|
||||
}
|
||||
|
||||
export interface CreateBookingRequest {
|
||||
flightId: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
passengers: NewPassenger[];
|
||||
}
|
||||
|
||||
export interface TopRoute {
|
||||
route: string;
|
||||
bookings: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalBookings: number;
|
||||
confirmedBookings: number;
|
||||
cancelledBookings: number;
|
||||
totalRevenue: number;
|
||||
currency: string;
|
||||
flightsInStock: number;
|
||||
seatsSold: number;
|
||||
recentBookings: Booking[];
|
||||
topRoutes: TopRoute[] | null;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
@if (loading()) {
|
||||
<div class="card card-pad"><div class="skeleton" style="height: 300px"></div></div>
|
||||
} @else if (notFound() || booking() === null) {
|
||||
<div class="card empty">
|
||||
<h3>Booking not found</h3>
|
||||
<p>It may belong to another agency, or have been removed.</p>
|
||||
<p style="margin-top: 14px">
|
||||
<a routerLink="/bookings" class="btn btn-primary btn-sm">Back to bookings</a>
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
@let b = booking()!;
|
||||
|
||||
@if (justBooked() && b.status === 'confirmed') {
|
||||
<div class="confirmed-banner" role="status">
|
||||
<span class="tick" aria-hidden="true">✓</span>
|
||||
<div>
|
||||
<strong>Booking confirmed.</strong>
|
||||
Reference <span class="pnr">{{ b.bookingReference }}</span> — a confirmation would be sent
|
||||
to {{ b.contactEmail }}.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Booking {{ b.bookingReference }}</h1>
|
||||
<p class="subtitle">
|
||||
Created {{ b.createdAt | date: 'd MMM y, HH:mm' }} by {{ b.bookedBy }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<span class="badge" [class]="'badge-' + b.status">{{ b.status }}</span>
|
||||
<a routerLink="/bookings" class="btn btn-ghost btn-sm">← All bookings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<div class="detail">
|
||||
<div class="stack">
|
||||
<!-- Itinerary -->
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Itinerary</h2></div>
|
||||
<div class="card-pad">
|
||||
<div class="trip">
|
||||
<span class="carrier-code">{{ b.flight.airline.iataCode }}</span>
|
||||
<div class="trip-body">
|
||||
<div class="trip-head">
|
||||
<span class="trip-airline">{{ b.flight.airline.name }}</span>
|
||||
<span class="trip-meta">
|
||||
{{ b.flight.flightNumber }} · {{ b.flight.aircraft }} ·
|
||||
<span class="cap">{{ cabinLabel(b.flight.cabinClass) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="trip-route">
|
||||
<div class="trip-point">
|
||||
<span class="trip-time tabular">{{ b.flight.departureTime | date: 'HH:mm' }}</span>
|
||||
<span class="trip-iata">{{ b.flight.origin.iataCode }}</span>
|
||||
<span class="trip-place">
|
||||
{{ b.flight.origin.city }} · {{ b.flight.origin.name }}
|
||||
</span>
|
||||
<span class="trip-date">{{ b.flight.departureTime | date: 'EEE d MMM y' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="trip-leg">
|
||||
<span class="trip-duration">{{ b.flight.durationMinutes | duration }}</span>
|
||||
<span class="trip-line" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<div class="trip-point">
|
||||
<span class="trip-time tabular">{{ b.flight.arrivalTime | date: 'HH:mm' }}</span>
|
||||
<span class="trip-iata">{{ b.flight.destination.iataCode }}</span>
|
||||
<span class="trip-place">
|
||||
{{ b.flight.destination.city }} · {{ b.flight.destination.name }}
|
||||
</span>
|
||||
<span class="trip-date">{{ b.flight.arrivalTime | date: 'EEE d MMM y' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Travellers -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Travellers</h2>
|
||||
<span class="muted">{{ b.passengerCount }} on this booking</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Date of birth</th>
|
||||
<th>Passport</th>
|
||||
<th>Seat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of b.passengers; track p.id) {
|
||||
<tr>
|
||||
<td><strong>{{ p.firstName }} {{ p.lastName }}</strong></td>
|
||||
<td class="tabular">
|
||||
{{ p.dateOfBirth ? (p.dateOfBirth | date: 'd MMM y') : '—' }}
|
||||
</td>
|
||||
<td>{{ p.passportNumber || '—' }}</td>
|
||||
<td>{{ p.seatNumber || 'Not assigned' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Summary and actions -->
|
||||
<aside class="stack">
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Summary</h2></div>
|
||||
<div class="card-pad">
|
||||
<dl class="sum-list">
|
||||
<div><dt>Reference</dt><dd class="pnr">{{ b.bookingReference }}</dd></div>
|
||||
<div><dt>Status</dt><dd class="cap">{{ b.status }}</dd></div>
|
||||
<div><dt>Travellers</dt><dd>{{ b.passengerCount }}</dd></div>
|
||||
<div>
|
||||
<dt>Fare each</dt>
|
||||
<dd class="tabular">
|
||||
{{ b.flight.basePrice | currency: b.currency : 'symbol' : '1.2-2' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>Contact</dt><dd>{{ b.contactEmail }}</dd></div>
|
||||
@if (b.contactPhone) {
|
||||
<div><dt>Phone</dt><dd>{{ b.contactPhone }}</dd></div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-pad total-row">
|
||||
<span>Total paid</span>
|
||||
<strong class="tabular">
|
||||
{{ b.totalAmount | currency: b.currency : 'symbol' : '1.2-2' }}
|
||||
</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (b.status === 'confirmed') {
|
||||
<section class="card card-pad">
|
||||
<h3>Cancel this booking</h3>
|
||||
<p class="muted cancel-copy">
|
||||
The reservation is voided and {{ b.passengerCount }} seat(s) return to inventory. This
|
||||
cannot be undone.
|
||||
</p>
|
||||
|
||||
@if (confirming()) {
|
||||
<div class="confirm-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
[disabled]="cancelling()"
|
||||
(click)="confirmCancel()"
|
||||
>
|
||||
{{ cancelling() ? 'Cancelling…' : 'Yes, cancel booking' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
[disabled]="cancelling()"
|
||||
(click)="dismissCancel()"
|
||||
>
|
||||
Keep it
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-danger btn-sm" (click)="askCancel()">
|
||||
Cancel booking
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<div class="alert alert-info">
|
||||
This booking was cancelled and its seats returned to inventory.
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
.confirmed-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--teal-100);
|
||||
border: 1px solid #a8e0d8;
|
||||
color: #045c51;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-600);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.head-actions .badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* --- itinerary ----------------------------------------------------------- */
|
||||
|
||||
.trip {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.carrier-code {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--navy-900);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.trip-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trip-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.trip-airline {
|
||||
font-weight: 650;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.trip-meta {
|
||||
font-size: 0.81rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trip-route {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(90px, 0.7fr) 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.trip-point {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trip-time {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.trip-iata {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
color: var(--brand-600);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.trip-place {
|
||||
font-size: 0.79rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trip-date {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-faint);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.trip-leg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.trip-duration {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trip-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-strong);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trip-line::after {
|
||||
content: '✈';
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--brand-500);
|
||||
font-size: 0.76rem;
|
||||
background: var(--surface);
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
/* --- summary ------------------------------------------------------------- */
|
||||
|
||||
.sum-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.sum-list > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.sum-list dt {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sum-list dd {
|
||||
margin: 0;
|
||||
font-weight: 550;
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pnr {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.cap {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.total-row strong {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* --- cancel -------------------------------------------------------------- */
|
||||
|
||||
.cancel-copy {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- narrow screens ------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 980px) {
|
||||
/* minmax(0, 1fr) keeps the travellers table inside its scroll container
|
||||
instead of stretching the column to its min-content width. */
|
||||
.detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.trip-route {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.trip-line {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import { BookingDetailPage } from './booking-detail';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { Booking } from '../../core/models';
|
||||
|
||||
function booking(status: 'confirmed' | 'cancelled' = 'confirmed'): Booking {
|
||||
return {
|
||||
id: 'b-1',
|
||||
bookingReference: 'S3GTBQ',
|
||||
flight: {
|
||||
id: 'f-1',
|
||||
flightNumber: 'AA100',
|
||||
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
|
||||
origin: {
|
||||
id: 'a-1', iataCode: 'JFK', name: 'JFK', city: 'New York', country: 'US', timezone: 'UTC',
|
||||
},
|
||||
destination: {
|
||||
id: 'a-2', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'UK', timezone: 'UTC',
|
||||
},
|
||||
departureTime: '2026-09-19T18:00:00Z',
|
||||
arrivalTime: '2026-09-20T01:00:00Z',
|
||||
durationMinutes: 420,
|
||||
aircraft: 'Boeing 787-9',
|
||||
cabinClass: 'economy',
|
||||
basePrice: 483.3,
|
||||
currency: 'USD',
|
||||
seatsTotal: 180,
|
||||
seatsAvailable: 163,
|
||||
status: 'scheduled',
|
||||
},
|
||||
passengers: [
|
||||
{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield', passportNumber: 'X1234567' },
|
||||
{ id: 'p-2', firstName: 'Elias', lastName: 'Whitfield' },
|
||||
],
|
||||
passengerCount: 2,
|
||||
totalAmount: 966.6,
|
||||
currency: 'USD',
|
||||
status,
|
||||
contactEmail: 'ops@example.com',
|
||||
contactPhone: '+1-555-0101',
|
||||
bookedBy: 'Marcus Reid',
|
||||
createdAt: '2026-09-12T13:10:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
class FakeApi {
|
||||
detail: Observable<Booking> = of(booking());
|
||||
cancelled: Observable<Booking> = of(booking('cancelled'));
|
||||
cancelCalls: string[] = [];
|
||||
|
||||
getBooking = () => this.detail;
|
||||
cancelBooking = (id: string) => {
|
||||
this.cancelCalls.push(id);
|
||||
return this.cancelled;
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookingDetailPage', () => {
|
||||
let fixture: ComponentFixture<BookingDetailPage>;
|
||||
let page: BookingDetailPage;
|
||||
let api: FakeApi;
|
||||
|
||||
function create(confirmedFlag = ''): void {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap(confirmedFlag ? { confirmed: confirmedFlag } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(BookingDetailPage);
|
||||
fixture.componentRef.setInput('bookingId', 'b-1');
|
||||
page = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api = new FakeApi();
|
||||
});
|
||||
|
||||
it('loads the booking with its travellers', () => {
|
||||
create();
|
||||
|
||||
expect(page.booking()?.bookingReference).toBe('S3GTBQ');
|
||||
expect(fixture.nativeElement.querySelectorAll('table.data tbody tr').length).toBe(2);
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the confirmation banner only when arriving from checkout', () => {
|
||||
create('1');
|
||||
expect(page.justBooked()).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.confirmed-banner')).toBeTruthy();
|
||||
|
||||
create();
|
||||
expect(page.justBooked()).toBe(false);
|
||||
expect(fixture.nativeElement.querySelector('.confirmed-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for confirmation before cancelling', () => {
|
||||
create();
|
||||
|
||||
expect(page.confirming()).toBe(false);
|
||||
expect(api.cancelCalls).toEqual([]);
|
||||
|
||||
page.askCancel();
|
||||
expect(page.confirming()).toBe(true);
|
||||
// Still nothing sent -- the destructive call waits for the second click.
|
||||
expect(api.cancelCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets the user back out', () => {
|
||||
create();
|
||||
|
||||
page.askCancel();
|
||||
page.dismissCancel();
|
||||
|
||||
expect(page.confirming()).toBe(false);
|
||||
expect(api.cancelCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('cancels on confirmation and reflects the new status', () => {
|
||||
create('1');
|
||||
|
||||
page.askCancel();
|
||||
page.confirmCancel();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.cancelCalls).toEqual(['b-1']);
|
||||
expect(page.booking()?.status).toBe('cancelled');
|
||||
expect(page.confirming()).toBe(false);
|
||||
expect(page.cancelling()).toBe(false);
|
||||
// The success banner is stale once the booking is voided.
|
||||
expect(page.justBooked()).toBe(false);
|
||||
expect(fixture.nativeElement.querySelector('.alert-info')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports a cancel that failed and leaves the booking alone', () => {
|
||||
api.cancelled = throwError(() => new HttpErrorResponse({ status: 500 }));
|
||||
create();
|
||||
|
||||
page.askCancel();
|
||||
page.confirmCancel();
|
||||
|
||||
expect(page.error()).toContain('could not be cancelled');
|
||||
expect(page.booking()?.status).toBe('confirmed');
|
||||
expect(page.cancelling()).toBe(false);
|
||||
});
|
||||
|
||||
it('offers no cancel control on an already cancelled booking', () => {
|
||||
api.detail = of(booking('cancelled'));
|
||||
create();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.cancel-copy')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.alert-info')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows a not-found state for a booking in another agency', () => {
|
||||
api.detail = throwError(() => new HttpErrorResponse({ status: 404 }));
|
||||
create();
|
||||
|
||||
expect(page.notFound()).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('Booking not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component, effect, inject, input, signal } from '@angular/core';
|
||||
import { CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { Booking } from '../../core/models';
|
||||
import { DurationPipe } from '../../shared/duration.pipe';
|
||||
|
||||
/** One reservation in full, with the option to cancel it. */
|
||||
@Component({
|
||||
selector: 'app-booking-detail',
|
||||
imports: [RouterLink, CurrencyPipe, DatePipe, DurationPipe],
|
||||
templateUrl: './booking-detail.html',
|
||||
styleUrl: './booking-detail.scss',
|
||||
})
|
||||
export class BookingDetailPage {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
/** Bound from the :bookingId route parameter. */
|
||||
readonly bookingId = input.required<string>();
|
||||
|
||||
readonly booking = signal<Booking | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly notFound = signal(false);
|
||||
readonly cancelling = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly confirming = signal(false);
|
||||
|
||||
/** True right after checkout, so the page can open with a success banner. */
|
||||
readonly justBooked = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.justBooked.set(this.route.snapshot.queryParamMap.get('confirmed') === '1');
|
||||
|
||||
effect(() => {
|
||||
const id = this.bookingId();
|
||||
this.load(id);
|
||||
});
|
||||
}
|
||||
|
||||
cabinLabel(cabin: string): string {
|
||||
return cabin.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** Two-step cancel: the button asks for confirmation before it fires. */
|
||||
askCancel(): void {
|
||||
this.confirming.set(true);
|
||||
}
|
||||
|
||||
dismissCancel(): void {
|
||||
this.confirming.set(false);
|
||||
}
|
||||
|
||||
confirmCancel(): void {
|
||||
const current = this.booking();
|
||||
if (current === null || this.cancelling()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelling.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.api.cancelBooking(current.id).subscribe({
|
||||
next: (updated) => {
|
||||
this.booking.set(updated);
|
||||
this.cancelling.set(false);
|
||||
this.confirming.set(false);
|
||||
this.justBooked.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.cancelling.set(false);
|
||||
this.confirming.set(false);
|
||||
this.error.set('The booking could not be cancelled. Please refresh and try again.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private load(id: string): void {
|
||||
this.loading.set(true);
|
||||
this.notFound.set(false);
|
||||
|
||||
this.api.getBooking(id).subscribe({
|
||||
next: (booking) => {
|
||||
this.booking.set(booking);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.notFound.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Bookings</h1>
|
||||
<p class="subtitle">Every reservation sold by your agency.</p>
|
||||
</div>
|
||||
<a routerLink="/search" class="btn btn-primary btn-sm">+ New booking</a>
|
||||
</div>
|
||||
|
||||
<div class="filters" role="group" aria-label="Filter by status">
|
||||
<button
|
||||
type="button"
|
||||
class="filter"
|
||||
[class.active]="status() === ''"
|
||||
(click)="filterBy('')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter"
|
||||
[class.active]="status() === 'confirmed'"
|
||||
(click)="filterBy('confirmed')"
|
||||
>
|
||||
Confirmed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter"
|
||||
[class.active]="status() === 'cancelled'"
|
||||
(click)="filterBy('cancelled')"
|
||||
>
|
||||
Cancelled
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="card card-pad"><div class="skeleton" style="height: 260px"></div></div>
|
||||
} @else if (bookings().length === 0) {
|
||||
<div class="card empty">
|
||||
<h3>No bookings yet</h3>
|
||||
<p>Search for a flight to create the first reservation.</p>
|
||||
<p style="margin-top: 14px">
|
||||
<a routerLink="/search" class="btn btn-primary btn-sm">Search flights</a>
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Route</th>
|
||||
<th>Departure</th>
|
||||
<th>Travellers</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
<th>Booked by</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (booking of bookings(); track booking.id) {
|
||||
<tr>
|
||||
<td><span class="pnr">{{ booking.bookingReference }}</span></td>
|
||||
<td>
|
||||
<span class="route">
|
||||
{{ booking.flight.origin.iataCode }} → {{ booking.flight.destination.iataCode }}
|
||||
</span>
|
||||
<span class="route-meta">
|
||||
{{ booking.flight.airline.iataCode }} {{ booking.flight.flightNumber }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="tabular">{{ booking.flight.departureTime | date: 'd MMM y, HH:mm' }}</td>
|
||||
<td class="tabular">{{ booking.passengerCount }}</td>
|
||||
<td class="tabular">
|
||||
{{ booking.totalAmount | currency: booking.currency : 'symbol' : '1.2-2' }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" [class]="'badge-' + booking.status">{{ booking.status }}</span>
|
||||
</td>
|
||||
<td class="muted">{{ booking.bookedBy }}</td>
|
||||
<td class="right">
|
||||
<a [routerLink]="['/bookings', booking.id]" class="btn btn-ghost btn-sm">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (canPrev() || canNext()) {
|
||||
<nav class="pager" aria-label="Pagination">
|
||||
<button type="button" class="btn btn-ghost btn-sm" [disabled]="!canPrev()" (click)="prevPage()">
|
||||
← Previous
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" [disabled]="!canNext()" (click)="nextPage()">
|
||||
Next →
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
padding: 7px 15px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 550;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.filter:hover {
|
||||
border-color: var(--brand-500);
|
||||
color: var(--brand-600);
|
||||
}
|
||||
|
||||
.filter.active {
|
||||
background: var(--navy-900);
|
||||
border-color: var(--navy-900);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pnr {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.route {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.route-meta {
|
||||
display: block;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import { BookingsPage } from './bookings';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { Booking, Page } from '../../core/models';
|
||||
|
||||
function booking(ref: string, status: 'confirmed' | 'cancelled' = 'confirmed'): Booking {
|
||||
return {
|
||||
id: `b-${ref}`,
|
||||
bookingReference: ref,
|
||||
flight: {
|
||||
id: 'f-1',
|
||||
flightNumber: 'AA100',
|
||||
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
|
||||
origin: {
|
||||
id: 'a-1', iataCode: 'JFK', name: 'JFK', city: 'New York', country: 'US', timezone: 'UTC',
|
||||
},
|
||||
destination: {
|
||||
id: 'a-2', iataCode: 'LHR', name: 'LHR', city: 'London', country: 'UK', timezone: 'UTC',
|
||||
},
|
||||
departureTime: '2026-09-19T18:00:00Z',
|
||||
arrivalTime: '2026-09-20T01:00:00Z',
|
||||
durationMinutes: 420,
|
||||
aircraft: 'Boeing 787-9',
|
||||
cabinClass: 'economy',
|
||||
basePrice: 483.3,
|
||||
currency: 'USD',
|
||||
seatsTotal: 180,
|
||||
seatsAvailable: 165,
|
||||
status: 'scheduled',
|
||||
},
|
||||
passengers: [],
|
||||
passengerCount: 2,
|
||||
totalAmount: 966.6,
|
||||
currency: 'USD',
|
||||
status,
|
||||
contactEmail: 'ops@example.com',
|
||||
bookedBy: 'Marcus Reid',
|
||||
createdAt: '2026-09-12T13:10:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
class FakeApi {
|
||||
result: Observable<Page<Booking>> = of({
|
||||
items: [booking('S3GTBQ')],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
calls: Array<[string, number, number]> = [];
|
||||
|
||||
listBookings = (status: string, limit: number, offset: number) => {
|
||||
this.calls.push([status, limit, offset]);
|
||||
return this.result;
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookingsPage', () => {
|
||||
let fixture: ComponentFixture<BookingsPage>;
|
||||
let page: BookingsPage;
|
||||
let api: FakeApi;
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(BookingsPage);
|
||||
page = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
api = new FakeApi();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: ApiService, useValue: api }],
|
||||
});
|
||||
});
|
||||
|
||||
it('loads every booking on open', () => {
|
||||
create();
|
||||
|
||||
expect(api.calls).toEqual([['', 20, 0]]);
|
||||
expect(page.bookings().length).toBe(1);
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('filters by status and starts again from page one', () => {
|
||||
api.result = of({ items: [booking('A')], total: 60, limit: 20, offset: 0 });
|
||||
create();
|
||||
|
||||
page.nextPage();
|
||||
expect(page.offset()).toBe(20);
|
||||
|
||||
page.filterBy('cancelled');
|
||||
|
||||
expect(page.status()).toBe('cancelled');
|
||||
expect(page.offset()).toBe(0);
|
||||
expect(api.calls[api.calls.length - 1]).toEqual(['cancelled', 20, 0]);
|
||||
});
|
||||
|
||||
it('pages through a long list', () => {
|
||||
api.result = of({ items: [booking('A')], total: 45, limit: 20, offset: 0 });
|
||||
create();
|
||||
|
||||
expect(page.canPrev()).toBe(false);
|
||||
expect(page.canNext()).toBe(true);
|
||||
|
||||
page.nextPage();
|
||||
page.nextPage();
|
||||
expect(page.offset()).toBe(40);
|
||||
expect(page.canNext()).toBe(false);
|
||||
|
||||
page.prevPage();
|
||||
expect(page.offset()).toBe(20);
|
||||
});
|
||||
|
||||
it('shows an empty state with a way forward', () => {
|
||||
api.result = of({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
create();
|
||||
|
||||
const text = fixture.nativeElement.textContent;
|
||||
expect(text).toContain('No bookings yet');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/search"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a load failure', () => {
|
||||
api.result = throwError(() => new Error('boom'));
|
||||
create();
|
||||
|
||||
expect(page.error()).toContain('Could not load bookings');
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a row with the reference, route and status', () => {
|
||||
create();
|
||||
|
||||
const row = fixture.nativeElement.querySelector('table.data tbody tr');
|
||||
expect(row.textContent).toContain('S3GTBQ');
|
||||
expect(row.textContent).toContain('JFK');
|
||||
expect(row.textContent).toContain('LHR');
|
||||
expect(row.querySelector('.badge-confirmed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { Booking } from '../../core/models';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** All reservations sold by this agency. */
|
||||
@Component({
|
||||
selector: 'app-bookings',
|
||||
imports: [RouterLink, CurrencyPipe, DatePipe],
|
||||
templateUrl: './bookings.html',
|
||||
styleUrl: './bookings.scss',
|
||||
})
|
||||
export class BookingsPage {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
readonly bookings = signal<Booking[]>([]);
|
||||
readonly total = signal(0);
|
||||
readonly offset = signal(0);
|
||||
readonly status = signal<'' | 'confirmed' | 'cancelled'>('');
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly canPrev = computed(() => this.offset() > 0);
|
||||
readonly canNext = computed(() => this.offset() + PAGE_SIZE < this.total());
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
filterBy(status: '' | 'confirmed' | 'cancelled'): void {
|
||||
this.status.set(status);
|
||||
this.offset.set(0);
|
||||
this.load();
|
||||
}
|
||||
|
||||
prevPage(): void {
|
||||
if (this.canPrev()) {
|
||||
this.offset.update((o) => Math.max(0, o - PAGE_SIZE));
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.canNext()) {
|
||||
this.offset.update((o) => o + PAGE_SIZE);
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.api.listBookings(this.status(), PAGE_SIZE, this.offset()).subscribe({
|
||||
next: (page) => {
|
||||
this.bookings.set(page.items);
|
||||
this.total.set(page.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Could not load bookings. Please try again.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
@if (loading()) {
|
||||
<div class="card card-pad"><div class="skeleton" style="height: 220px"></div></div>
|
||||
} @else if (loadFailed() || flight() === null) {
|
||||
<div class="card empty">
|
||||
<h3>Flight not available</h3>
|
||||
<p>It may have been withdrawn from your agency's inventory.</p>
|
||||
<p style="margin-top: 14px">
|
||||
<a routerLink="/search" class="btn btn-primary btn-sm">Back to search</a>
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
@let f = flight()!;
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Complete booking</h1>
|
||||
<p class="subtitle">Enter traveller details, then confirm.</p>
|
||||
</div>
|
||||
<a routerLink="/search" class="btn btn-ghost btn-sm">← Back to search</a>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<div class="checkout">
|
||||
<!-- Left: traveller and contact details -->
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="stack" novalidate>
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Travellers</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
(click)="addPassenger()"
|
||||
[disabled]="passengers.length >= 9"
|
||||
>
|
||||
+ Add traveller
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-pad stack" formArrayName="passengers">
|
||||
@for (group of passengers.controls; track $index) {
|
||||
<fieldset class="pax" [formGroupName]="$index">
|
||||
<legend>
|
||||
Traveller {{ $index + 1 }}
|
||||
@if (passengers.length > 1) {
|
||||
<button type="button" class="link-remove" (click)="removePassenger($index)">
|
||||
Remove
|
||||
</button>
|
||||
}
|
||||
</legend>
|
||||
|
||||
<div class="pax-grid">
|
||||
<div class="field">
|
||||
<label [attr.for]="'first-' + $index">First name</label>
|
||||
<input
|
||||
[id]="'first-' + $index"
|
||||
type="text"
|
||||
formControlName="firstName"
|
||||
autocomplete="given-name"
|
||||
[class.invalid]="showError($index, 'firstName')"
|
||||
/>
|
||||
@if (showError($index, 'firstName')) {
|
||||
<span class="error">Required.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label [attr.for]="'last-' + $index">Last name</label>
|
||||
<input
|
||||
[id]="'last-' + $index"
|
||||
type="text"
|
||||
formControlName="lastName"
|
||||
autocomplete="family-name"
|
||||
[class.invalid]="showError($index, 'lastName')"
|
||||
/>
|
||||
@if (showError($index, 'lastName')) {
|
||||
<span class="error">Required.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label [attr.for]="'dob-' + $index">Date of birth</label>
|
||||
<input [id]="'dob-' + $index" type="date" formControlName="dateOfBirth" />
|
||||
<span class="hint">Optional</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label [attr.for]="'passport-' + $index">Passport number</label>
|
||||
<input [id]="'passport-' + $index" type="text" formControlName="passportNumber" />
|
||||
<span class="hint">Optional</span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Contact details</h2></div>
|
||||
<div class="card-pad">
|
||||
<div class="pax-grid">
|
||||
<div class="field">
|
||||
<label for="contactEmail">Email</label>
|
||||
<input id="contactEmail" type="email" formControlName="contactEmail" />
|
||||
@if (form.controls.contactEmail.touched && form.controls.contactEmail.invalid) {
|
||||
<span class="error">Enter a valid email address.</span>
|
||||
}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="contactPhone">Phone</label>
|
||||
<input id="contactPhone" type="tel" formControlName="contactPhone" />
|
||||
<span class="hint">Optional</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-confirm"
|
||||
[disabled]="submitting() || seatsShort()"
|
||||
>
|
||||
{{ submitting() ? 'Confirming…' : 'Confirm booking' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Right: itinerary and fare summary -->
|
||||
<aside class="summary">
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Your flight</h2></div>
|
||||
<div class="card-pad stack">
|
||||
<div class="sum-carrier">
|
||||
<span class="carrier-code">{{ f.airline.iataCode }}</span>
|
||||
<span>
|
||||
<span class="sum-airline">{{ f.airline.name }}</span>
|
||||
<span class="sum-meta">{{ f.flightNumber }} · {{ f.aircraft }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sum-route">
|
||||
<div class="sum-point">
|
||||
<span class="sum-time tabular">{{ f.departureTime | date: 'HH:mm' }}</span>
|
||||
<span class="sum-iata">{{ f.origin.iataCode }}</span>
|
||||
<span class="sum-city">{{ f.origin.city }}</span>
|
||||
</div>
|
||||
<span class="sum-arrow" aria-hidden="true">→</span>
|
||||
<div class="sum-point">
|
||||
<span class="sum-time tabular">{{ f.arrivalTime | date: 'HH:mm' }}</span>
|
||||
<span class="sum-iata">{{ f.destination.iataCode }}</span>
|
||||
<span class="sum-city">{{ f.destination.city }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="sum-list">
|
||||
<div><dt>Date</dt><dd>{{ f.departureTime | date: 'EEE d MMM y' }}</dd></div>
|
||||
<div><dt>Duration</dt><dd>{{ f.durationMinutes | duration }}</dd></div>
|
||||
<div><dt>Cabin</dt><dd class="cap">{{ cabinLabel(f.cabinClass) }}</dd></div>
|
||||
<div><dt>Seats left</dt><dd>{{ f.seatsAvailable }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card-pad fare-total">
|
||||
<div class="fare-row muted">
|
||||
<span>
|
||||
{{ f.basePrice | currency: f.currency : 'symbol' : '1.2-2' }} ×
|
||||
{{ passengerCount() }}
|
||||
{{ passengerCount() === 1 ? 'traveller' : 'travellers' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fare-row total">
|
||||
<span>Total</span>
|
||||
<strong class="tabular">{{ total() | currency: f.currency : 'symbol' : '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
@if (seatsShort()) {
|
||||
<div class="alert alert-error" style="margin-top: 12px">
|
||||
Only {{ f.seatsAvailable }} seat(s) remain. Remove travellers to continue.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
.checkout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* --- traveller fieldsets ------------------------------------------------- */
|
||||
|
||||
.pax {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px 18px;
|
||||
margin: 0;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.pax legend {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--rose-600);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pax-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
align-self: flex-start;
|
||||
padding: 12px 26px;
|
||||
}
|
||||
|
||||
/* --- summary ------------------------------------------------------------- */
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.sum-carrier {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sum-carrier > span:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.carrier-code {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--navy-900);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sum-airline {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sum-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sum-route {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sum-point {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sum-time {
|
||||
font-size: 1.16rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sum-iata {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sum-city {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.sum-arrow {
|
||||
color: var(--brand-500);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sum-list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sum-list > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.sum-list dt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sum-list dd {
|
||||
margin: 0;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.cap {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.fare-total {
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
|
||||
.fare-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fare-row.total {
|
||||
margin-top: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.fare-row.total strong {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* --- narrow screens ------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.checkout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: static;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import { CheckoutPage } from './checkout';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { Booking, CreateBookingRequest, Flight } from '../../core/models';
|
||||
|
||||
function flight(overrides: Partial<Flight> = {}): Flight {
|
||||
return {
|
||||
id: 'f-1',
|
||||
flightNumber: 'AA100',
|
||||
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
|
||||
origin: {
|
||||
id: 'a-1', iataCode: 'JFK', name: 'JFK', city: 'New York', country: 'US', timezone: 'UTC',
|
||||
},
|
||||
destination: {
|
||||
id: 'a-2', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'UK', timezone: 'UTC',
|
||||
},
|
||||
departureTime: '2026-09-19T18:00:00Z',
|
||||
arrivalTime: '2026-09-20T01:00:00Z',
|
||||
durationMinutes: 420,
|
||||
aircraft: 'Boeing 787-9',
|
||||
cabinClass: 'economy',
|
||||
basePrice: 200,
|
||||
currency: 'USD',
|
||||
seatsTotal: 180,
|
||||
seatsAvailable: 165,
|
||||
status: 'scheduled',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeApi {
|
||||
flight: Observable<Flight> = of(flight());
|
||||
created: Observable<Booking> = of({ id: 'b-1' } as Booking);
|
||||
sent: CreateBookingRequest[] = [];
|
||||
|
||||
getFlight = () => this.flight;
|
||||
createBooking = (req: CreateBookingRequest) => {
|
||||
this.sent.push(req);
|
||||
return this.created;
|
||||
};
|
||||
}
|
||||
|
||||
describe('CheckoutPage', () => {
|
||||
let fixture: ComponentFixture<CheckoutPage>;
|
||||
let page: CheckoutPage;
|
||||
let api: FakeApi;
|
||||
let navigations: Array<{ commands: unknown[]; extras?: unknown }>;
|
||||
|
||||
/** Builds the component with the given ?passengers= carried from search. */
|
||||
function create(requestedPassengers = '1'): void {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ApiService, useValue: api },
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: { user: () => ({ email: 'agent@skyline.test' }) },
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { queryParamMap: convertToParamMap({ passengers: requestedPassengers }) },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
router.navigate = ((commands: unknown[], extras?: unknown) => {
|
||||
navigations.push({ commands, extras });
|
||||
return Promise.resolve(true);
|
||||
}) as Router['navigate'];
|
||||
|
||||
fixture = TestBed.createComponent(CheckoutPage);
|
||||
fixture.componentRef.setInput('flightId', 'f-1');
|
||||
page = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api = new FakeApi();
|
||||
navigations = [];
|
||||
});
|
||||
|
||||
it('loads the flight and prefills the agent as the contact', () => {
|
||||
create();
|
||||
|
||||
expect(page.flight()?.flightNumber).toBe('AA100');
|
||||
expect(page.loading()).toBe(false);
|
||||
expect(page.form.controls.contactEmail.value).toBe('agent@skyline.test');
|
||||
});
|
||||
|
||||
it('opens with one traveller form by default', () => {
|
||||
create();
|
||||
|
||||
expect(page.passengers.length).toBe(1);
|
||||
expect(page.passengerCount()).toBe(1);
|
||||
expect(page.total()).toBe(200);
|
||||
});
|
||||
|
||||
it('honours the traveller count carried over from search', () => {
|
||||
create('3');
|
||||
|
||||
expect(page.passengers.length).toBe(3);
|
||||
expect(page.total()).toBe(600);
|
||||
});
|
||||
|
||||
it('clamps a silly traveller count to what the cabin allows', () => {
|
||||
create('99');
|
||||
|
||||
expect(page.passengers.length).toBe(9);
|
||||
});
|
||||
|
||||
it('never asks for more travellers than there are seats', () => {
|
||||
api.flight = of(flight({ seatsAvailable: 2 }));
|
||||
create('6');
|
||||
|
||||
expect(page.passengers.length).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores a non-numeric traveller count', () => {
|
||||
create('lots');
|
||||
|
||||
expect(page.passengers.length).toBe(1);
|
||||
});
|
||||
|
||||
it('adds and removes travellers, repricing as it goes', () => {
|
||||
create();
|
||||
|
||||
page.addPassenger();
|
||||
expect(page.passengerCount()).toBe(2);
|
||||
expect(page.total()).toBe(400);
|
||||
|
||||
page.addPassenger();
|
||||
expect(page.total()).toBe(600);
|
||||
|
||||
page.removePassenger(0);
|
||||
expect(page.passengerCount()).toBe(2);
|
||||
expect(page.total()).toBe(400);
|
||||
});
|
||||
|
||||
it('will not go below one traveller or above nine', () => {
|
||||
create();
|
||||
|
||||
page.removePassenger(0);
|
||||
expect(page.passengers.length).toBe(1);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
page.addPassenger();
|
||||
}
|
||||
expect(page.passengers.length).toBe(9);
|
||||
});
|
||||
|
||||
it('warns when the party outgrows the remaining seats', () => {
|
||||
api.flight = of(flight({ seatsAvailable: 2 }));
|
||||
create('2');
|
||||
|
||||
expect(page.seatsShort()).toBe(false);
|
||||
|
||||
page.addPassenger();
|
||||
expect(page.seatsShort()).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks submission until every required field is filled', async () => {
|
||||
create();
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(api.sent).toEqual([]);
|
||||
expect(page.error()).toContain('complete every required field');
|
||||
expect(page.showError(0, 'firstName')).toBe(true);
|
||||
});
|
||||
|
||||
it('submits the booking and moves to the confirmation', async () => {
|
||||
create();
|
||||
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(api.sent.length).toBe(1);
|
||||
expect(api.sent[0].flightId).toBe('f-1');
|
||||
expect(api.sent[0].passengers).toEqual([
|
||||
{ firstName: 'Dana', lastName: 'Whitfield', dateOfBirth: '', passportNumber: '' },
|
||||
]);
|
||||
expect(navigations).toEqual([
|
||||
{ commands: ['/bookings', 'b-1'], extras: { queryParams: { confirmed: 1 } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('explains a seat sold out from under the form', async () => {
|
||||
api.created = throwError(() => new HttpErrorResponse({ status: 409 }));
|
||||
create();
|
||||
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(page.error()).toContain('just sold');
|
||||
expect(page.submitting()).toBe(false);
|
||||
expect(navigations).toEqual([]);
|
||||
});
|
||||
|
||||
it('explains a flight that disappeared', async () => {
|
||||
api.created = throwError(() => new HttpErrorResponse({ status: 404 }));
|
||||
create();
|
||||
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(page.error()).toContain('no longer available');
|
||||
});
|
||||
|
||||
it('relays the server`s own validation message', async () => {
|
||||
api.created = throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 400,
|
||||
error: { error: 'contactEmail is not a valid email address' },
|
||||
}),
|
||||
);
|
||||
create();
|
||||
page.passengers.at(0).patchValue({ firstName: 'Dana', lastName: 'Whitfield' });
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(page.error()).toBe('contactEmail is not a valid email address');
|
||||
});
|
||||
|
||||
it('shows a not-found state when the flight cannot be loaded', () => {
|
||||
api.flight = throwError(() => new HttpErrorResponse({ status: 404 }));
|
||||
create();
|
||||
|
||||
expect(page.loadFailed()).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain('Flight not available');
|
||||
});
|
||||
|
||||
it('renders cabin codes as words', () => {
|
||||
create();
|
||||
|
||||
expect(page.cabinLabel('premium_economy')).toBe('premium economy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||
import { CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { Flight, NewPassenger } from '../../core/models';
|
||||
import { DurationPipe } from '../../shared/duration.pipe';
|
||||
|
||||
/**
|
||||
* Passenger details and confirmation for one flight.
|
||||
*
|
||||
* The fare summary re-reads seat availability from the flight record, and the
|
||||
* server re-checks it under a row lock at submit time, so a seat sold while
|
||||
* this form was open surfaces as a clear 409 rather than an oversell.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-checkout',
|
||||
imports: [ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe, DurationPipe],
|
||||
templateUrl: './checkout.html',
|
||||
styleUrl: './checkout.scss',
|
||||
})
|
||||
export class CheckoutPage {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
/** Bound from the :flightId route parameter. */
|
||||
readonly flightId = input.required<string>();
|
||||
|
||||
readonly flight = signal<Flight | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly submitting = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly loadFailed = signal(false);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
contactEmail: ['', [Validators.required, Validators.email]],
|
||||
contactPhone: [''],
|
||||
passengers: this.fb.array([this.passengerGroup()]),
|
||||
});
|
||||
|
||||
readonly passengerCount = signal(1);
|
||||
|
||||
readonly total = computed(() => {
|
||||
const flight = this.flight();
|
||||
return flight === null ? 0 : flight.basePrice * this.passengerCount();
|
||||
});
|
||||
|
||||
readonly seatsShort = computed(() => {
|
||||
const flight = this.flight();
|
||||
return flight !== null && flight.seatsAvailable < this.passengerCount();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Prefill the contact address with the signed-in agent's, which is what
|
||||
// they want for the majority of walk-up bookings.
|
||||
const email = this.auth.user()?.email;
|
||||
if (email) {
|
||||
this.form.controls.contactEmail.setValue(email);
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const id = this.flightId();
|
||||
this.load(id);
|
||||
});
|
||||
}
|
||||
|
||||
get passengers(): FormArray {
|
||||
return this.form.controls.passengers;
|
||||
}
|
||||
|
||||
addPassenger(): void {
|
||||
if (this.passengers.length < 9) {
|
||||
this.passengers.push(this.passengerGroup());
|
||||
this.passengerCount.set(this.passengers.length);
|
||||
}
|
||||
}
|
||||
|
||||
removePassenger(index: number): void {
|
||||
if (this.passengers.length > 1) {
|
||||
this.passengers.removeAt(index);
|
||||
this.passengerCount.set(this.passengers.length);
|
||||
}
|
||||
}
|
||||
|
||||
cabinLabel(cabin: string): string {
|
||||
return cabin.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** True once a passenger field has been touched and is failing validation. */
|
||||
showError(index: number, field: string): boolean {
|
||||
const control = this.passengers.at(index).get(field);
|
||||
return control !== null && control.touched && control.invalid;
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const flight = this.flight();
|
||||
if (flight === null || this.submitting()) {
|
||||
return;
|
||||
}
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
this.error.set('Please complete every required field before confirming.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const raw = this.form.getRawValue();
|
||||
const passengers = raw.passengers as NewPassenger[];
|
||||
|
||||
this.api
|
||||
.createBooking({
|
||||
flightId: flight.id,
|
||||
contactEmail: raw.contactEmail,
|
||||
contactPhone: raw.contactPhone,
|
||||
passengers,
|
||||
})
|
||||
.subscribe({
|
||||
next: (booking) => {
|
||||
this.submitting.set(false);
|
||||
void this.router.navigate(['/bookings', booking.id], {
|
||||
queryParams: { confirmed: 1 },
|
||||
});
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.submitting.set(false);
|
||||
this.error.set(describe(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private load(id: string): void {
|
||||
this.loading.set(true);
|
||||
this.loadFailed.set(false);
|
||||
|
||||
this.api.getFlight(id).subscribe({
|
||||
next: (flight) => {
|
||||
this.flight.set(flight);
|
||||
this.loading.set(false);
|
||||
this.syncPassengersToQuery(flight);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loadFailed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Honours the traveller count carried over from the search screen. */
|
||||
private syncPassengersToQuery(flight: Flight): void {
|
||||
const requested = Number(this.route.snapshot.queryParamMap.get('passengers') ?? '1');
|
||||
const wanted = Math.min(
|
||||
Math.max(Number.isFinite(requested) ? requested : 1, 1),
|
||||
Math.min(9, Math.max(flight.seatsAvailable, 1)),
|
||||
);
|
||||
|
||||
while (this.passengers.length < wanted) {
|
||||
this.passengers.push(this.passengerGroup());
|
||||
}
|
||||
while (this.passengers.length > wanted) {
|
||||
this.passengers.removeAt(this.passengers.length - 1);
|
||||
}
|
||||
this.passengerCount.set(this.passengers.length);
|
||||
}
|
||||
|
||||
private passengerGroup() {
|
||||
return this.fb.nonNullable.group({
|
||||
firstName: ['', Validators.required],
|
||||
lastName: ['', Validators.required],
|
||||
dateOfBirth: [''],
|
||||
passportNumber: [''],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
if (err.status === 409) {
|
||||
return 'Those seats were just sold. Reduce the traveller count or pick another flight.';
|
||||
}
|
||||
if (err.status === 404) {
|
||||
return 'This flight is no longer available.';
|
||||
}
|
||||
if (err.status === 400) {
|
||||
const body = err.error as { error?: string } | null;
|
||||
return body?.error ?? 'Some details were rejected. Please check the form.';
|
||||
}
|
||||
}
|
||||
return 'The booking could not be completed. Please try again.';
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Good day, {{ userFirstName() }}</h1>
|
||||
<p class="subtitle">Trading overview for {{ tenantName() }}.</p>
|
||||
</div>
|
||||
<a routerLink="/search" class="btn btn-primary btn-sm">+ New booking</a>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="tiles">
|
||||
@for (n of [1, 2, 3, 4]; track n) {
|
||||
<div class="card card-pad"><div class="skeleton" style="height: 62px"></div></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (stats(); as s) {
|
||||
<!-- Headline figures -->
|
||||
<div class="tiles">
|
||||
<article class="card card-pad tile">
|
||||
<span class="tile-label">Revenue</span>
|
||||
<span class="tile-value tabular">
|
||||
{{ s.totalRevenue | currency: s.currency : 'symbol' : '1.0-0' }}
|
||||
</span>
|
||||
<span class="tile-meta">from {{ s.confirmedBookings }} confirmed bookings</span>
|
||||
</article>
|
||||
|
||||
<article class="card card-pad tile">
|
||||
<span class="tile-label">Bookings</span>
|
||||
<span class="tile-value tabular">{{ s.totalBookings }}</span>
|
||||
<span class="tile-meta">
|
||||
<span class="badge badge-confirmed">{{ s.confirmedBookings }} confirmed</span>
|
||||
@if (s.cancelledBookings > 0) {
|
||||
<span class="badge badge-cancelled">{{ s.cancelledBookings }} cancelled</span>
|
||||
}
|
||||
</span>
|
||||
</article>
|
||||
|
||||
<article class="card card-pad tile">
|
||||
<span class="tile-label">Seats sold</span>
|
||||
<span class="tile-value tabular">{{ s.seatsSold }}</span>
|
||||
<span class="tile-meta">across all confirmed reservations</span>
|
||||
</article>
|
||||
|
||||
<article class="card card-pad tile">
|
||||
<span class="tile-label">Flights in stock</span>
|
||||
<span class="tile-value tabular">{{ s.flightsInStock }}</span>
|
||||
<span class="tile-meta">future departures in your inventory</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panels">
|
||||
<!-- Recent bookings -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Recent bookings</h2>
|
||||
<a routerLink="/bookings">View all</a>
|
||||
</div>
|
||||
|
||||
@if (s.recentBookings.length === 0) {
|
||||
<div class="empty">
|
||||
<h3>No bookings yet</h3>
|
||||
<p>Reservations you sell will appear here.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Route</th>
|
||||
<th>Travellers</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (booking of s.recentBookings; track booking.id) {
|
||||
<tr [routerLink]="['/bookings', booking.id]" class="row-link">
|
||||
<td><span class="pnr">{{ booking.bookingReference }}</span></td>
|
||||
<td>
|
||||
{{ booking.flight.origin.iataCode }} →
|
||||
{{ booking.flight.destination.iataCode }}
|
||||
</td>
|
||||
<td class="tabular">{{ booking.passengerCount }}</td>
|
||||
<td class="tabular">
|
||||
{{ booking.totalAmount | currency: booking.currency : 'symbol' : '1.2-2' }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" [class]="'badge-' + booking.status">
|
||||
{{ booking.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">{{ booking.createdAt | date: 'd MMM, HH:mm' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Top routes -->
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Top routes</h2></div>
|
||||
|
||||
@if (topRoutes().length === 0) {
|
||||
<div class="empty">
|
||||
<h3>Nothing sold yet</h3>
|
||||
<p>Your best-selling routes will rank here.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card-pad routes">
|
||||
@for (route of topRoutes(); track route.route) {
|
||||
<div class="route-row">
|
||||
<div class="route-head">
|
||||
<span class="route-name">{{ route.route }}</span>
|
||||
<span class="route-revenue tabular">
|
||||
{{ route.revenue | currency: s.currency : 'symbol' : '1.0-0' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="route-bar" aria-hidden="true">
|
||||
<span [style.width]="barWidth(route)"></span>
|
||||
</div>
|
||||
<span class="route-meta">
|
||||
{{ route.bookings }} {{ route.bookings === 1 ? 'booking' : 'bookings' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.tile-value {
|
||||
font-size: 1.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
font-size: 0.79rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* --- panels -------------------------------------------------------------- */
|
||||
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pnr {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.row-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- top routes ---------------------------------------------------------- */
|
||||
|
||||
.routes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 17px;
|
||||
}
|
||||
|
||||
.route-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.route-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.route-revenue {
|
||||
font-weight: 650;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.route-bar {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-sunken);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.route-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--brand-500), var(--teal-600));
|
||||
}
|
||||
|
||||
.route-meta {
|
||||
font-size: 0.77rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- narrow screens ------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
/* minmax(0, 1fr) rather than 1fr: a bare 1fr track refuses to shrink below
|
||||
its content's min-content width, which lets the bookings table push the
|
||||
whole card past the viewport instead of scrolling inside .table-wrap. */
|
||||
.panels {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import { DashboardPage } from './dashboard';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { DashboardStats, Tenant, User } from '../../core/models';
|
||||
|
||||
const tenant: Tenant = {
|
||||
id: 't-1',
|
||||
slug: 'skyline',
|
||||
name: 'Skyline Travel',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const user: User = {
|
||||
id: 'u-1',
|
||||
tenantId: 't-1',
|
||||
email: 'agent@skyline.test',
|
||||
fullName: 'Marcus Reid',
|
||||
role: 'agent',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
function stats(overrides: Partial<DashboardStats> = {}): DashboardStats {
|
||||
return {
|
||||
totalBookings: 6,
|
||||
confirmedBookings: 5,
|
||||
cancelledBookings: 1,
|
||||
totalRevenue: 7263.16,
|
||||
currency: 'USD',
|
||||
flightsInStock: 294,
|
||||
seatsSold: 12,
|
||||
recentBookings: [],
|
||||
topRoutes: [
|
||||
{ route: 'JFK to LHR', bookings: 3, revenue: 4000 },
|
||||
{ route: 'LAX to DXB', bookings: 1, revenue: 1000 },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeApi {
|
||||
result: Observable<DashboardStats> = of(stats());
|
||||
dashboard = () => this.result;
|
||||
}
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
let fixture: ComponentFixture<DashboardPage>;
|
||||
let page: DashboardPage;
|
||||
let api: FakeApi;
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(DashboardPage);
|
||||
page = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
api = new FakeApi();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ApiService, useValue: api },
|
||||
{ provide: AuthService, useValue: { tenant: () => tenant, user: () => user } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('greets the user by first name and names the agency', () => {
|
||||
create();
|
||||
|
||||
expect(page.userFirstName()).toBe('Marcus');
|
||||
expect(page.tenantName()).toBe('Skyline Travel');
|
||||
expect(fixture.nativeElement.textContent).toContain('Good day, Marcus');
|
||||
expect(fixture.nativeElement.textContent).toContain('Skyline Travel');
|
||||
});
|
||||
|
||||
it('renders the headline figures', () => {
|
||||
create();
|
||||
|
||||
const text = fixture.nativeElement.textContent;
|
||||
expect(text).toContain('294');
|
||||
expect(text).toContain('12');
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('scales the route bars against the biggest earner', () => {
|
||||
create();
|
||||
|
||||
expect(page.maxRouteRevenue()).toBe(4000);
|
||||
expect(page.barWidth({ route: 'JFK to LHR', bookings: 3, revenue: 4000 })).toBe('100%');
|
||||
expect(page.barWidth({ route: 'LAX to DXB', bookings: 1, revenue: 1000 })).toBe('25%');
|
||||
});
|
||||
|
||||
it('keeps a tiny route visible rather than drawing nothing', () => {
|
||||
create();
|
||||
|
||||
// 1 of 4000 rounds to 0%, which would render an invisible bar.
|
||||
expect(page.barWidth({ route: 'TINY', bookings: 1, revenue: 1 })).toBe('4%');
|
||||
});
|
||||
|
||||
it('copes with no sales at all', () => {
|
||||
api.result = of(stats({ topRoutes: [], totalRevenue: 0, recentBookings: [] }));
|
||||
create();
|
||||
|
||||
expect(page.maxRouteRevenue()).toBe(0);
|
||||
expect(page.barWidth({ route: 'NONE', bookings: 0, revenue: 0 })).toBe('0%');
|
||||
expect(fixture.nativeElement.textContent).toContain('Nothing sold yet');
|
||||
});
|
||||
|
||||
// Go marshals an empty slice as null, which would break a bare .length.
|
||||
it('treats a null topRoutes as an empty list', () => {
|
||||
api.result = of(stats({ topRoutes: null }));
|
||||
create();
|
||||
|
||||
expect(page.topRoutes()).toEqual([]);
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
});
|
||||
|
||||
it('surfaces a failure to load', () => {
|
||||
api.result = throwError(() => new Error('boom'));
|
||||
create();
|
||||
|
||||
expect(page.error()).toContain('Could not load dashboard');
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { DashboardStats, TopRoute } from '../../core/models';
|
||||
|
||||
/** Agency overview: trading totals, best routes, latest reservations. */
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [RouterLink, CurrencyPipe, DatePipe],
|
||||
templateUrl: './dashboard.html',
|
||||
styleUrl: './dashboard.scss',
|
||||
})
|
||||
export class DashboardPage {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
readonly stats = signal<DashboardStats | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly tenantName = computed(() => this.auth.tenant()?.name ?? '');
|
||||
readonly userFirstName = computed(() => this.auth.user()?.fullName.split(' ')[0] ?? '');
|
||||
|
||||
/** topRoutes is null rather than [] when Go marshals an unset slice. */
|
||||
readonly topRoutes = computed<TopRoute[]>(() => this.stats()?.topRoutes ?? []);
|
||||
|
||||
/** Widest revenue figure, used to scale the bars in the routes panel. */
|
||||
readonly maxRouteRevenue = computed(() =>
|
||||
this.topRoutes().reduce((max, route) => Math.max(max, route.revenue), 0),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.api.dashboard().subscribe({
|
||||
next: (stats) => {
|
||||
this.stats.set(stats);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Could not load dashboard figures. Please try again.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
barWidth(route: TopRoute): string {
|
||||
const max = this.maxRouteRevenue();
|
||||
if (max <= 0) {
|
||||
return '0%';
|
||||
}
|
||||
// Floor at 4% so a tiny-but-real route is still visible as a bar.
|
||||
return `${Math.max(4, Math.round((route.revenue / max) * 100))}%`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<div class="auth">
|
||||
<!-- Left: brand panel. Purely decorative, hidden on narrow screens. -->
|
||||
<section class="hero" aria-hidden="true">
|
||||
<div class="hero-inner">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">✈</span>
|
||||
<span class="brand-name">TravelHub</span>
|
||||
</div>
|
||||
<h1>One platform.<br />Every agency.</h1>
|
||||
<p>
|
||||
Search live fares, hold seats and manage reservations — with each agency's inventory,
|
||||
customers and revenue kept strictly its own.
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>Per-tenant fares and availability</li>
|
||||
<li>Booking and cancellation with instant seat accounting</li>
|
||||
<li>Revenue and route reporting per agency</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right: the actual sign-in form. -->
|
||||
<section class="panel">
|
||||
<div class="panel-inner">
|
||||
<header class="panel-head">
|
||||
<h2>Sign in</h2>
|
||||
<p class="muted">Choose your agency, then enter your credentials.</p>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="stack" novalidate>
|
||||
<div class="field">
|
||||
<label for="tenant">Agency</label>
|
||||
<select id="tenant" formControlName="tenant">
|
||||
<option value="" disabled>Select an agency…</option>
|
||||
@for (tenant of tenants(); track tenant.slug) {
|
||||
<option [value]="tenant.slug">{{ tenant.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (form.controls.tenant.touched && form.controls.tenant.invalid) {
|
||||
<span class="error">Please choose an agency.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" formControlName="email" autocomplete="username" />
|
||||
@if (form.controls.email.touched && form.controls.email.invalid) {
|
||||
<span class="error">Enter a valid email address.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
formControlName="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
@if (form.controls.password.touched && form.controls.password.invalid) {
|
||||
<span class="error">Enter your password.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" [disabled]="submitting()">
|
||||
{{ submitting() ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="demo">
|
||||
<h3>Demo accounts</h3>
|
||||
<p class="muted">
|
||||
Password <code>demo1234</code>. Sign in to each agency in turn and search JFK → LHR: the
|
||||
same routes are priced differently per tenant.
|
||||
</p>
|
||||
<div class="demo-grid">
|
||||
<button type="button" class="demo-chip" (click)="useDemo('skyline', 'admin@skyline.test')">
|
||||
<span class="demo-agency">Skyline Travel</span>
|
||||
<span class="demo-email">admin@skyline.test</span>
|
||||
<span class="badge badge-brand">admin</span>
|
||||
</button>
|
||||
<button type="button" class="demo-chip" (click)="useDemo('skyline', 'agent@skyline.test')">
|
||||
<span class="demo-agency">Skyline Travel</span>
|
||||
<span class="demo-email">agent@skyline.test</span>
|
||||
<span class="badge badge-neutral">agent</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="demo-chip"
|
||||
(click)="useDemo('globetrotter', 'admin@globetrotter.test')"
|
||||
>
|
||||
<span class="demo-agency">Globetrotter Tours</span>
|
||||
<span class="demo-email">admin@globetrotter.test</span>
|
||||
<span class="badge badge-brand">admin</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="demo-chip"
|
||||
(click)="useDemo('globetrotter', 'agent@globetrotter.test')"
|
||||
>
|
||||
<span class="demo-agency">Globetrotter Tours</span>
|
||||
<span class="demo-email">agent@globetrotter.test</span>
|
||||
<span class="badge badge-neutral">agent</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,237 @@
|
||||
.auth {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* --- brand panel --------------------------------------------------------- */
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 56px;
|
||||
color: #e8f0fb;
|
||||
background:
|
||||
radial-gradient(1100px 500px at 12% 18%, rgba(20, 112, 240, 0.5), transparent 62%),
|
||||
radial-gradient(900px 460px at 88% 88%, rgba(0, 137, 123, 0.42), transparent 60%),
|
||||
linear-gradient(150deg, #0b1727 0%, #13263d 58%, #0d2036 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* A faint great-circle arc, the one bit of travel iconography on the page. */
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto -18% -46% -18%;
|
||||
height: 70%;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.14);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
max-width: 470px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand-500);
|
||||
color: #fff;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.18rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.7rem;
|
||||
line-height: 1.12;
|
||||
color: #fff;
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.6;
|
||||
color: #b9cbe2;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.hero-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.hero-points li {
|
||||
position: relative;
|
||||
padding-left: 27px;
|
||||
color: #cfdcec;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.hero-points li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 3px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 179, 164, 0.22);
|
||||
color: #5fdfd0;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- form panel ---------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 44px 32px;
|
||||
background: var(--surface);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
padding: 12px 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* --- demo accounts ------------------------------------------------------- */
|
||||
|
||||
.demo {
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.demo h3 {
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.demo > p {
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.demo code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-chip {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 2px 10px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.demo-chip:hover {
|
||||
border-color: var(--brand-500);
|
||||
background: var(--brand-50);
|
||||
}
|
||||
|
||||
.demo-agency {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-chip .badge {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.demo-email {
|
||||
grid-column: 1;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
}
|
||||
|
||||
/* --- narrow screens ------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.auth {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 32px 18px;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
padding: 26px 22px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import { LoginPage } from './login';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { PublicTenant, Session } from '../../core/models';
|
||||
|
||||
class FakeAuth {
|
||||
tenants: Observable<PublicTenant[]> = of([
|
||||
{ slug: 'skyline', name: 'Skyline Travel' },
|
||||
{ slug: 'globetrotter', name: 'Globetrotter Tours' },
|
||||
]);
|
||||
signInResult: Promise<Session> = Promise.resolve({} as Session);
|
||||
attempted: Array<[string, string, string]> = [];
|
||||
|
||||
listTenants = () => this.tenants;
|
||||
signIn = (tenant: string, email: string, password: string) => {
|
||||
this.attempted.push([tenant, email, password]);
|
||||
return this.signInResult;
|
||||
};
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
let fixture: ComponentFixture<LoginPage>;
|
||||
let page: LoginPage;
|
||||
let auth: FakeAuth;
|
||||
let navigatedTo: string[];
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(LoginPage);
|
||||
page = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
auth = new FakeAuth();
|
||||
navigatedTo = [];
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: AuthService, useValue: auth }],
|
||||
});
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
router.navigateByUrl = ((url: string) => {
|
||||
navigatedTo.push(String(url));
|
||||
return Promise.resolve(true);
|
||||
}) as Router['navigateByUrl'];
|
||||
});
|
||||
|
||||
it('loads the agency list on open', () => {
|
||||
create();
|
||||
|
||||
expect(page.tenants().length).toBe(2);
|
||||
expect(page.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('preselects the only agency in a single-tenant deployment', () => {
|
||||
auth.tenants = of([{ slug: 'solo', name: 'Solo Travel' }]);
|
||||
create();
|
||||
|
||||
expect(page.form.controls.tenant.value).toBe('solo');
|
||||
});
|
||||
|
||||
it('does not preselect when there is a real choice to make', () => {
|
||||
create();
|
||||
|
||||
expect(page.form.controls.tenant.value).toBe('');
|
||||
});
|
||||
|
||||
it('explains a backend that is not running', () => {
|
||||
auth.tenants = throwError(() => new HttpErrorResponse({ status: 0 }));
|
||||
create();
|
||||
|
||||
expect(page.error()).toContain('Go backend');
|
||||
});
|
||||
|
||||
it('fills the form from a demo chip', () => {
|
||||
create();
|
||||
|
||||
page.useDemo('skyline', 'agent@skyline.test');
|
||||
|
||||
expect(page.form.getRawValue()).toEqual({
|
||||
tenant: 'skyline',
|
||||
email: 'agent@skyline.test',
|
||||
password: 'demo1234',
|
||||
});
|
||||
expect(page.form.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to submit an incomplete form', async () => {
|
||||
create();
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(auth.attempted).toEqual([]);
|
||||
expect(page.form.controls.tenant.touched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a malformed email before calling the server', async () => {
|
||||
create();
|
||||
page.form.setValue({ tenant: 'skyline', email: 'not-an-email', password: 'demo1234' });
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(auth.attempted).toEqual([]);
|
||||
});
|
||||
|
||||
it('signs in and moves to the dashboard', async () => {
|
||||
create();
|
||||
page.useDemo('skyline', 'agent@skyline.test');
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(auth.attempted).toEqual([['skyline', 'agent@skyline.test', 'demo1234']]);
|
||||
expect(navigatedTo).toEqual(['/dashboard']);
|
||||
expect(page.submitting()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the user to the page they were trying to reach', async () => {
|
||||
// Rebuild the injector with a route carrying ?returnTo=/bookings/abc.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AuthService, useValue: auth },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { queryParamMap: convertToParamMap({ returnTo: '/bookings/abc' }) },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
TestBed.inject(Router).navigateByUrl = ((url: string) => {
|
||||
navigatedTo.push(String(url));
|
||||
return Promise.resolve(true);
|
||||
}) as Router['navigateByUrl'];
|
||||
|
||||
create();
|
||||
page.useDemo('skyline', 'agent@skyline.test');
|
||||
await page.submit();
|
||||
|
||||
expect(navigatedTo).toEqual(['/bookings/abc']);
|
||||
});
|
||||
|
||||
it('reports wrong credentials in the agency`s terms', async () => {
|
||||
auth.signInResult = Promise.reject(new HttpErrorResponse({ status: 401 }));
|
||||
create();
|
||||
page.useDemo('skyline', 'agent@skyline.test');
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(page.error()).toBe('Those credentials are not valid for this agency.');
|
||||
expect(navigatedTo).toEqual([]);
|
||||
expect(page.submitting()).toBe(false);
|
||||
});
|
||||
|
||||
it('reports an unreachable API distinctly from a bad password', async () => {
|
||||
auth.signInResult = Promise.reject(new HttpErrorResponse({ status: 0 }));
|
||||
create();
|
||||
page.useDemo('skyline', 'agent@skyline.test');
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(page.error()).toContain('Go backend');
|
||||
});
|
||||
|
||||
it('falls back to a generic message for unexpected failures', async () => {
|
||||
auth.signInResult = Promise.reject(new HttpErrorResponse({ status: 500 }));
|
||||
create();
|
||||
page.useDemo('skyline', 'agent@skyline.test');
|
||||
|
||||
await page.submit();
|
||||
|
||||
expect(page.error()).toContain('Something went wrong');
|
||||
});
|
||||
|
||||
it('renders one option per agency plus the placeholder', () => {
|
||||
create();
|
||||
|
||||
const options: NodeListOf<HTMLOptionElement> =
|
||||
fixture.nativeElement.querySelectorAll('#tenant option');
|
||||
expect(options.length).toBe(3);
|
||||
expect(options[1].textContent).toContain('Skyline Travel');
|
||||
});
|
||||
|
||||
it('shows four demo account shortcuts', () => {
|
||||
create();
|
||||
|
||||
const chips = fixture.nativeElement.querySelectorAll('.demo-chip');
|
||||
expect(chips.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { PublicTenant } from '../../core/models';
|
||||
|
||||
/**
|
||||
* Sign-in screen.
|
||||
*
|
||||
* Choosing the agency is the first step, not an afterthought: the same email
|
||||
* and password can exist at more than one tenant, and the credentials are only
|
||||
* valid against the tenant they were issued for.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss',
|
||||
})
|
||||
export class LoginPage {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
readonly tenants = signal<PublicTenant[]>([]);
|
||||
readonly submitting = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
tenant: ['', Validators.required],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.auth.listTenants().subscribe({
|
||||
next: (tenants) => {
|
||||
this.tenants.set(tenants);
|
||||
// Preselect when there is only one agency, so the common single-tenant
|
||||
// deployment does not make people pick from a list of one.
|
||||
if (tenants.length === 1) {
|
||||
this.form.controls.tenant.setValue(tenants[0].slug);
|
||||
}
|
||||
},
|
||||
error: () =>
|
||||
this.error.set('Could not reach the API. Is the Go backend running on port 8080?'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fills the form from the demo table so the sample is one click to try. */
|
||||
useDemo(tenant: string, email: string): void {
|
||||
this.form.setValue({ tenant, email, password: 'demo1234' });
|
||||
this.error.set(null);
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
if (this.form.invalid || this.submitting()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const { tenant, email, password } = this.form.getRawValue();
|
||||
|
||||
try {
|
||||
await this.auth.signIn(tenant, email, password);
|
||||
const returnTo = this.route.snapshot.queryParamMap.get('returnTo');
|
||||
await this.router.navigateByUrl(returnTo ?? '/dashboard');
|
||||
} catch (err: unknown) {
|
||||
this.error.set(describe(err));
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
if (err.status === 401) {
|
||||
return 'Those credentials are not valid for this agency.';
|
||||
}
|
||||
if (err.status === 0) {
|
||||
return 'Could not reach the API. Is the Go backend running on port 8080?';
|
||||
}
|
||||
}
|
||||
return 'Something went wrong signing in. Please try again.';
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Search flights</h1>
|
||||
<p class="subtitle">Live fares from your agency's inventory.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter panel ---------------------------------------------------------- -->
|
||||
<form [formGroup]="form" (ngSubmit)="search()" class="card search-panel" novalidate>
|
||||
<div class="field">
|
||||
<label for="origin">From</label>
|
||||
<select id="origin" formControlName="origin">
|
||||
<option value="">Any airport</option>
|
||||
@for (airport of airports(); track airport.id) {
|
||||
<option [value]="airport.iataCode">{{ airport.city }} ({{ airport.iataCode }})</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="destination">To</label>
|
||||
<select id="destination" formControlName="destination">
|
||||
<option value="">Any airport</option>
|
||||
@for (airport of airports(); track airport.id) {
|
||||
<option [value]="airport.iataCode">{{ airport.city }} ({{ airport.iataCode }})</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="date">Departing</label>
|
||||
<input id="date" type="date" formControlName="date" [min]="today" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="cabin">Cabin</label>
|
||||
<select id="cabin" formControlName="cabin">
|
||||
<option value="">Any cabin</option>
|
||||
<option value="economy">Economy</option>
|
||||
<option value="premium_economy">Premium economy</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="first">First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field field-narrow">
|
||||
<label for="passengers">Travellers</label>
|
||||
<select id="passengers" formControlName="passengers">
|
||||
@for (n of [1, 2, 3, 4, 5, 6, 7, 8, 9]; track n) {
|
||||
<option [value]="n">{{ n }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<button type="button" class="btn btn-ghost" (click)="reset()">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert alert-error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<!-- Result count and sort ------------------------------------------------- -->
|
||||
<div class="results-bar">
|
||||
<span class="muted">
|
||||
@if (loading()) {
|
||||
Searching…
|
||||
} @else if (total() === 0) {
|
||||
No flights match these filters
|
||||
} @else {
|
||||
Showing {{ showingFrom() }}–{{ showingTo() }} of {{ total() }} flights
|
||||
}
|
||||
</span>
|
||||
|
||||
<label class="sort">
|
||||
<span class="muted">Sort by</span>
|
||||
<select [formControl]="form.controls.sort" (change)="search()">
|
||||
<option value="price">Lowest fare</option>
|
||||
<option value="duration">Shortest trip</option>
|
||||
<option value="departure">Earliest departure</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Results --------------------------------------------------------------- -->
|
||||
@if (loading()) {
|
||||
<div class="stack">
|
||||
@for (n of [1, 2, 3, 4]; track n) {
|
||||
<div class="card skeleton-card"><div class="skeleton" style="height: 92px"></div></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (flights().length === 0) {
|
||||
<div class="card empty">
|
||||
<h3>Nothing on this route yet</h3>
|
||||
<p>Try widening the dates, clearing the cabin filter, or resetting the search.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="stack">
|
||||
@for (flight of flights(); track flight.id) {
|
||||
<article class="card flight">
|
||||
<!-- carrier -->
|
||||
<div class="carrier">
|
||||
<span class="carrier-code">{{ flight.airline.iataCode }}</span>
|
||||
<span class="carrier-text">
|
||||
<span class="carrier-name">{{ flight.airline.name }}</span>
|
||||
<span class="carrier-meta">{{ flight.flightNumber }} · {{ flight.aircraft }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- itinerary -->
|
||||
<div class="itinerary">
|
||||
<div class="endpoint">
|
||||
<span class="time tabular">{{ flight.departureTime | date: 'HH:mm' }}</span>
|
||||
<span class="iata">{{ flight.origin.iataCode }}</span>
|
||||
<span class="city">{{ flight.origin.city }}</span>
|
||||
</div>
|
||||
|
||||
<div class="leg">
|
||||
<span class="leg-duration">{{ flight.durationMinutes | duration }}</span>
|
||||
<span class="leg-line" aria-hidden="true"><i></i></span>
|
||||
<span class="leg-stops">Nonstop</span>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="time tabular">
|
||||
{{ flight.arrivalTime | date: 'HH:mm' }}
|
||||
@if (dayOffset(flight) > 0) {
|
||||
<sup class="next-day">+{{ dayOffset(flight) }}</sup>
|
||||
}
|
||||
</span>
|
||||
<span class="iata">{{ flight.destination.iataCode }}</span>
|
||||
<span class="city">{{ flight.destination.city }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- fare -->
|
||||
<div class="fare">
|
||||
<span class="price tabular">
|
||||
{{ flight.basePrice | currency: flight.currency : 'symbol' : '1.2-2' }}
|
||||
</span>
|
||||
<span class="price-meta">per traveller</span>
|
||||
<div class="fare-tags">
|
||||
<span class="badge badge-neutral">{{ cabinLabel(flight.cabinClass) }}</span>
|
||||
<span class="badge" [class]="seatTone(flight)">{{ flight.seatsAvailable }} left</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" (click)="select(flight)">
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="departure-date muted">{{ flight.departureTime | date: 'EEE d MMM y' }}</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (canPrev() || canNext()) {
|
||||
<nav class="pager" aria-label="Pagination">
|
||||
<button type="button" class="btn btn-ghost btn-sm" [disabled]="!canPrev()" (click)="prevPage()">
|
||||
← Previous
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" [disabled]="!canNext()" (click)="nextPage()">
|
||||
Next →
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/* --- filter panel -------------------------------------------------------- */
|
||||
|
||||
.search-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field-narrow {
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- results bar --------------------------------------------------------- */
|
||||
|
||||
.results-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 2px 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sort select {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sort select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-500);
|
||||
box-shadow: 0 0 0 3px var(--brand-100);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* --- flight card --------------------------------------------------------- */
|
||||
|
||||
/* The date gets its own header row rather than floating over the card, so it
|
||||
can never collide with the fare on the right. */
|
||||
.flight {
|
||||
display: grid;
|
||||
grid-template-columns: 210px 1fr 190px;
|
||||
grid-template-areas:
|
||||
'date date date'
|
||||
'carrier itinerary fare';
|
||||
gap: 8px 20px;
|
||||
align-items: center;
|
||||
padding: 14px 20px 20px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.carrier {
|
||||
grid-area: carrier;
|
||||
}
|
||||
.itinerary {
|
||||
grid-area: itinerary;
|
||||
}
|
||||
.fare {
|
||||
grid-area: fare;
|
||||
}
|
||||
.departure-date {
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.flight:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* carrier ---------------------------------------------------------------- */
|
||||
|
||||
.carrier {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.carrier-code {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--navy-900);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.carrier-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.carrier-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.carrier-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* itinerary --------------------------------------------------------------- */
|
||||
|
||||
.itinerary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(110px, 1.3fr) 1fr;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.endpoint .time {
|
||||
font-size: 1.28rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.next-day {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--brand-600);
|
||||
vertical-align: super;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.endpoint .iata {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.endpoint .city {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.leg-duration {
|
||||
font-size: 0.79rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
/* The route line: a rule with a dot at each end and a plane at the tip. */
|
||||
.leg-line {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.leg-line::before,
|
||||
.leg-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.leg-line::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.leg-line::after {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.leg-line i {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--brand-500);
|
||||
font-size: 0.7rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.leg-line i::before {
|
||||
content: '➤';
|
||||
}
|
||||
|
||||
.leg-stops {
|
||||
font-size: 0.71rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--teal-600);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
/* fare -------------------------------------------------------------------- */
|
||||
|
||||
.fare {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
text-align: right;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 1.42rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.price-meta {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.fare-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
margin: 3px 0 5px;
|
||||
}
|
||||
|
||||
.fare-tags .badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.departure-date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 550;
|
||||
text-align: right;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- pager --------------------------------------------------------------- */
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* --- narrow screens ------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.flight {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'date'
|
||||
'carrier'
|
||||
'itinerary'
|
||||
'fare';
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fare {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
padding-top: 16px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fare-tags {
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.departure-date {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.itinerary {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.leg-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
|
||||
import { SearchPage } from './search';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { Airport, Flight, FlightQuery, Page } from '../../core/models';
|
||||
|
||||
function airport(code: string, city: string): Airport {
|
||||
return { id: `a-${code}`, iataCode: code, name: `${city} Airport`, city, country: 'X', timezone: 'UTC' };
|
||||
}
|
||||
|
||||
function flight(overrides: Partial<Flight> = {}): Flight {
|
||||
return {
|
||||
id: 'f-1',
|
||||
flightNumber: 'AA100',
|
||||
airline: { id: 'al-1', iataCode: 'AA', name: 'American Airlines' },
|
||||
origin: airport('JFK', 'New York'),
|
||||
destination: airport('LHR', 'London'),
|
||||
departureTime: '2026-09-19T18:00:00Z',
|
||||
arrivalTime: '2026-09-20T01:00:00Z',
|
||||
durationMinutes: 420,
|
||||
aircraft: 'Boeing 787-9',
|
||||
cabinClass: 'economy',
|
||||
basePrice: 483.3,
|
||||
currency: 'USD',
|
||||
seatsTotal: 180,
|
||||
seatsAvailable: 165,
|
||||
status: 'scheduled',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeApi {
|
||||
airports: Observable<Airport[]> = of([airport('JFK', 'New York'), airport('LHR', 'London')]);
|
||||
result: Observable<Page<Flight>> = of({ items: [flight()], total: 1, limit: 10, offset: 0 });
|
||||
queries: FlightQuery[] = [];
|
||||
|
||||
listAirports = () => this.airports;
|
||||
searchFlights = (q: FlightQuery) => {
|
||||
this.queries.push(q);
|
||||
return this.result;
|
||||
};
|
||||
}
|
||||
|
||||
describe('SearchPage', () => {
|
||||
let fixture: ComponentFixture<SearchPage>;
|
||||
let page: SearchPage;
|
||||
let api: FakeApi;
|
||||
let navigations: Array<{ commands: unknown[]; extras?: unknown }>;
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(SearchPage);
|
||||
page = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
api = new FakeApi();
|
||||
navigations = [];
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([]), { provide: ApiService, useValue: api }],
|
||||
});
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
router.navigate = ((commands: unknown[], extras?: unknown) => {
|
||||
navigations.push({ commands, extras });
|
||||
return Promise.resolve(true);
|
||||
}) as Router['navigate'];
|
||||
});
|
||||
|
||||
it('searches once on open and fills the airport pickers', () => {
|
||||
create();
|
||||
|
||||
expect(api.queries.length).toBe(1);
|
||||
expect(page.airports().length).toBe(2);
|
||||
expect(page.flights().length).toBe(1);
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('asks for the first page with the default page size', () => {
|
||||
create();
|
||||
|
||||
expect(api.queries[0].limit).toBe(10);
|
||||
expect(api.queries[0].offset).toBe(0);
|
||||
expect(api.queries[0].sort).toBe('price');
|
||||
});
|
||||
|
||||
it('passes the filter panel through to the API', () => {
|
||||
create();
|
||||
page.form.setValue({
|
||||
origin: 'JFK',
|
||||
destination: 'LHR',
|
||||
date: '2026-09-19',
|
||||
cabin: 'business',
|
||||
passengers: 3,
|
||||
sort: 'duration',
|
||||
});
|
||||
|
||||
page.search();
|
||||
|
||||
const last = api.queries[api.queries.length - 1];
|
||||
expect(last.origin).toBe('JFK');
|
||||
expect(last.destination).toBe('LHR');
|
||||
expect(last.date).toBe('2026-09-19');
|
||||
expect(last.cabin).toBe('business');
|
||||
expect(last.passengers).toBe(3);
|
||||
expect(last.sort).toBe('duration');
|
||||
});
|
||||
|
||||
it('returns to page one when the filters change', () => {
|
||||
api.result = of({ items: [flight()], total: 40, limit: 10, offset: 0 });
|
||||
create();
|
||||
|
||||
page.nextPage();
|
||||
expect(page.offset()).toBe(10);
|
||||
|
||||
page.search();
|
||||
expect(page.offset()).toBe(0);
|
||||
});
|
||||
|
||||
it('clears the filters on reset', () => {
|
||||
create();
|
||||
page.form.setValue({
|
||||
origin: 'JFK',
|
||||
destination: 'LHR',
|
||||
date: '2026-09-19',
|
||||
cabin: 'first',
|
||||
passengers: 5,
|
||||
sort: 'departure',
|
||||
});
|
||||
|
||||
page.reset();
|
||||
|
||||
expect(page.form.getRawValue()).toEqual({
|
||||
origin: '',
|
||||
destination: '',
|
||||
date: '',
|
||||
cabin: '',
|
||||
passengers: 1,
|
||||
sort: 'price',
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
beforeEach(() => {
|
||||
api.result = of({ items: [flight()], total: 25, limit: 10, offset: 0 });
|
||||
create();
|
||||
});
|
||||
|
||||
it('describes the visible slice', () => {
|
||||
expect(page.showingFrom()).toBe(1);
|
||||
expect(page.showingTo()).toBe(10);
|
||||
});
|
||||
|
||||
it('moves forward and back', () => {
|
||||
expect(page.canPrev()).toBe(false);
|
||||
expect(page.canNext()).toBe(true);
|
||||
|
||||
page.nextPage();
|
||||
expect(page.offset()).toBe(10);
|
||||
expect(page.showingFrom()).toBe(11);
|
||||
expect(page.canPrev()).toBe(true);
|
||||
|
||||
page.nextPage();
|
||||
expect(page.offset()).toBe(20);
|
||||
// 25 results: the last page is partial and there is no next.
|
||||
expect(page.showingTo()).toBe(25);
|
||||
expect(page.canNext()).toBe(false);
|
||||
|
||||
page.prevPage();
|
||||
expect(page.offset()).toBe(10);
|
||||
});
|
||||
|
||||
it('never steps past the ends', () => {
|
||||
page.prevPage();
|
||||
expect(page.offset()).toBe(0);
|
||||
|
||||
page.nextPage();
|
||||
page.nextPage();
|
||||
page.nextPage();
|
||||
expect(page.offset()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an empty result set rather than an error', () => {
|
||||
api.result = of({ items: [], total: 0, limit: 10, offset: 0 });
|
||||
create();
|
||||
|
||||
expect(page.flights()).toEqual([]);
|
||||
expect(page.showingFrom()).toBe(0);
|
||||
expect(page.error()).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.empty')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a failed search', () => {
|
||||
api.result = throwError(() => new Error('network'));
|
||||
create();
|
||||
|
||||
expect(page.error()).toContain('Could not load flights');
|
||||
expect(page.flights()).toEqual([]);
|
||||
expect(page.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the traveller count into checkout', () => {
|
||||
create();
|
||||
page.form.controls.passengers.setValue(4);
|
||||
|
||||
page.select(flight({ id: 'f-99' }));
|
||||
|
||||
expect(navigations).toEqual([
|
||||
{ commands: ['/book', 'f-99'], extras: { queryParams: { passengers: 4 } } },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('presentation helpers', () => {
|
||||
beforeEach(() => create());
|
||||
|
||||
it('marks an arrival on a later calendar day', () => {
|
||||
// Compare local calendar days, which is what the badge means.
|
||||
const depart = new Date(2026, 8, 19, 22, 0);
|
||||
const arrive = new Date(2026, 8, 20, 6, 0);
|
||||
const overnight = flight({
|
||||
departureTime: depart.toISOString(),
|
||||
arrivalTime: arrive.toISOString(),
|
||||
});
|
||||
expect(page.dayOffset(overnight)).toBe(1);
|
||||
});
|
||||
|
||||
it('shows no offset for a same-day arrival', () => {
|
||||
const depart = new Date(2026, 8, 19, 8, 0);
|
||||
const arrive = new Date(2026, 8, 19, 15, 0);
|
||||
const sameDay = flight({
|
||||
departureTime: depart.toISOString(),
|
||||
arrivalTime: arrive.toISOString(),
|
||||
});
|
||||
expect(page.dayOffset(sameDay)).toBe(0);
|
||||
});
|
||||
|
||||
it('flags scarce inventory as urgent', () => {
|
||||
expect(page.seatTone(flight({ seatsAvailable: 3 }))).toBe('badge-warn');
|
||||
expect(page.seatTone(flight({ seatsAvailable: 5 }))).toBe('badge-warn');
|
||||
expect(page.seatTone(flight({ seatsAvailable: 6 }))).toBe('badge-neutral');
|
||||
});
|
||||
|
||||
it('renders cabin codes as words', () => {
|
||||
expect(page.cabinLabel('premium_economy')).toBe('premium economy');
|
||||
expect(page.cabinLabel('economy')).toBe('economy');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a result card with the fare and route', () => {
|
||||
create();
|
||||
|
||||
const card = fixture.nativeElement.querySelector('.flight');
|
||||
expect(card).toBeTruthy();
|
||||
expect(card.textContent).toContain('AA100');
|
||||
expect(card.textContent).toContain('JFK');
|
||||
expect(card.textContent).toContain('LHR');
|
||||
expect(card.textContent).toContain('165 left');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { Airport, Flight, FlightQuery } from '../../core/models';
|
||||
import { DurationPipe } from '../../shared/duration.pipe';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Flight search: filter panel on top, fare results below. */
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
imports: [ReactiveFormsModule, CurrencyPipe, DatePipe, DurationPipe],
|
||||
templateUrl: './search.html',
|
||||
styleUrl: './search.scss',
|
||||
})
|
||||
export class SearchPage {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
readonly airports = signal<Airport[]>([]);
|
||||
readonly flights = signal<Flight[]>([]);
|
||||
readonly total = signal(0);
|
||||
readonly offset = signal(0);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
/** Today, as YYYY-MM-DD, so the date picker cannot select the past. */
|
||||
readonly today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
origin: [''],
|
||||
destination: [''],
|
||||
date: [''],
|
||||
cabin: [''],
|
||||
passengers: [1],
|
||||
sort: ['price'],
|
||||
});
|
||||
|
||||
readonly showingFrom = computed(() => (this.total() === 0 ? 0 : this.offset() + 1));
|
||||
readonly showingTo = computed(() => Math.min(this.offset() + PAGE_SIZE, this.total()));
|
||||
readonly canPrev = computed(() => this.offset() > 0);
|
||||
readonly canNext = computed(() => this.offset() + PAGE_SIZE < this.total());
|
||||
|
||||
constructor() {
|
||||
this.api.listAirports().subscribe({
|
||||
next: (airports) => this.airports.set(airports),
|
||||
error: () => this.airports.set([]),
|
||||
});
|
||||
this.run();
|
||||
}
|
||||
|
||||
/** Applies the filter panel, returning to the first page of results. */
|
||||
search(): void {
|
||||
this.offset.set(0);
|
||||
this.run();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.form.reset({ origin: '', destination: '', date: '', cabin: '', passengers: 1, sort: 'price' });
|
||||
this.search();
|
||||
}
|
||||
|
||||
prevPage(): void {
|
||||
if (this.canPrev()) {
|
||||
this.offset.update((o) => Math.max(0, o - PAGE_SIZE));
|
||||
this.run();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.canNext()) {
|
||||
this.offset.update((o) => o + PAGE_SIZE);
|
||||
this.run();
|
||||
}
|
||||
}
|
||||
|
||||
select(flight: Flight): void {
|
||||
void this.router.navigate(['/book', flight.id], {
|
||||
queryParams: { passengers: this.form.getRawValue().passengers },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flights crossing midnight arrive on a later calendar day; airlines show
|
||||
* that as a "+1" next to the arrival time and so do we.
|
||||
*/
|
||||
dayOffset(flight: Flight): number {
|
||||
const depart = new Date(flight.departureTime);
|
||||
const arrive = new Date(flight.arrivalTime);
|
||||
const days = (d: Date) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000;
|
||||
return days(arrive) - days(depart);
|
||||
}
|
||||
|
||||
/** Availability colour: scarce inventory should read as urgent. */
|
||||
seatTone(flight: Flight): string {
|
||||
if (flight.seatsAvailable <= 5) {
|
||||
return 'badge-warn';
|
||||
}
|
||||
return 'badge-neutral';
|
||||
}
|
||||
|
||||
cabinLabel(cabin: string): string {
|
||||
return cabin.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const raw = this.form.getRawValue();
|
||||
const query: FlightQuery = {
|
||||
origin: raw.origin,
|
||||
destination: raw.destination,
|
||||
date: raw.date,
|
||||
cabin: raw.cabin as FlightQuery['cabin'],
|
||||
passengers: raw.passengers,
|
||||
sort: raw.sort as FlightQuery['sort'],
|
||||
limit: PAGE_SIZE,
|
||||
offset: this.offset(),
|
||||
};
|
||||
|
||||
this.api.searchFlights(query).subscribe({
|
||||
next: (page) => {
|
||||
this.flights.set(page.items);
|
||||
this.total.set(page.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Could not load flights. Please try again.');
|
||||
this.flights.set([]);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">✈</span>
|
||||
<span class="brand-name">TravelHub</span>
|
||||
</div>
|
||||
|
||||
<div class="tenant-card">
|
||||
<span class="tenant-label">Agency</span>
|
||||
<span class="tenant-name">{{ tenant()?.name }}</span>
|
||||
<span class="tenant-slug">{{ tenant()?.slug }}</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav" aria-label="Main">
|
||||
<a routerLink="/dashboard" routerLinkActive="active" class="nav-link">
|
||||
<span class="nav-icon" aria-hidden="true">▤</span> Dashboard
|
||||
</a>
|
||||
<a routerLink="/search" routerLinkActive="active" class="nav-link">
|
||||
<span class="nav-icon" aria-hidden="true">⌕</span> Search flights
|
||||
</a>
|
||||
<a routerLink="/bookings" routerLinkActive="active" class="nav-link">
|
||||
<span class="nav-icon" aria-hidden="true">🎫</span> Bookings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<p class="faint">Multi-tenant demo. Data is scoped to {{ tenant()?.name }}.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="account">
|
||||
<span class="avatar" aria-hidden="true">{{ initials(user()?.fullName) }}</span>
|
||||
<span class="account-text">
|
||||
<span class="account-name">{{ user()?.fullName }}</span>
|
||||
<span class="account-meta">
|
||||
{{ user()?.email }} · <span class="role">{{ user()?.role }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" (click)="signOut()">Sign out</button>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,225 @@
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 258px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* --- sidebar ------------------------------------------------------------- */
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding: 22px 18px;
|
||||
background: var(--navy-900);
|
||||
color: #d5e0ee;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background: var(--brand-500);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tenant-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 13px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tenant-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: #8fa8c6;
|
||||
}
|
||||
|
||||
.tenant-name {
|
||||
font-weight: 650;
|
||||
color: #fff;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.tenant-slug {
|
||||
font-size: 0.76rem;
|
||||
color: #8fa8c6;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #cbd8e8;
|
||||
font-weight: 550;
|
||||
font-size: 0.94rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--brand-600);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 0 6px;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-foot .faint {
|
||||
color: #7f97b5;
|
||||
}
|
||||
|
||||
/* --- main ---------------------------------------------------------------- */
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 28px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-100);
|
||||
color: var(--brand-600);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Only the role reads better title-cased -- an email address must not. */
|
||||
.role {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 28px;
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- narrow screens ------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.tenant-card {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.tenant-label,
|
||||
.sidebar-foot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
|
||||
import { Shell } from './shell';
|
||||
import { AuthService } from '../core/auth.service';
|
||||
import { Tenant, User } from '../core/models';
|
||||
|
||||
const tenant: Tenant = {
|
||||
id: 't-1',
|
||||
slug: 'skyline',
|
||||
name: 'Skyline Travel',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const user: User = {
|
||||
id: 'u-1',
|
||||
tenantId: 't-1',
|
||||
email: 'agent@skyline.test',
|
||||
fullName: 'Marcus Reid',
|
||||
role: 'agent',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('Shell', () => {
|
||||
let fixture: ComponentFixture<Shell>;
|
||||
let shell: Shell;
|
||||
let signedOut: boolean;
|
||||
let navigatedTo: unknown[][];
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
signedOut = false;
|
||||
navigatedTo = [];
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {
|
||||
tenant: () => tenant,
|
||||
user: () => user,
|
||||
signOut: () => {
|
||||
signedOut = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
router.navigate = ((commands: unknown[]) => {
|
||||
navigatedTo.push(commands);
|
||||
return Promise.resolve(true);
|
||||
}) as Router['navigate'];
|
||||
|
||||
fixture = TestBed.createComponent(Shell);
|
||||
shell = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('keeps the agency permanently on screen', () => {
|
||||
const sidebar = fixture.nativeElement.querySelector('.tenant-card');
|
||||
|
||||
expect(sidebar.textContent).toContain('Skyline Travel');
|
||||
expect(sidebar.textContent).toContain('skyline');
|
||||
});
|
||||
|
||||
it('shows who is signed in', () => {
|
||||
const account = fixture.nativeElement.querySelector('.account');
|
||||
|
||||
expect(account.textContent).toContain('Marcus Reid');
|
||||
expect(account.textContent).toContain('agent@skyline.test');
|
||||
});
|
||||
|
||||
describe('initials', () => {
|
||||
it('takes the first letter of the first two names', () => {
|
||||
expect(shell.initials('Marcus Reid')).toBe('MR');
|
||||
expect(shell.initials('Ada Lovelace Byron')).toBe('AL');
|
||||
});
|
||||
|
||||
it('copes with a single name', () => {
|
||||
expect(shell.initials('Prince')).toBe('P');
|
||||
});
|
||||
|
||||
it('copes with extra spacing', () => {
|
||||
expect(shell.initials(' Priya Nair ')).toBe('PN');
|
||||
});
|
||||
|
||||
it('falls back when there is no name yet', () => {
|
||||
expect(shell.initials(undefined)).toBe('?');
|
||||
expect(shell.initials('')).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the three main destinations', () => {
|
||||
const links: NodeListOf<HTMLAnchorElement> =
|
||||
fixture.nativeElement.querySelectorAll('.nav-link');
|
||||
|
||||
expect(Array.from(links).map((a) => a.getAttribute('href'))).toEqual([
|
||||
'/dashboard',
|
||||
'/search',
|
||||
'/bookings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('signs out and returns to the sign-in screen', () => {
|
||||
shell.signOut();
|
||||
|
||||
expect(signedOut).toBe(true);
|
||||
expect(navigatedTo).toEqual([['/login']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../core/auth.service';
|
||||
|
||||
/**
|
||||
* The signed-in chrome: tenant-branded sidebar, account bar, routed content.
|
||||
* Which agency you are working in is shown permanently in the sidebar, because
|
||||
* in a multi-tenant console that is the single most important piece of context
|
||||
* on screen.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
templateUrl: './shell.html',
|
||||
styleUrl: './shell.scss',
|
||||
})
|
||||
export class Shell {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly tenant = this.auth.tenant;
|
||||
readonly user = this.auth.user;
|
||||
|
||||
/** Initials for the avatar chip, e.g. "Marcus Reid" -> "MR". */
|
||||
initials(name: string | undefined): string {
|
||||
if (!name) {
|
||||
return '?';
|
||||
}
|
||||
return name
|
||||
.split(' ')
|
||||
.filter((part) => part.length > 0)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0].toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
signOut(): void {
|
||||
this.auth.signOut();
|
||||
void this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { DurationPipe } from './duration.pipe';
|
||||
|
||||
describe('DurationPipe', () => {
|
||||
const pipe = new DurationPipe();
|
||||
|
||||
it('renders hours and zero-padded minutes', () => {
|
||||
expect(pipe.transform(445)).toBe('7h 25m');
|
||||
expect(pipe.transform(420)).toBe('7h 00m');
|
||||
expect(pipe.transform(65)).toBe('1h 05m');
|
||||
});
|
||||
|
||||
it('drops the hour part under an hour', () => {
|
||||
expect(pipe.transform(45)).toBe('45m');
|
||||
expect(pipe.transform(0)).toBe('0m');
|
||||
});
|
||||
|
||||
it('handles long-haul durations past a day', () => {
|
||||
expect(pipe.transform(1500)).toBe('25h 00m');
|
||||
});
|
||||
|
||||
it('renders a placeholder for missing or nonsensical values', () => {
|
||||
expect(pipe.transform(null)).toBe('--');
|
||||
expect(pipe.transform(undefined)).toBe('--');
|
||||
expect(pipe.transform(-5)).toBe('--');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
/** Renders a flight duration in minutes as "7h 25m" (or "45m" under an hour). */
|
||||
@Pipe({ name: 'duration' })
|
||||
export class DurationPipe implements PipeTransform {
|
||||
transform(minutes: number | null | undefined): string {
|
||||
if (minutes === null || minutes === undefined || minutes < 0) {
|
||||
return '--';
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours === 0) {
|
||||
return `${mins}m`;
|
||||
}
|
||||
return `${hours}h ${String(mins).padStart(2, '0')}m`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>TravelHub</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -0,0 +1,400 @@
|
||||
/* TravelHub design system ---------------------------------------------------
|
||||
One place for colour, type, elevation and the handful of primitives (button,
|
||||
card, field, badge) that every screen reuses. Feature styles stay thin
|
||||
because of it. */
|
||||
|
||||
:root {
|
||||
/* Brand: a deep navy for chrome, a confident blue for actions, teal for
|
||||
positive money/availability signals. */
|
||||
--navy-900: #0b1727;
|
||||
--navy-800: #13263d;
|
||||
--navy-700: #24405f;
|
||||
|
||||
--brand-600: #0b5cd6;
|
||||
--brand-500: #1470f0;
|
||||
--brand-100: #e3edfd;
|
||||
--brand-50: #f2f7ff;
|
||||
|
||||
--teal-600: #00897b;
|
||||
--teal-100: #d8f3ef;
|
||||
|
||||
--amber-600: #b45309;
|
||||
--amber-100: #fef3c7;
|
||||
|
||||
--rose-600: #be123c;
|
||||
--rose-100: #ffe4e9;
|
||||
|
||||
--text: #0f172a;
|
||||
--text-muted: #5b6b82;
|
||||
--text-faint: #8496ad;
|
||||
|
||||
--surface: #ffffff;
|
||||
--surface-sunken: #f4f7fb;
|
||||
--border: #e3e9f2;
|
||||
--border-strong: #cfd9e8;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 18px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 18px 48px rgba(11, 23, 39, 0.16);
|
||||
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-600);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* --- layout helpers ------------------------------------------------------ */
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-head .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.93rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.faint {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* --- card ---------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-pad {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- buttons ------------------------------------------------------------- */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
transform 0.06s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-600);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #0a4fb8;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--surface);
|
||||
border-color: #f2c2cd;
|
||||
color: var(--rose-600);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--rose-100);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 7px 13px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- forms --------------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-500);
|
||||
box-shadow: 0 0 0 3px var(--brand-100);
|
||||
}
|
||||
|
||||
.field input.invalid,
|
||||
.field select.invalid {
|
||||
border-color: #e2879a;
|
||||
}
|
||||
|
||||
.field .hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.field .error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--rose-600);
|
||||
}
|
||||
|
||||
/* --- badges -------------------------------------------------------------- */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-confirmed {
|
||||
background: var(--teal-100);
|
||||
color: var(--teal-600);
|
||||
}
|
||||
.badge-cancelled {
|
||||
background: var(--rose-100);
|
||||
color: var(--rose-600);
|
||||
}
|
||||
.badge-neutral {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.badge-brand {
|
||||
background: var(--brand-100);
|
||||
color: var(--brand-600);
|
||||
}
|
||||
.badge-warn {
|
||||
background: var(--amber-100);
|
||||
color: var(--amber-600);
|
||||
}
|
||||
|
||||
/* --- feedback ------------------------------------------------------------ */
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--rose-100);
|
||||
border-color: #f4c4cf;
|
||||
color: #8c1030;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--brand-50);
|
||||
border-color: var(--brand-100);
|
||||
color: var(--brand-600);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 44px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty h3 {
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- skeleton loading ---------------------------------------------------- */
|
||||
|
||||
.skeleton {
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, #eef2f7 25%, #e2e8f0 37%, #eef2f7 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- tables -------------------------------------------------------------- */
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table.data {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
table.data th {
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
font-weight: 650;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.data td {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table.data tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
table.data tbody tr:hover {
|
||||
background: var(--brand-50);
|
||||
}
|
||||
|
||||
/* --- number formatting --------------------------------------------------- */
|
||||
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user