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 (
<>
- Home
+ Home
›
Shopping cart
@@ -144,7 +144,7 @@ export function CartPage() {
navigate('/checkout')}>
Proceed to checkout
-
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() {
{CATEGORIES.filter((c) => c.slug).map((c) => (
-
+
{c.icon}
{c.label}
@@ -122,7 +122,7 @@ export function CatalogPage() {
{!browsing && (
- Home
+ Home
›
{heading}
@@ -172,7 +172,7 @@ export function CatalogPage() {
? `We couldn't find anything for “${q}”${category ? ` in ${category.label}` : ''}. Try a different term or browse all departments.`
: 'Nothing in this category yet. Browse the full catalog instead.'}
- Browse all widgets
+ Browse all widgets
)}
>
diff --git a/web/src/pages/CheckoutPage.tsx b/web/src/pages/CheckoutPage.tsx
index b7d46ab..b8bcc7f 100644
--- a/web/src/pages/CheckoutPage.tsx
+++ b/web/src/pages/CheckoutPage.tsx
@@ -55,7 +55,7 @@ export function CheckoutPage() {
🧾
There is nothing to check out
Your cart is empty — add a widget or two and come back.
- Browse widgets
+ Browse widgets
)
}
diff --git a/web/src/pages/DemoGuidePage.tsx b/web/src/pages/DemoGuidePage.tsx
new file mode 100644
index 0000000..f707985
--- /dev/null
+++ b/web/src/pages/DemoGuidePage.tsx
@@ -0,0 +1,201 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+
+/**
+ * Landing page for first-time visitors. A working storefront is confusing without context:
+ * people need to know it is a demo, that nothing will charge them, which accounts to use, and
+ * where the emails go. Everything here is public, documented information — the demo credentials
+ * are the repository's one sanctioned exception and are already in its README.
+ */
+
+const ACCOUNTS = [
+ {
+ role: 'Customer',
+ email: 'demo@widgetworks.demo',
+ password: 'DemoUser!Change01',
+ summary: 'The everyday shopper.',
+ can: ['Browse and search the catalog', 'Add to cart and check out', 'See their own order history and tracking'],
+ cannot: ['Anything in the Admin area'],
+ },
+ {
+ role: 'Manager',
+ email: 'manager@widgetworks.demo',
+ password: 'DemoManager!Change01',
+ summary: 'Runs the shop day to day.',
+ can: [
+ 'Everything a Customer can',
+ 'Admin → Catalog: add a widget, edit it, adjust stock, hide it from the storefront',
+ 'Admin → Orders: mark shipped or delivered, add tracking, cancel',
+ ],
+ cannot: ['Delete or retire a widget — that is Administrator-only', 'Manage users'],
+ },
+ {
+ role: 'Administrator',
+ email: 'admin@widgetworks.demo',
+ password: 'DemoAdmin!Change01',
+ summary: 'Full control.',
+ can: [
+ 'Everything a Manager can',
+ 'Delete a widget — removed outright if never ordered, archived if it appears on an order',
+ 'Revoke another user’s sessions',
+ ],
+ cannot: [],
+ },
+]
+
+function CopyField({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false)
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1600)
+ } catch {
+ /* clipboard blocked — the value is on screen to type instead */
+ }
+ }
+
+ return (
+
+ {label}
+ {value}
+
+ {copied ? '✓ Copied' : 'Copy'}
+
+
+ )
+}
+
+export function DemoGuidePage() {
+ return (
+
+
+ Portfolio demo
+ A widget store you can actually use.
+
+ Browse the catalog, fill a cart, and place a real order through a real checkout — with
+ stock reservation, tax and shipping calculated server-side, and an order you can track
+ afterwards. Everything works. Nothing is real.
+
+
+ Enter the store
+ Sign in with a demo account
+
+
+
+ {/* The trust question, answered before anything else is asked of the visitor. */}
+
+
+
🔒 No payment is ever taken
+
+ Checkout runs against a mock payment gateway . No card details are
+ collected, no payment processor is contacted, and no charge of any kind can occur —
+ there is nothing to charge, because the form never asks for a card number.
+
+
+ The same code can run against Stripe, and when it does it is restricted to Stripe's
+ test mode , which by design cannot bill a real card. Live keys are
+ blocked from the repository by an automated secret scan.
+
+
+
+
+
+
Three roles, three accounts
+
+ You can browse and check out as a guest. Sign in to keep an order history — or to see
+ how far each role is allowed to go. Every account below is already seeded.
+
+
+ {ACCOUNTS.map((a) => (
+
+
+
{a.role}
+
{a.summary}
+
+
+
+
+
+
+ {a.can.map((c) => ✓ {c} )}
+ {a.cannot.map((c) => ✕ {c} )}
+
+
+
+ ))}
+
+
+ These are throwaway accounts on a throwaway database. Please don't enter a real
+ password anywhere on this site.
+
+
+
+
+
Things worth trying
+
+
+ Buy something. Add a widget, open the cart, and check out. Stock is
+ reserved the moment the order is placed, so the catalog count moves.
+
+
+ Pick a different payment method. Card and Google Pay settle
+ immediately. Klarna — Pay later parks the order as Awaiting payment
+ until the provider confirms it, and the confirmation page lets you play the provider
+ and approve or decline it. Test: declined card always fails, so you can see
+ the order cancel itself and release the stock.
+
+
+ Sign in as the administrator and open Admin → Catalog. Adjust stock,
+ hide a product, or delete one. A widget that has never been ordered is deleted; one
+ that appears on an order is archived instead, so past orders still report correctly.
+
+
+
+
+
+
+
✉️ About the emails
+
+ Placing an order, registering, and requesting a password reset all send real
+ transactional email — a receipt with your line items, a welcome note, a reset link.
+
+
+ On this hosted demo they are written to the application log rather than
+ delivered , so nothing reaches a real inbox and you can use any address you
+ like. You are not missing anything: sign in and open{' '}
+ Your orders — the order detail page shows
+ the same line items, totals, payment method and tracking that the receipt contains.
+
+
+ Running the project locally with Docker swaps the log for a real inbox: it includes a
+ mail catcher at localhost:8025 where every message appears, HTML and all.
+
+
+
+
+
+
+
What this is
+
+ A portfolio build of an end-to-end storefront: .NET 10 minimal API, Dapper and
+ PostgreSQL, React and TypeScript, onion architecture. It covers the parts most demos
+ skip — JWT auth with rotating refresh tokens, TOTP two-factor, Google sign-in, atomic
+ stock reservation, server-side re-priced checkout, asynchronous payment confirmation
+ by webhook, and the full order lifecycle.
+
+
+
+
+
+ )
+}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index 8c02e9c..b7c91f9 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -22,7 +22,7 @@ export function LoginPage() {
try { await api('/cart/merge', { method: 'POST', body: { guestCartId: cart.id } }) } catch { /* ignore */ }
await refresh()
}
- navigate('/')
+ navigate('/store')
}
async function submit(e: React.FormEvent) {
diff --git a/web/src/pages/OrderConfirmationPage.tsx b/web/src/pages/OrderConfirmationPage.tsx
index e2eef45..6bfbae8 100644
--- a/web/src/pages/OrderConfirmationPage.tsx
+++ b/web/src/pages/OrderConfirmationPage.tsx
@@ -21,7 +21,7 @@ export function OrderConfirmationPage() {
No recent order to show
Order confirmations appear here right after checkout. Sign in to look up past orders.
- Back to shop
+ Back to shop
Your orders
@@ -120,7 +120,7 @@ export function OrderConfirmationPage() {
Head back to the shop and try again with a different payment method.
- Back to the shop
+ Back to the shop
@@ -138,7 +138,7 @@ export function OrderConfirmationPage() {
- Continue shopping
+ Continue shopping
Your orders
diff --git a/web/src/pages/OrderDetailPage.tsx b/web/src/pages/OrderDetailPage.tsx
index 7c7b50a..f23748e 100644
--- a/web/src/pages/OrderDetailPage.tsx
+++ b/web/src/pages/OrderDetailPage.tsx
@@ -27,7 +27,7 @@ export function OrderDetailPage() {
return (
<>
- Home
+ Home
›
Your orders
›
@@ -39,7 +39,14 @@ export function OrderDetailPage() {
Order {order.orderNumber}
Placed {dateFmt.format(new Date(order.createdAt))}
-
+
+
+ {/* The receipt email cannot be delivered on the hosted demo, so the order page is the
+ receipt. The print stylesheet already drops the header, rail, footer and buttons. */}
+ window.print()}>
+ Print receipt
+
+
diff --git a/web/src/pages/OrdersPage.tsx b/web/src/pages/OrdersPage.tsx
index 73705f4..97f716c 100644
--- a/web/src/pages/OrdersPage.tsx
+++ b/web/src/pages/OrdersPage.tsx
@@ -30,7 +30,7 @@ export function OrdersPage() {
return (
<>
- Home
+ Home
›
Your orders
@@ -40,7 +40,7 @@ export function OrdersPage() {
Your orders
{orders.length} {orders.length === 1 ? 'order' : 'orders'} placed with this account.
- Continue shopping
+ Continue shopping
{orders.length === 0 ? (
@@ -48,7 +48,7 @@ export function OrdersPage() {
📦
No orders yet
When you place an order it will appear here with its status and tracking.
- Start shopping
+ Start shopping
) : (
diff --git a/web/src/pages/ProductPage.tsx b/web/src/pages/ProductPage.tsx
index 7b58a21..b68bbcc 100644
--- a/web/src/pages/ProductPage.tsx
+++ b/web/src/pages/ProductPage.tsx
@@ -38,7 +38,7 @@ export function ProductPage() {
📦
We couldn't load that widget
{error}
-
Back to the shop
+
Back to the shop
)
}
@@ -46,7 +46,7 @@ export function ProductPage() {
if (!widget) {
return (
<>
- Home
+ Home
>
)
@@ -61,9 +61,9 @@ export function ProductPage() {
return (
<>
- Home
+ Home
›
- All widgets
+ All widgets
›
{widget.name}
diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx
index 160e8d7..9042b9b 100644
--- a/web/src/pages/RegisterPage.tsx
+++ b/web/src/pages/RegisterPage.tsx
@@ -17,7 +17,7 @@ export function RegisterPage() {
try {
await register(email, password)
await login(email, password)
- navigate('/')
+ navigate('/store')
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed.')
} finally {
diff --git a/web/src/pages/admin/AdminOrderPage.tsx b/web/src/pages/admin/AdminOrderPage.tsx
index eea0c27..1dbd2cb 100644
--- a/web/src/pages/admin/AdminOrderPage.tsx
+++ b/web/src/pages/admin/AdminOrderPage.tsx
@@ -1,27 +1,40 @@
-import { useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client'
-import type { OrderView } from '../../api/types'
+import type { OrderSummary, OrderView } from '../../api/types'
import { money } from '../../lib/format'
import { StatusPill } from '../../components/StatusPill'
+import { PanelSkeleton } from '../../components/Skeleton'
+
+const dateFmt = new Intl.DateTimeFormat('en-US', {
+ month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
+})
export function AdminOrderPage() {
- const [orderId, setOrderId] = useState('')
+ const [orders, setOrders] = useState(null)
const [order, setOrder] = useState(null)
const [tracking, setTracking] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
- async function lookup(e: React.FormEvent) {
- e.preventDefault()
+ // The list is the entry point. Looking an order up by GUID was the only way in before, and
+ // nobody has a GUID to hand — so staff could not actually find an order.
+ const loadList = useCallback(() => {
+ api('/admin/orders')
+ .then(setOrders)
+ .catch((e) => setError(e.message))
+ }, [])
+
+ useEffect(() => { loadList() }, [loadList])
+
+ async function open(id: string) {
setError(null)
setBusy(true)
try {
- const o = await api(`/admin/orders/${orderId.trim()}`)
+ const o = await api(`/admin/orders/${id}`)
setOrder(o)
setTracking(o.trackingNumber ?? '')
} catch (err) {
- setError(err instanceof Error ? err.message : 'Not found.')
- setOrder(null)
+ setError(err instanceof Error ? err.message : 'Could not load that order.')
} finally {
setBusy(false)
}
@@ -37,6 +50,7 @@ export function AdminOrderPage() {
body: { status, trackingNumber: tracking || null },
})
setOrder(o)
+ loadList()
} catch (err) {
setError(err instanceof Error ? err.message : 'Update failed.')
} finally {
@@ -52,58 +66,96 @@ export function AdminOrderPage() {
Admin
Orders
- Look up an order by its id to update fulfilment status and tracking.
+
+ {orders ? `${orders.length} most recent ${orders.length === 1 ? 'order' : 'orders'}.` : 'Loading orders…'}
+ {' '}Select one to update its fulfilment status and tracking.
+
+ Refresh
-
-
- {error && {error}
}
+ {error && {error}
}
- {order && (
-
-
-
-
{order.orderNumber}
-
+
+
+ {!orders ? (
+
+ ) : orders.length === 0 ? (
+
+
🧾
+
No orders yet
+
Orders placed in the store will appear here.
-
-
-
-
Customer {order.email}
-
Total {money(order.total)}
-
Items {order.items.length}
-
Shipping {order.shippingMethod}
+ ) : (
+
+
+
+
+ Order Placed Status
+ Items Total
+
+
+
+ {orders.map((o) => (
+
+ {o.orderNumber}
+ {dateFmt.format(new Date(o.createdAt))}
+
+ {o.itemCount}
+ {money(o.total)}
+
+ open(o.id)}>
+ Open
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {!order ? (
+
+
+
No order selected
+
+ Pick an order from the list to see its detail and change its status.
+
+
+ ) : (
+
+
+
+
{order.orderNumber}
+
+
+
+
+
Customer {order.email}
+
Shipping {order.shippingMethod}
+
Items {order.items.length}
+
Total {money(order.total)}
-
- Tracking number
- setTracking(e.target.value)} placeholder="1Z999AA10123456784" />
-
+
+ Tracking number
+ setTracking(e.target.value)} placeholder="1Z999AA10123456784" />
+
-
-
setStatus('Shipped')}>Mark shipped
-
setStatus('Delivered')}>Mark delivered
-
setStatus('Cancelled')}>Cancel order
+
+ setStatus('Shipped')}>Mark shipped
+ setStatus('Delivered')}>Delivered
+ setStatus('Cancelled')}>Cancel
+
+
Marking shipped or cancelled emails the customer.
+
-
-
- )}
+ )}
+
+
>
)
}
diff --git a/web/src/styles.css b/web/src/styles.css
index 6fc4257..1384956 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -289,6 +289,7 @@ select{
}
.table tbody tr:last-child td{border-bottom:0}
.table tbody tr:hover{background:var(--surface-2)}
+.table tbody tr.on{background:var(--info-bg);box-shadow:inset 3px 0 0 var(--link)}
.table .num{text-align:right;font-variant-numeric:tabular-nums}
.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);box-shadow:var(--sh-1)}
@@ -793,6 +794,63 @@ select{
.btn-danger-solid{background:var(--danger);border-color:var(--danger-ink);color:var(--ink-inv);box-shadow:var(--sh-1)}
.btn-danger-solid:hover:not(:disabled){background:var(--danger-ink)}
+/* --------------------------------------------------------------------------
+ Regions — demo guide (landing page)
+ -------------------------------------------------------------------------- */
+.guide{display:flex;flex-direction:column;gap:22px;max-width:1040px;margin:0 auto}
+.guide section{scroll-margin-top:80px}
+
+.guide-hero{
+ padding:34px;border-radius:var(--r-lg);color:#fff;box-shadow:var(--sh-2);
+ background:
+ radial-gradient(900px 320px at 88% -10%,var(--hero-glow),transparent 60%),
+ linear-gradient(118deg,var(--hero-from) 0%,var(--hero-mid) 55%,var(--hero-to) 100%);
+}
+.guide-hero h1{font-size:2.1rem;color:#fff;margin:12px 0 10px;max-width:20ch}
+.guide-hero p{max-width:62ch;color:var(--hero-body);font-size:1rem;margin-bottom:18px}
+
+/* The "will this charge me?" answer gets its own emphasis. */
+.guide-assure{border-left:4px solid var(--ok)}
+.guide-assure h2{font-size:1.15rem;margin-bottom:8px}
+
+.guide-accounts{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}
+.guide-role .panel-head{display:flex;flex-direction:column;gap:2px}
+.guide-role .panel-body{display:flex;flex-direction:column;gap:8px}
+
+.copyfield{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.copyfield-label{font-size:.75rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--ink-3);width:64px;flex:0 0 auto}
+.copyfield-value{
+ flex:1;min-width:0;padding:6px 9px;border-radius:var(--r-xs);
+ background:var(--surface-3);border:1px solid var(--line);
+ font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.82rem;
+ overflow-x:auto;white-space:nowrap;
+}
+
+.guide-can{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:.84rem;line-height:1.45}
+.guide-can li{display:flex;gap:8px;align-items:flex-start;color:var(--ink-2)}
+.guide-can li span{color:var(--ok);font-weight:700;flex:0 0 auto}
+.guide-can li.no{color:var(--ink-3)}
+.guide-can li.no span{color:var(--danger)}
+
+.guide-steps{display:flex;flex-direction:column;gap:12px;counter-reset:step;padding-left:0}
+.guide-steps li{
+ position:relative;padding:14px 18px 14px 52px;list-style:none;line-height:1.55;color:var(--ink-2);
+ background:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);box-shadow:var(--sh-1);
+}
+.guide-steps li::before{
+ counter-increment:step;content:counter(step);
+ position:absolute;left:16px;top:14px;display:grid;place-items:center;
+ width:24px;height:24px;border-radius:var(--r-pill);
+ background:var(--chrome-900);color:#fff;font-size:.78rem;font-weight:800;
+}
+.guide-steps strong{color:var(--ink)}
+
+@media (max-width:640px){
+ .guide-hero{padding:24px}
+ .guide-hero h1{font-size:1.6rem}
+ .copyfield-label{width:auto}
+}
+
/* --------------------------------------------------------------------------
Regions — admin
-------------------------------------------------------------------------- */
@@ -881,6 +939,13 @@ select{
}
@media print{
- .util,.hdr,.rail,.foot,.foot-top,.btn,.buybox{display:none !important}
+ /* What survives a print is the receipt: the order, its items and its totals. */
+ .util,.hdr,.rail,.foot,.foot-top,.btn,.buybox,.crumbs,.pill{display:none !important}
body{background:#fff}
+ .content{padding:0;max-width:none}
+ .panel,.table-wrap{border:0;box-shadow:none;break-inside:avoid}
+ .confirm-grid,.cartlayout,.co{grid-template-columns:1fr !important;gap:12px}
+ .summary,.cart-aside{position:static}
+ .cline{break-inside:avoid}
+ a[href]::after{content:""}
}