diff --git a/README.md b/README.md index dfa594f..2b43376 100644 --- a/README.md +++ b/README.md @@ -305,14 +305,16 @@ This adds a purple FAB button (bottom-right) that opens the full devtools UI in ## Demo App -The repository includes a demo Angular app (`src/`) that showcases the devtools with a product catalog built using `@ngrx/signals`: +The repository includes a demo app, **Angular Travel** (`src/`), that looks and behaves like a real booking site so every inspector has something to show: -- **Home** — simple counter with `signal()` -- **Products** — product list and detail pages powered by a `signalStore` with `withState`, `withComputed`, and `withMethods` -- **About** — static page -- **Examples → SSR & HTTP** (`/examples/http`) — a product list fetched from `/api/products` during SSR and replayed from the transfer cache. The endpoint accepts `?delay=` and `?fail=` for backend scenarios; run the SSR server (`pnpm build --configuration development && node dist/angular-devtools/server/server.mjs`) to see server calls +- **Destinations**: search, region filter and sort kept in the URL, backed by an `@ngrx/signals` store (`withState`, `withComputed`, `withMethods`) +- **Trip pages**: loaded by a resolver that redirects unknown trips, with a route title resolver +- **Booking**: a Signal Forms checkout with cross-field rules, a seat limit and an unsaved-changes guard +- **My Trips**: behind a sign-in guard that redirects to a reactive form and back +- **DevTools Lab** (`/examples`): small, focused pages for signals, components, DI, routes and all three form APIs +- **SSR & HTTP** (`/examples/http`): a product list fetched from `/api/products` during SSR and replayed from the transfer cache. The endpoint accepts `?delay=` and `?fail=` for backend scenarios; run the SSR server (`pnpm build --configuration development && node dist/angular-devtools/server/server.mjs`) to see server calls -Run `pnpm start` and click the purple FAB button to open the devtools popup and see all inspectors in action. +Run `pnpm start` and click the floating button in the corner to open the devtools. Destination photos are from Unsplash, credited in `public/destinations/CREDITS.md`. ## Development diff --git a/packages/ng-devtools/src/__tests__/router-mcp.test.ts b/packages/ng-devtools/src/__tests__/router-mcp.test.ts index 66b88a2..40bc042 100644 --- a/packages/ng-devtools/src/__tests__/router-mcp.test.ts +++ b/packages/ng-devtools/src/__tests__/router-mcp.test.ts @@ -302,8 +302,8 @@ describe('router MCP tools', () => { it('explain-render-mode reads the workspace server routes', async () => { const { push, call } = await boot(); - await push('push-router', report({ snapshot: { ...report().snapshot, url: '/products/9' } })); - const text = await call('explain-render-mode', { url: '/products/9' }); + await push('push-router', report({ snapshot: { ...report().snapshot, url: '/book/lisbon' } })); + const text = await call('explain-render-mode', { url: '/book/lisbon' }); expect(text).toContain('renders with `Client`'); expect(text).toContain('app.routes.server.ts'); }); diff --git a/public/destinations/CREDITS.md b/public/destinations/CREDITS.md new file mode 100644 index 0000000..dc7aecc --- /dev/null +++ b/public/destinations/CREDITS.md @@ -0,0 +1,12 @@ +# Destination photos + +From [Unsplash](https://unsplash.com), free to use under the [Unsplash License](https://unsplash.com/license). + +- santorini.webp: photo by Raimond Klavins, https://unsplash.com/photos/Bmx1VE5jjy0 +- kyoto.webp: photo by Juan Munoz, https://unsplash.com/photos/n4Dl8szrGJU +- lisbon.webp: photo by Sarah Markstaller, https://unsplash.com/photos/r_ShROkN3E8 +- iceland.webp: photo by WeRoad, https://unsplash.com/photos/p8-oTF8Fh9I +- bali.webp: photo by Radoslav Bali, https://unsplash.com/photos/jN9JnZ-SyVc +- swiss-alps.webp: photo by Florian van Duyn, https://unsplash.com/photos/77mHoK2JZDQ +- cape-town.webp: photo by Xandro Vandewalle, https://unsplash.com/photos/2t-q7ONxb-w +- istanbul.webp: photo by Engin Yapici, https://unsplash.com/photos/WA1u0scVLZU diff --git a/public/destinations/bali.webp b/public/destinations/bali.webp new file mode 100644 index 0000000..091985e Binary files /dev/null and b/public/destinations/bali.webp differ diff --git a/public/destinations/cape-town.webp b/public/destinations/cape-town.webp new file mode 100644 index 0000000..9621788 Binary files /dev/null and b/public/destinations/cape-town.webp differ diff --git a/public/destinations/iceland.webp b/public/destinations/iceland.webp new file mode 100644 index 0000000..122fe37 Binary files /dev/null and b/public/destinations/iceland.webp differ diff --git a/public/destinations/istanbul.webp b/public/destinations/istanbul.webp new file mode 100644 index 0000000..d8177e0 Binary files /dev/null and b/public/destinations/istanbul.webp differ diff --git a/public/destinations/kyoto.webp b/public/destinations/kyoto.webp new file mode 100644 index 0000000..5a2ff2f Binary files /dev/null and b/public/destinations/kyoto.webp differ diff --git a/public/destinations/lisbon.webp b/public/destinations/lisbon.webp new file mode 100644 index 0000000..6c31160 Binary files /dev/null and b/public/destinations/lisbon.webp differ diff --git a/public/destinations/santorini.webp b/public/destinations/santorini.webp new file mode 100644 index 0000000..851fa22 Binary files /dev/null and b/public/destinations/santorini.webp differ diff --git a/public/destinations/swiss-alps.webp b/public/destinations/swiss-alps.webp new file mode 100644 index 0000000..5d13d1e Binary files /dev/null and b/public/destinations/swiss-alps.webp differ diff --git a/src/app/app.config.ts b/src/app/app.config.ts index da6a623..aa4abab 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { routes } from './app.routes'; import { provideClientHydration } from '@angular/platform-browser'; import { provideHttpClient, withFetch } from '@angular/common/http'; @@ -8,7 +8,7 @@ import { provideNgDevtoolsHttp, withNgDevtools } from '@santoshyadavdev/ng-devto export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), + provideRouter(routes, withComponentInputBinding()), provideClientHydration(), provideHttpClient(withFetch(), withNgDevtools()), provideNgDevtoolsHttp(), diff --git a/src/app/app.css b/src/app/app.css index 3faed7f..8cd1b95 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -1,66 +1,72 @@ :host { - display: block; + display: flex; + flex-direction: column; min-height: 100vh; + min-height: 100dvh; background: var(--page); } .navbar { display: flex; align-items: center; - gap: 32px; + gap: 24px; padding: 0 24px; - min-height: 56px; - background: var(--surface); + min-height: 64px; + background: color-mix(in srgb, var(--surface) 86%, transparent); + backdrop-filter: saturate(1.4) blur(12px); + -webkit-backdrop-filter: saturate(1.4) blur(12px); border-bottom: 1px solid var(--line); - box-shadow: 0 1px 3px var(--shadow); position: sticky; top: 0; z-index: 100; } -/* The bar carries five items; below this it needs two rows rather than - clipping the last of them off the screen. */ -@media (max-width: 700px) { - .navbar { - flex-wrap: wrap; - gap: 8px 16px; - padding: 8px 16px; - } - .nav-links { - order: 3; - flex-basis: 100%; - } -} - .brand { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 40px; + border-radius: var(--radius-sm); white-space: nowrap; - font-weight: 700; - font-size: 16px; - text-decoration: none; + font-size: 17px; letter-spacing: -0.01em; + color: var(--ink); + text-decoration: none; +} + +.brand strong { color: var(--brand); } -.navbar app-theme-toggle { - margin-left: auto; +.mark { + flex: none; + transition: transform 0.3s var(--ease); +} + +.brand:hover .mark { + transform: rotate(-12deg); } .nav-links { display: flex; - flex-wrap: wrap; gap: 4px; } .nav-links a { - padding: 6px 14px; - border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 12px; + border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; color: var(--muted); text-decoration: none; + white-space: nowrap; transition: - background 0.15s, - color 0.15s; + background-color 0.15s var(--ease), + color 0.15s var(--ease); } .nav-links a:hover { @@ -71,18 +77,187 @@ .nav-links a.active { background: var(--brand-soft); color: var(--brand); + font-weight: 600; +} + +.actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.saved, +.account { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font-size: 13px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + text-decoration: none; + cursor: pointer; + transition: + border-color 0.15s var(--ease), + background-color 0.15s var(--ease); +} + +.saved:hover, +.account:hover { + border-color: var(--line-strong); + background: var(--subtle); +} + +.heart { + flex: none; + fill: var(--brand); +} + +.count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 99px; + background: var(--accent); + color: var(--accent-ink); + font-size: 12px; + font-weight: 700; + line-height: 20px; + text-align: center; + font-variant-numeric: tabular-nums; + transition: + background-color 0.2s var(--ease), + color 0.2s var(--ease); +} + +.count.empty { + background: var(--subtle); + color: var(--muted); +} + +@media (max-width: 900px) { + .navbar { + flex-wrap: wrap; + gap: 8px 16px; + padding: 12px 16px 8px; + } + + .nav-links { + order: 3; + flex-basis: 100%; + overflow-x: auto; + scrollbar-width: none; + } + + .nav-links::-webkit-scrollbar { + display: none; + } +} + +@media (max-width: 520px) { + .nav-links a { + flex: 1 0 auto; + padding: 0 10px; + } + + .saved { + padding: 0 10px; + } + + .saved .label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } +} + +@media (max-width: 400px) { + .brand-name { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .actions { + gap: 6px; + } +} + +main { + flex: 1; +} + +main:focus { + outline: none; +} + +.footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 8px 24px; + padding: 24px 96px 24px 24px; + border-top: 1px solid var(--line); + background: var(--surface); + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.footer p { + margin: 0; + max-width: 64ch; + text-wrap: pretty; +} + +.footer a { + color: var(--brand); + text-underline-offset: 2px; +} + +.footer a:hover { + color: var(--brand-strong); +} + +.footer code { + overflow-wrap: anywhere; +} + +@media (max-width: 600px) { + .footer { + padding: 20px 88px 24px 16px; + } } .skip-link { position: absolute; - left: -9999px; - top: 0; + left: 8px; + top: 8px; z-index: 200; padding: 8px 16px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); background: var(--surface); color: var(--brand); + font-weight: 600; + transform: translateY(-200%); } .skip-link:focus { - left: 0; + transform: none; } diff --git a/src/app/app.html b/src/app/app.html index 7558d8f..b4d4d71 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,20 +1,50 @@ - +
+ diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index e0c2afb..b77c9d5 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,10 +1,11 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ - { - path: 'products/:id', - renderMode: RenderMode.Client, - }, + { path: 'destinations', renderMode: RenderMode.Server }, + { path: 'destinations/:id', renderMode: RenderMode.Server }, + { path: 'book/:id', renderMode: RenderMode.Client }, + { path: 'trips', renderMode: RenderMode.Client }, + { path: 'sign-in', renderMode: RenderMode.Client }, { path: 'examples/routes/users/:id', renderMode: RenderMode.Client }, { path: 'examples/routes/admin', renderMode: RenderMode.Client }, { path: 'examples/routes/locked', renderMode: RenderMode.Client }, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index bb2123e..628ee69 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,15 +1,46 @@ import { Routes } from '@angular/router'; +import { signedInGuard } from './travel/auth'; +import { confirmLeaveGuard, destinationResolver, destinationTitle } from './travel/trip-routes'; export const routes: Routes = [ - { path: '', loadComponent: () => import('./pages/home').then((m) => m.Home) }, - { path: 'about', loadComponent: () => import('./pages/about').then((m) => m.About) }, { - path: 'products', - loadComponent: () => import('./products/product-list').then((m) => m.ProductList), + path: '', + title: 'Angular Travel', + loadComponent: () => import('./pages/home').then((m) => m.Home), }, { - path: 'products/:id', - loadComponent: () => import('./products/product-detail').then((m) => m.ProductDetail), + path: 'destinations', + title: 'Destinations · Angular Travel', + loadComponent: () => import('./pages/destinations').then((m) => m.Destinations), + }, + { + path: 'destinations/:id', + title: destinationTitle, + loadComponent: () => import('./pages/destination-detail').then((m) => m.DestinationDetail), + resolve: { destination: destinationResolver }, + }, + { + path: 'book/:id', + title: 'Book your trip · Angular Travel', + loadComponent: () => import('./pages/booking').then((m) => m.Booking), + resolve: { destination: destinationResolver }, + canDeactivate: [confirmLeaveGuard], + }, + { + path: 'trips', + title: 'My trips · Angular Travel', + canActivate: [signedInGuard], + loadComponent: () => import('./pages/trips').then((m) => m.Trips), + }, + { + path: 'sign-in', + title: 'Sign in · Angular Travel', + loadComponent: () => import('./pages/sign-in').then((m) => m.SignIn), + }, + { + path: 'about', + title: 'About · Angular Travel', + loadComponent: () => import('./pages/about').then((m) => m.About), }, { path: 'examples', @@ -17,4 +48,6 @@ export const routes: Routes = [ data: { title: 'DevTools examples' }, loadChildren: () => import('./examples/examples.routes').then((m) => m.examplesRoutes), }, + { path: 'products', redirectTo: 'destinations' }, + { path: '**', redirectTo: '' }, ]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 93fbb28..14b308d 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -34,7 +34,9 @@ describe('App', () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; - const hrefs = Array.from(compiled.querySelectorAll('nav a')).map((a) => a.getAttribute('href')); - expect(hrefs).toEqual(['/', '/', '/products', '/examples', '/about']); + const hrefs = Array.from(compiled.querySelectorAll('.brand, nav a')).map((a) => + a.getAttribute('href'), + ); + expect(hrefs).toEqual(['/', '/destinations', '/trips', '/examples', '/about']); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index ae671dd..a39948b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,6 +1,8 @@ -import { Component, signal } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { Component, inject } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { ThemeToggle } from './theme-toggle'; +import { Account } from './travel/auth'; +import { TravelStore } from './travel/travel.store'; @Component({ imports: [RouterOutlet, RouterLink, RouterLinkActive, ThemeToggle], @@ -9,7 +11,14 @@ import { ThemeToggle } from './theme-toggle'; templateUrl: './app.html', }) export class App { - protected readonly title = signal('angular-devtools'); + protected readonly store = inject(TravelStore); + protected readonly account = inject(Account); + private readonly router = inject(Router); + + protected signOut() { + this.account.signOut(); + if (this.router.url.startsWith('/trips')) void this.router.navigateByUrl('/'); + } /** * `` makes a bare `#main` resolve to `/#main`, so the browser diff --git a/src/app/examples/example-page.ts b/src/app/examples/example-page.ts index e64be12..c24ad4c 100644 --- a/src/app/examples/example-page.ts +++ b/src/app/examples/example-page.ts @@ -10,10 +10,10 @@ import { Component, input } from '@angular/core';

- + + @@ -22,16 +22,18 @@ import { Component, input } from '@angular/core'; section { display: grid; gap: 20px; - padding: 24px 0 48px; + min-width: 0; + padding: 24px 0 64px; } header { display: grid; - gap: 6px; + gap: 8px; } h2 { margin: 0; - font-size: 22px; color: var(--ink); + font-size: 22px; + line-height: 1.25; letter-spacing: -0.01em; } .lead { @@ -41,27 +43,38 @@ import { Component, input } from '@angular/core'; } .hint { display: flex; - align-items: baseline; - gap: 10px; - padding: 10px 14px; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; border: 1px solid var(--line); - border-left: 3px solid var(--line-strong); - border-radius: 6px; - background: var(--subtle); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm, 8px); + background: var(--surface); color: var(--muted); font-size: 14px; } .tab { flex: none; padding: 1px 8px; - border: 1px solid var(--line-strong); - border-radius: 4px; - background: var(--surface); - color: var(--muted); - font-weight: 500; + border-radius: 6px; + background: var(--brand-soft); + color: var(--brand-strong); font-size: 12px; + font-weight: 600; + line-height: 1.6; white-space: nowrap; } + .hint-text { + min-width: 0; + overflow-wrap: anywhere; + } + @media (max-width: 480px) { + .hint { + flex-direction: column; + gap: 8px; + padding: 12px; + } + } `, }) export class ExamplePage { diff --git a/src/app/examples/example-panel.ts b/src/app/examples/example-panel.ts index 522f38d..ae2c6f1 100644 --- a/src/app/examples/example-panel.ts +++ b/src/app/examples/example-panel.ts @@ -18,24 +18,34 @@ import { StatCard } from './stat-card'; `, styles: ` .panel { - border: 1px solid var(--line); - border-radius: 10px; padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius, 12px); background: var(--surface); + box-shadow: var(--shadow-sm); } h3 { margin: 0 0 12px; font-size: 16px; + line-height: 1.3; } .slot { display: grid; gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); + align-items: start; } .summary { margin: 12px 0 0; + padding-top: 12px; + border-top: 1px solid var(--line); color: var(--muted); font-size: 14px; + font-variant-numeric: tabular-nums; + } + strong { + color: var(--ink); + font-weight: 600; } `, }) diff --git a/src/app/examples/examples-overview.ts b/src/app/examples/examples-overview.ts index cba6066..9e97801 100644 --- a/src/app/examples/examples-overview.ts +++ b/src/app/examples/examples-overview.ts @@ -22,7 +22,12 @@ interface ExampleLink { @for (example of examples; track example.path) {
  • - {{ example.tab }} + + {{ example.tab }} + + {{ example.title }} {{ example.blurb }} @@ -35,7 +40,7 @@ interface ExampleLink { section { display: grid; gap: 20px; - padding: 24px 0 48px; + padding: 24px 0 64px; } .lead { margin: 0; @@ -44,8 +49,8 @@ interface ExampleLink { } .grid { display: grid; - gap: 24px; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); margin: 0; padding: 0; list-style: none; @@ -59,25 +64,32 @@ interface ExampleLink { align-content: start; gap: 8px; height: 100%; - padding: 18px; + box-sizing: border-box; + padding: 20px; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius, 12px); background: var(--surface); + box-shadow: var(--shadow-sm); text-decoration: none; overflow: hidden; + transition: + border-color 0.15s var(--ease, ease), + box-shadow 0.15s var(--ease, ease), + transform 0.15s var(--ease, ease); } a::before { content: ''; position: absolute; inset: 0 0 auto; height: 3px; - background: var(--angular-gradient); + background: var(--brand-gradient); opacity: 0; - transition: opacity 0.15s; + transition: opacity 0.15s var(--ease, ease); } a:hover { border-color: var(--line-strong); - box-shadow: 0 2px 10px var(--shadow); + box-shadow: 0 6px 20px var(--shadow); + transform: translateY(-1px); } a:hover::before, a:focus-visible::before { @@ -87,26 +99,55 @@ interface ExampleLink { outline: 2px solid var(--brand); outline-offset: 2px; } + .top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; + } .tab { - justify-self: start; - align-self: start; - padding: 1px 8px; - border: 1px solid var(--line); - border-radius: 4px; - background: var(--subtle); - color: var(--muted); + padding: 2px 8px; + border-radius: 6px; + background: var(--brand-soft); + color: var(--brand-strong); font-size: 12px; - font-weight: 500; + font-weight: 600; + line-height: 1.5; letter-spacing: 0.02em; + white-space: nowrap; + } + .arrow { + flex: none; + fill: none; + stroke: var(--muted); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + transition: + transform 0.15s var(--ease, ease), + stroke 0.15s var(--ease, ease); + } + a:hover .arrow, + a:focus-visible .arrow { + stroke: var(--brand); + transform: translateX(2px); } .title { color: var(--ink); + font-size: 16px; font-weight: 600; + line-height: 1.35; } .blurb { color: var(--muted); font-size: 14px; } + @media (prefers-reduced-motion: reduce) { + a:hover { + transform: none; + } + } `, }) export class ExamplesOverview { diff --git a/src/app/examples/examples.ts b/src/app/examples/examples.ts index d3a5900..7f68275 100644 --- a/src/app/examples/examples.ts +++ b/src/app/examples/examples.ts @@ -5,8 +5,9 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; selector: 'app-examples', imports: [RouterOutlet, RouterLink, RouterLinkActive], template: ` -
    +
    +

    DevTools Lab

    DevTools examples

    Each page below feeds one inspector. Serve the app, open the DevTools popup and switch @@ -40,47 +41,45 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; `, styles: ` .wrap { - max-width: 1080px; - margin: 0 auto; - padding: 24px 24px 0; + display: block; + padding-top: 40px; } h1 { margin: 0 0 8px; - font-size: 26px; + font-size: clamp(26px, 3.5vw, 34px); + line-height: 1.15; letter-spacing: -0.02em; } - h1::after { - content: ''; - display: block; - width: 56px; - height: 3px; - margin-top: 10px; - border-radius: 2px; - background: var(--angular-gradient); - } .lead { margin: 0; - max-width: 70ch; + max-width: 68ch; color: var(--muted); } .tabs { display: flex; - flex-wrap: wrap; gap: 4px; - margin: 16px 0 0; + margin: 24px -4px 0; + padding: 0 4px 12px; border-bottom: 1px solid var(--line); - padding-bottom: 12px; + overflow-x: auto; + scrollbar-width: thin; + scroll-padding-inline: 16px; } .tabs a { - padding: 6px 14px; - border-radius: 6px; + display: inline-flex; + flex: none; + align-items: center; + height: 36px; + padding: 0 14px; + border-radius: var(--radius-sm, 8px); + color: var(--muted); font-size: 14px; font-weight: 500; - color: var(--muted); text-decoration: none; + white-space: nowrap; transition: - background 0.15s, - color 0.15s; + background-color 0.15s var(--ease, ease), + color 0.15s var(--ease, ease); } .tabs a:hover { background: var(--subtle); @@ -88,11 +87,20 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; } .tabs a.active { background: var(--brand-soft); - color: var(--brand); + color: var(--brand-strong); } .tabs a:focus-visible { outline: 2px solid var(--brand); - outline-offset: 2px; + outline-offset: -2px; + } + @media (max-width: 600px) { + .wrap { + padding-top: 28px; + } + .tabs { + margin-inline: -16px; + padding-inline: 16px; + } } `, }) diff --git a/src/app/examples/forms-example.css b/src/app/examples/forms-example.css index c92b456..ad5e696 100644 --- a/src/app/examples/forms-example.css +++ b/src/app/examples/forms-example.css @@ -1,62 +1,155 @@ .card { display: grid; - gap: 10px; + gap: 12px; align-content: start; - padding: 16px; + min-width: 0; + padding: 20px; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius, 12px); background: var(--surface); + box-shadow: var(--shadow-sm); } h3 { margin: 0 0 4px; font-size: 16px; + line-height: 1.3; } label { display: grid; - gap: 4px; - color: var(--muted); + gap: 6px; + min-width: 0; + color: var(--ink); font-size: 14px; + font-weight: 500; } label.inline { display: flex; align-items: center; gap: 8px; + font-weight: 400; + cursor: pointer; +} +input[type='radio'], +input[type='checkbox'] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; } -input:not([type='radio'], [type='checkbox']) { - padding: 6px 8px; +input:not([type='radio'], [type='checkbox']), +select { + box-sizing: border-box; + width: 100%; + min-width: 0; + height: 36px; + padding: 0 10px; border: 1px solid var(--line-strong); - border-radius: 6px; + border-radius: var(--radius-sm, 8px); + background-color: var(--surface); + color: var(--ink); + font-weight: 400; + transition: + border-color 0.15s var(--ease, ease), + box-shadow 0.15s var(--ease, ease); +} +select { + padding-right: 36px; +} +textarea { + padding: 8px 10px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm, 8px); background: var(--surface); color: var(--ink); } +input:not([type='radio'], [type='checkbox']):hover, +select:hover, +textarea:hover { + border-color: var(--muted); +} +input:not([type='radio'], [type='checkbox']):focus-visible, +select:focus-visible, +textarea:focus-visible { + border-color: var(--brand); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 28%, transparent); +} +input[aria-invalid='true']:not([type='radio'], [type='checkbox']), +input.ng-invalid.ng-touched:not([type='radio'], [type='checkbox']) { + border-color: var(--danger-ink); +} fieldset { display: grid; gap: 8px; + min-width: 0; margin: 0; - padding: 10px 12px; + padding: 12px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-sm, 8px); } legend { padding: 0 4px; color: var(--muted); font-size: 13px; + font-weight: 600; } button { justify-self: start; - padding: 6px 12px; + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 14px; border: 1px solid var(--line-strong); - border-radius: 6px; + border-radius: var(--radius-sm, 8px); background: var(--surface); color: var(--ink); + font-size: 14px; + font-weight: 500; cursor: pointer; + transition: + background-color 0.15s var(--ease, ease), + border-color 0.15s var(--ease, ease); +} +button:hover { + border-color: var(--ink); + background: var(--subtle); +} +button[type='submit'] { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; +} +button[type='submit']:hover { + border-color: var(--accent-strong); + background: var(--accent-strong); +} +button:disabled { + opacity: 0.55; + cursor: not-allowed; } .error { + display: flex; + align-items: flex-start; + gap: 6px; margin: 0; color: var(--danger-ink); font-size: 13px; + line-height: 1.4; +} +.error::before { + content: ''; + flex: none; + width: 13px; + height: 13px; + margin-top: 2px; + background: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9.5'/%3E%3Cpath d='M12 7.5v5.5M12 16.5v.01'/%3E%3C/svg%3E") + center / contain no-repeat; } .status { + min-height: 20px; margin: 0; color: var(--muted); font-size: 13px; diff --git a/src/app/examples/http-example.ts b/src/app/examples/http-example.ts index 68ada30..b49ce32 100644 --- a/src/app/examples/http-example.ts +++ b/src/app/examples/http-example.ts @@ -41,40 +41,72 @@ const SCENARIOS: { value: Scenario; label: string; query: string }[] = [ } - + -

    -

    Products

    -

    {{ status() }}

    +
    +
    +

    Products

    +

    {{ status() }}

    +
    @if (products.error(); as error) { - + } @if (products.hasValue()) { -
      - @for (product of products.value(); track product.id) { -
    • - -
    • + @if (products.value().length) { +
        + @for (product of products.value(); track product.id) { +
      • + +
      • + } +
      + } @else { +

      The backend returned no products. Press Reload to try again.

      + } + } @else if (products.isLoading()) { + }
    @if (selectedId() !== null) { -
    +

    Detail (client-only request)

    @if (detail.hasValue()) { -

    {{ detail.value().name }}: {{ detail.value().stock }} left

    +

    + {{ detail.value().name }}: {{ detail.value().stock }} left +

    } @else if (detail.error(); as error) { - + } @else {

    Loading…

    } @@ -91,76 +123,226 @@ const SCENARIOS: { value: Scenario; label: string; query: string }[] = [ } label { display: grid; - gap: 4px; - color: var(--muted); + gap: 6px; + min-width: 0; + color: var(--ink); font-size: 14px; + font-weight: 500; } select, - .controls button { - padding: 6px 12px; + .reload { + box-sizing: border-box; + height: 36px; border: 1px solid var(--line-strong); - border-radius: 6px; - background: var(--surface); - color: inherit; + border-radius: var(--radius-sm, 8px); + background-color: var(--surface); + color: var(--ink); font: inherit; + font-size: 14px; cursor: pointer; + transition: + border-color 0.15s var(--ease, ease), + background-color 0.15s var(--ease, ease), + box-shadow 0.15s var(--ease, ease); + } + select { + min-width: 220px; + max-width: 100%; + padding: 0 36px 0 10px; + font-weight: 400; + } + select:hover { + border-color: var(--muted); + } + select:focus-visible { + border-color: var(--brand); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 28%, transparent); + } + .reload { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 14px; + font-weight: 500; + } + .reload svg { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .reload:hover { + border-color: var(--ink); + background-color: var(--subtle); + } + .block { + display: grid; + gap: 12px; + min-width: 0; + } + .block-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 12px; } h3 { - margin: 0 0 8px; + margin: 0; font-size: 16px; + line-height: 1.3; } .status { - margin: 0 0 8px; + margin: 0; color: var(--muted); + font-size: 14px; + font-variant-numeric: tabular-nums; } .error { - margin: 0 0 8px; - color: var(--danger, #b91c1c); + display: grid; + gap: 4px; + padding: 12px 14px 12px 38px; + border: 1px solid color-mix(in srgb, var(--danger-ink) 35%, transparent); + border-radius: var(--radius-sm, 8px); + background: var(--danger-soft) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23dc2626' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9.5'/%3E%3Cpath d='M12 7.5v5.5M12 16.5v.01'/%3E%3C/svg%3E") + no-repeat 14px 15px / 16px; + color: var(--danger-ink); + font-size: 14px; + } + .error p { + margin: 0; + overflow-wrap: anywhere; + } + .error-title { + font-weight: 600; } .grid { display: grid; gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 180px), 1fr)); margin: 0; padding: 0; list-style: none; + transition: opacity 0.15s var(--ease, ease); + } + .grid.stale { + opacity: 0.6; + } + .card { + display: grid; } .card button { display: grid; + align-content: start; gap: 4px; width: 100%; - padding: 12px; + min-width: 0; + padding: 12px 14px; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius, 12px); background: var(--surface); - color: inherit; + color: var(--ink); font: inherit; text-align: left; cursor: pointer; + transition: + border-color 0.15s var(--ease, ease), + background-color 0.15s var(--ease, ease), + box-shadow 0.15s var(--ease, ease); + } + .card button:hover { + border-color: var(--line-strong); + box-shadow: var(--shadow-sm); } .card button[aria-pressed='true'] { border-color: var(--brand); + background: var(--brand-soft); + box-shadow: inset 0 0 0 1px var(--brand); } .name { + overflow-wrap: anywhere; font-weight: 600; } .meta { color: var(--muted); font-size: 14px; + font-variant-numeric: tabular-nums; + } + .card button[aria-pressed='true'] .meta { + color: var(--ink); } - .detail p { + .skeleton { + gap: 8px; + height: 70px; + box-sizing: border-box; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius, 12px); + background: var(--surface); + } + .skeleton span { + height: 12px; + border-radius: 4px; + background: var(--subtle); + animation: pulse 1.2s ease-in-out infinite; + } + .skeleton span:last-child { + width: 60%; + } + .empty { margin: 0; + padding: 24px 16px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius, 12px); + color: var(--muted); + text-align: center; + } + .detail { + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius, 12px); + background: var(--surface); + } + .detail-body { + margin: 0; + } + .num { + font-weight: 600; + font-variant-numeric: tabular-nums; } :focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } + @keyframes pulse { + 50% { + opacity: 0.45; + } + } + @media (prefers-reduced-motion: reduce) { + .skeleton span { + animation: none; + } + } + @media (max-width: 480px) { + .controls label { + flex: 1 1 100%; + } + select { + width: 100%; + min-width: 0; + } + } `, }) export class HttpExample { protected readonly scenarios = SCENARIOS; protected readonly scenario = signal('ok'); protected readonly selectedId = signal(null); + protected readonly skeleton = [0, 1, 2, 3]; protected readonly products = httpResource( () => `/api/products${SCENARIOS.find((s) => s.value === this.scenario())?.query ?? ''}`, diff --git a/src/app/examples/stat-card.ts b/src/app/examples/stat-card.ts index 084c392..ea6165b 100644 --- a/src/app/examples/stat-card.ts +++ b/src/app/examples/stat-card.ts @@ -7,54 +7,106 @@ import { Component, input, model, output } from '@angular/core'; @if (expanded()) { -

    {{ hint() }}

    - +
    +

    {{ hint() }}

    + +
    } `, styles: ` .card { + overflow: hidden; border: 1px solid var(--line); - border-radius: 8px; - padding: 12px; + border-radius: var(--radius-sm, 8px); background: var(--surface); + transition: border-color 0.15s var(--ease, ease); + } + .card:hover { + border-color: var(--line-strong); } .head { - display: flex; - justify-content: space-between; - align-items: baseline; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 16px; + align-items: center; gap: 12px; width: 100%; - padding: 0; + min-height: 48px; + padding: 8px 12px; border: 0; background: none; - font: inherit; color: var(--ink); + font: inherit; + text-align: left; cursor: pointer; } + .head:hover { + background: var(--subtle); + } .label { + overflow: hidden; font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; } .value { - font-variant-numeric: tabular-nums; font-size: 20px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.2; + } + .chevron { + fill: none; + stroke: var(--muted); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 0.15s var(--ease, ease); + } + [aria-expanded='true'] .chevron { + transform: rotate(180deg); + } + .body { + display: grid; + justify-items: start; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--line); } .hint { - margin: 8px 0 0; + margin: 0; color: var(--muted); font-size: 14px; } .refresh { - margin-top: 8px; - padding: 4px 10px; + height: 32px; + padding: 0 12px; border: 1px solid var(--line-strong); - border-radius: 6px; + border-radius: var(--radius-sm, 8px); background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 14px; + font-weight: 500; cursor: pointer; + transition: + background-color 0.15s var(--ease, ease), + border-color 0.15s var(--ease, ease); + } + .refresh:hover { + border-color: var(--ink); + background: var(--subtle); + } + .head:focus-visible { + outline: 2px solid var(--brand); + outline-offset: -2px; } - :focus-visible { + .refresh:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } diff --git a/src/app/pages/about.ts b/src/app/pages/about.ts index 99228f8..676e8e6 100644 --- a/src/app/pages/about.ts +++ b/src/app/pages/about.ts @@ -1,22 +1,181 @@ import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; @Component({ selector: 'app-about', + imports: [RouterLink], template: ` -
    -

    About Us

    -

    - Angular DevTools inspects components, signals, DI and routes in any Angular application. +

    +

    About this demo

    +

    A real-looking app to inspect

    +

    + Angular Travel is the playground for the devtools. Open them with the amber button in the + corner, then try these:

    -
    +
      +
    • +

      Destinations

      +

      + Filter and sort, then watch the NgRx dock: the whole list lives in a + signal store. The Signals tab shows the computed results update. +

      +
    • +
    • +

      A trip page

      +

      + Loaded by a resolver that redirects unknown trips. The Routes tab + explains the navigation, including the resolver and the redirect. +

      +
    • +
    • +

      Booking

      +

      + A Signal Forms checkout with cross-field rules and an unsaved-changes guard. The + Forms tab shows every field, its errors and why submit is blocked. +

      +
    • +
    • +

      My Trips

      +

      + Behind a sign-in guard that sends you to a reactive form and back. Great for the + navigation timeline. +

      +
    • +
    • +

      DevTools Lab

      +

      + Small, focused examples for components, DI, routes, signals and all three form APIs. +

      +
    • +
    + `, styles: ` - section { - padding: 24px; + .page { + max-width: 860px; + padding-top: 40px; + padding-bottom: 72px; } h1 { - font-size: 24px; - margin-bottom: 12px; + margin: 0 0 12px; + font-size: clamp(28px, 4vw, 40px); + line-height: 1.15; + letter-spacing: -0.02em; + } + .lead { + max-width: 60ch; + margin: 0 0 32px; + color: var(--muted); + font-size: 17px; + } + .tries { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; + counter-reset: try; + } + .tries li { + position: relative; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 20px; + column-gap: 16px; + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--radius, 12px); + background: var(--surface); + box-shadow: var(--shadow-sm); + counter-increment: try; + transition: + border-color 0.15s var(--ease, ease), + box-shadow 0.15s var(--ease, ease), + transform 0.15s var(--ease, ease); + } + .tries li::before { + content: counter(try); + grid-row: 1 / span 2; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand-soft); + color: var(--brand-strong); + font-size: 14px; + font-weight: 700; + font-variant-numeric: tabular-nums; + } + .tries li::after { + content: ''; + grid-column: 3; + grid-row: 1 / span 2; + align-self: center; + width: 20px; + height: 20px; + background: var(--muted); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E") + center / contain no-repeat; + transition: + transform 0.15s var(--ease, ease), + background-color 0.15s var(--ease, ease); + } + .tries li:hover { + border-color: var(--line-strong); + box-shadow: 0 4px 16px var(--shadow); + } + .tries li:hover::after { + background: var(--brand); + transform: translateX(2px); + } + .tries li:has(a:focus-visible) { + outline: 2px solid var(--brand); + outline-offset: 2px; + } + h2 { + margin: 4px 0; + font-size: 17px; + line-height: 1.3; + } + h2 a { + color: var(--ink); + text-decoration: none; + } + h2 a::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + } + h2 a:focus-visible { + outline: none; + } + .tries li:hover h2 a { + color: var(--brand); + } + .tries p { + grid-column: 2; + margin: 0; + color: var(--muted); + } + strong { + color: var(--ink); + font-weight: 600; + } + @media (max-width: 480px) { + .tries li { + grid-template-columns: 28px minmax(0, 1fr); + column-gap: 12px; + padding: 16px; + } + .tries li::before { + width: 28px; + height: 28px; + font-size: 13px; + } + .tries li::after { + display: none; + } } `, }) diff --git a/src/app/pages/booking.ts b/src/app/pages/booking.ts new file mode 100644 index 0000000..96f7c93 --- /dev/null +++ b/src/app/pages/booking.ts @@ -0,0 +1,711 @@ +import { CurrencyPipe, NgOptimizedImage } from '@angular/common'; +import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core'; +import { + email, + form, + FormField, + FormRoot, + max, + min, + minLength, + required, + validate, +} from '@angular/forms/signals'; +import { RouterLink } from '@angular/router'; +import { Account } from '../travel/auth'; +import type { LeavesSafely } from '../travel/trip-routes'; +import type { Destination } from '../travel/destination'; +import { TravelStore, type Booking as TripBooking } from '../travel/travel.store'; + +interface BookingModel { + startDate: string; + travelers: number; + name: string; + email: string; + requests: string; + terms: boolean; +} + +function isoDay(offsetDays: number): string { + const day = new Date(); + day.setDate(day.getDate() + offsetDays); + const month = String(day.getMonth() + 1).padStart(2, '0'); + const date = String(day.getDate()).padStart(2, '0'); + return `${day.getFullYear()}-${month}-${date}`; +} + +@Component({ + selector: 'app-booking', + imports: [FormField, FormRoot, RouterLink, CurrencyPipe, NgOptimizedImage], + template: ` + @let trip = live(); +
    + + + {{ trip.name }} + + + @if (confirmed(); as done) { +
    + +

    Booking {{ done.id }}

    +

    You're going to {{ trip.name }}

    +

    + {{ done.travelers }} {{ done.travelers === 1 ? 'traveler' : 'travelers' }} from + {{ done.startDate }}, {{ done.total | currency: 'EUR' : 'symbol' : '1.0-0' }} in total. + A confirmation is on its way to . +

    + +
    + } @else { +

    Book your trip

    +
    +
    +

    Trip details

    +
    +
    + + +
    + @if (booking.startDate().touched()) { + @for (error of booking.startDate().errors(); track error.kind) { +

    {{ error.message }}

    + } + } +
    +
    +
    + + +

    {{ trip.seats }} seats left

    +
    + @if (booking.travelers().touched()) { + @for (error of booking.travelers().errors(); track error.kind) { +

    {{ error.message }}

    + } + } +
    +
    +
    + +

    Lead traveler

    +
    +
    + + +
    + @if (booking.name().touched()) { + @for (error of booking.name().errors(); track error.kind) { +

    {{ error.message }}

    + } + } +
    +
    +
    + + +
    + @if (booking.email().touched()) { + @for (error of booking.email().errors(); track error.kind) { +

    {{ error.message }}

    + } + } +
    +
    +
    + +
    + + +
    + + +
    + @if (booking.terms().touched()) { + @for (error of booking.terms().errors(); track error.kind) { +

    {{ error.message }}

    + } + } +
    + +

    {{ status() }}

    + +
    + +
    + +
    +

    {{ trip.name }}

    +

    {{ trip.nights }} nights · {{ trip.country }}

    +
    +
    +
    Per person
    +
    {{ trip.price | currency: 'EUR' : 'symbol' : '1.0-0' }}
    +
    +
    +
    Travelers
    +
    {{ travelers() }}
    +
    +
    +
    Total
    +
    {{ total() | currency: 'EUR' : 'symbol' : '1.0-0' }}
    +
    +
    +
    +
    +
    + } +
    + `, + styles: ` + .page { + padding-top: 32px; + padding-bottom: 72px; + } + .back { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + min-height: 32px; + margin-left: -4px; + padding: 0 8px 0 4px; + border-radius: 8px; + color: var(--muted); + font-size: 14px; + font-weight: 500; + text-decoration: none; + transition: + background-color 0.15s var(--ease), + color 0.15s var(--ease); + } + .back span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .back svg { + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .back:hover { + background: var(--subtle); + color: var(--ink); + } + h1 { + margin: 12px 0 24px; + font-size: clamp(28px, 4vw, 40px); + line-height: 1.15; + letter-spacing: -0.02em; + overflow-wrap: anywhere; + } + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 32px; + align-items: start; + } + .panel { + display: grid; + gap: 4px; + min-width: 0; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius-lg, 16px); + background: var(--surface); + box-shadow: var(--shadow-sm); + } + h2 { + margin: 0 0 12px; + font-size: 18px; + line-height: 1.3; + letter-spacing: -0.01em; + } + h2.section { + margin-top: 12px; + padding-top: 24px; + border-top: 1px solid var(--line); + } + .row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + gap: 0 16px; + } + .field { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; + } + label { + font-size: 14px; + font-weight: 600; + } + .optional, + .hint { + color: var(--muted); + font-size: 13px; + font-weight: 400; + } + .optional { + margin-left: 4px; + } + .hint { + margin: 0; + font-variant-numeric: tabular-nums; + } + input:not([type='checkbox']), + textarea { + box-sizing: border-box; + display: block; + width: 100%; + min-width: 0; + height: var(--control-h, 40px); + padding: 0 12px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--surface); + color: var(--ink); + transition: + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + input[type='number'] { + font-variant-numeric: tabular-nums; + } + textarea { + height: auto; + padding: 10px 12px; + } + textarea::placeholder { + color: var(--muted); + opacity: 1; + } + input:not([type='checkbox']):hover, + textarea:hover { + border-color: var(--muted); + } + input:not([type='checkbox']):focus-visible, + textarea:focus-visible { + border-color: var(--brand); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 28%, transparent); + } + input[aria-invalid='true']:not([type='checkbox']) { + border-color: var(--danger-ink); + } + input[aria-invalid='true']:not([type='checkbox']):focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger-ink) 24%, transparent); + } + .errors { + min-height: 24px; + } + .errors p { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 0; + padding-top: 2px; + color: var(--danger-ink); + font-size: 13px; + line-height: 1.4; + } + .errors p::before, + .status::before { + content: ''; + flex: none; + width: 14px; + height: 14px; + margin-top: 2px; + background: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9.5'/%3E%3Cpath d='M12 7.5v5.5M12 16.5v.01'/%3E%3C/svg%3E") + center / contain no-repeat; + } + .check { + display: flex; + gap: 10px; + align-items: flex-start; + margin-top: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + font-weight: 400; + cursor: pointer; + transition: + border-color 0.15s var(--ease), + background-color 0.15s var(--ease); + } + .check:hover { + border-color: var(--line-strong); + } + .check.invalid { + border-color: var(--danger-ink); + background: var(--danger-soft); + } + .check input { + flex: none; + width: 18px; + height: 18px; + margin: 2px 0 0; + accent-color: var(--accent); + cursor: pointer; + } + .status { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 4px 0 12px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--danger-ink) 35%, transparent); + border-radius: 10px; + background: var(--danger-soft); + color: var(--danger-ink); + font-size: 14px; + font-weight: 500; + } + .status:empty { + margin: 0; + padding: 0; + border: 0; + } + .status:empty::before { + display: none; + } + .wide { + width: 100%; + } + .spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + .summary { + position: sticky; + top: 88px; + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-lg, 16px); + background: var(--surface); + box-shadow: var(--shadow-sm); + } + .summary img { + width: 100%; + height: auto; + display: block; + aspect-ratio: 16 / 10; + object-fit: cover; + background: var(--subtle); + } + .summary-body { + padding: 20px; + } + .summary h2 { + margin: 0; + overflow-wrap: anywhere; + } + .muted { + margin: 4px 0 16px; + color: var(--muted); + font-size: 14px; + } + dl { + display: grid; + gap: 8px; + margin: 0; + font-variant-numeric: tabular-nums; + } + dl div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + } + dt { + color: var(--muted); + } + dd { + margin: 0; + font-weight: 600; + text-align: right; + } + .total { + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--line); + font-size: 18px; + } + .total dt { + color: var(--ink); + font-weight: 600; + } + .total dd { + font-weight: 700; + } + .done { + display: grid; + justify-items: center; + max-width: 620px; + margin: 40px auto 0; + padding: 40px 32px 32px; + border: 1px solid var(--line); + border-radius: var(--radius-lg, 16px); + background: var(--surface); + box-shadow: var(--shadow-sm); + text-align: center; + animation: rise 0.3s ease-out; + } + .done-mark { + display: grid; + place-items: center; + width: 56px; + height: 56px; + margin-bottom: 16px; + border-radius: 50%; + background: var(--ok-soft); + color: var(--ok-ink); + } + .done-mark svg { + fill: none; + stroke: currentColor; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + } + .done h1 { + margin: 12px 0 8px; + font-size: clamp(24px, 4vw, 32px); + } + .badge { + display: inline-block; + margin: 0; + padding: 4px 12px; + border-radius: 99px; + background: var(--brand-soft); + color: var(--brand-strong); + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + } + .done p:not(.badge) { + max-width: 48ch; + margin: 0; + color: var(--muted); + font-variant-numeric: tabular-nums; + } + .email { + color: var(--ink); + font-weight: 600; + overflow-wrap: anywhere; + } + .done-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 24px; + } + @keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + } + @media (max-width: 860px) { + .layout { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } + .summary { + position: static; + order: -1; + } + } + @media (max-width: 480px) { + .panel, + .summary-body { + padding: 20px 16px; + } + .done { + margin-top: 24px; + padding: 32px 16px 24px; + } + .done-actions .btn { + flex: 1 1 100%; + } + } + `, +}) +export class Booking implements LeavesSafely { + readonly destination = input.required(); + private readonly store = inject(TravelStore); + private readonly account = inject(Account); + private readonly destroyRef = inject(DestroyRef); + + protected readonly earliest = isoDay(7); + protected readonly status = signal(''); + protected readonly confirmed = signal(null); + protected readonly live = computed( + () => + this.store.destinations().find((d) => d.id === this.destination().id) ?? this.destination(), + ); + + private readonly model = signal({ + startDate: '', + travelers: 2, + name: this.account.name() ?? '', + email: this.account.email() ?? '', + requests: '', + terms: false, + }); + + protected readonly booking = form( + this.model, + (path) => { + required(path.startDate, { message: 'Pick a departure date' }); + validate(path.startDate, ({ value }) => + value() && value() < this.earliest + ? { kind: 'too-soon', message: 'Departures open 7 days from today' } + : null, + ); + min(path.travelers, 1, { message: 'At least one traveler' }); + validate(path.travelers, ({ value }) => + Number.isInteger(value()) + ? null + : { kind: 'whole-number', message: 'Enter a whole number of travelers' }, + ); + max(path.travelers, () => this.live().seats, { + message: 'There are not enough seats left', + }); + required(path.name, { message: 'Enter the lead traveler’s name' }); + minLength(path.name, 2, { message: 'Enter the full name' }); + required(path.email, { message: 'Enter an email for the confirmation' }); + email(path.email, { message: 'Enter a valid email address' }); + validate(path.terms, ({ value }) => + value() ? null : { kind: 'terms', message: 'Accept the terms to book' }, + ); + }, + { + submission: { + action: async (tree) => { + const value = tree().value(); + await new Promise((resolve) => setTimeout(resolve, 600)); + if (this.destroyRef.destroyed) return; + const trip = this.live(); + const created = this.store.book({ + destinationId: trip.id, + destinationName: trip.name, + startDate: value.startDate, + travelers: value.travelers, + name: value.name, + email: value.email, + ...(value.requests.trim() ? { requests: value.requests.trim() } : {}), + total: value.travelers * trip.price, + }); + if (!created) { + this.status.set( + `Only ${trip.seats} seats are left now. Change the number of travelers.`, + ); + return; + } + this.account.signIn(value.name, value.email); + this.confirmed.set(created); + this.status.set(''); + }, + onInvalid: () => this.status.set('Check the highlighted fields.'), + }, + }, + ); + + protected readonly travelers = computed(() => Math.max(0, this.model().travelers || 0)); + protected readonly total = computed(() => this.travelers() * this.live().price); + + protected showErrors(invalid: boolean, touched: boolean) { + return invalid && touched; + } + + canLeave(): boolean { + return !!this.confirmed() || !this.booking().dirty(); + } +} diff --git a/src/app/pages/destination-detail.ts b/src/app/pages/destination-detail.ts new file mode 100644 index 0000000..e8c397f --- /dev/null +++ b/src/app/pages/destination-detail.ts @@ -0,0 +1,361 @@ +import { CurrencyPipe, DecimalPipe, NgOptimizedImage } from '@angular/common'; +import { Component, computed, inject, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import type { Destination } from '../travel/destination'; +import { TravelStore } from '../travel/travel.store'; + +@Component({ + selector: 'app-destination-detail', + imports: [NgOptimizedImage, RouterLink, CurrencyPipe, DecimalPipe], + template: ` + @let trip = live(); +
    +
    + +
    + + + All destinations + +

    + {{ trip.country }} · {{ trip.region }} +

    +

    {{ trip.name }}

    +

    + + {{ trip.rating | number: '1.1-1' }} + from {{ trip.reviews | number }} reviews +

    +
    +
    + +
    +
    +

    {{ trip.description }}

    +

    Highlights

    +
      + @for (item of trip.highlights; track item) { +
    • {{ item }}
    • + } +
    +
    +
    +
    Length
    +
    {{ trip.nights }} nights
    +
    +
    +
    Best time
    +
    {{ trip.bestTime }}
    +
    +
    +
    Group size
    +
    Up to {{ trip.groupSize }}
    +
    +
    +
    + +
    +

    From

    +

    + {{ trip.price | currency: 'EUR' : 'symbol' : '1.0-0' }} + per person +

    +

    + {{ trip.seats ? trip.seats + ' seats left on the next departure' : 'Fully booked' }} +

    + @if (trip.seats) { + Book This Trip + } @else { + + } + +

    Free changes up to 30 days before departure.

    +
    +
    +
    + `, + styles: ` + .cover { + position: relative; + display: grid; + align-items: end; + min-height: min(62vh, 520px); + overflow: hidden; + background: #0c0c10; + color: #fff; + } + .cover img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + .cover::after { + content: ''; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(12, 12, 16, 0.45) 0%, rgba(12, 12, 16, 0) 28%), + linear-gradient(180deg, rgba(12, 12, 16, 0) 35%, rgba(12, 12, 16, 0.88) 100%); + } + .cover-inner { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + min-height: inherit; + padding-top: 24px; + padding-bottom: 40px; + } + .back { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + margin-bottom: auto; + padding: 0 12px 0 10px; + border-radius: 99px; + background: rgba(12, 12, 16, 0.5); + color: #fff; + font-size: 14px; + font-weight: 500; + text-decoration: none; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + transition: background-color 0.15s var(--ease); + } + .back:hover { + background: rgba(12, 12, 16, 0.75); + } + .back:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; + } + .cover .eyebrow { + margin-top: 48px; + color: var(--accent); + } + h1 { + margin: 0 0 12px; + font-size: clamp(34px, 5vw, 56px); + letter-spacing: -0.03em; + line-height: 1.05; + overflow-wrap: anywhere; + } + .rating { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + margin: 0; + color: rgba(255, 255, 255, 0.88); + font-variant-numeric: tabular-nums; + } + .rating strong { + color: #fff; + } + .star { + color: var(--accent); + } + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 48px; + padding-top: 40px; + padding-bottom: 80px; + } + .lead { + max-width: 68ch; + margin: 0 0 32px; + font-size: 18px; + line-height: 1.6; + text-wrap: pretty; + } + h2 { + margin: 0 0 12px; + font-size: 20px; + line-height: 1.3; + } + .highlights { + display: grid; + gap: 12px; + margin: 0 0 32px; + padding: 0; + list-style: none; + } + .highlights li { + display: flex; + gap: 12px; + align-items: flex-start; + } + .highlights li::before { + content: '✓'; + display: grid; + flex: none; + place-items: center; + width: 22px; + height: 22px; + margin-top: 1px; + border-radius: 50%; + background: var(--brand-soft); + color: var(--brand); + font-size: 12px; + font-weight: 700; + } + .facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr)); + gap: 12px; + margin: 0; + } + .facts div { + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + } + dt { + color: var(--muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + dd { + margin: 4px 0 0; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-wrap: pretty; + } + .booking { + position: sticky; + top: 88px; + align-self: start; + display: grid; + gap: 12px; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: 0 16px 40px -24px var(--shadow); + } + .from { + margin: 0 0 -8px; + color: var(--muted); + font-size: 13px; + } + .price { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + margin: 0; + font-size: 32px; + font-weight: 800; + line-height: 1.2; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + } + .price span { + color: var(--muted); + font-size: 14px; + font-weight: 400; + letter-spacing: 0; + } + .seats { + margin: 0 0 4px; + padding-bottom: 16px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 14px; + font-variant-numeric: tabular-nums; + } + .seats.low { + color: var(--brand); + font-weight: 600; + } + .wide { + width: 100%; + } + .save.on svg { + color: var(--brand); + } + .note { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; + text-align: center; + text-wrap: pretty; + } + @media (max-width: 860px) { + .layout { + grid-template-columns: 1fr; + gap: 32px; + } + .booking { + position: static; + } + } + @media (max-width: 600px) { + .cover-inner { + padding-top: 16px; + padding-bottom: 32px; + } + .layout { + padding-top: 32px; + padding-bottom: 64px; + } + .lead { + font-size: 16px; + } + .booking { + padding: 20px; + } + } + `, +}) +export class DestinationDetail { + readonly destination = input.required(); + protected readonly store = inject(TravelStore); + protected readonly live = computed( + () => + this.store.destinations().find((d) => d.id === this.destination().id) ?? this.destination(), + ); + protected readonly saved = computed(() => this.store.saved().includes(this.destination().id)); +} diff --git a/src/app/pages/destinations.ts b/src/app/pages/destinations.ts new file mode 100644 index 0000000..e4d4eea --- /dev/null +++ b/src/app/pages/destinations.ts @@ -0,0 +1,352 @@ +import { Component, computed, effect, inject, input } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import type { Region } from '../travel/destination'; +import { DestinationCard } from '../travel/destination-card'; +import { TravelStore, type SortOrder } from '../travel/travel.store'; + +const SORTS: { id: SortOrder; label: string }[] = [ + { id: 'popular', label: 'Most popular' }, + { id: 'rating', label: 'Top rated' }, + { id: 'price', label: 'Lowest price' }, +]; + +@Component({ + selector: 'app-destinations', + imports: [DestinationCard, RouterLink], + template: ` +
    +
    +

    {{ savedOnly() ? 'Your list' : 'All trips' }}

    +

    {{ savedOnly() ? 'Saved destinations' : 'Destinations' }}

    +
    + + + +

    + {{ shown().length }} {{ shown().length === 1 ? 'trip' : 'trips' }} + @if (savedOnly()) { + Show all + } +

    + + @if (shown().length) { +

    Results

    +
    + @for (destination of shown(); track destination.id) { + + } +
    + } @else { +
    + +

    {{ savedOnly() ? 'Nothing saved yet' : 'No trips match' }}

    +

    + {{ + savedOnly() + ? 'Tap the heart on a trip to keep it here.' + : 'Try another place, or clear the filters.' + }} +

    + {{ + savedOnly() ? 'Browse Destinations' : 'Clear Filters' + }} +
    + } +
    + `, + styles: ` + .page { + padding-top: 40px; + padding-bottom: 80px; + } + h1 { + margin: 0 0 24px; + font-size: clamp(28px, 4vw, 40px); + line-height: 1.15; + letter-spacing: -0.02em; + } + .toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); + } + .field { + position: relative; + flex: 1 1 240px; + min-width: 0; + } + .field-icon { + position: absolute; + top: 50%; + left: 12px; + color: var(--muted); + transform: translateY(-50%); + pointer-events: none; + } + input, + select { + box-sizing: border-box; + width: 100%; + height: var(--control-h); + padding: 0 12px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background-color: var(--surface); + color: var(--ink); + transition: + border-color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + input { + padding-left: 36px; + } + input::placeholder { + color: var(--muted); + opacity: 1; + } + input:hover, + select:hover { + border-color: var(--ink); + } + input:focus-visible, + select:focus-visible { + border-color: var(--brand); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 30%, transparent); + } + select { + width: auto; + max-width: 100%; + padding-right: 36px; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .chip { + height: var(--control-h); + padding: 0 16px; + border: 1px solid var(--line); + border-radius: 99px; + background: var(--subtle); + color: var(--ink); + font-size: 14px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + color 0.15s var(--ease); + } + .chip:hover { + border-color: var(--line-strong); + } + .chip.active { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + .sort { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + color: var(--muted); + font-size: 14px; + white-space: nowrap; + } + .count { + display: flex; + align-items: baseline; + gap: 6px; + margin: 24px 0 16px; + color: var(--muted); + font-size: 14px; + font-variant-numeric: tabular-nums; + } + .count strong { + color: var(--ink); + font-weight: 600; + } + .count a { + color: var(--brand); + font-weight: 500; + text-underline-offset: 2px; + } + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); + gap: 24px; + } + .empty { + display: grid; + justify-items: center; + padding: 56px 24px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-lg); + background: var(--surface); + text-align: center; + } + .empty-icon { + display: grid; + place-items: center; + width: 48px; + height: 48px; + margin-bottom: 16px; + border-radius: 50%; + background: var(--brand-soft); + color: var(--brand); + } + .empty h2 { + margin: 0 0 8px; + font-size: 20px; + line-height: 1.3; + } + .empty p { + max-width: 40ch; + margin: 0 0 24px; + color: var(--muted); + } + @media (max-width: 600px) { + .page { + padding-top: 32px; + } + .toolbar { + align-items: stretch; + } + .field { + flex-basis: 100%; + } + .chips { + flex: 1 1 100%; + } + .chip { + flex: 1 1 auto; + } + .sort { + flex: 1 1 100%; + margin-left: 0; + } + .sort select { + flex: 1; + } + } + `, +}) +export class Destinations { + readonly q = input(); + readonly region = input(); + readonly sort = input(); + readonly saved = input(); + + protected readonly store = inject(TravelStore); + private readonly router = inject(Router); + protected readonly sorts = SORTS; + protected readonly regionOptions = computed(() => ['All', ...this.store.regions()] as const); + protected readonly savedOnly = computed(() => !!this.saved()); + protected readonly shown = computed(() => + this.savedOnly() + ? this.store.results().filter((d) => this.store.saved().includes(d.id)) + : this.store.results(), + ); + + constructor() { + effect(() => { + this.store.setQuery(this.q() ?? ''); + const region = this.region(); + this.store.setRegion( + region && this.store.regions().includes(region as Region) ? (region as Region) : 'All', + ); + const sort = this.sort(); + this.store.setSort(SORTS.some((s) => s.id === sort) ? (sort as SortOrder) : 'popular'); + }); + } + + protected update(params: Record) { + void this.router.navigate([], { + queryParams: params, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } +} diff --git a/src/app/pages/home.ts b/src/app/pages/home.ts index a411f44..4cc8919 100644 --- a/src/app/pages/home.ts +++ b/src/app/pages/home.ts @@ -1,40 +1,303 @@ -import { Component, signal } from '@angular/core'; +import { NgOptimizedImage } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { DestinationCard } from '../travel/destination-card'; +import { TravelStore } from '../travel/travel.store'; @Component({ selector: 'app-home', + imports: [NgOptimizedImage, RouterLink, DestinationCard], template: ` -
    -

    Welcome to Angular DevTools Demo

    -

    Counter: {{ counter() }}

    - +
    + +
    +

    Small group trips · {{ store.destinations().length }} destinations

    +

    Trips planned around how you like to travel

    +

    + Hand-picked stays, local guides and routes that leave room for slow mornings. +

    + +
    +
    + +
    +
    +
    +

    Popular this season

    + +
    + View all {{ store.destinations().length }} +
    +
    + @for (destination of featured(); track destination.id; let first = $first) { + + } +
    +
    + +
    +

    How it works

    +
      +
    1. + +

      Pick a trip

      +

      Every route is walked by our team first, so the days are paced for real people.

      +
    2. +
    3. + +

      Book in two minutes

      +

      Choose a date and how many are coming. Change it for free up to 30 days before.

      +
    4. +
    5. + +

      Just show up

      +

      Transfers, stays and tickets are in the app, with a local host on call.

      +
    6. +
    `, styles: ` - section { - padding: 24px; + .hero { + position: relative; + display: grid; + align-items: end; + min-height: min(78vh, 640px); + overflow: hidden; + background: #0c0c10; + color: #fff; } - h1 { - font-size: 24px; + .hero-photo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + .hero::after { + content: ''; + position: absolute; + inset: 0; + background: + linear-gradient( + 90deg, + rgba(12, 12, 16, 0.8) 0%, + rgba(12, 12, 16, 0.5) 45%, + rgba(12, 12, 16, 0.05) 80% + ), + linear-gradient(180deg, rgba(12, 12, 16, 0) 40%, rgba(12, 12, 16, 0.75) 100%); + } + .hero-inner { + position: relative; + z-index: 1; + padding-top: 96px; + padding-bottom: 56px; + animation: rise 0.6s var(--ease) both; + } + @keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + } + .hero .eyebrow { margin-bottom: 12px; + color: var(--accent); + font-variant-numeric: tabular-nums; + } + h1 { + max-width: 16ch; + margin: 0 0 16px; + font-size: clamp(34px, 5.5vw, 60px); + line-height: 1.05; + letter-spacing: -0.03em; } - button { - padding: 8px 16px; - border: 1px solid var(--line-strong); - border-radius: 6px; + .lead { + max-width: 52ch; + margin: 0 0 32px; + color: rgba(255, 255, 255, 0.88); + font-size: 18px; + line-height: 1.5; + text-wrap: pretty; + } + .search { + position: relative; + display: flex; + align-items: center; + gap: 8px; + max-width: 560px; + padding: 6px; + border-radius: 14px; background: var(--surface); color: var(--ink); - cursor: pointer; + box-shadow: 0 24px 48px -20px rgba(0, 0, 0, 0.6); + } + .search-icon { + position: absolute; + top: 50%; + left: 18px; + color: var(--muted); + transform: translateY(-50%); + pointer-events: none; + } + .search input { + flex: 1; + min-width: 0; + min-height: var(--control-h); + padding: 0 12px 0 38px; + border: none; + background: transparent; + color: var(--ink); + font-size: 16px; } - button:hover { - border-color: var(--brand); + .search input::placeholder { + color: var(--muted); + opacity: 1; + } + .search input:focus-visible { + outline: none; + } + .search:focus-within { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .section { + padding-top: 72px; + } + .section-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: 16px; + margin-bottom: 24px; + } + .section-head .btn { + flex: none; + font-variant-numeric: tabular-nums; + } + h2 { + margin: 0; + font-size: clamp(24px, 3vw, 32px); + line-height: 1.2; + letter-spacing: -0.02em; + } + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); + gap: 24px; + } + .steps { + padding-bottom: 80px; + } + .steps ol { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); + gap: 16px; + margin: 24px 0 0; + padding: 0; + list-style: none; + } + .steps li { + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); + } + .num { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin-bottom: 16px; + border-radius: 50%; + background: var(--brand-soft); color: var(--brand); + font-weight: 700; + font-variant-numeric: tabular-nums; + } + .steps h3 { + margin: 0 0 8px; + font-size: 17px; + line-height: 1.3; + } + .steps p { + margin: 0; + color: var(--muted); + text-wrap: pretty; + } + @media (max-width: 600px) { + .hero-inner { + padding-top: 72px; + padding-bottom: 40px; + } + .lead { + font-size: 16px; + } + .search { + flex-direction: column; + align-items: stretch; + } + .search-icon { + top: 26px; + } + .search input { + min-height: var(--control-h); + } + .section { + padding-top: 56px; + } + .section-head { + flex-direction: column; + align-items: start; + } } `, }) export class Home { - counter = signal(0); + protected readonly store = inject(TravelStore); + private readonly router = inject(Router); + protected readonly query = signal(''); + protected readonly featured = computed(() => + [...this.store.destinations()].sort((a, b) => b.rating - a.rating).slice(0, 3), + ); - increment() { - this.counter.update((c) => c + 1); + protected search(event: Event) { + event.preventDefault(); + const q = this.query().trim(); + void this.router.navigate(['/destinations'], { queryParams: q ? { q } : {} }); } } diff --git a/src/app/pages/sign-in.ts b/src/app/pages/sign-in.ts new file mode 100644 index 0000000..80a02dd --- /dev/null +++ b/src/app/pages/sign-in.ts @@ -0,0 +1,210 @@ +import { Component, inject, input, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { Account } from '../travel/auth'; + +@Component({ + selector: 'app-sign-in', + imports: [ReactiveFormsModule], + template: ` +
    +
    +
    +

    Welcome back

    +

    Sign in

    +

    Use any name and email. This demo keeps nothing.

    +
    + +
    + + +

    + @if (invalid('name')) { + Enter your name + } +

    +
    + +
    + + +

    + @if (invalid('email')) { + Enter a valid email address + } +

    +
    + +

    {{ status() }}

    + +
    +
    + `, + styles: ` + .page { + padding-top: 56px; + padding-bottom: 72px; + } + .panel { + display: grid; + gap: 4px; + max-width: 420px; + margin: 0 auto; + padding: 32px 28px 28px; + border: 1px solid var(--line); + border-radius: var(--radius-lg, 16px); + background: var(--surface); + box-shadow: var(--shadow-sm); + } + .head { + margin-bottom: 16px; + } + h1 { + margin: 0; + font-size: 28px; + line-height: 1.2; + letter-spacing: -0.02em; + } + .muted { + margin: 8px 0 0; + color: var(--muted); + font-size: 14px; + } + .field { + display: grid; + gap: 6px; + } + label { + font-size: 14px; + font-weight: 600; + } + input { + box-sizing: border-box; + width: 100%; + min-width: 0; + height: var(--control-h, 40px); + padding: 0 12px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--surface); + color: var(--ink); + transition: + border-color 0.15s var(--ease, ease), + box-shadow 0.15s var(--ease, ease); + } + input:hover { + border-color: var(--muted); + } + input:focus-visible { + border-color: var(--brand); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 28%, transparent); + } + input[aria-invalid='true'] { + border-color: var(--danger-ink); + } + input[aria-invalid='true']:focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger-ink) 24%, transparent); + } + .error { + display: flex; + align-items: flex-start; + gap: 6px; + min-height: 24px; + margin: 0; + padding-top: 2px; + color: var(--danger-ink); + font-size: 13px; + line-height: 1.4; + } + .error:not(:empty)::before, + .status::before { + content: ''; + flex: none; + width: 14px; + height: 14px; + margin-top: 2px; + background: currentColor; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='9.5'/%3E%3Cpath d='M12 7.5v5.5M12 16.5v.01'/%3E%3C/svg%3E") + center / contain no-repeat; + } + .status { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--danger-ink) 35%, transparent); + border-radius: 10px; + background: var(--danger-soft); + color: var(--danger-ink); + font-size: 14px; + font-weight: 500; + } + .status:empty { + margin: 0; + padding: 0; + border: 0; + } + .status:empty::before { + display: none; + } + .wide { + width: 100%; + margin-top: 4px; + } + @media (max-width: 480px) { + .page { + padding-top: 32px; + } + .panel { + padding: 24px 16px 20px; + } + } + `, +}) +export class SignIn { + readonly returnUrl = input(); + private readonly account = inject(Account); + private readonly router = inject(Router); + protected readonly status = signal(''); + + protected readonly form = new FormGroup({ + name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + email: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.email], + }), + }); + + protected invalid(name: 'name' | 'email') { + const control = this.form.controls[name]; + return control.invalid && (control.touched || control.dirty); + } + + protected submit() { + if (this.form.invalid) { + this.form.markAllAsTouched(); + this.status.set('Check the highlighted fields.'); + return; + } + const { name, email } = this.form.getRawValue(); + this.account.signIn(name, email); + const target = this.returnUrl(); + void this.router.navigateByUrl(target?.startsWith('/') ? target : '/trips'); + } +} diff --git a/src/app/pages/trips.ts b/src/app/pages/trips.ts new file mode 100644 index 0000000..e1acf0c --- /dev/null +++ b/src/app/pages/trips.ts @@ -0,0 +1,286 @@ +import { CurrencyPipe, NgOptimizedImage } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Account } from '../travel/auth'; +import { TravelStore } from '../travel/travel.store'; + +@Component({ + selector: 'app-trips', + imports: [RouterLink, CurrencyPipe, NgOptimizedImage], + template: ` +
    +

    Signed in as {{ account.name() }}

    +

    My trips

    +

    {{ message() }}

    + + @if (trips().length) { +
      + @for (trip of trips(); track trip.booking.id) { +
    • + @if (trip.destination; as place) { + + } @else { + + } +
      +

      {{ trip.booking.id }}

      +

      + {{ + trip.booking.destinationName + }} +

      +

      + Departs + + · {{ trip.booking.travelers }} + {{ trip.booking.travelers === 1 ? 'traveler' : 'travelers' }} +

      +
      +
      +

      {{ trip.booking.total | currency: 'EUR' : 'symbol' : '1.0-0' }}

      + +
      +
    • + } +
    + } @else { +
    + +

    No trips booked yet

    +

    When you book a trip it shows up here, with everything you need for the day.

    + Browse Destinations +
    + } +
    + `, + styles: ` + .page { + padding-top: 40px; + padding-bottom: 72px; + } + .signed { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + h1 { + margin: 0 0 16px; + font-size: clamp(28px, 4vw, 40px); + line-height: 1.15; + letter-spacing: -0.02em; + } + .status { + margin: 0 0 16px; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--ok-ink) 30%, transparent); + border-radius: var(--radius, 12px); + background: var(--ok-soft); + color: var(--ok-ink); + font-size: 14px; + font-weight: 500; + font-variant-numeric: tabular-nums; + animation: fade 0.2s var(--ease, ease-out); + } + .status:empty { + margin: 0; + padding: 0; + border: 0; + } + .trips { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; + } + .trip { + display: grid; + grid-template-columns: 160px minmax(0, 1fr) auto; + gap: 20px; + align-items: center; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-lg, 16px); + background: var(--surface); + box-shadow: var(--shadow-sm); + transition: border-color 0.15s var(--ease, ease); + } + .trip:hover { + border-color: var(--line-strong); + } + .trip img, + .thumb { + display: block; + width: 160px; + height: 100px; + object-fit: cover; + border-radius: var(--radius-sm, 8px); + background: var(--subtle); + } + .thumb { + display: grid; + place-items: center; + color: var(--muted); + } + .thumb svg, + .empty-mark svg { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + .info { + min-width: 0; + } + .ref { + margin: 0; + color: var(--brand); + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; + } + h2 { + margin: 4px 0; + font-size: 19px; + line-height: 1.3; + overflow-wrap: anywhere; + } + h2 a { + color: var(--ink); + text-decoration: none; + text-underline-offset: 3px; + } + h2 a:hover { + color: var(--brand); + text-decoration: underline; + } + .muted { + margin: 0; + color: var(--muted); + font-size: 14px; + font-variant-numeric: tabular-nums; + } + .side { + display: grid; + justify-items: end; + gap: 8px; + padding-right: 8px; + } + .total { + margin: 0; + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .cancel { + min-height: 36px; + padding: 0 14px; + font-size: 14px; + } + .cancel:hover { + border-color: var(--danger-ink); + background: var(--danger-soft); + color: var(--danger-ink); + } + .empty { + display: grid; + justify-items: center; + padding: 56px 24px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-lg, 16px); + background: var(--surface); + text-align: center; + } + .empty-mark { + display: grid; + place-items: center; + width: 48px; + height: 48px; + margin-bottom: 16px; + border-radius: 50%; + background: var(--brand-soft); + color: var(--brand); + } + .empty h2 { + margin: 0 0 8px; + font-size: 20px; + } + .empty p { + max-width: 44ch; + margin: 0 0 24px; + color: var(--muted); + } + @keyframes fade { + from { + opacity: 0; + transform: translateY(-4px); + } + } + @media (max-width: 700px) { + .trip { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + } + .trip img, + .thumb { + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + } + .info { + padding: 0 4px; + } + .side { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + padding: 12px 4px 4px; + border-top: 1px solid var(--line); + } + } + @media (max-width: 480px) { + .empty { + padding: 40px 16px; + } + } + `, +}) +export class Trips { + protected readonly account = inject(Account); + private readonly store = inject(TravelStore); + protected readonly message = signal(''); + protected readonly trips = computed(() => { + const email = this.account.email(); + return this.store + .upcomingTrips() + .filter((booking) => booking.email === email) + .map((booking) => ({ + booking, + destination: this.store.destinations().find((d) => d.id === booking.destinationId), + })); + }); + + protected cancel(id: string) { + if (!this.trips().some((trip) => trip.booking.id === id)) return; + this.store.cancel(id); + this.message.set(`Trip ${id} was cancelled and the seats were released.`); + } +} diff --git a/src/app/products/product-detail.ts b/src/app/products/product-detail.ts deleted file mode 100644 index c745ec2..0000000 --- a/src/app/products/product-detail.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Component, inject, OnInit } from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { CurrencyPipe } from '@angular/common'; -import { ProductStore } from './product.store'; - -@Component({ - selector: 'app-product-detail', - imports: [RouterLink, CurrencyPipe], - template: ` -
    - ← Back to Products - - @if (store.selectedProduct(); as product) { -
    -

    {{ product.name }}

    -

    {{ product.price | currency }}

    - {{ product.category }} - - {{ product.inStock ? 'In Stock' : 'Out of Stock' }} - -

    {{ product.description }}

    - -
    - } @else { -

    Product not found.

    - } -
    - `, - styles: ` - section { - padding: 24px; - max-width: 600px; - } - .back { - font-size: 14px; - color: var(--brand); - text-decoration: none; - } - .back:hover { - text-decoration: underline; - } - .detail { - margin-top: 20px; - } - h1 { - font-size: 28px; - margin-bottom: 8px; - } - .price { - font-size: 24px; - font-weight: 700; - color: var(--brand); - margin-bottom: 12px; - } - .category { - font-size: 13px; - color: var(--muted); - margin-right: 12px; - } - .stock { - font-size: 12px; - padding: 2px 8px; - border-radius: 99px; - background: var(--ok-soft); - color: var(--ok-ink); - } - .stock.out { - background: var(--danger-soft); - color: var(--danger-ink); - } - .description { - margin-top: 16px; - font-size: 15px; - color: var(--ink); - line-height: 1.6; - } - button { - margin-top: 20px; - padding: 10px 20px; - border: 1px solid var(--brand); - background: var(--brand); - color: var(--brand-ink); - border-radius: 6px; - cursor: pointer; - font-size: 14px; - } - button:hover { - background: var(--brand-strong); - } - .not-found { - color: var(--muted); - padding: 40px 0; - } - `, -}) -export class ProductDetail implements OnInit { - readonly store = inject(ProductStore); - private readonly route = inject(ActivatedRoute); - - ngOnInit() { - if (this.store.totalProducts() === 0) { - this.store.loadProducts(); - } - const id = Number(this.route.snapshot.paramMap.get('id')); - this.store.selectProduct(id); - } -} diff --git a/src/app/products/product-list.ts b/src/app/products/product-list.ts deleted file mode 100644 index 6cc4864..0000000 --- a/src/app/products/product-list.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Component, inject, OnInit } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { ProductStore } from './product.store'; -import { CurrencyPipe } from '@angular/common'; - -@Component({ - selector: 'app-product-list', - imports: [RouterLink, CurrencyPipe], - template: ` -
    -

    Products ({{ store.totalProducts() }})

    - -
    - - - {{ store.inStockCount() }} in stock -
    - - @if (store.isLoading()) { -

    Loading products…

    - } @else { -
    - @for (product of store.filteredProducts(); track product.id) { - -

    {{ product.name }}

    -

    {{ product.price | currency }}

    -

    {{ product.category }}

    - - {{ product.inStock ? 'In Stock' : 'Out of Stock' }} - -
    - } @empty { -

    No products match your filters.

    - } -
    - } -
    - `, - styles: ` - section { - padding: 24px; - } - h1 { - font-size: 24px; - margin-bottom: 16px; - } - .toolbar { - display: flex; - gap: 12px; - align-items: center; - margin-bottom: 20px; - flex-wrap: wrap; - } - input, - select { - background: var(--surface); - color: var(--ink); - padding: 8px 12px; - border: 1px solid var(--line-strong); - border-radius: 6px; - font-size: 14px; - } - input { - flex: 1; - min-width: 200px; - } - .stock-info { - font-size: 13px; - color: var(--muted); - } - .loading { - color: var(--muted); - text-align: center; - padding: 40px; - } - .grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 16px; - } - .card { - display: block; - padding: 20px; - border: 1px solid var(--line); - border-radius: 10px; - text-decoration: none; - color: inherit; - transition: - border-color 0.15s, - box-shadow 0.15s; - } - .card:hover { - border-color: var(--brand); - box-shadow: 0 2px 8px var(--shadow); - } - h3 { - font-size: 16px; - margin-bottom: 8px; - } - .price { - font-size: 20px; - font-weight: 700; - color: var(--brand); - margin-bottom: 4px; - } - .category { - font-size: 13px; - color: var(--muted); - margin-bottom: 8px; - } - .stock { - font-size: 12px; - padding: 2px 8px; - border-radius: 99px; - background: var(--ok-soft); - color: var(--ok-ink); - } - .stock.out { - background: var(--danger-soft); - color: var(--danger-ink); - } - .empty { - color: var(--muted); - text-align: center; - grid-column: 1 / -1; - padding: 40px; - } - `, -}) -export class ProductList implements OnInit { - readonly store = inject(ProductStore); - - ngOnInit() { - if (this.store.totalProducts() === 0) { - this.store.loadProducts(); - } - } -} diff --git a/src/app/products/product.store.ts b/src/app/products/product.store.ts deleted file mode 100644 index 6df7004..0000000 --- a/src/app/products/product.store.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { computed } from '@angular/core'; -import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals'; -import { type Product, MOCK_PRODUCTS } from './product'; - -type ProductState = { - products: Product[]; - selectedProductId: number | null; - filter: { query: string; category: string }; - isLoading: boolean; -}; - -const initialState: ProductState = { - products: [], - selectedProductId: null, - filter: { query: '', category: '' }, - isLoading: false, -}; - -export const ProductStore = signalStore( - { providedIn: 'root' }, - withState(initialState), - withComputed(({ products, filter, selectedProductId }) => ({ - filteredProducts: computed(() => { - let items = products(); - const query = filter.query().toLowerCase(); - const category = filter.category(); - - if (query) { - items = items.filter( - (p) => - p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query), - ); - } - if (category) { - items = items.filter((p) => p.category === category); - } - return items; - }), - selectedProduct: computed(() => { - const id = selectedProductId(); - return id ? (products().find((p) => p.id === id) ?? null) : null; - }), - categories: computed(() => [...new Set(products().map((p) => p.category))]), - totalProducts: computed(() => products().length), - inStockCount: computed(() => products().filter((p) => p.inStock).length), - })), - withMethods((store) => ({ - loadProducts(): void { - if (store.isLoading()) return; - patchState(store, { isLoading: true }); - setTimeout(() => { - patchState(store, { products: MOCK_PRODUCTS, isLoading: false }); - }, 300); - }, - selectProduct(id: number | null): void { - patchState(store, { selectedProductId: id }); - }, - updateFilter(query: string): void { - patchState(store, (state) => ({ - filter: { ...state.filter, query }, - })); - }, - filterByCategory(category: string): void { - patchState(store, (state) => ({ - filter: { ...state.filter, category }, - })); - }, - toggleStock(productId: number): void { - patchState(store, (state) => ({ - products: state.products.map((p) => - p.id === productId ? { ...p, inStock: !p.inStock } : p, - ), - })); - }, - })), -); diff --git a/src/app/products/product.ts b/src/app/products/product.ts deleted file mode 100644 index a5a1c9a..0000000 --- a/src/app/products/product.ts +++ /dev/null @@ -1,59 +0,0 @@ -export interface Product { - id: number; - name: string; - price: number; - description: string; - category: string; - inStock: boolean; -} - -export const MOCK_PRODUCTS: Product[] = [ - { - id: 1, - name: 'Wireless Headphones', - price: 79.99, - description: 'Noise-cancelling over-ear headphones with 30h battery life.', - category: 'Electronics', - inStock: true, - }, - { - id: 2, - name: 'Mechanical Keyboard', - price: 129.99, - description: 'RGB backlit mechanical keyboard with Cherry MX switches.', - category: 'Electronics', - inStock: true, - }, - { - id: 3, - name: 'Running Shoes', - price: 99.99, - description: 'Lightweight running shoes with responsive cushioning.', - category: 'Sports', - inStock: false, - }, - { - id: 4, - name: 'Coffee Maker', - price: 49.99, - description: '12-cup programmable drip coffee maker.', - category: 'Home', - inStock: true, - }, - { - id: 5, - name: 'Backpack', - price: 59.99, - description: 'Water-resistant laptop backpack with USB charging port.', - category: 'Accessories', - inStock: true, - }, - { - id: 6, - name: 'Yoga Mat', - price: 29.99, - description: 'Non-slip exercise mat with carrying strap.', - category: 'Sports', - inStock: true, - }, -]; diff --git a/src/app/theme-toggle.ts b/src/app/theme-toggle.ts index 02d1a27..92af9f2 100644 --- a/src/app/theme-toggle.ts +++ b/src/app/theme-toggle.ts @@ -6,7 +6,6 @@ type Theme = 'system' | 'light' | 'dark'; const STORAGE_KEY = 'ng-devtools-demo-theme'; const NEXT: Record = { system: 'light', light: 'dark', dark: 'system' }; const LABEL: Record = { system: 'System', light: 'Light', dark: 'Dark' }; -const ICON: Record = { system: '◐', light: '☀', dark: '☾' }; @Component({ selector: 'app-theme-toggle', @@ -16,30 +15,82 @@ const ICON: Record = { system: '◐', light: '☀', dark: '☾' } [attr.aria-label]="'Theme: ' + label() + '. Activate to switch.'" (click)="next()" > - - {{ label() }} + + {{ label() }} `, styles: ` + :host { + display: inline-flex; + } button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; - /* Fixed, so cycling the label does not resize the button. */ - min-width: 96px; - padding: 5px 12px; - border: 1px solid var(--line-strong); - border-radius: 6px; + min-width: 92px; + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); background: var(--surface); color: var(--muted); font-size: 13px; font-weight: 500; + line-height: 1; cursor: pointer; + transition: + border-color 0.15s var(--ease), + background-color 0.15s var(--ease), + color 0.15s var(--ease); } button:hover { - color: var(--ink); border-color: var(--line-strong); + background: var(--subtle); + color: var(--ink); + } + svg { + flex: none; + } + @media (max-width: 520px) { + button { + min-width: 34px; + padding: 0; + } + .label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } } `, }) @@ -49,7 +100,6 @@ export class ThemeToggle { readonly theme = signal(this.restore()); readonly label = computed(() => LABEL[this.theme()]); - readonly icon = computed(() => ICON[this.theme()]); constructor() { effect(() => { diff --git a/src/app/travel/auth.ts b/src/app/travel/auth.ts new file mode 100644 index 0000000..064ac45 --- /dev/null +++ b/src/app/travel/auth.ts @@ -0,0 +1,24 @@ +import { Service, computed, inject, signal } from '@angular/core'; +import { Router, type CanActivateFn } from '@angular/router'; + +@Service() +export class Account { + readonly name = signal(null); + readonly email = signal(null); + readonly signedIn = computed(() => this.name() !== null); + + signIn(name: string, email: string) { + this.name.set(name); + this.email.set(email); + } + + signOut() { + this.name.set(null); + this.email.set(null); + } +} + +export const signedInGuard: CanActivateFn = (_route, state) => { + if (inject(Account).signedIn()) return true; + return inject(Router).createUrlTree(['/sign-in'], { queryParams: { returnUrl: state.url } }); +}; diff --git a/src/app/travel/destination-card.ts b/src/app/travel/destination-card.ts new file mode 100644 index 0000000..d0a453a --- /dev/null +++ b/src/app/travel/destination-card.ts @@ -0,0 +1,281 @@ +import { CurrencyPipe, DecimalPipe, NgOptimizedImage } from '@angular/common'; +import { Component, computed, inject, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import type { Destination } from './destination'; +import { TravelStore } from './travel.store'; + +@Component({ + selector: 'app-destination-card', + imports: [RouterLink, NgOptimizedImage, CurrencyPipe, DecimalPipe], + template: ` +
    +
    + + + @if (!destination().seats) { + Fully Booked + } @else if (destination().seats <= 3) { + {{ destination().seats }} seats left + } +
    +
    +

    + {{ destination().country }} + + {{ destination().nights }} nights +

    +

    + {{ destination().name }} +

    +

    {{ destination().summary }}

    +
    + + + {{ destination().rating | number: '1.1-1' }} + ({{ destination().reviews }}) + + + from + {{ destination().price | currency: 'EUR' : 'symbol' : '1.0-0' }} + +
    +
    +
    + `, + styles: ` + :host { + display: block; + min-width: 0; + } + .card { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); + transition: + transform 0.2s var(--ease), + box-shadow 0.2s var(--ease), + border-color 0.2s var(--ease); + } + .card:hover { + transform: translateY(-2px); + border-color: var(--line-strong); + box-shadow: 0 16px 32px -16px var(--shadow); + } + .card:has(h3 a:focus-visible) { + outline: 2px solid var(--brand); + outline-offset: 2px; + } + .photo { + position: relative; + aspect-ratio: 16 / 10; + overflow: hidden; + background: var(--subtle); + } + .photo img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s var(--ease); + } + .card:hover .photo img { + transform: scale(1.03); + } + .save { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + display: grid; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(12, 12, 16, 0.55); + color: #fff; + cursor: pointer; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + transition: + background-color 0.15s var(--ease), + transform 0.15s var(--ease); + } + .save:hover { + background: rgba(12, 12, 16, 0.75); + } + .save:active { + transform: scale(0.92); + } + .save:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; + box-shadow: 0 0 0 5px rgba(12, 12, 16, 0.6); + } + .save.on { + background: var(--accent); + color: var(--accent-ink); + } + .save.on:hover { + background: var(--accent-strong); + } + .tag { + position: absolute; + left: 12px; + bottom: 12px; + padding: 4px 10px; + border-radius: 99px; + font-size: 12px; + font-weight: 600; + line-height: 1.3; + font-variant-numeric: tabular-nums; + } + .tag.low { + background: var(--accent); + color: var(--accent-ink); + } + .tag.sold { + background: rgba(12, 12, 16, 0.8); + color: #fff; + } + .body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + padding: 16px; + } + .where { + display: flex; + gap: 6px; + min-width: 0; + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + } + .country { + overflow: hidden; + text-overflow: ellipsis; + } + .nights { + flex: none; + font-variant-numeric: tabular-nums; + } + h3 { + margin: 0; + font-size: 18px; + line-height: 1.3; + letter-spacing: -0.01em; + overflow-wrap: anywhere; + } + h3 a { + color: var(--ink); + text-decoration: none; + } + h3 a::after { + content: ''; + position: absolute; + inset: 0; + } + h3 a:focus-visible { + outline: none; + } + .card:hover h3 a { + color: var(--brand); + } + .summary { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.5; + text-wrap: pretty; + } + .meta { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 4px 12px; + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--line); + font-size: 14px; + font-variant-numeric: tabular-nums; + } + .rating { + font-weight: 600; + } + .star { + color: var(--brand); + } + .price { + font-size: 16px; + font-weight: 700; + } + .muted { + color: var(--muted); + font-size: 13px; + font-weight: 400; + } + @media (prefers-reduced-motion: reduce) { + .card, + .photo img { + transition: none; + } + .card:hover, + .card:hover .photo img { + transform: none; + } + } + `, +}) +export class DestinationCard { + readonly destination = input.required(); + readonly priority = input(false); + protected readonly store = inject(TravelStore); + protected readonly saved = computed(() => this.store.saved().includes(this.destination().id)); +} diff --git a/src/app/travel/destination.ts b/src/app/travel/destination.ts new file mode 100644 index 0000000..d5a883f --- /dev/null +++ b/src/app/travel/destination.ts @@ -0,0 +1,179 @@ +export type Region = 'Europe' | 'Asia' | 'Africa'; + +export interface Destination { + id: string; + name: string; + country: string; + region: Region; + image: string; + imageAlt: string; + price: number; + nights: number; + rating: number; + reviews: number; + summary: string; + description: string; + highlights: string[]; + bestTime: string; + seats: number; + groupSize: number; +} + +export const DESTINATIONS: Destination[] = [ + { + id: 'istanbul', + name: 'Istanbul', + country: 'Türkiye', + region: 'Europe', + image: '/destinations/istanbul.webp', + imageAlt: 'A ferry on the Bosphorus with the Süleymaniye Mosque above the old city', + price: 990, + nights: 6, + rating: 4.9, + reviews: 874, + summary: 'Two continents, ferry rides at sunset and the best breakfast in the world.', + description: + 'Wander the Grand Bazaar and Balat, take the ferry to Kadıköy for a food walk on the Asian side and watch the sunset over the Golden Horn from a rooftop in Galata. Mornings start with a proper Turkish breakfast.', + highlights: ['Bosphorus ferry at sunset', 'Food walk in Kadıköy', 'Hagia Sophia and Topkapı'], + bestTime: 'April to June, September to November', + seats: 7, + groupSize: 12, + }, + { + id: 'santorini', + name: 'Santorini', + country: 'Greece', + region: 'Europe', + image: '/destinations/santorini.webp', + imageAlt: 'A blue dome above white houses on a cliff over the Aegean Sea', + price: 1290, + nights: 6, + rating: 4.9, + reviews: 412, + summary: 'Cliffside villages, volcanic beaches and the calmest sunsets in the Aegean.', + description: + 'Stay in a cave suite in Oia, sail around the caldera and spend slow afternoons on black sand beaches. The trip includes a wine tasting at a family vineyard in Pyrgos.', + highlights: ['Caldera sailing trip', 'Cave suite in Oia', 'Vineyard tasting in Pyrgos'], + bestTime: 'May to October', + seats: 8, + groupSize: 12, + }, + { + id: 'kyoto', + name: 'Kyoto', + country: 'Japan', + region: 'Asia', + image: '/destinations/kyoto.webp', + imageAlt: 'A quiet stone street lined with wooden houses and lanterns at dusk', + price: 1840, + nights: 8, + rating: 4.8, + reviews: 538, + summary: 'Temple mornings, lantern-lit streets and a tea ceremony in Gion.', + description: + 'Walk the Higashiyama lanes before the crowds, visit Fushimi Inari at sunrise and join a tea ceremony with a local host. Nights are split between a ryokan and a design hotel.', + highlights: ['Sunrise at Fushimi Inari', 'Tea ceremony in Gion', 'Two nights in a ryokan'], + bestTime: 'March to May, October to November', + seats: 3, + groupSize: 12, + }, + { + id: 'lisbon', + name: 'Lisbon', + country: 'Portugal', + region: 'Europe', + image: '/destinations/lisbon.webp', + imageAlt: 'A yellow tram climbing a narrow street between old buildings', + price: 890, + nights: 5, + rating: 4.7, + reviews: 689, + summary: 'Tram 28, tiled facades and pastel de nata on every corner.', + description: + 'Ride the historic trams through Alfama, take a day trip to Sintra and end each evening with fado in a small tavern. Your hotel sits between Chiado and the river.', + highlights: ['Day trip to Sintra', 'Fado night in Alfama', 'Food walk in Mouraria'], + bestTime: 'April to October', + seats: 12, + groupSize: 12, + }, + { + id: 'iceland', + name: 'Northern Iceland', + country: 'Iceland', + region: 'Europe', + image: '/destinations/iceland.webp', + imageAlt: 'A wide waterfall pouring into a turquoise river under a grey sky', + price: 2150, + nights: 7, + rating: 4.9, + reviews: 204, + summary: 'Waterfalls, geothermal baths and whale watching in Húsavík.', + description: + 'Drive the Diamond Circle with a local guide, soak in the Mývatn Nature Baths and head out on a whale watching trip from Húsavík. Small group, maximum 10 travelers.', + highlights: ['Goðafoss and Dettifoss', 'Mývatn Nature Baths', 'Whale watching'], + bestTime: 'June to September', + seats: 0, + groupSize: 10, + }, + { + id: 'bali', + name: 'Ubud', + country: 'Indonesia', + region: 'Asia', + image: '/destinations/bali.webp', + imageAlt: 'Green rice terraces stepping down a hillside between palm trees', + price: 1120, + nights: 9, + rating: 4.6, + reviews: 751, + summary: 'Rice terraces, jungle villas and a cooking class with a Balinese family.', + description: + 'Stay in a villa over the Ayung river, walk the Tegallalang terraces at dawn and cook with a family in their compound. The last two nights are on the coast in Sanur.', + highlights: ['Tegallalang at dawn', 'Balinese cooking class', 'Two nights in Sanur'], + bestTime: 'April to October', + seats: 6, + groupSize: 12, + }, + { + id: 'swiss-alps', + name: 'Bernese Oberland', + country: 'Switzerland', + region: 'Europe', + image: '/destinations/swiss-alps.webp', + imageAlt: 'Snowy mountains above a still lake with a farmhouse on a hill', + price: 2480, + nights: 6, + rating: 4.8, + reviews: 318, + summary: 'Lakeside villages, mountain trains and alpine hikes above Lake Thun.', + description: + 'Base yourself in a lakeside chalet, ride the Jungfrau railway and hike between mountain huts. A Swiss Travel Pass covers every train, boat and cable car.', + highlights: ['Jungfrau railway', 'Hut-to-hut hike', 'Swiss Travel Pass included'], + bestTime: 'June to September', + seats: 5, + groupSize: 12, + }, + { + id: 'cape-town', + name: 'Cape Town', + country: 'South Africa', + region: 'Africa', + image: '/destinations/cape-town.webp', + imageAlt: 'Table Mountain rising above the city and the Atlantic coastline', + price: 1680, + nights: 8, + rating: 4.7, + reviews: 296, + summary: 'Table Mountain, Cape Point and wine country an hour away.', + description: + 'Hike or ride up Table Mountain, drive the Chapman’s Peak road to Cape Point and spend two nights in Franschhoek wine country. Includes a visit to Boulders Beach penguins.', + highlights: ['Table Mountain', 'Cape Point drive', 'Two nights in Franschhoek'], + bestTime: 'November to March', + seats: 9, + groupSize: 12, + }, +]; + +export function findDestination(id: string | null | undefined): Destination | undefined { + return DESTINATIONS.find((destination) => destination.id === id); +} diff --git a/src/app/travel/travel.store.ts b/src/app/travel/travel.store.ts new file mode 100644 index 0000000..5a50bf0 --- /dev/null +++ b/src/app/travel/travel.store.ts @@ -0,0 +1,110 @@ +import { computed } from '@angular/core'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { DESTINATIONS, type Destination, type Region } from './destination'; + +export type SortOrder = 'popular' | 'price' | 'rating'; + +export interface Booking { + id: string; + destinationId: string; + destinationName: string; + startDate: string; + travelers: number; + name: string; + email: string; + requests?: string; + total: number; +} + +interface TravelState { + destinations: Destination[]; + query: string; + region: Region | 'All'; + sort: SortOrder; + saved: string[]; + bookings: Booking[]; + nextBookingNumber: number; +} + +const initialState: TravelState = { + destinations: DESTINATIONS, + query: '', + region: 'All', + sort: 'popular', + saved: [], + bookings: [], + nextBookingNumber: 1041, +}; + +export const TravelStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withComputed(({ destinations, query, region, sort, saved, bookings }) => ({ + results: computed(() => { + const text = query().trim().toLowerCase(); + const list = destinations().filter( + (d) => + (region() === 'All' || d.region === region()) && + (!text || `${d.name} ${d.country} ${d.summary}`.toLowerCase().includes(text)), + ); + const order: Record number> = { + popular: (a, b) => b.reviews - a.reviews, + price: (a, b) => a.price - b.price, + rating: (a, b) => b.rating - a.rating, + }; + return [...list].sort(order[sort()]); + }), + regions: computed(() => [...new Set(destinations().map((d) => d.region))]), + savedDestinations: computed(() => destinations().filter((d) => saved().includes(d.id))), + savedCount: computed(() => saved().length), + upcomingTrips: computed(() => + [...bookings()].sort((a, b) => a.startDate.localeCompare(b.startDate)), + ), + })), + withMethods((store) => ({ + setQuery(query: string): void { + patchState(store, { query }); + }, + setRegion(region: Region | 'All'): void { + patchState(store, { region }); + }, + setSort(sort: SortOrder): void { + patchState(store, { sort }); + }, + isSaved(id: string): boolean { + return store.saved().includes(id); + }, + toggleSaved(id: string): void { + patchState(store, (state) => ({ + saved: state.saved.includes(id) + ? state.saved.filter((saved) => saved !== id) + : [...state.saved, id], + })); + }, + book(booking: Omit): Booking | null { + const destination = store.destinations().find((d) => d.id === booking.destinationId); + if (!destination || booking.travelers < 1 || booking.travelers > destination.seats) { + return null; + } + const created = { ...booking, id: `TRV-${store.nextBookingNumber()}` }; + patchState(store, (state) => ({ + nextBookingNumber: state.nextBookingNumber + 1, + bookings: [...state.bookings, created], + destinations: state.destinations.map((d) => + d.id === booking.destinationId ? { ...d, seats: d.seats - booking.travelers } : d, + ), + })); + return created; + }, + cancel(bookingId: string): void { + const booking = store.bookings().find((b) => b.id === bookingId); + if (!booking) return; + patchState(store, (state) => ({ + bookings: state.bookings.filter((b) => b.id !== bookingId), + destinations: state.destinations.map((d) => + d.id === booking.destinationId ? { ...d, seats: d.seats + booking.travelers } : d, + ), + })); + }, + })), +); diff --git a/src/app/travel/trip-routes.ts b/src/app/travel/trip-routes.ts new file mode 100644 index 0000000..427c85c --- /dev/null +++ b/src/app/travel/trip-routes.ts @@ -0,0 +1,26 @@ +import { inject } from '@angular/core'; +import { RedirectCommand, Router, type CanDeactivateFn, type ResolveFn } from '@angular/router'; +import type { Destination } from './destination'; +import { TravelStore } from './travel.store'; + +export const destinationResolver: ResolveFn = (route) => { + const found = inject(TravelStore) + .destinations() + .find((d) => d.id === route.paramMap.get('id')); + if (found) return found; + return new RedirectCommand(inject(Router).parseUrl('/destinations')); +}; + +export const destinationTitle: ResolveFn = (route) => { + const found = inject(TravelStore) + .destinations() + .find((d) => d.id === route.paramMap.get('id')); + return `${found?.name ?? 'Trip'} · Angular Travel`; +}; + +export interface LeavesSafely { + canLeave(): boolean; +} + +export const confirmLeaveGuard: CanDeactivateFn = (page) => + page.canLeave() || confirm('Leave this booking? The details you entered will be lost.'); diff --git a/src/index.html b/src/index.html index eeb0bad..3e343b2 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - AngularDevtools + Angular Travel @@ -27,23 +27,23 @@ --ink: #e8e8ed; --muted: #9ca3af; --line: #2a2a31; - --line-strong: #63636e; + --line-strong: #6e6e7a; --subtle: #1c1c22; --surface: #16161b; --page: #0c0c10; - --brand: #ff6b85; - --brand-strong: #ff97a9; - --brand-ink: #16161b; - --ng-devtools-title: #ff6b85; - --brand-soft: #3a1220; + --brand: #fbbf4d; + --brand-strong: #fcd34d; + --brand-ink: #1c1300; + --brand-soft: #3a2a0a; --ok-soft: #0f2e22; --ok-ink: #6ee7b7; --danger-soft: #3b1d1d; --danger-ink: #fca5a5; - --shadow: rgba(0, 0, 0, 0.4); + --shadow: rgba(0, 0, 0, 0.5); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); } } @@ -53,22 +53,23 @@ --ink: #e8e8ed; --muted: #9ca3af; --line: #2a2a31; - --line-strong: #63636e; + --line-strong: #6e6e7a; --subtle: #1c1c22; --surface: #16161b; --page: #0c0c10; - --brand: #ff6b85; - --brand-strong: #ff97a9; - --brand-ink: #16161b; - --brand-soft: #3a1220; + --brand: #fbbf4d; + --brand-strong: #fcd34d; + --brand-ink: #1c1300; + --brand-soft: #3a2a0a; --ok-soft: #0f2e22; --ok-ink: #6ee7b7; --danger-soft: #3b1d1d; --danger-ink: #fca5a5; - --shadow: rgba(0, 0, 0, 0.4); + --shadow: rgba(0, 0, 0, 0.5); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); } *, diff --git a/src/styles.css b/src/styles.css index b642143..ce42c79 100644 --- a/src/styles.css +++ b/src/styles.css @@ -10,46 +10,47 @@ --ink: #111827; --muted: #656c79; --line: #e5e7eb; - --line-strong: #8b9099; + --line-strong: #80858e; --subtle: #f3f4f6; --surface: #fff; --page: #fafafa; - /* Angular red, from the warm end of the logo gradient. */ - --brand: #cc0033; - --brand-strong: #a30028; + /* Amber, the devtools brand. Text-safe on light surfaces. */ + --brand: #b45309; + --brand-strong: #92400e; /* Text on a --brand background. */ --brand-ink: #fff; - --brand-soft: #ffe3e9; + --brand-soft: #fef3c7; + /* Filled call-to-action buttons, the same in both themes. */ + --accent: #f5a524; + --accent-strong: #e8930c; + --accent-ink: #1c1300; --ok-soft: #d1fae5; --ok-ink: #065f46; --danger-soft: #fee2e2; --danger-ink: #991b1b; - --shadow: rgba(0, 0, 0, 0.04); + --shadow: rgba(0, 0, 0, 0.08); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); - /* Themes the devtools launcher, which reads these through its shadow root. */ - --ng-devtools-accent: var(--angular-gradient); - --ng-devtools-accent-hover: var(--angular-gradient); - /* The popup chrome is dark in both themes, so this stays light. */ - --ng-devtools-title: #ff6b85; - --ng-devtools-accent-ink: #fff; + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 16px; + --control-h: 40px; + --ease: cubic-bezier(0.2, 0, 0, 1); - /* The Angular logo gradient, taken from the wordmark on angular.dev. */ - --angular-gradient: linear-gradient( - 92deg, - #e40035 0%, - #f60a48 24%, - #f20755 35.2%, - #dc087d 49.4%, - #9717e7 74.5%, - #6c00f5 100% - ); + --brand-gradient: linear-gradient(92deg, #f5a524 0%, #f97316 100%); } html { -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +::selection { + background: var(--brand-soft); + color: var(--ink); } body { @@ -67,6 +68,7 @@ body { font-size: 15px; line-height: 1.55; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } code, @@ -88,6 +90,18 @@ textarea { outline-offset: 2px; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -98,3 +112,114 @@ textarea { scroll-behavior: auto !important; } } + +.container { + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: 0 24px; +} + +@media (max-width: 600px) { + .container { + padding: 0 16px; + } +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: var(--control-h); + padding: 0 18px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--surface); + color: var(--ink); + font-size: 15px; + font-weight: 600; + line-height: 1.2; + text-decoration: none; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + border-color 0.15s var(--ease), + color 0.15s var(--ease), + transform 0.1s var(--ease); +} + +.btn:hover { + border-color: var(--ink); + background: var(--subtle); +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn-primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + box-shadow: var(--shadow-sm); +} + +.btn-primary:hover { + border-color: var(--accent-strong); + background: var(--accent-strong); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn:disabled:hover { + border-color: var(--line-strong); + background: var(--surface); +} + +.btn-primary:disabled:hover { + border-color: var(--accent); + background: var(--accent); +} + +.eyebrow { + margin: 0 0 8px; + color: var(--brand); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3 { + text-wrap: balance; +} + +textarea { + box-sizing: border-box; + display: block; + width: 100%; + max-width: 100%; + min-height: 5.5em; + max-height: 20em; + resize: vertical; + field-sizing: content; + line-height: 1.5; +} + +select:not([multiple]) { + appearance: none; + -webkit-appearance: none; + padding-right: 36px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238e8e99' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 14px; + text-overflow: ellipsis; + cursor: pointer; +}