diff --git a/.env.example b/.env.example index fe41ea9..6cc5913 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,5 @@ Seed__DemoAdminEmail=admin@widgetworks.demo Seed__DemoAdminPassword=DemoAdmin!Change01 Seed__DemoCustomerEmail=demo@widgetworks.demo Seed__DemoCustomerPassword=DemoUser!Change01 +Seed__DemoManagerEmail=manager@widgetworks.demo +Seed__DemoManagerPassword=DemoManager!Change01 diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 0000000..b6ac9ab --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,112 @@ +name: Deploy API + +# Fires ONLY for changes that can affect the compiled API. `paths` is an allowlist, so a +# docs-only or web-only commit simply does not match and no deployment runs — the SPA has its +# own workflow, and neither is triggered by markdown. +on: + push: + branches: [main] + paths: + - 'src/**' + - 'tests/**' + - 'Directory.Build.props' + - 'global.json' + - 'WidgetWorks.slnx' + - 'Dockerfile.api' + - 'docker-compose.yml' + - 'scripts/smoke-test.ps1' + - '.github/workflows/deploy-api.yml' + - '.github/workflows/test-suite.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-api + cancel-in-progress: false + +env: + APP_NAME: widgetworks-api-41d09d + RESOURCE_GROUP: rg-widgetworks + +jobs: + # The gate. Backend units, frontend units and the end-to-end smoke test must all pass; + # `needs: tests` below means a single failure stops the deployment entirely. + tests: + name: Tests + uses: ./.github/workflows/test-suite.yml + + deploy: + name: Publish and deploy the API + needs: tests + runs-on: ubuntu-latest + environment: production + # No stored Azure credential: id-token lets the job exchange a short-lived GitHub OIDC + # token for an Azure one. contents:read is all the checkout needs. + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + + # Publish to a directory OUTSIDE the workspace, so the artifact cannot pick up the + # repository. Deploying the repo would serve .cs sources and git history from wwwroot. + - name: Publish (Release) + run: dotnet publish src/WidgetWorks.WebApi/WidgetWorks.WebApi.csproj -c Release -o "${{ runner.temp }}/publish" --nologo + + - name: Audit the publish output + run: | + cd "${{ runner.temp }}/publish" + bad=$(find . \( -name '*.cs' -o -name '*.csproj' -o -name '*.sln*' -o -name '.env' \ + -o -name 'docker-compose*.yml' \) -print) + for d in .git node_modules src web tests docs; do + [ -e "$d" ] && bad="$bad $d/" + done + if [ -n "$(printf '%s' "$bad" | tr -d '[:space:]')" ]; then + echo "::error::Publish output contains files that must never be deployed:" + printf '%s\n' "$bad" + exit 1 + fi + test -f WidgetWorks.WebApi.dll || { echo "::error::app dll missing"; exit 1; } + test -f appsettings.json || { echo "::error::appsettings.json missing"; exit 1; } + echo "Clean: $(find . -type f | wc -l) build-output files" + + - name: Azure login (OIDC — no stored credential) + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy + uses: azure/webapps-deploy@2fdd5c3ebb4e540834e86ecc1f6fdcd5539023ee # v3.0.2 + with: + app-name: ${{ env.APP_NAME }} + package: ${{ runner.temp }}/publish + + # The API degrades to a 503 rather than crash-looping when the database is unreachable, + # so a bad deploy shows up here instead of silently eating the F1 tier's CPU quota. + - name: Health check (stops the app if unhealthy) + run: | + for i in $(seq 1 20); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \ + "https://${{ env.APP_NAME }}.azurewebsites.net/health" || true) + if [ "$code" = "200" ]; then echo "healthy"; exit 0; fi + if [ "$code" = "503" ]; then + echo "::error::App is up but unhealthy (database unreachable). Stopping it to protect CPU quota." + az webapp stop --name "${{ env.APP_NAME }}" --resource-group "${{ env.RESOURCE_GROUP }}" + exit 1 + fi + echo " $code (attempt $i/20)" + sleep 6 + done + echo "::error::No healthy response; stopping the app to protect CPU quota." + az webapp stop --name "${{ env.APP_NAME }}" --resource-group "${{ env.RESOURCE_GROUP }}" + exit 1 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..f1afb11 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,74 @@ +name: Deploy web + +# Fires ONLY for changes under web/. `paths` is an allowlist, so a docs-only or API-only commit +# does not match and no deployment runs. The API has its own workflow; neither is triggered by +# markdown. +on: + push: + branches: [main] + paths: + - 'web/**' + - '.github/workflows/deploy-web.yml' + - '.github/workflows/test-suite.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-web + cancel-in-progress: true + +jobs: + # The same gate the API deploy uses. The SPA is useless against a broken API, so the + # end-to-end smoke test guards this deployment too, not just the frontend unit tests. + tests: + name: Tests + uses: ./.github/workflows/test-suite.yml + + deploy: + name: Build and deploy the SPA + needs: tests + runs-on: ubuntu-latest + environment: production + defaults: + run: + working-directory: web + env: + # Public build-time config from Actions Variables. Vite inlines both into the bundle, so + # neither may ever be a secret: VITE_* values ship to every browser. + VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }} + VITE_GOOGLE_CLIENT_ID: ${{ vars.VITE_GOOGLE_CLIENT_ID }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install + run: npm ci --no-audit --no-fund + + - name: Build + run: npm run build + + # staticwebapp.config.json ships a placeholder because the API hostname is not known at + # authoring time. Without this substitution the CSP blocks every call the SPA makes to its + # own API, and Google sign-in fails silently. + - name: Point the CSP at the API + run: | + test -n "$VITE_API_BASE_URL" || { echo "::error::VITE_API_BASE_URL variable is not set"; exit 1; } + cfg=dist/staticwebapp.config.json + test -f "$cfg" || { echo "::error::$cfg missing — it must live in web/public/"; exit 1; } + sed -i "s|https://REPLACE_API_ORIGIN|${VITE_API_BASE_URL}|" "$cfg" + if grep -q REPLACE_API_ORIGIN "$cfg"; then + echo "::error::CSP placeholder not substituted"; exit 1 + fi + grep -o "connect-src[^;]*" "$cfg" + + # Uploads dist/ only — the built bundle, never the source tree. + - name: Deploy to Static Web Apps + uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9 # v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + action: upload + app_location: web/dist + skip_app_build: true + skip_api_build: true diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml new file mode 100644 index 0000000..ccf9a32 --- /dev/null +++ b/.github/workflows/test-suite.yml @@ -0,0 +1,76 @@ +name: Test suite + +# Reusable gate: every deployment calls this and will not proceed unless all three jobs pass. +# Kept in one file so the API and web deploys cannot drift apart on what "tests passed" means. +on: + workflow_call: + +permissions: + contents: read + +jobs: + backend: + name: Backend unit tests (xUnit) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '10.0.x' + - name: Test + run: dotnet test WidgetWorks.slnx --configuration Release --nologo + + frontend: + name: Frontend unit tests (Vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install + run: npm ci --no-audit --no-fund + - name: Test + run: npm test + - name: Build (type-check + bundle) + run: npm run build + + smoke: + name: API smoke test (end-to-end) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Prepare env (placeholder values are valid for CI) + run: cp .env.example .env + + - name: Start database + API + run: docker compose up -d --build db api + + - name: Wait for API health + run: | + for i in $(seq 1 80); do + if curl -fsS http://localhost:8080/health >/dev/null 2>&1; then + echo "API healthy"; exit 0 + fi + sleep 3 + done + echo "::error::API did not become healthy in time" + docker compose logs api + exit 1 + + - name: Run smoke test (PowerShell) + shell: pwsh + run: ./scripts/smoke-test.ps1 -BaseUrl http://localhost:8080 + + - name: Dump container logs on failure + if: failure() + run: docker compose logs + + - name: Tear down + if: always() + run: docker compose down -v diff --git a/docker-compose.yml b/docker-compose.yml index 9a4c518..80f7504 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: Google__ClientId: ${Google__ClientId:-} Seed__DemoAdminPassword: ${Seed__DemoAdminPassword:-DemoAdmin!Change01} Seed__DemoCustomerPassword: ${Seed__DemoCustomerPassword:-DemoUser!Change01} + Seed__DemoManagerPassword: ${Seed__DemoManagerPassword:-DemoManager!Change01} ports: - "8080:8080" depends_on: diff --git a/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs b/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs index 947cea6..372f362 100644 --- a/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs +++ b/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs @@ -25,4 +25,8 @@ public interface IOrderRepository Task GetByNumberAndEmailAsync(string orderNumber, string email, CancellationToken ct); Task> GetForUserAsync(Guid userId, CancellationToken ct); + + /// Most recent orders across all customers — the staff view. Capped, not paged: + /// staff want "what came in lately", and an unbounded scan is the wrong default. + Task> GetRecentAsync(int limit, CancellationToken ct); } diff --git a/src/WidgetWorks.Application/DependencyInjection.cs b/src/WidgetWorks.Application/DependencyInjection.cs index 8493342..b11eef3 100644 --- a/src/WidgetWorks.Application/DependencyInjection.cs +++ b/src/WidgetWorks.Application/DependencyInjection.cs @@ -22,6 +22,7 @@ using WidgetWorks.Application.Orders.Admin; using WidgetWorks.Application.Orders.GetMine; using WidgetWorks.Application.Orders.ListMine; +using WidgetWorks.Application.Orders.ListRecent; using WidgetWorks.Application.Orders.Lookup; using WidgetWorks.Application.Orders.UpdateStatus; using WidgetWorks.Application.Security.SecureAccount; @@ -67,6 +68,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs b/src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs new file mode 100644 index 0000000..a76029b --- /dev/null +++ b/src/WidgetWorks.Application/Orders/ListRecent/ListRecentOrdersHandler.cs @@ -0,0 +1,23 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Orders.ListMine; + +namespace WidgetWorks.Application.Orders.ListRecent; + +public sealed record ListRecentOrdersQuery(int Limit); + +/// +/// The staff order list. Without it the admin screen can only look an order up by its GUID, which +/// nobody has to hand — so orders were effectively invisible to Managers and Administrators. +/// +public sealed class ListRecentOrdersHandler(IOrderRepository orders) +{ + private const int DefaultLimit = 50; + private const int MaxLimit = 200; + + public async Task> Handle(ListRecentOrdersQuery query, CancellationToken ct) + { + var limit = query.Limit is < 1 or > MaxLimit ? DefaultLimit : query.Limit; + var list = await orders.GetRecentAsync(limit, ct); + return list.Select(OrderSummary.From).ToList(); + } +} diff --git a/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs b/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs index cb5425e..55b3620 100644 --- a/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs +++ b/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs @@ -156,6 +156,31 @@ await db.ExecuteAsync( return order; } + public async Task> GetRecentAsync(int limit, CancellationToken ct) + { + using var db = await factory.OpenAsync(ct); + var list = (await db.QueryAsync( + $"select {OrderColumns} from orders order by created_at desc limit @limit", + new { limit })).ToList(); + if (list.Count == 0) + { + return list; + } + + // The item rows are loaded, not skipped: OrderSummary derives its item count from them, + // so leaving Items empty reported every order as containing nothing. + var ids = list.Select(o => o.Id).ToArray(); + var items = await db.QueryAsync( + $"select {ItemColumns} from order_items where order_id = any(@ids)", new { ids }); + var grouped = items.GroupBy(i => i.OrderId).ToDictionary(g => g.Key, g => g.ToList()); + foreach (var o in list) + { + o.Items = grouped.TryGetValue(o.Id, out var it) ? it : []; + } + + return list; + } + public async Task> GetForUserAsync(Guid userId, CancellationToken ct) { using var db = await factory.OpenAsync(ct); diff --git a/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs b/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs index c7e5574..2d4f022 100644 --- a/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs +++ b/src/WidgetWorks.Infrastructure/Seeding/DbSeeder.cs @@ -14,6 +14,15 @@ public sealed class SeedOptions public string DemoCustomerEmail { get; set; } = string.Empty; public string DemoCustomerPassword { get; set; } = string.Empty; + + /// + /// The middle role. Without a seeded Manager the demo cannot show what ManageCatalog actually + /// buys you — a Manager may create, edit, restock and hide a widget but not retire one, which + /// is the whole point of the Administrator-only DeleteCatalog policy. + /// + public string DemoManagerEmail { get; set; } = string.Empty; + + public string DemoManagerPassword { get; set; } = string.Empty; } public sealed class DbSeeder(IDbConnectionFactory factory, IPasswordHasher hasher, TimeProvider clock) @@ -31,6 +40,7 @@ public async Task SeedAsync(SeedOptions options, CancellationToken ct) { await UpsertUserAsync(options.DemoAdminEmail, options.DemoAdminPassword, UserRoles.Administrator, isProtected: true, ct); await UpsertUserAsync(options.DemoCustomerEmail, options.DemoCustomerPassword, UserRoles.Customer, isProtected: false, ct); + await UpsertUserAsync(options.DemoManagerEmail, options.DemoManagerPassword, UserRoles.Manager, isProtected: false, ct); await SeedWidgetsAsync(ct); } diff --git a/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs b/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs index 6842541..3d5c1de 100644 --- a/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs +++ b/src/WidgetWorks.WebApi/Orders/OrderEndpoints.cs @@ -1,65 +1,73 @@ -using System.Security.Claims; -using WidgetWorks.Application.Orders.Admin; -using WidgetWorks.Application.Orders.GetMine; -using WidgetWorks.Application.Orders.ListMine; -using WidgetWorks.Application.Orders.Lookup; -using WidgetWorks.Application.Orders.UpdateStatus; -using WidgetWorks.WebApi.Authorization; - -namespace WidgetWorks.WebApi.Orders; - -public static class OrderEndpoints -{ - public static void MapOrderEndpoints(this IEndpointRouteBuilder routes) - { - // Guest order tracking by order number + email (anonymous). - routes.MapGet("/orders/lookup", async (string number, string email, GuestOrderLookupHandler handler, CancellationToken ct) => - { - var result = await handler.Handle(new GuestOrderLookupQuery(number, email), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); - }); - - var mine = routes.MapGroup("/orders").RequireAuthorization(); - - mine.MapGet("", async (ClaimsPrincipal principal, ListMyOrdersHandler handler, CancellationToken ct) => - { - if (UserId(principal) is not { } userId) - { - return Results.Unauthorized(); - } - - return Results.Ok(await handler.Handle(new ListMyOrdersQuery(userId), ct)); - }); - - mine.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, GetMyOrderHandler handler, CancellationToken ct) => - { - if (UserId(principal) is not { } userId) - { - return Results.Unauthorized(); - } - - var result = await handler.Handle(new GetMyOrderQuery(userId, id), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); - }); - - // Admin/manager order management (ManageCatalog covers widgets, inventory, and orders). - var admin = routes.MapGroup("/admin/orders").RequireAuthorization(Policies.ManageCatalog); - - admin.MapGet("/{id:guid}", async (Guid id, GetOrderByIdHandler handler, CancellationToken ct) => - { - var result = await handler.Handle(new GetOrderByIdQuery(id), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); - }); - - admin.MapPost("/{id:guid}/status", async (Guid id, UpdateStatusRequest body, UpdateOrderStatusHandler handler, CancellationToken ct) => - { - var result = await handler.Handle(new UpdateOrderStatusCommand(id, body.Status, body.TrackingNumber), ct); - return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error }); - }); - - static Guid? UserId(ClaimsPrincipal principal) - => Guid.TryParse(principal.FindFirst("sub")?.Value, out var id) ? id : null; - } - - public sealed record UpdateStatusRequest(string Status, string? TrackingNumber); -} +using System.Security.Claims; +using WidgetWorks.Application.Orders.Admin; +using WidgetWorks.Application.Orders.GetMine; +using WidgetWorks.Application.Orders.ListMine; +using WidgetWorks.Application.Orders.ListRecent; +using WidgetWorks.Application.Orders.Lookup; +using WidgetWorks.Application.Orders.UpdateStatus; +using WidgetWorks.WebApi.Authorization; + +namespace WidgetWorks.WebApi.Orders; + +public static class OrderEndpoints +{ + public static void MapOrderEndpoints(this IEndpointRouteBuilder routes) + { + // Guest order tracking by order number + email (anonymous). + routes.MapGet("/orders/lookup", async (string number, string email, GuestOrderLookupHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new GuestOrderLookupQuery(number, email), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); + }); + + var mine = routes.MapGroup("/orders").RequireAuthorization(); + + mine.MapGet("", async (ClaimsPrincipal principal, ListMyOrdersHandler handler, CancellationToken ct) => + { + if (UserId(principal) is not { } userId) + { + return Results.Unauthorized(); + } + + return Results.Ok(await handler.Handle(new ListMyOrdersQuery(userId), ct)); + }); + + mine.MapGet("/{id:guid}", async (Guid id, ClaimsPrincipal principal, GetMyOrderHandler handler, CancellationToken ct) => + { + if (UserId(principal) is not { } userId) + { + return Results.Unauthorized(); + } + + var result = await handler.Handle(new GetMyOrderQuery(userId, id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); + }); + + // Admin/manager order management (ManageCatalog covers widgets, inventory, and orders). + var admin = routes.MapGroup("/admin/orders").RequireAuthorization(Policies.ManageCatalog); + + // Staff order list. Summary rows only — open one to load its items. + admin.MapGet("/", async (int? limit, ListRecentOrdersHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new ListRecentOrdersQuery(limit ?? 50), ct); + return Results.Ok(result); + }); + + admin.MapGet("/{id:guid}", async (Guid id, GetOrderByIdHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new GetOrderByIdQuery(id), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error }); + }); + + admin.MapPost("/{id:guid}/status", async (Guid id, UpdateStatusRequest body, UpdateOrderStatusHandler handler, CancellationToken ct) => + { + var result = await handler.Handle(new UpdateOrderStatusCommand(id, body.Status, body.TrackingNumber), ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error }); + }); + + static Guid? UserId(ClaimsPrincipal principal) + => Guid.TryParse(principal.FindFirst("sub")?.Value, out var id) ? id : null; + } + + public sealed record UpdateStatusRequest(string Status, string? TrackingNumber); +} diff --git a/src/WidgetWorks.WebApi/appsettings.json b/src/WidgetWorks.WebApi/appsettings.json index d46f37f..48a66c9 100644 --- a/src/WidgetWorks.WebApi/appsettings.json +++ b/src/WidgetWorks.WebApi/appsettings.json @@ -15,6 +15,7 @@ }, "Seed": { "DemoAdminEmail": "admin@widgetworks.demo", - "DemoCustomerEmail": "demo@widgetworks.demo" + "DemoCustomerEmail": "demo@widgetworks.demo", + "DemoManagerEmail": "manager@widgetworks.demo" } } diff --git a/tests/WidgetWorks.UnitTests/Fakes.cs b/tests/WidgetWorks.UnitTests/Fakes.cs index 52b0ced..e3c002d 100644 --- a/tests/WidgetWorks.UnitTests/Fakes.cs +++ b/tests/WidgetWorks.UnitTests/Fakes.cs @@ -245,6 +245,9 @@ public Task UpdateStatusAsync(Guid orderId, string status, string? trackingNumbe public Task> GetForUserAsync(Guid userId, CancellationToken ct) => Task.FromResult>(Orders.Where(o => o.UserId == userId).OrderByDescending(o => o.CreatedAt).ToList()); + + public Task> GetRecentAsync(int limit, CancellationToken ct) + => Task.FromResult>(Orders.OrderByDescending(o => o.CreatedAt).Take(limit).ToList()); } public sealed class InMemoryPasswordResetTokenRepository : IPasswordResetTokenRepository diff --git a/web/src/App.tsx b/web/src/App.tsx index 1839483..5bebbac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { CartProvider } from './cart/CartContext' import { Layout } from './components/Layout' import { ProtectedRoute } from './components/ProtectedRoute' import { CatalogPage } from './pages/CatalogPage' +import { DemoGuidePage } from './pages/DemoGuidePage' import { ProductPage } from './pages/ProductPage' import { CartPage } from './pages/CartPage' import { CheckoutPage } from './pages/CheckoutPage' @@ -24,7 +25,10 @@ export default function App() { }> - } /> + {/* The guide is the landing page: a working store is confusing without knowing it is + a demo, that nothing can charge you, and which account to use. */} + } /> + } /> } /> } /> } /> diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 52fdf0e..bdffc7f 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -13,7 +13,7 @@ export function Layout() { const q = params.get('q') ?? '' const cat = params.get('cat') ?? '' - const onCatalog = location.pathname === '/' + const onCatalog = location.pathname === '/store' // The input is local so typing doesn't re-run the catalog query on every // keystroke; the URL (and the fetch) updates when the search is submitted. @@ -25,7 +25,7 @@ export function Layout() { if (nextQ.trim()) sp.set('q', nextQ.trim()) if (nextCat) sp.set('cat', nextCat) const qs = sp.toString() - navigate(qs ? `/?${qs}` : '/') + navigate(qs ? `/store?${qs}` : '/store') } return ( @@ -35,7 +35,8 @@ export function Layout() {
- Free standard shipping on orders over ${FREE_SHIPPING_THRESHOLD} · Demo store + Free standard shipping on orders over ${FREE_SHIPPING_THRESHOLD} ·{' '} + Demo store — read the guide @@ -51,7 +52,7 @@ export function Layout() { {/* Header --------------------------------------------------------- */}
- + WidgetWorks @@ -59,7 +60,7 @@ export function Layout() { - + Shipping to @@ -136,13 +137,13 @@ export function Layout() { {CATEGORIES.map((c) => ( {c.slug ? c.label : 'All widgets'} ))} - Today's deals + Today's deals
@@ -177,10 +178,10 @@ export function Layout() {

Shop

- All widgets - Kits - Mega widgets - Mini widgets + All widgets + Kits + Mega widgets + Mini widgets
@@ -193,13 +194,14 @@ export function Layout() {

Customer service

- Shipping rates - Returns policy + Shipping rates + Returns policy Password help

About the project

+ Demo guide
Source on GitHub Engineering handbook Security policy diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 5083892..3cc7f19 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -5,6 +5,6 @@ import type { ReactNode } from 'react' export function ProtectedRoute({ children, staff }: { children: ReactNode; staff?: boolean }) { const { isAuthenticated, isStaff } = useAuth() if (!isAuthenticated) return - if (staff && !isStaff) return + if (staff && !isStaff) return return <>{children} } diff --git a/web/src/pages/CartPage.tsx b/web/src/pages/CartPage.tsx index 99b9939..5a79d00 100644 --- a/web/src/pages/CartPage.tsx +++ b/web/src/pages/CartPage.tsx @@ -30,7 +30,7 @@ export function CartPage() {

Your cart is empty

Once you add widgets they will show up here, ready for checkout.

- Start shopping + Start shopping
) } @@ -40,7 +40,7 @@ export function CartPage() { return ( <> @@ -144,7 +144,7 @@ export function CartPage() { - Continue shopping + Continue shopping
diff --git a/web/src/pages/CatalogPage.tsx b/web/src/pages/CatalogPage.tsx index ba65e3f..0216b59 100644 --- a/web/src/pages/CatalogPage.tsx +++ b/web/src/pages/CatalogPage.tsx @@ -91,18 +91,18 @@ export function CatalogPage() { From the everyday Standard to the heavy-duty Mega — quality widgets, fast shipping and honest prices, backed by a 30-day return window.

- + Shop the kits - + Deals

Save on Widget Pro kits

Shop kits - + New

Weatherproof Mega widgets

See what's new @@ -111,7 +111,7 @@ export function CatalogPage() {