Fix/deploy migration safety #26
@@ -11,7 +11,7 @@ This is a **self-hosted** application running on a **Synology NAS**, deployed an
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's gone). Public storefront + cart + checkout, with an authentik-SSO-gated admin panel for inventory/customer/settings management.
|
A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's gone). Public storefront + cart + checkout, with an authentik-SSO-gated admin panel for inventory, category/tag taxonomy, customer, and settings management. The storefront filters items by category, tags, and price range.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
@@ -27,11 +27,18 @@ A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's go
|
|||||||
- **DB migrations**: `node-pg-migrate` — NOT Entity Framework (this is a Node/TS backend, EF doesn't apply here). Migration files live in `backend/migrations/`, run via `node migrate.js up` (bundled into the production image specifically so it can be invoked via `docker exec` against the live container). There's no `init.sql` anymore — it was deleted when migrations were introduced; the migration files are the single source of schema truth.
|
- **DB migrations**: `node-pg-migrate` — NOT Entity Framework (this is a Node/TS backend, EF doesn't apply here). Migration files live in `backend/migrations/`, run via `node migrate.js up` (bundled into the production image specifically so it can be invoked via `docker exec` against the live container). There's no `init.sql` anymore — it was deleted when migrations were introduced; the migration files are the single source of schema truth.
|
||||||
- **Testing**: Jest (unit + integration against a disposable tmpfs Postgres via `node-pg-migrate`), Playwright (e2e)
|
- **Testing**: Jest (unit + integration against a disposable tmpfs Postgres via `node-pg-migrate`), Playwright (e2e)
|
||||||
- **CI**: Gitea Actions — `sonarqube.yml` (static analysis + TS build check) and `tests.yml` (unit/integration/e2e with job summaries posted to Gitea's step summary)
|
- **CI**: Gitea Actions — `sonarqube.yml` (static analysis + TS build check) and `tests.yml` (unit/integration/e2e with job summaries posted to Gitea's step summary)
|
||||||
|
- **Node**: 20+ required. **Node 18 cannot run this project's tooling** — `node-pg-migrate` pulls in a `lru-cache` that calls `diagnostics_channel.tracingChannel()`, which doesn't exist before Node 19.9, so `npm run migrate:up` dies with `TypeError: (0 , U.tracingChannel) is not a function`. This is a confusing failure because it looks like a broken dependency rather than a version problem. The dev machine has nvm4w with both 18.16.1 and 24.13.1 installed and **18 is the active default**, so a command can be run against the newer binary without switching globally:
|
||||||
|
```bash
|
||||||
|
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
## Data model (key tables)
|
## Data model (key tables)
|
||||||
|
|
||||||
- `items` — one-of-a-kind inventory. `status`: `available | reserved | sold`
|
- `items` — one-of-a-kind inventory. `status`: `available | reserved | sold`. `category_id` is nullable (`NULL` = Uncategorized) and `ON DELETE SET NULL`
|
||||||
- `item_images` — multiple images per item (front/back/etc.), ordered
|
- `item_images` — multiple images per item (front/back/etc.), ordered
|
||||||
|
- `categories` — self-referencing tree (`parent_id`, `ON DELETE CASCADE`), arbitrary depth, one category per item. Two *partial* unique indexes enforce "siblings can't share a name" — a single plain unique constraint doesn't work, because a `NULL` `parent_id` compares unequal to every other `NULL` and duplicate root categories slip straight through
|
||||||
|
- `tags` — flexible labels with a `color`; unique on `lower(name)`
|
||||||
|
- `item_tags` — many-to-many between items and tags
|
||||||
- `customers` — accounts, with `marketing_consent` (explicit opt-in, GDPR-style — unchecked by default, timestamped, unsubscribe token)
|
- `customers` — accounts, with `marketing_consent` (explicit opt-in, GDPR-style — unchecked by default, timestamped, unsubscribe token)
|
||||||
- `admin_settings` — key/value store, currently just `cart_expiry_hours` (admin-configurable, default 24)
|
- `admin_settings` — key/value store, currently just `cart_expiry_hours` (admin-configurable, default 24)
|
||||||
- `carts` / `cart_items` — one cart per customer, items reserved with the admin-configurable expiry; unique constraint on `item_id` means an item can only be in one cart at a time
|
- `carts` / `cart_items` — one cart per customer, items reserved with the admin-configurable expiry; unique constraint on `item_id` means an item can only be in one cart at a time
|
||||||
@@ -48,6 +55,20 @@ A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's go
|
|||||||
5. **Admin panel security boundary**: NPM's Advanced nginx config only wraps `auth_request` around `location ~ ^/(admin|api/admin)` — everything else (storefront, cart, checkout, PayPal webhook) bypasses authentik entirely. Deliberate split — don't accidentally widen or narrow that regex without checking both directions.
|
5. **Admin panel security boundary**: NPM's Advanced nginx config only wraps `auth_request` around `location ~ ^/(admin|api/admin)` — everything else (storefront, cart, checkout, PayPal webhook) bypasses authentik entirely. Deliberate split — don't accidentally widen or narrow that regex without checking both directions.
|
||||||
6. **Cookie `secure` flag** is gated on `NODE_ENV === 'production'`, not hardcoded `true` — otherwise integration tests (plain HTTP, no TLS) silently fail to persist sessions.
|
6. **Cookie `secure` flag** is gated on `NODE_ENV === 'production'`, not hardcoded `true` — otherwise integration tests (plain HTTP, no TLS) silently fail to persist sessions.
|
||||||
7. **`app.ts`/`server.ts` split**: `app.ts` exports the Express app with no `.listen()` call, so tests can import and exercise it via `supertest` without binding a port. `server.ts` is the actual entry point — imports `app`, adds the cart-expiry sweep and cron job, calls `.listen()`. Any new background job or process-level concern goes in `server.ts`, not `app.ts`.
|
7. **`app.ts`/`server.ts` split**: `app.ts` exports the Express app with no `.listen()` call, so tests can import and exercise it via `supertest` without binding a port. `server.ts` is the actual entry point — imports `app`, adds the cart-expiry sweep and cron job, calls `.listen()`. Any new background job or process-level concern goes in `server.ts`, not `app.ts`.
|
||||||
|
8. **Categories are manual metadata, deliberately not a rule engine.** Issue #23's wording ("rules that dictate how the app automatically organizes items") was explicitly resolved with the author to mean an admin-built tree with a per-item assignment — there is no predicate evaluation anywhere, and adding one would be a new feature, not a completion of this one. Categories are single-valued per item on purpose, to stay distinct from multi-valued tags. **Tag filtering is AND** ("must have all"), not OR.
|
||||||
|
9. **Item filtering happens in SQL, and malformed filter params return `400`** rather than being ignored — a broken filter link should show itself instead of quietly returning the whole catalogue. Parsing/SQL-building live in `backend/src/itemFilters.ts`, apart from the route so they're unit-testable with no database. Category filtering matches a node *and all descendants* via a recursive CTE; a materialized path column was rejected because reparenting would then have to rewrite every descendant's path, which is a standing drift risk.
|
||||||
|
10. **Migrations run automatically at container start**, via `CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]` in the `Dockerfile`. Deployed code can therefore never be ahead of the schema. This replaced a separate manual `docker exec ... node migrate.js up` step that was easy to forget — and forgetting it took the storefront down (see the incident note below). `migrate.js` waits for Postgres to accept connections before running (the NAS routinely brings the DB container up slower than the app), and exits non-zero on failure, so `&&` stops a bad migration from serving against a half-migrated schema.
|
||||||
|
11. **Every async route is wrapped in `asyncRoute()` and the app mounts error middleware.** Express 4 does *not* forward a rejected promise from an async handler, and with no error middleware such a request **never responds at all** — it hangs until the client gives up. A hung request is indistinguishable from an empty result in the UI. New async routes must use the wrapper (`backend/src/asyncRoute.ts`); it becomes unnecessary only if the project moves to Express 5. Note the older route files predate this and are still unwrapped.
|
||||||
|
12. **Item SELECTs aggregate with scalar subqueries, never `LEFT JOIN` + `GROUP BY`** — see `backend/src/itemSelect.ts`, the single source for both the public and admin shapes. Joining two one-to-many relations in one query multiplies their rows together: with the old shape, an item with 2 images and 3 tags repeated every image three times. This bit once already when tags were added. If a third one-to-many relation is ever attached to items, extend `itemSelect.ts` the same way rather than adding a join.
|
||||||
|
|
||||||
|
## Frontend gotchas
|
||||||
|
|
||||||
|
- **antd's `defaultExpandAll` on `Tree` is evaluated once, at mount.** Any branch created *after* that renders collapsed, and its children become unreachable — in the admin Categories tab this meant adding a subcategory made it appear to vanish. Anywhere a tree outlives the data it displays, track `expandedKeys` in state instead and expand the parent when a node is added or moved. The storefront's filter drawer gets away with `defaultExpandAll` only because `destroyOnHidden` remounts it after the categories have loaded — that's a coincidence, not a pattern to copy.
|
||||||
|
- **`destroyOnClose` is deprecated in antd 5.29** in favour of `destroyOnHidden`. Several older files (`Admin.tsx`, `Cart.tsx`, `AuthPromptModal.tsx`) still use the old prop and log a console warning; new code uses `destroyOnHidden`.
|
||||||
|
- **A closed antd `Drawer` keeps its content in the DOM** unless `destroyOnHidden` is set, so duplicate control labels (two "Clear all" buttons, say) collide in Playwright's strict mode even when only one is on screen. The active-filter chip row is marked `role="group" aria-label="Active filters"` so its controls stay addressable independently of the drawer's.
|
||||||
|
- **An antd `Button` with an icon does not have the plain text as its accessible name** — the icon contributes leading whitespace, so `getByRole('button', { name: /^Filters/ })` matches nothing while `/Filters/` and the exact string `'Filters'` both work. Prefer a substring regex for icon buttons.
|
||||||
|
- **A failed request must never render as an empty result.** `App.tsx` tracks a `failed` flag separately from `loading`, and shows an error with a Retry rather than falling through to `Empty`. Saying "no items yet" when the server is broken hides an outage and reads to a customer as an empty shop — this is exactly how the 2026-08-17 incident presented. `fetchItems`/`fetchFilterOptions` throw on a non-OK response rather than returning the parsed error body, which would otherwise be set as the item list and crash the grid on `.map`. Covered by `tests/e2e/storefront-errors.spec.ts`.
|
||||||
|
- **antd import style is inconsistent across the codebase.** New files use deep imports (`import Drawer from 'antd/lib/drawer'`) per the user's standing rule; most older files still use barrel imports from `'antd'`. Don't churn existing files just to convert them.
|
||||||
|
|
||||||
## Deployment workflow — read this before touching the NAS
|
## Deployment workflow — read this before touching the NAS
|
||||||
|
|
||||||
@@ -117,6 +138,10 @@ name = Thom Lamb
|
|||||||
|
|
||||||
- **Always double check `git status` shows a file as staged before committing**, and check it again after committing to confirm a clean working tree before pushing. This project has repeatedly had files silently left out of a commit (most notably `Admin.tsx` once, and the entire cart feature's branch/push never happening at all despite believing it had) — the cost of that mistake compounds badly (hours of debugging "why doesn't the deployed code match what I was shown"), so the few seconds to verify `git status` at each step is well worth it.
|
- **Always double check `git status` shows a file as staged before committing**, and check it again after committing to confirm a clean working tree before pushing. This project has repeatedly had files silently left out of a commit (most notably `Admin.tsx` once, and the entire cart feature's branch/push never happening at all despite believing it had) — the cost of that mistake compounds badly (hours of debugging "why doesn't the deployed code match what I was shown"), so the few seconds to verify `git status` at each step is well worth it.
|
||||||
|
|
||||||
|
- **Incident (2026-08-17): the storefront showed no inventory after a deploy.** No data was lost — the cause was deploying code that queried `categories` / `item_tags` / `items.category_id` against a database where that migration hadn't been run yet. Because Express 4 doesn't forward async errors and no error middleware existed, every item query **hung with no response**, and the frontend rendered that as an empty catalogue. Three changes now prevent this class: migrations run at container start (so code can't outrun the schema), an error middleware guarantees a response, and the storefront distinguishes "request failed" from "no items". **The general lesson is broader than migrations: any deploy where new code needs something the running environment doesn't have yet will surface as silence, not an error, unless something is written to make it loud.**
|
||||||
|
|
||||||
|
- **Check `git branch --contains <sha>` before assuming a commit shipped.** A documentation commit made after the feature branch was pushed was left out of PR #24 entirely and ended up dangling — reachable only through the reflog. `git log` on the branch looked normal, because the missing commit simply wasn't there. If a commit was made after the push, confirm it actually made it onto the branch that got merged.
|
||||||
|
|
||||||
### Standard deploy sequence, once code is confirmed on `main`
|
### Standard deploy sequence, once code is confirmed on `main`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -128,20 +153,36 @@ sudo docker build --no-cache -t redefined-designs:latest /volume1/docker/redefin
|
|||||||
sudo docker stop redefined-designs-syn
|
sudo docker stop redefined-designs-syn
|
||||||
sudo docker rm redefined-designs-syn
|
sudo docker rm redefined-designs-syn
|
||||||
# redeploy the stack in Portainer UI
|
# redeploy the stack in Portainer UI
|
||||||
|
# migrations now run automatically as the container starts — no separate step
|
||||||
|
sudo docker logs redefined-designs-syn | head -20 # confirm migrations ran before the app came up
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running migrations against the live DB (via Portainer-managed container)
|
### Migrations against the live DB
|
||||||
|
|
||||||
|
**Migrations apply automatically at container start**, so a normal deploy needs no manual step. `docker logs` on the app container shows the migration output ahead of the server starting; a failed migration exits the container rather than serving a half-migrated schema.
|
||||||
|
|
||||||
|
The manual invocation still works and is useful for applying a migration without redeploying, or for inspecting state:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker exec redefined-designs-syn node migrate.js up
|
sudo docker exec redefined-designs-syn node migrate.js up
|
||||||
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT name, run_on FROM pgmigrations ORDER BY run_on;"
|
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT name, run_on FROM pgmigrations ORDER BY run_on;"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the storefront ever looks empty after a deploy, check the row count before assuming data loss — the far more likely cause is a schema/code mismatch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT count(*) FROM items;"
|
||||||
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/)**: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `ci:`, `refactor:`, etc.
|
- **Every commit message follows [Conventional Commits](https://www.conventionalcommits.org/)**: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `ci:`, `refactor:`, etc.
|
||||||
- **Never commit directly to `main`.** Always branch: `feature/<short-description>` or `fix/<short-description>`. Open a PR, merge, branch auto-deletes (repo setting is on).
|
- **Never commit directly to `main`.** Always branch: `feature/<short-description>` or `fix/<short-description>`. Open a PR, merge, branch auto-deletes (repo setting is on).
|
||||||
- Local dev/editing happens in **VS Code**, pushed via **PowerShell** `git` — the NAS-side `gitc` workflow is *only* for pulling already-merged code down to deploy, never for authoring changes.
|
- Local dev/editing happens in **VS Code**, pushed via **PowerShell** `git` — the NAS-side `gitc` workflow is *only* for pulling already-merged code down to deploy, never for authoring changes.
|
||||||
|
- **Thom does the pushing.** Commit locally and stop; don't `git push` on his behalf.
|
||||||
|
- **When work comes from a Gitea issue, post every clarifying question and its answer back to that issue as a comment** — including the options considered and why the rejected ones were rejected. The issue is the durable record; decisions made in a chat session are invisible to anyone reading it later. Post each round as the answers come in rather than batching everything to the end.
|
||||||
|
- **Design specs live in `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`** and are committed before implementation starts.
|
||||||
|
- **`.superpowers/` stays gitignored, but design artifacts inside it must be lifted out before they are lost.** That directory is scratch state belonging to the brainstorming tool and contains a session token, PID files, and absolute local paths — none of which belong in the repo. The mockups it holds *are* worth keeping, so copy them into `docs/superpowers/specs/<date>-<topic>-mockups/` and wrap them as standalone pages (they are served as fragments inside a tool-provided frame, so they need its style tokens and `toggleSelect` helper inlined to open on their own). Keep the rejected options, not just the chosen one — the value is in the comparison.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -150,6 +191,15 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
|
|||||||
- **E2e (Playwright)**: needs backend running against a migrated DB; `cd frontend && npm run test:e2e`
|
- **E2e (Playwright)**: needs backend running against a migrated DB; `cd frontend && npm run test:e2e`
|
||||||
- CI (`tests.yml`) runs all three as separate jobs, each posting a pass/fail summary to Gitea's job Summary tab. The `frontend-e2e` job runs `node migrate.js up` against a Postgres service container — same migration mechanism as everywhere else, no schema duplication anywhere in the project anymore.
|
- CI (`tests.yml`) runs all three as separate jobs, each posting a pass/fail summary to Gitea's job Summary tab. The `frontend-e2e` job runs `node migrate.js up` against a Postgres service container — same migration mechanism as everywhere else, no schema duplication anywhere in the project anymore.
|
||||||
|
|
||||||
|
### E2E constraints — the local database is never reset
|
||||||
|
|
||||||
|
Integration tests truncate between cases (`resetDb()` in `tests/integration/setup/testDb.ts` — **add any new table to that TRUNCATE list**, or state leaks between tests). Playwright specs have no such hook and run against whatever is already there, which locally accumulates across every previous run. Consequences worth knowing before writing a new spec:
|
||||||
|
|
||||||
|
- **Name every fixture with a per-run suffix** and assert on those names only. A bare `.first()` or an unscoped `getByText` will find leftovers from an earlier run and produce a failure that looks like a product bug but isn't. This cost real debugging time.
|
||||||
|
- **`beforeAll` runs once per worker, but a worker can be handed the same spec file in more than one batch**, re-running it against the module-cached suffix. `filters.spec.ts` therefore checks whether its fixtures already exist and returns early — without that, the second pass 409s on category names and duplicates every item, which then breaks strict-mode locators.
|
||||||
|
- **Tables paginate.** With dozens of accumulated rows a freshly created record often isn't on page 1; `admin-taxonomy.spec.ts` confirms tag creation through the API rather than hunting for the row.
|
||||||
|
- **Clicking a submit button only dispatches the request.** Wait for the resulting confirmation (`'Tag added'`) before querying the API, or the read races the write. This produced a one-in-four flake until fixed.
|
||||||
|
|
||||||
## Known gaps / natural next steps
|
## Known gaps / natural next steps
|
||||||
|
|
||||||
- **No CD** — Gitea Actions runs tests/analysis but doesn't deploy. Manual NAS rebuild is still required after every merge.
|
- **No CD** — Gitea Actions runs tests/analysis but doesn't deploy. Manual NAS rebuild is still required after every merge.
|
||||||
@@ -159,10 +209,19 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
|
|||||||
- **Daily cart reminder emails** fire via `node-cron` inside the app process at 9am container-local time — if the container restarts frequently, reminders could silently stop firing with no alerting on that failure mode.
|
- **Daily cart reminder emails** fire via `node-cron` inside the app process at 9am container-local time — if the container restarts frequently, reminders could silently stop firing with no alerting on that failure mode.
|
||||||
- **Old single-item checkout route files** (`backend/src/routes/paypal.ts`, `backend/src/routes/demo.ts`) are dead code, unmounted but never deleted — safe cleanup opportunity.
|
- **Old single-item checkout route files** (`backend/src/routes/paypal.ts`, `backend/src/routes/demo.ts`) are dead code, unmounted but never deleted — safe cleanup opportunity.
|
||||||
- **`backend/src/routes/shippingAddresses.ts` USPS OAuth token format** was implemented against the current (2026) USPS Addresses API docs at time of writing, using a JSON-body `client_credentials` request — if USPS changes their API again, this is the first place to check.
|
- **`backend/src/routes/shippingAddresses.ts` USPS OAuth token format** was implemented against the current (2026) USPS Addresses API docs at time of writing, using a JSON-body `client_credentials` request — if USPS changes their API again, this is the first place to check.
|
||||||
|
- **`TAG_COLORS` is duplicated** between `backend/src/utils.ts` and `frontend/src/admin/Tags.tsx`. The server validates against its copy, so editing one alone makes the admin colour picker offer values that get rejected with a `400`. There's no shared module between backend and frontend in this repo to put it in.
|
||||||
|
- **The local e2e database accumulates junk indefinitely** — every Playwright run seeds categories, tags, and items that are never cleaned up, so the admin tables and filter drawer fill with `Furniture fmsxb…` noise over time. Harmless, but `npm run db:test:down` + `db:test:up` + `migrate:up` resets it when the clutter starts getting in the way. CI is unaffected (fresh service container per run).
|
||||||
|
- **The storefront still lists sold items** and the price-range bounds are computed across all items regardless of status. Unchanged by the filters work — deliberately left as-is, but worth revisiting if sold stock ever outnumbers available stock.
|
||||||
|
- **No admin-side filtering.** The admin inventory table shows category and tag columns but can't filter or search on them; with 100+ items that will start to hurt.
|
||||||
|
|
||||||
## Where to look first for common tasks
|
## Where to look first for common tasks
|
||||||
|
|
||||||
- Add/change a DB table → new file in `backend/migrations/` via `npm run migrate:create -- descriptive-name`, fill in `pgm.sql(...)` for up/down
|
- Add/change a DB table → new file in `backend/migrations/` via `npm run migrate:create -- descriptive-name`, fill in `pgm.sql(...)` for up/down, **and add the table to `resetDb()`** in `backend/tests/integration/setup/testDb.ts`
|
||||||
- Change cart/checkout behavior → `backend/src/routes/cartCheckout.ts` (the whole reserve → checkout → complete lifecycle lives here)
|
- Change cart/checkout behavior → `backend/src/routes/cartCheckout.ts` (the whole reserve → checkout → complete lifecycle lives here)
|
||||||
- Change admin-configurable settings → `admin_settings` table + `backend/src/routes/adminSettings.ts` + `frontend/src/admin/Settings.tsx`
|
- Change admin-configurable settings → `admin_settings` table + `backend/src/routes/adminSettings.ts` + `frontend/src/admin/Settings.tsx`
|
||||||
|
- Add a new async route → wrap the handler in `asyncRoute()` from `backend/src/asyncRoute.ts`, or a failure will hang the request instead of returning 500
|
||||||
|
- Change what an item row returns → `backend/src/itemSelect.ts` (one place, used by both the public and admin routes)
|
||||||
|
- Change storefront filtering → `backend/src/itemFilters.ts` (parsing + SQL), `backend/src/routes/filters.ts` (`/api/filters`, the drawer's single fetch), `frontend/src/filters.ts` (state, URL round-trip, tree building), `frontend/src/components/FilterDrawer.tsx`
|
||||||
|
- Change category/tag management → `backend/src/routes/adminCategories.ts`, `backend/src/routes/adminTags.ts`, `frontend/src/admin/Categories.tsx`, `frontend/src/admin/Tags.tsx`
|
||||||
|
- Change tag colours → `TAG_COLORS` + `tagColorFor()` in `backend/src/utils.ts`; the list is **duplicated** in `frontend/src/admin/Tags.tsx` for the override picker, and the server rejects anything outside it, so the two must be changed together
|
||||||
- NPM/authentik/DSM reverse-proxy config for this app → not in this repo; documented in the broader homelab's Claude Project knowledge base, not here
|
- NPM/authentik/DSM reverse-proxy config for this app → not in this repo; documented in the broader homelab's Claude Project knowledge base, not here
|
||||||
+9
-1
@@ -22,4 +22,12 @@ COPY --from=backend-build /app/backend/migrations ./migrations
|
|||||||
COPY --from=frontend-build /app/frontend/dist ./public
|
COPY --from=frontend-build /app/frontend/dist ./public
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "dist/server.js"]
|
# Migrations run before the app serves, so deployed code can never be ahead of
|
||||||
|
# the database schema. Previously this was a separate, manual
|
||||||
|
# `docker exec ... node migrate.js up` that was easy to forget — and forgetting
|
||||||
|
# it meant every item query referenced tables that did not exist yet, which
|
||||||
|
# surfaced as an empty storefront rather than an error.
|
||||||
|
#
|
||||||
|
# `&&` matters: migrate.js exits non-zero on failure, so a bad migration stops
|
||||||
|
# the container instead of letting it serve against a half-migrated schema.
|
||||||
|
CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]
|
||||||
@@ -161,3 +161,77 @@ Gitea Actions runs two workflows on every push to `main` and on pull requests:
|
|||||||
## Production deployment
|
## Production deployment
|
||||||
|
|
||||||
Production runs as a single Docker image (multi-stage build — the frontend is built to static files and served directly by the backend), deployed via Portainer behind Nginx Proxy Manager, with authentik forward-auth gating `/admin`. That infrastructure is homelab-specific and documented separately outside this repo.
|
Production runs as a single Docker image (multi-stage build — the frontend is built to static files and served directly by the backend), deployed via Portainer behind Nginx Proxy Manager, with authentik forward-auth gating `/admin`. That infrastructure is homelab-specific and documented separately outside this repo.
|
||||||
|
|
||||||
|
The container applies pending migrations before starting the server, so deployed code can never be ahead of the database schema. A failed migration stops the container rather than letting it serve against a schema it doesn't match — check `docker logs` on the app container if it doesn't come up.
|
||||||
|
|
||||||
|
## QA environment
|
||||||
|
|
||||||
|
`docker-compose.qa.yml` defines a disposable QA stack for reviewing merged-but-undeployed changes online. It runs alongside production on the same NAS with its own containers, database, volumes, and host port, and is started only when a review is needed.
|
||||||
|
|
||||||
|
It differs from production deliberately: `DEMO_MODE=true` with no PayPal credentials (so checkout is exercisable but cannot reach live PayPal), no SMTP configuration (so it cannot email anyone), and `restart: "no"` (so a NAS reboot doesn't quietly bring it back up).
|
||||||
|
|
||||||
|
### One-time setup on the NAS
|
||||||
|
|
||||||
|
**Build the image before creating the stack.** `redefined-designs:qa` exists only on the NAS — it is never pushed to a registry. If the stack is deployed first, Compose finds no local image, falls back to pulling from Docker Hub, and fails with a misleading `pull access denied ... repository does not exist or may require 'docker login'`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/redefined-designs
|
||||||
|
sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
|
||||||
|
sudo docker images | grep redefined-designs # confirm the qa tag is present
|
||||||
|
```
|
||||||
|
|
||||||
|
For the same reason, leave Portainer's **"Pull latest image"** toggle **off** when deploying or redeploying this stack. The compose file sets `pull_policy: never` so a missing image reports itself as missing rather than as a registry authentication problem.
|
||||||
|
|
||||||
|
Next, create the directory structure. These paths are deliberately **not** under `/volume1/configs/redefined-designs` — sharing production's Postgres directory would mean QA writing into production's database files.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /volume1/configs/redefined-designs-qa/postgres
|
||||||
|
sudo mkdir -p /volume1/configs/redefined-designs-qa/uploads
|
||||||
|
|
||||||
|
# The official postgres image runs as uid/gid 999. Without this the DB
|
||||||
|
# container exits immediately with a data-directory permissions error.
|
||||||
|
sudo chown -R 999:999 /volume1/configs/redefined-designs-qa/postgres
|
||||||
|
sudo chmod 700 /volume1/configs/redefined-designs-qa/postgres
|
||||||
|
|
||||||
|
# Uploads are written by the app container, which runs as root.
|
||||||
|
sudo chown -R root:root /volume1/configs/redefined-designs-qa/uploads
|
||||||
|
sudo chmod 755 /volume1/configs/redefined-designs-qa/uploads
|
||||||
|
|
||||||
|
# Confirm the two environments are separate before going further.
|
||||||
|
ls -la /volume1/configs/redefined-designs-qa/
|
||||||
|
ls -la /volume1/configs/redefined-designs/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in Portainer, create a stack **named `redefined-designs-qa`** from `docker-compose.qa.yml`, with one environment variable `QA_DB_PASSWORD`. The stack name matters: it becomes the compose project name, and reusing production's name would make compose reconcile the two stacks against each other and remove the production containers.
|
||||||
|
|
||||||
|
Finally, add an Nginx Proxy Manager host for `qa-redefined-designs` pointing at the NAS on port `32751`, with authentik forward-auth on `location /` — the whole site, not just `/admin`, so nothing unreleased is publicly reachable.
|
||||||
|
|
||||||
|
### Reviewing a change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/redefined-designs
|
||||||
|
gitc() { sudo docker run --rm -it -v /volume1/docker/redefined-designs:/repo -v ~/.gitconfig-docker/.gitconfig:/root/.gitconfig -w /repo alpine/git "$@"; }
|
||||||
|
gitc pull origin main
|
||||||
|
gitc log --oneline -3
|
||||||
|
|
||||||
|
# Rebuild the QA image before restarting the stack — the stack does not build
|
||||||
|
# anything itself, and will otherwise run whatever was last tagged :qa.
|
||||||
|
sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
|
||||||
|
# start the redefined-designs-qa stack in Portainer
|
||||||
|
|
||||||
|
# Migrations run as the container starts — confirm before reviewing.
|
||||||
|
sudo docker logs redefined-designs-qa-syn | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop the stack in Portainer when finished.
|
||||||
|
|
||||||
|
### Resetting QA data
|
||||||
|
|
||||||
|
QA data is disposable. To start from an empty database, stop the stack first, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check the path before running this. It must contain -qa.
|
||||||
|
sudo rm -rf /volume1/configs/redefined-designs-qa/postgres/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
Restarting the stack recreates the database and re-runs every migration from scratch.
|
||||||
+46
-14
@@ -1,22 +1,54 @@
|
|||||||
const { runner } = require('node-pg-migrate');
|
const { runner } = require('node-pg-migrate');
|
||||||
|
const { Client } = require('pg');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const direction = process.argv[2] || 'up';
|
const direction = process.argv[2] || 'up';
|
||||||
|
|
||||||
runner({
|
const dbConfig = {
|
||||||
databaseUrl: {
|
host: process.env.PGHOST || 'localhost',
|
||||||
host: process.env.PGHOST || 'localhost',
|
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||||
port: parseInt(process.env.PGPORT || '5432', 10),
|
user: process.env.PGUSER,
|
||||||
user: process.env.PGUSER,
|
password: process.env.PGPASSWORD,
|
||||||
password: process.env.PGPASSWORD,
|
database: process.env.PGDATABASE
|
||||||
database: process.env.PGDATABASE
|
};
|
||||||
},
|
|
||||||
dir: path.resolve(__dirname, 'migrations'),
|
// This now runs at container start, ahead of the app, so it can come up before
|
||||||
direction,
|
// Postgres is accepting connections — on the NAS the database container is
|
||||||
migrationsTable: 'pgmigrations',
|
// routinely slower to be ready than the app container. Without a wait the
|
||||||
count: direction === 'down' ? 1 : Infinity,
|
// migration would fail, take the app down with it, and look like a broken
|
||||||
log: (msg) => console.log(msg)
|
// deploy rather than a startup race.
|
||||||
})
|
const WAIT_ATTEMPTS = 30;
|
||||||
|
const WAIT_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
async function waitForDb() {
|
||||||
|
for (let attempt = 1; attempt <= WAIT_ATTEMPTS; attempt++) {
|
||||||
|
const client = new Client(dbConfig);
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.end();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
await client.end().catch(() => {});
|
||||||
|
if (attempt === WAIT_ATTEMPTS) {
|
||||||
|
throw new Error(`Database unreachable after ${WAIT_ATTEMPTS} attempts: ${err.message}`);
|
||||||
|
}
|
||||||
|
console.log(`Waiting for database (attempt ${attempt}/${WAIT_ATTEMPTS})...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForDb()
|
||||||
|
.then(() =>
|
||||||
|
runner({
|
||||||
|
databaseUrl: dbConfig,
|
||||||
|
dir: path.resolve(__dirname, 'migrations'),
|
||||||
|
direction,
|
||||||
|
migrationsTable: 'pgmigrations',
|
||||||
|
count: direction === 'down' ? 1 : Infinity,
|
||||||
|
log: (msg) => console.log(msg)
|
||||||
|
})
|
||||||
|
)
|
||||||
.then((applied) => {
|
.then((applied) => {
|
||||||
console.log(`Migration complete — ${applied.length} migration(s) ${direction === 'down' ? 'reverted' : 'applied'}.`);
|
console.log(`Migration complete — ${applied.length} migration(s) ${direction === 'down' ? 'reverted' : 'applied'}.`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
+17
-1
@@ -1,4 +1,4 @@
|
|||||||
import express from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import itemsRouter from './routes/items';
|
import itemsRouter from './routes/items';
|
||||||
@@ -58,4 +58,20 @@ if (process.env.NODE_ENV !== 'test') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mounted last, so it sees errors from every route above. Without it, a route
|
||||||
|
// that hands an error to next() falls through to Express's default handler,
|
||||||
|
// and — worse — an async route that rejects never responds at all, leaving the
|
||||||
|
// client hanging. A hung request is indistinguishable from an empty catalogue
|
||||||
|
// in the UI, which is exactly how a schema mismatch once read as "the store
|
||||||
|
// has no items". Always answer.
|
||||||
|
//
|
||||||
|
// The four-argument signature is what marks this as error middleware; `next`
|
||||||
|
// is unused but must stay for Express to recognise it.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
console.error(err);
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||||
|
|
||||||
|
// Express 4 does not forward a rejected promise from an async handler to the
|
||||||
|
// error middleware. An async route that throws therefore never responds at all
|
||||||
|
// — the request hangs until the client gives up, which reads to a user as "the
|
||||||
|
// page is empty" rather than "the server failed". Wrapping the handler routes
|
||||||
|
// the rejection into next(), so the error middleware can answer with a 500.
|
||||||
|
//
|
||||||
|
// Express 5 does this natively; drop this helper if the project ever upgrades.
|
||||||
|
export function asyncRoute(
|
||||||
|
handler: (req: Request, res: Response, next: NextFunction) => unknown
|
||||||
|
): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
// Promise.resolve also captures a synchronous throw, so both failure modes
|
||||||
|
// reach the same place.
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(() => handler(req, res, next))
|
||||||
|
.catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ async function parentExists(id: number): Promise<boolean> {
|
|||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT c.id, c.name, c.parent_id, c.sort_order,
|
`SELECT c.id, c.name, c.parent_id, c.sort_order,
|
||||||
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
|
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
|
||||||
@@ -44,9 +45,9 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
ORDER BY c.sort_order, lower(c.name)`
|
ORDER BY c.sort_order, lower(c.name)`
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const name = readName(req.body.name);
|
const name = readName(req.body.name);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'name is required' });
|
return res.status(400).json({ error: 'name is required' });
|
||||||
@@ -76,9 +77,9 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
|
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
|
||||||
if (!existing.rows.length) {
|
if (!existing.rows.length) {
|
||||||
@@ -134,9 +135,9 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
|
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
|
||||||
if (!subtree.length) {
|
if (!subtree.length) {
|
||||||
@@ -154,6 +155,6 @@ router.delete('/:id', async (req: Request, res: Response) => {
|
|||||||
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
|
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
|
||||||
|
|
||||||
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
|
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
|
||||||
});
|
}));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { TAG_COLORS, tagColorFor } from '../utils';
|
import { TAG_COLORS, tagColorFor } from '../utils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -20,7 +21,7 @@ function readColor(value: unknown): string | null | undefined {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT t.id, t.name, t.color,
|
`SELECT t.id, t.name, t.color,
|
||||||
(SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count
|
(SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count
|
||||||
@@ -28,9 +29,9 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
ORDER BY lower(t.name)`
|
ORDER BY lower(t.name)`
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const name = readName(req.body.name);
|
const name = readName(req.body.name);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'name is required' });
|
return res.status(400).json({ error: 'name is required' });
|
||||||
@@ -54,9 +55,9 @@ router.post('/', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = await pool.query(`SELECT id, name, color FROM tags WHERE id = $1`, [id]);
|
const existing = await pool.query(`SELECT id, name, color FROM tags WHERE id = $1`, [id]);
|
||||||
if (!existing.rows.length) {
|
if (!existing.rows.length) {
|
||||||
@@ -93,12 +94,12 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||||
// item_tags cascades; the items themselves are untouched.
|
// item_tags cascades; the items themselves are untouched.
|
||||||
await pool.query(`DELETE FROM tags WHERE id = $1`, [req.params.id]);
|
await pool.query(`DELETE FROM tags WHERE id = $1`, [req.params.id]);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
}));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Everything the storefront's filter drawer needs, in one request: the whole
|
// Everything the storefront's filter drawer needs, in one request: the whole
|
||||||
// category tree (flat — the frontend nests it), every tag with its colour, and
|
// category tree (flat — the frontend nests it), every tag with its colour, and
|
||||||
// the catalogue's price bounds for the slider.
|
// the catalogue's price bounds for the slider.
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||||
const [categories, tags, price] = await Promise.all([
|
const [categories, tags, price] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)`
|
`SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)`
|
||||||
@@ -31,6 +32,6 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
tags: tags.rows,
|
tags: tags.rows,
|
||||||
priceRange: price.rows[0]
|
priceRange: price.rows[0]
|
||||||
});
|
});
|
||||||
});
|
}));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
|
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
|
||||||
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', asyncRoute(async (req: Request, res: Response) => {
|
||||||
let filters;
|
let filters;
|
||||||
try {
|
try {
|
||||||
filters = parseItemFilters(req.query as Record<string, unknown>);
|
filters = parseItemFilters(req.query as Record<string, unknown>);
|
||||||
@@ -22,12 +23,12 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
});
|
}));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { pool } from '../../src/db';
|
||||||
|
import { resetDb, closeDb } from './setup/testDb';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unexpected route failures', () => {
|
||||||
|
it('answers with 500 instead of leaving the request hanging', async () => {
|
||||||
|
// A non-numeric id reaches Postgres as `WHERE i.id = 'not-a-number'`, which
|
||||||
|
// raises invalid-input-syntax. Express 4 does not forward a rejected async
|
||||||
|
// handler on its own, so without the asyncRoute wrapper plus the error
|
||||||
|
// middleware this request never gets a response at all — and a hung request
|
||||||
|
// renders as an empty storefront rather than a visible failure.
|
||||||
|
const res = await request(app).get('/api/items/not-a-number');
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toBe('internal error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak the underlying database error to the client', async () => {
|
||||||
|
const res = await request(app).get('/api/items/not-a-number');
|
||||||
|
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('syntax');
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('items');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { asyncRoute } from '../../src/asyncRoute';
|
||||||
|
|
||||||
|
function fakeArgs() {
|
||||||
|
const next = jest.fn() as unknown as NextFunction;
|
||||||
|
return { req: {} as Request, res: {} as Response, next };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('asyncRoute', () => {
|
||||||
|
it('passes a rejected handler to next so Express can answer the request', async () => {
|
||||||
|
const boom = new Error('relation "categories" does not exist');
|
||||||
|
const { req, res, next } = fakeArgs();
|
||||||
|
|
||||||
|
await asyncRoute(async () => { throw boom; })(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledWith(boom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a synchronous throw to next as well', async () => {
|
||||||
|
const boom = new Error('sync failure');
|
||||||
|
const { req, res, next } = fakeArgs();
|
||||||
|
|
||||||
|
await asyncRoute(() => { throw boom; })(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledWith(boom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a successful handler alone', async () => {
|
||||||
|
const { req, res, next } = fakeArgs();
|
||||||
|
|
||||||
|
await asyncRoute(async () => 'fine')(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the same arguments it was given', async () => {
|
||||||
|
const handler = jest.fn(async () => undefined);
|
||||||
|
const { req, res, next } = fakeArgs();
|
||||||
|
|
||||||
|
await asyncRoute(handler)(req, res, next);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(req, res, next);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# QA stack — a disposable copy of the app for reviewing merged-but-undeployed
|
||||||
|
# changes online. See issue #25.
|
||||||
|
#
|
||||||
|
# Deployed as its own Portainer stack, separate from production. Every value
|
||||||
|
# that could collide with production has been changed: container names, host
|
||||||
|
# port, volume paths, database name, and image tag. Do not copy a path or port
|
||||||
|
# back from the production stack — a shared Postgres data directory would mean
|
||||||
|
# QA writing into production's database files.
|
||||||
|
#
|
||||||
|
# Name the Portainer stack `redefined-designs-qa`, NOT `redefined-designs`.
|
||||||
|
# The stack name becomes the compose project name. Reusing production's name
|
||||||
|
# would make compose treat this as the same project and reconcile the two
|
||||||
|
# against each other — it would happily remove the production containers
|
||||||
|
# because they are not declared in this file.
|
||||||
|
#
|
||||||
|
# Required stack environment variable:
|
||||||
|
# QA_DB_PASSWORD — deliberately not named DB_PASSWORD, so pasting the
|
||||||
|
# production stack's variables here does nothing silently.
|
||||||
|
#
|
||||||
|
# BUILD THE IMAGE BEFORE DEPLOYING THIS STACK. redefined-designs:qa exists only
|
||||||
|
# on the NAS and is never pushed to a registry, so deploying first makes Compose
|
||||||
|
# fall back to pulling from Docker Hub and fail with a misleading
|
||||||
|
# "pull access denied ... repository does not exist or may require docker login".
|
||||||
|
#
|
||||||
|
# sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
|
||||||
|
#
|
||||||
|
# For the same reason, leave Portainer's "Pull latest image" toggle off.
|
||||||
|
# `pull_policy: never` below makes a missing image report itself as missing
|
||||||
|
# rather than as a registry authentication problem. If the Docker Compose
|
||||||
|
# version on the NAS ever rejects that key, it is safe to delete the line — it
|
||||||
|
# only improves the error message.
|
||||||
|
|
||||||
|
services:
|
||||||
|
redefined-designs-qa:
|
||||||
|
image: redefined-designs:qa
|
||||||
|
pull_policy: never
|
||||||
|
container_name: redefined-designs-qa-syn
|
||||||
|
environment:
|
||||||
|
- TZ=America/Chicago
|
||||||
|
- PORT=3000
|
||||||
|
- PGHOST=redefined-designs-qa-db-syn
|
||||||
|
- PGPORT=5432
|
||||||
|
- PGUSER=redefined_qa
|
||||||
|
- PGPASSWORD=${QA_DB_PASSWORD}
|
||||||
|
- PGDATABASE=redefined_qa
|
||||||
|
|
||||||
|
# No PayPal credentials at all. DEMO_MODE lets the full cart and checkout
|
||||||
|
# flow run without them, so QA can exercise the whole purchase path with
|
||||||
|
# no way to reach live PayPal. Never set PAYPAL_ENV=live here. To test a
|
||||||
|
# real PayPal integration change, add sandbox credentials and set
|
||||||
|
# PAYPAL_ENV=sandbox — never the live ones.
|
||||||
|
- DEMO_MODE=true
|
||||||
|
|
||||||
|
# No SMTP configuration either. The mailer degrades gracefully when
|
||||||
|
# unconfigured: it logs a warning and skips sending. That is the desired
|
||||||
|
# behaviour here — a QA run must not be able to email real customers if
|
||||||
|
# a fixture ever contains a real address.
|
||||||
|
- SITE_CURRENCY=USD
|
||||||
|
- RESERVATION_MINUTES=15
|
||||||
|
- PUBLIC_URL=https://qa-redefined-designs.bermudalamb.synology.me
|
||||||
|
volumes:
|
||||||
|
# Separate uploads directory. Sharing production's would let a QA run
|
||||||
|
# write into, and a QA teardown delete, real product images.
|
||||||
|
- /volume1/configs/redefined-designs-qa/uploads:/app/uploads
|
||||||
|
ports:
|
||||||
|
# 32751, not production's 32750.
|
||||||
|
- 32751:3000
|
||||||
|
depends_on:
|
||||||
|
redefined-designs-qa-db-syn:
|
||||||
|
condition: service_healthy
|
||||||
|
# Not `unless-stopped`: QA is meant to be up only while a review is
|
||||||
|
# happening. `unless-stopped` would silently bring it back after every NAS
|
||||||
|
# reboot and leave it running indefinitely.
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
redefined-designs-qa-db-syn:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: redefined-designs-qa-db-syn
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=redefined_qa
|
||||||
|
- POSTGRES_PASSWORD=${QA_DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=redefined_qa
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
# Distinct data directory from production's
|
||||||
|
# /volume1/configs/redefined-designs/postgres. This is the single most
|
||||||
|
# important difference in this file.
|
||||||
|
- /volume1/configs/redefined-designs-qa/postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U redefined_qa -d redefined_qa"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: "no"
|
||||||
@@ -40,6 +40,10 @@ The issue's phrase "rules that dictate how the app automatically organizes items
|
|||||||
resolved to mean manual tree assignment, matching its own follow-on sentence that categories are
|
resolved to mean manual tree assignment, matching its own follow-on sentence that categories are
|
||||||
"only metadata for organizing the items into a tree-like structure."
|
"only metadata for organizing the items into a tree-like structure."
|
||||||
|
|
||||||
|
The layout decisions were made against wireframes, archived alongside this spec in
|
||||||
|
[`2026-08-17-categories-and-tags-mockups/`](2026-08-17-categories-and-tags-mockups/) — they record
|
||||||
|
the rejected options (sidebar rail, dropdown row, bottom sheet) as well as the chosen one.
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
One new migration, created with `npm run migrate:create -- add-categories-and-tags`.
|
One new migration, created with `npm run migrate:create -- add-categories-and-tags`.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Categories and tags — design mockups
|
||||||
|
|
||||||
|
Wireframes produced while designing [issue #23](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23),
|
||||||
|
kept because they record the options that were rejected, not just the one that shipped.
|
||||||
|
|
||||||
|
Open either file directly in a browser — they are self-contained, with no server,
|
||||||
|
build step, or network access required.
|
||||||
|
|
||||||
|
| File | Question it answered | Outcome |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `filter-layout.html` | Where do the storefront's category / tag / price filters live? | **C — drawer plus removable chips.** A was a permanent sidebar rail (most discoverable, but costs ~25% of grid width); B was a row of dropdowns (full width, but hides the category tree until opened). |
|
||||||
|
| `filter-layout-mobile.html` | How does that drawer arrive on a phone? | **C1 — side drawer**, entering from the right at near full-screen. C2 was a bottom sheet: better thumb reach, but a deep category tree gets cramped. |
|
||||||
|
|
||||||
|
The reasoning behind each choice is in
|
||||||
|
[the design spec](../2026-08-17-categories-and-tags-design.md), and the
|
||||||
|
question-and-answer trail is on the issue itself.
|
||||||
|
|
||||||
|
## Why these are copies
|
||||||
|
|
||||||
|
These began as content fragments inside `.superpowers/brainstorm/`, a scratch
|
||||||
|
directory belonging to the brainstorming tool. That directory is gitignored and
|
||||||
|
should stay that way — it also holds a session token, PID files, and absolute
|
||||||
|
local paths, none of which belong in the repository.
|
||||||
|
|
||||||
|
The mockups themselves are design artifacts, so they were lifted out and wrapped
|
||||||
|
into standalone pages: the frame's style tokens and its `toggleSelect` helper are
|
||||||
|
inlined here, which is the only difference from what was reviewed.
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mobile drawer variants</title>
|
||||||
|
<!--
|
||||||
|
Archived design mockup from the brainstorming session for issue #23.
|
||||||
|
Originally a content fragment served inside a tool-provided frame; the
|
||||||
|
frame's tokens and helper are inlined below so the file opens standalone
|
||||||
|
in any browser with no server and no network access.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--border: #d9d9d9;
|
||||||
|
--bg: #ffffff;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--border: #3a3a3a;
|
||||||
|
--bg: #141414;
|
||||||
|
--bg-elevated: #1d1d1d;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
h2 { margin: 0 0 8px; font-size: 24px; }
|
||||||
|
h3 { margin: 0 0 6px; font-size: 16px; }
|
||||||
|
h4 { margin: 0; }
|
||||||
|
.subtitle { margin: 0 0 24px; opacity: .75; font-size: 15px; }
|
||||||
|
.section { margin-bottom: 24px; }
|
||||||
|
.label {
|
||||||
|
font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
opacity: .6; font-weight: 700; margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: rgba(128,128,128,.14); padding: 1px 5px;
|
||||||
|
border-radius: 4px; font-size: .9em;
|
||||||
|
}
|
||||||
|
.archive-note {
|
||||||
|
border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 6px; padding: 12px 16px; margin-bottom: 28px;
|
||||||
|
font-size: 14px; background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="archive-note">
|
||||||
|
<strong>Archived mockup — Mobile drawer variants.</strong>
|
||||||
|
Produced while designing
|
||||||
|
<a href="https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23">issue #23</a>.
|
||||||
|
The option marked as chosen is recorded in
|
||||||
|
<a href="../2026-08-17-categories-and-tags-design.md">the design spec</a>.
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.mb-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; align-items: start; justify-items: center; }
|
||||||
|
@media (max-width: 1000px) { .mb-row { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.mb-col { width: 100%; max-width: 300px; }
|
||||||
|
.mb-cap { text-align: center; font-weight: 700; font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.mb-sub { text-align: center; font-size: 12px; opacity: .7; margin-bottom: 12px; line-height: 1.5; min-height: 34px; }
|
||||||
|
|
||||||
|
/* phone chrome */
|
||||||
|
.phone {
|
||||||
|
border: 8px solid #2a2a2a; border-radius: 26px;
|
||||||
|
background: var(--bg-elevated, #fff);
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
height: 500px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.22);
|
||||||
|
}
|
||||||
|
.phone-notch {
|
||||||
|
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 84px; height: 15px; background: #2a2a2a;
|
||||||
|
border-radius: 0 0 9px 9px; z-index: 40;
|
||||||
|
}
|
||||||
|
.scr { position: absolute; inset: 0; display: flex; flex-direction: column; font-size: 11px; }
|
||||||
|
|
||||||
|
.m-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 20px 10px 8px; border-bottom: 1px solid var(--border, #ccc);
|
||||||
|
background: rgba(128,128,128,.08); flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.m-brand { font-weight: 700; font-size: 12px; }
|
||||||
|
.m-icons { display: flex; gap: 6px; }
|
||||||
|
.m-ico {
|
||||||
|
width: 22px; height: 22px; border: 1px solid var(--border, #ccc); border-radius: 5px;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 11px;
|
||||||
|
background: rgba(128,128,128,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-filterbtn {
|
||||||
|
margin: 8px 10px 0; padding: 7px; border-radius: 6px;
|
||||||
|
background: #444; color: #fff; text-align: center; font-weight: 600; font-size: 11px;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.m-count {
|
||||||
|
background: #fff; color: #444; border-radius: 9px;
|
||||||
|
padding: 0 6px; font-size: 10px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px 10px; flex: 0 0 auto; }
|
||||||
|
.m-chip {
|
||||||
|
border: 1px solid var(--accent, #3b82f6); color: var(--accent, #3b82f6);
|
||||||
|
border-radius: 10px; padding: 2px 7px; font-size: 10px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-chip.clear { border-color: var(--border, #bbb); opacity: .7; color: inherit; }
|
||||||
|
|
||||||
|
.m-scroll { flex: 1; overflow: hidden; padding: 0 10px 10px; }
|
||||||
|
.m-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.m-card { border: 1px solid var(--border, #ccc); border-radius: 6px; overflow: hidden; }
|
||||||
|
.m-photo { height: 62px; background: repeating-linear-gradient(45deg, rgba(128,128,128,.14) 0 6px, rgba(128,128,128,.05) 6px 12px); }
|
||||||
|
.m-meta { padding: 5px 6px; line-height: 1.5; }
|
||||||
|
.m-meta b { display: block; font-size: 10px; font-weight: 600; }
|
||||||
|
.m-meta span { font-size: 10px; opacity: .75; }
|
||||||
|
.m-tagrow { display: flex; gap: 3px; margin-top: 4px; }
|
||||||
|
.m-tag { border-radius: 8px; padding: 0 5px; font-size: 8px; color: #fff; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* drawer variants */
|
||||||
|
.m-scrim { position: absolute; inset: 0; background: rgba(0,0,0,.45); z-index: 20; }
|
||||||
|
|
||||||
|
.m-sheet-right {
|
||||||
|
position: absolute; top: 0; right: 0; bottom: 0; left: 10%;
|
||||||
|
background: var(--bg-elevated, #fff); z-index: 30;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: -8px 0 18px rgba(0,0,0,.28);
|
||||||
|
}
|
||||||
|
.m-sheet-bottom {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; height: 78%;
|
||||||
|
background: var(--bg-elevated, #fff); z-index: 30;
|
||||||
|
border-radius: 14px 14px 0 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 -8px 18px rgba(0,0,0,.28);
|
||||||
|
}
|
||||||
|
.m-grab { width: 34px; height: 4px; border-radius: 2px; background: rgba(128,128,128,.5); margin: 7px auto 0; }
|
||||||
|
|
||||||
|
.m-sheet-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px; border-bottom: 1px solid var(--border, #ccc); font-weight: 700; font-size: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.m-sheet-body { flex: 1; overflow: hidden; padding: 10px; }
|
||||||
|
.m-sheet-foot {
|
||||||
|
display: flex; gap: 8px; padding: 9px 10px;
|
||||||
|
border-top: 1px solid var(--border, #ccc); flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.m-foot-btn {
|
||||||
|
flex: 1; text-align: center; padding: 7px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
}
|
||||||
|
.m-foot-btn.primary { background: #444; color: #fff; border-color: #444; }
|
||||||
|
|
||||||
|
.m-lab {
|
||||||
|
font-size: 9px; letter-spacing: .07em; text-transform: uppercase;
|
||||||
|
opacity: .6; font-weight: 700; margin: 12px 0 5px;
|
||||||
|
}
|
||||||
|
.m-lab:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.m-tree { line-height: 2; font-size: 11px; }
|
||||||
|
.m-tree .on { font-weight: 700; background: rgba(59,130,246,.2); border-radius: 4px; padding: 1px 5px; }
|
||||||
|
.m-tree .muted { opacity: .6; }
|
||||||
|
|
||||||
|
.m-pills { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.m-pill { border-radius: 10px; padding: 3px 9px; font-size: 10px; border: 1px solid transparent; }
|
||||||
|
.m-pill.off { background: rgba(128,128,128,.12); border-color: var(--border, #ccc); opacity: .8; }
|
||||||
|
|
||||||
|
.m-slider { padding: 8px 4px 0; }
|
||||||
|
.m-track { height: 4px; background: rgba(128,128,128,.3); border-radius: 2px; position: relative; }
|
||||||
|
.m-fill { position: absolute; left: 18%; right: 28%; top: 0; bottom: 0; background: var(--accent, #3b82f6); border-radius: 2px; }
|
||||||
|
.m-knob { position: absolute; top: -5px; width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 3px solid var(--accent, #3b82f6); }
|
||||||
|
.m-range { display: flex; justify-content: space-between; font-size: 10px; margin-top: 8px; opacity: .85; }
|
||||||
|
|
||||||
|
.pick { border: 2px solid transparent; border-radius: 12px; padding: 6px; cursor: pointer; transition: border-color .15s; }
|
||||||
|
.pick:hover { border-color: rgba(128,128,128,.4); }
|
||||||
|
.pick.selected { border-color: var(--accent, #3b82f6); }
|
||||||
|
|
||||||
|
.magenta { background: #c41d7f; color: #fff; }
|
||||||
|
.green { background: #389e0d; color: #fff; }
|
||||||
|
.blue { background: #096dd9; color: #fff; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Option C on a phone — and one sub-choice</h2>
|
||||||
|
<p class="subtitle">
|
||||||
|
The closed state is the same either way. What differs is how the filter panel arrives: a
|
||||||
|
<b>full-height side drawer</b> or a <b>bottom sheet</b>. Both are one antd <code>Drawer</code> with a
|
||||||
|
different <code>placement</code>, so this is purely a feel decision. Click the drawer style you prefer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-row">
|
||||||
|
|
||||||
|
<!-- ===== 1. CLOSED STATE ===== -->
|
||||||
|
<div class="mb-col">
|
||||||
|
<div class="mb-cap">Closed — browsing</div>
|
||||||
|
<div class="mb-sub">Full-width button, applied filters as removable chips, two cards per row.</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-notch"></div>
|
||||||
|
<div class="scr">
|
||||||
|
<div class="m-header">
|
||||||
|
<span class="m-brand">Redefined Designs</span>
|
||||||
|
<span class="m-icons"><span class="m-ico">☾</span><span class="m-ico">🛒</span><span class="m-ico">☰</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="m-filterbtn">⚙ Filters <span class="m-count">3</span></div>
|
||||||
|
<div class="m-chips">
|
||||||
|
<span class="m-chip">Coffee Tables ×</span>
|
||||||
|
<span class="m-chip">vintage ×</span>
|
||||||
|
<span class="m-chip">$120–$800 ×</span>
|
||||||
|
<span class="m-chip clear">Clear all</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-scroll">
|
||||||
|
<div class="m-grid">
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Oak coffee table</b><span>$340</span>
|
||||||
|
<div class="m-tagrow"><span class="m-tag magenta">vintage</span><span class="m-tag green">oak</span></div></div></div>
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Elm slab</b><span>$520</span>
|
||||||
|
<div class="m-tagrow"><span class="m-tag magenta">vintage</span></div></div></div>
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Walnut low</b><span>$690</span>
|
||||||
|
<div class="m-tagrow"><span class="m-tag magenta">vintage</span><span class="m-tag blue">rare</span></div></div></div>
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Teak round</b><span>$275</span>
|
||||||
|
<div class="m-tagrow"><span class="m-tag magenta">vintage</span></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 2. SIDE DRAWER ===== -->
|
||||||
|
<div class="mb-col pick" data-choice="side" onclick="toggleSelect(this)">
|
||||||
|
<div class="mb-cap">C1 — Side drawer</div>
|
||||||
|
<div class="mb-sub">Slides in from the right, near full-screen. Reads as "a page of filters".</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-notch"></div>
|
||||||
|
<div class="scr">
|
||||||
|
<div class="m-header">
|
||||||
|
<span class="m-brand">Redefined Designs</span>
|
||||||
|
<span class="m-icons"><span class="m-ico">☾</span><span class="m-ico">🛒</span><span class="m-ico">☰</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="m-scroll" style="margin-top:8px;">
|
||||||
|
<div class="m-grid">
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Oak coffee table</b><span>$340</span></div></div>
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Elm slab</b><span>$520</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-scrim"></div>
|
||||||
|
<div class="m-sheet-right">
|
||||||
|
<div class="m-sheet-head"><span>Filters</span><span>✕</span></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<div class="m-lab">Category</div>
|
||||||
|
<div class="m-tree">
|
||||||
|
<div class="muted">All items</div>
|
||||||
|
<div>▾ Furniture</div>
|
||||||
|
<div> ▾ Tables</div>
|
||||||
|
<div> <span class="on">Coffee Tables</span></div>
|
||||||
|
<div class="muted"> Side Tables</div>
|
||||||
|
<div class="muted">▸ Decor</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-lab">Tags — must have all</div>
|
||||||
|
<div class="m-pills">
|
||||||
|
<span class="m-pill magenta">vintage ✓</span>
|
||||||
|
<span class="m-pill off">handmade</span>
|
||||||
|
<span class="m-pill off">oak</span>
|
||||||
|
<span class="m-pill off">restored</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-lab">Price</div>
|
||||||
|
<div class="m-slider">
|
||||||
|
<div class="m-track"><div class="m-fill"></div>
|
||||||
|
<div class="m-knob" style="left:18%"></div><div class="m-knob" style="left:calc(72% - 14px)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="m-range"><span>$120</span><span>$800</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-sheet-foot">
|
||||||
|
<div class="m-foot-btn">Clear all</div>
|
||||||
|
<div class="m-foot-btn primary">Show 12 items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 3. BOTTOM SHEET ===== -->
|
||||||
|
<div class="mb-col pick" data-choice="bottom" onclick="toggleSelect(this)">
|
||||||
|
<div class="mb-cap">C2 — Bottom sheet</div>
|
||||||
|
<div class="mb-sub">Rises from the bottom, ~78% height. Controls sit within thumb reach; grid stays partly visible.</div>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-notch"></div>
|
||||||
|
<div class="scr">
|
||||||
|
<div class="m-header">
|
||||||
|
<span class="m-brand">Redefined Designs</span>
|
||||||
|
<span class="m-icons"><span class="m-ico">☾</span><span class="m-ico">🛒</span><span class="m-ico">☰</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="m-filterbtn">⚙ Filters <span class="m-count">3</span></div>
|
||||||
|
<div class="m-scroll" style="margin-top:8px;">
|
||||||
|
<div class="m-grid">
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Oak coffee table</b><span>$340</span></div></div>
|
||||||
|
<div class="m-card"><div class="m-photo"></div><div class="m-meta"><b>Elm slab</b><span>$520</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-scrim"></div>
|
||||||
|
<div class="m-sheet-bottom">
|
||||||
|
<div class="m-grab"></div>
|
||||||
|
<div class="m-sheet-head"><span>Filters</span><span>✕</span></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<div class="m-lab">Category</div>
|
||||||
|
<div class="m-tree">
|
||||||
|
<div class="muted">All items</div>
|
||||||
|
<div>▾ Furniture ▸ Tables</div>
|
||||||
|
<div> <span class="on">Coffee Tables</span></div>
|
||||||
|
<div class="muted">▸ Decor</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-lab">Tags — must have all</div>
|
||||||
|
<div class="m-pills">
|
||||||
|
<span class="m-pill magenta">vintage ✓</span>
|
||||||
|
<span class="m-pill off">handmade</span>
|
||||||
|
<span class="m-pill off">oak</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-lab">Price</div>
|
||||||
|
<div class="m-slider">
|
||||||
|
<div class="m-track"><div class="m-fill"></div>
|
||||||
|
<div class="m-knob" style="left:18%"></div><div class="m-knob" style="left:calc(72% - 14px)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="m-range"><span>$120</span><span>$800</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-sheet-foot">
|
||||||
|
<div class="m-foot-btn">Clear all</div>
|
||||||
|
<div class="m-foot-btn primary">Show 12 items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:26px;">
|
||||||
|
<p class="label">What carries over from desktop</p>
|
||||||
|
<p style="font-size:14px;line-height:1.7;opacity:.88;">
|
||||||
|
Identical component tree at both sizes — the drawer just changes <code>placement</code> and
|
||||||
|
<code>width</code>/<code>height</code> at the antd <code>md</code> breakpoint, so there is no second
|
||||||
|
implementation to keep in sync. The chip row is the only piece that behaves differently: on desktop it
|
||||||
|
sits inline next to the Filters button, on mobile it wraps onto its own line beneath it.
|
||||||
|
</p>
|
||||||
|
<p style="font-size:14px;line-height:1.7;opacity:.88;">
|
||||||
|
<b>Tags now confirmed as AND</b> — an item must carry <i>every</i> selected tag to show up. The panel
|
||||||
|
labels this explicitly ("must have all") so a user who selects two tags and sees the grid shrink to
|
||||||
|
nothing understands why.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The original frame recorded clicks back to the tool. Here it only toggles
|
||||||
|
// the highlight, so the archived mockup stays explorable offline.
|
||||||
|
function toggleSelect(el) {
|
||||||
|
var multi = el.parentElement && el.parentElement.hasAttribute('data-multiselect');
|
||||||
|
if (!multi) {
|
||||||
|
Array.prototype.forEach.call(
|
||||||
|
el.parentElement.children,
|
||||||
|
function (sibling) { if (sibling !== el) sibling.classList.remove('selected'); }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
el.classList.toggle('selected');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Storefront filter layout options</title>
|
||||||
|
<!--
|
||||||
|
Archived design mockup from the brainstorming session for issue #23.
|
||||||
|
Originally a content fragment served inside a tool-provided frame; the
|
||||||
|
frame's tokens and helper are inlined below so the file opens standalone
|
||||||
|
in any browser with no server and no network access.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--border: #d9d9d9;
|
||||||
|
--bg: #ffffff;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--border: #3a3a3a;
|
||||||
|
--bg: #141414;
|
||||||
|
--bg-elevated: #1d1d1d;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
h2 { margin: 0 0 8px; font-size: 24px; }
|
||||||
|
h3 { margin: 0 0 6px; font-size: 16px; }
|
||||||
|
h4 { margin: 0; }
|
||||||
|
.subtitle { margin: 0 0 24px; opacity: .75; font-size: 15px; }
|
||||||
|
.section { margin-bottom: 24px; }
|
||||||
|
.label {
|
||||||
|
font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
opacity: .6; font-weight: 700; margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: rgba(128,128,128,.14); padding: 1px 5px;
|
||||||
|
border-radius: 4px; font-size: .9em;
|
||||||
|
}
|
||||||
|
.archive-note {
|
||||||
|
border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 6px; padding: 12px 16px; margin-bottom: 28px;
|
||||||
|
font-size: 14px; background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="archive-note">
|
||||||
|
<strong>Archived mockup — Storefront filter layout options.</strong>
|
||||||
|
Produced while designing
|
||||||
|
<a href="https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23">issue #23</a>.
|
||||||
|
The option marked as chosen is recorded in
|
||||||
|
<a href="../2026-08-17-categories-and-tags-design.md">the design spec</a>.
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.fl-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; align-items: start; }
|
||||||
|
@media (max-width: 1100px) { .fl-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.fl-card {
|
||||||
|
border: 2px solid var(--border, #d0d0d0);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
background: var(--bg-elevated, #fff);
|
||||||
|
}
|
||||||
|
.fl-card:hover { transform: translateY(-2px); }
|
||||||
|
.fl-card.selected { border-color: var(--accent, #3b82f6); }
|
||||||
|
|
||||||
|
.fl-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border, #d0d0d0);
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fl-badge {
|
||||||
|
width: 22px; height: 22px; flex: 0 0 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent, #3b82f6); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.fl-body { padding: 12px; }
|
||||||
|
.fl-note { padding: 0 14px 14px; font-size: 13px; line-height: 1.5; opacity: .85; }
|
||||||
|
|
||||||
|
/* --- wireframe primitives, scaled small so 3 fit side by side --- */
|
||||||
|
.wf { border: 1px solid var(--border, #ccc); border-radius: 6px; overflow: hidden; font-size: 10px; }
|
||||||
|
.wf-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 8px; border-bottom: 1px solid var(--border, #ccc);
|
||||||
|
background: rgba(128,128,128,.10);
|
||||||
|
}
|
||||||
|
.wf-brand { font-weight: 700; font-size: 11px; }
|
||||||
|
.wf-actions { display: flex; gap: 4px; }
|
||||||
|
.wf-btn {
|
||||||
|
border: 1px solid var(--border, #ccc); border-radius: 4px;
|
||||||
|
padding: 2px 6px; font-size: 9px; background: rgba(128,128,128,.06);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wf-btn.solid { background: #555; color: #fff; border-color: #555; }
|
||||||
|
.wf-main { display: flex; gap: 8px; padding: 8px; }
|
||||||
|
|
||||||
|
.wf-rail { flex: 0 0 33%; border-right: 1px dashed var(--border, #ccc); padding-right: 8px; }
|
||||||
|
.wf-section-label {
|
||||||
|
font-size: 8px; letter-spacing: .06em; text-transform: uppercase;
|
||||||
|
opacity: .6; margin: 8px 0 4px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.wf-section-label:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.wf-tree { line-height: 1.7; }
|
||||||
|
.wf-tree div { white-space: nowrap; }
|
||||||
|
.wf-tree .on { font-weight: 700; background: rgba(59,130,246,.18); border-radius: 3px; padding: 0 3px; }
|
||||||
|
.wf-tree .muted { opacity: .6; }
|
||||||
|
|
||||||
|
.wf-pills { display: flex; flex-wrap: wrap; gap: 3px; }
|
||||||
|
.wf-pill {
|
||||||
|
border-radius: 9px; padding: 1px 6px; font-size: 9px;
|
||||||
|
border: 1px solid transparent; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wf-pill.off { background: rgba(128,128,128,.12); border-color: var(--border, #ccc); opacity: .75; }
|
||||||
|
.wf-pill.magenta { background: #c41d7f; color: #fff; }
|
||||||
|
.wf-pill.green { background: #389e0d; color: #fff; }
|
||||||
|
.wf-pill.blue { background: #096dd9; color: #fff; }
|
||||||
|
.wf-pill.orange { background: #d46b08; color: #fff; }
|
||||||
|
|
||||||
|
.wf-slider { padding: 6px 2px 2px; }
|
||||||
|
.wf-track { height: 3px; background: rgba(128,128,128,.3); border-radius: 2px; position: relative; }
|
||||||
|
.wf-fill { position: absolute; left: 20%; right: 30%; top: 0; bottom: 0; background: var(--accent, #3b82f6); border-radius: 2px; }
|
||||||
|
.wf-knob { position: absolute; top: -3px; width: 9px; height: 9px; border-radius: 50%; background: #fff; border: 2px solid var(--accent, #3b82f6); }
|
||||||
|
.wf-range { display: flex; justify-content: space-between; font-size: 9px; margin-top: 5px; opacity: .8; }
|
||||||
|
|
||||||
|
.wf-grid { flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; align-content: start; }
|
||||||
|
.wf-grid.wide { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.wf-item { border: 1px solid var(--border, #ccc); border-radius: 4px; overflow: hidden; }
|
||||||
|
.wf-photo {
|
||||||
|
height: 34px; background: repeating-linear-gradient(45deg, rgba(128,128,128,.13) 0 5px, rgba(128,128,128,.05) 5px 10px);
|
||||||
|
}
|
||||||
|
.wf-meta { padding: 3px 4px; font-size: 8px; line-height: 1.4; }
|
||||||
|
.wf-meta b { display: block; font-weight: 600; }
|
||||||
|
.wf-meta span { opacity: .7; }
|
||||||
|
.wf-tagrow { display: flex; gap: 2px; margin-top: 2px; }
|
||||||
|
.wf-dot { width: 5px; height: 5px; border-radius: 50%; }
|
||||||
|
|
||||||
|
.wf-bar {
|
||||||
|
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
|
||||||
|
padding: 7px 8px; border-bottom: 1px solid var(--border, #ccc);
|
||||||
|
}
|
||||||
|
.wf-select {
|
||||||
|
border: 1px solid var(--border, #ccc); border-radius: 4px;
|
||||||
|
padding: 3px 6px; font-size: 9px; background: rgba(128,128,128,.06);
|
||||||
|
display: flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wf-caret { opacity: .5; font-size: 8px; }
|
||||||
|
.wf-chiprow { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 6px 8px; border-bottom: 1px dashed var(--border, #ccc); }
|
||||||
|
.wf-chip {
|
||||||
|
border: 1px solid var(--accent, #3b82f6); color: var(--accent, #3b82f6);
|
||||||
|
border-radius: 9px; padding: 1px 6px; font-size: 9px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wf-drawer {
|
||||||
|
position: absolute; top: 0; right: 0; bottom: 0; width: 44%;
|
||||||
|
background: var(--bg-elevated, #fff);
|
||||||
|
border-left: 2px solid var(--accent, #3b82f6);
|
||||||
|
box-shadow: -6px 0 14px rgba(0,0,0,.18);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.wf-scrim { position: absolute; inset: 0; background: rgba(0,0,0,.22); }
|
||||||
|
.wf-rel { position: relative; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Storefront filter layout — which shape fits?</h2>
|
||||||
|
<p class="subtitle">
|
||||||
|
Three filters land on the storefront: <b>category</b> (a tree), <b>tags</b> (multi-select, color-coded),
|
||||||
|
and <b>price range</b>. They differ mainly in how much screen they take from the item grid, and how well
|
||||||
|
they survive on a phone. Click the one you prefer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="fl-grid">
|
||||||
|
|
||||||
|
<!-- ============ A: SIDEBAR RAIL ============ -->
|
||||||
|
<div class="fl-card" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="fl-head"><span class="fl-badge">A</span> Sidebar filter rail</div>
|
||||||
|
<div class="fl-body">
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-header">
|
||||||
|
<span class="wf-brand">Redefined Designs</span>
|
||||||
|
<span class="wf-actions">
|
||||||
|
<span class="wf-btn">Dark</span><span class="wf-btn">Cart</span><span class="wf-btn solid">Sign up</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-main">
|
||||||
|
<div class="wf-rail">
|
||||||
|
<div class="wf-section-label">Category</div>
|
||||||
|
<div class="wf-tree">
|
||||||
|
<div class="muted">All items</div>
|
||||||
|
<div>▾ Furniture</div>
|
||||||
|
<div> ▾ Tables</div>
|
||||||
|
<div> <span class="on">Coffee</span></div>
|
||||||
|
<div class="muted"> Side</div>
|
||||||
|
<div class="muted">▸ Decor</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-section-label">Tags</div>
|
||||||
|
<div class="wf-pills">
|
||||||
|
<span class="wf-pill magenta">vintage</span>
|
||||||
|
<span class="wf-pill off">handmade</span>
|
||||||
|
<span class="wf-pill off">oak</span>
|
||||||
|
<span class="wf-pill off">restored</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-section-label">Price</div>
|
||||||
|
<div class="wf-slider">
|
||||||
|
<div class="wf-track"><div class="wf-fill"></div>
|
||||||
|
<div class="wf-knob" style="left:20%"></div><div class="wf-knob" style="left:calc(70% - 9px)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-range"><span>$120</span><span>$800</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-grid">
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Oak table</b><span>$340</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#389e0d"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Elm slab</b><span>$520</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Walnut</b><span>$690</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#096dd9"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Teak low</b><span>$275</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Ash round</b><span>$410</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#d46b08"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Pine box</b><span>$180</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fl-note">
|
||||||
|
Everything visible at once — the tree reads as actual folders, tag colors are on display, no clicking to
|
||||||
|
discover what's filterable. Costs ~25% of the grid width, so cards drop from 4 to 3 per row on desktop.
|
||||||
|
On mobile the rail has to collapse into an accordion above the grid.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ B: HORIZONTAL BAR ============ -->
|
||||||
|
<div class="fl-card" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="fl-head"><span class="fl-badge">B</span> Horizontal filter bar</div>
|
||||||
|
<div class="fl-body">
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-header">
|
||||||
|
<span class="wf-brand">Redefined Designs</span>
|
||||||
|
<span class="wf-actions">
|
||||||
|
<span class="wf-btn">Dark</span><span class="wf-btn">Cart</span><span class="wf-btn solid">Sign up</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-bar">
|
||||||
|
<span class="wf-select">Furniture / Tables / Coffee <span class="wf-caret">▾</span></span>
|
||||||
|
<span class="wf-select">
|
||||||
|
<span class="wf-pill magenta">vintage</span><span class="wf-pill green">oak</span>
|
||||||
|
<span class="wf-caret">▾</span>
|
||||||
|
</span>
|
||||||
|
<span class="wf-select">$120 – $800 <span class="wf-caret">▾</span></span>
|
||||||
|
<span class="wf-btn">Clear</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-main">
|
||||||
|
<div class="wf-grid wide">
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Oak table</b><span>$340</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#389e0d"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Elm slab</b><span>$520</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Walnut</b><span>$690</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#096dd9"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Teak low</b><span>$275</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Ash round</b><span>$410</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i><i class="wf-dot" style="background:#d46b08"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Pine box</b><span>$180</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Cedar</b><span>$230</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#389e0d"></i></div></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Maple</b><span>$610</span>
|
||||||
|
<div class="wf-tagrow"><i class="wf-dot" style="background:#c41d7f"></i></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fl-note">
|
||||||
|
Three compact controls in one row — an antd <i>Cascader</i> for the category tree, a tag multi-select,
|
||||||
|
and a price-range dropdown. Full grid width is preserved (4 cards per row), and the same row wraps
|
||||||
|
naturally on a phone. The tree is only visible once you open the cascader, so the category structure
|
||||||
|
is less discoverable.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ C: DRAWER + CHIPS ============ -->
|
||||||
|
<div class="fl-card" data-choice="c" onclick="toggleSelect(this)">
|
||||||
|
<div class="fl-head"><span class="fl-badge">C</span> Drawer + active-filter chips</div>
|
||||||
|
<div class="fl-body">
|
||||||
|
<div class="wf wf-rel">
|
||||||
|
<div class="wf-header">
|
||||||
|
<span class="wf-brand">Redefined Designs</span>
|
||||||
|
<span class="wf-actions">
|
||||||
|
<span class="wf-btn solid">Filters (3)</span><span class="wf-btn">Cart</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-chiprow">
|
||||||
|
<span class="wf-chip">Coffee Tables ×</span>
|
||||||
|
<span class="wf-chip">vintage ×</span>
|
||||||
|
<span class="wf-chip">$120–$800 ×</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-main">
|
||||||
|
<div class="wf-grid wide">
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Oak table</b><span>$340</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Elm slab</b><span>$520</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Walnut</b><span>$690</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Teak low</b><span>$275</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Ash round</b><span>$410</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Pine box</b><span>$180</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Cedar</b><span>$230</span></div></div>
|
||||||
|
<div class="wf-item"><div class="wf-photo"></div><div class="wf-meta"><b>Maple</b><span>$610</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scrim"></div>
|
||||||
|
<div class="wf-drawer">
|
||||||
|
<div style="font-weight:700;font-size:11px;margin-bottom:6px;">Filters</div>
|
||||||
|
<div class="wf-section-label">Category</div>
|
||||||
|
<div class="wf-tree">
|
||||||
|
<div class="muted">All items</div>
|
||||||
|
<div>▾ Furniture</div>
|
||||||
|
<div> ▾ Tables</div>
|
||||||
|
<div> <span class="on">Coffee</span></div>
|
||||||
|
<div class="muted">▸ Decor</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-section-label">Tags</div>
|
||||||
|
<div class="wf-pills">
|
||||||
|
<span class="wf-pill magenta">vintage</span>
|
||||||
|
<span class="wf-pill off">handmade</span>
|
||||||
|
<span class="wf-pill off">oak</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-section-label">Price</div>
|
||||||
|
<div class="wf-slider">
|
||||||
|
<div class="wf-track"><div class="wf-fill"></div>
|
||||||
|
<div class="wf-knob" style="left:20%"></div><div class="wf-knob" style="left:calc(70% - 9px)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-range"><span>$120</span><span>$800</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fl-note">
|
||||||
|
A "Filters" button opens a slide-over panel holding the full tree, tag pills, and price slider; what's
|
||||||
|
currently applied stays visible as removable chips above the grid. Full grid width, and identical
|
||||||
|
behaviour on desktop and mobile. Trade-off: filtering is a two-step interaction, and nothing hints at
|
||||||
|
the category structure until you open the drawer.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:22px;">
|
||||||
|
<p class="label">Same for all three</p>
|
||||||
|
<p style="font-size:14px;line-height:1.6;opacity:.85;">
|
||||||
|
Filters combine with AND (category <b>and</b> tags <b>and</b> price). Multiple selected tags are the one
|
||||||
|
open sub-question — "has <i>any</i> of these tags" vs "has <i>all</i> of them" — I'll ask that next.
|
||||||
|
Filtering happens server-side via query params on <code>/api/items</code>, so the result set stays correct
|
||||||
|
no matter how many items exist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The original frame recorded clicks back to the tool. Here it only toggles
|
||||||
|
// the highlight, so the archived mockup stays explorable offline.
|
||||||
|
function toggleSelect(el) {
|
||||||
|
var multi = el.parentElement && el.parentElement.hasAttribute('data-multiselect');
|
||||||
|
if (!multi) {
|
||||||
|
Array.prototype.forEach.call(
|
||||||
|
el.parentElement.children,
|
||||||
|
function (sibling) { if (sibling !== el) sibling.classList.remove('selected'); }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
el.classList.toggle('selected');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,7 +7,12 @@ export default defineConfig({
|
|||||||
reporter: [['list']],
|
reporter: [['list']],
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:5173',
|
baseURL: 'http://localhost:5173',
|
||||||
trace: 'on-first-retry'
|
trace: 'on-first-retry',
|
||||||
|
// The app turns off antd's transitions under this preference. Animated
|
||||||
|
// popups never settle long enough for Playwright's stability check when
|
||||||
|
// the machine is loaded, which showed up as clicks timing out on a button
|
||||||
|
// that was plainly visible and enabled.
|
||||||
|
reducedMotion: 'reduce'
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
|
|||||||
+20
-4
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd';
|
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
|
||||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||||
@@ -27,6 +27,7 @@ const FILTER_DEBOUNCE_MS = 250;
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -55,7 +56,14 @@ export default function App() {
|
|||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||||
.then(setItems)
|
.then((loaded) => {
|
||||||
|
setItems(loaded);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
// A failed request must never fall through to the empty state: telling a
|
||||||
|
// customer "no items yet" when the server is broken hides the outage and
|
||||||
|
// reads as an empty shop.
|
||||||
|
.catch(() => setFailed(true))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [filterKey]);
|
}, [filterKey]);
|
||||||
|
|
||||||
@@ -124,8 +132,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !items.length ? <Spin /> : null}
|
{loading && !items.length && !failed ? <Spin /> : null}
|
||||||
{!loading && !items.length ? (
|
{failed ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Couldn't load items"
|
||||||
|
description="The server didn't return the catalogue. This is usually temporary."
|
||||||
|
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
|
||||||
|
/>
|
||||||
|
) : !loading && !items.length ? (
|
||||||
<Empty
|
<Empty
|
||||||
description={
|
description={
|
||||||
hasActiveFilters(filters)
|
hasActiveFilters(filters)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
||||||
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
||||||
TreeSelect, Select
|
Select
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
@@ -14,26 +14,12 @@ import {
|
|||||||
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable,
|
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable,
|
||||||
fetchAdminCategories, fetchAdminTags
|
fetchAdminCategories, fetchAdminTags
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { buildCategoryTree, CategoryNode } from '../filters';
|
|
||||||
import { useThemeMode } from '../theme/ThemeContext';
|
import { useThemeMode } from '../theme/ThemeContext';
|
||||||
import Customers from './Customers';
|
import Customers from './Customers';
|
||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
|
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||||
interface CategoryTreeOption {
|
|
||||||
value: number;
|
|
||||||
title: string;
|
|
||||||
children?: CategoryTreeOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
|
||||||
return nodes.map(node => ({
|
|
||||||
value: node.id,
|
|
||||||
title: node.name,
|
|
||||||
children: node.children.length ? toCategoryTreeData(node.children) : undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
@@ -47,6 +33,7 @@ function Inventory() {
|
|||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const { mode } = useThemeMode();
|
const { mode } = useThemeMode();
|
||||||
|
|
||||||
const load = () => fetchAdminItems().then(setItems);
|
const load = () => fetchAdminItems().then(setItems);
|
||||||
@@ -94,21 +81,55 @@ function Inventory() {
|
|||||||
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
|
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
|
||||||
fd.append('tags', JSON.stringify(values.tags ?? []));
|
fd.append('tags', JSON.stringify(values.tags ?? []));
|
||||||
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
||||||
await saveItem(editingItem?.id ?? null, fd);
|
|
||||||
|
// Only report success, close the form, and discard the entered values once
|
||||||
|
// the server has actually accepted the write.
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await saveItem(editingItem?.id ?? null, fd);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`Couldn't save item — ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
message.success(editingItem ? 'Item updated' : 'Item added');
|
message.success(editingItem ? 'Item updated' : 'Item added');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
load();
|
load();
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status changes silently did nothing on failure — the row simply stayed put
|
||||||
|
// with no indication why.
|
||||||
|
async function handleStatusChange(action: (id: number) => Promise<Item>, id: number, label: string) {
|
||||||
|
try {
|
||||||
|
await action(id);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`Couldn't ${label} — ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
await deleteItem(id);
|
try {
|
||||||
|
await deleteItem(id);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`Couldn't delete item — ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.success('Item deleted');
|
message.success('Item deleted');
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteImage(itemId: number, imageId: number) {
|
async function handleDeleteImage(itemId: number, imageId: number) {
|
||||||
await deleteItemImage(itemId, imageId);
|
try {
|
||||||
|
await deleteItemImage(itemId, imageId);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`Couldn't remove image — ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.success('Image removed');
|
message.success('Image removed');
|
||||||
load();
|
load();
|
||||||
setEditingItem(prev => prev && prev.id === itemId
|
setEditingItem(prev => prev && prev.id === itemId
|
||||||
@@ -159,8 +180,8 @@ function Inventory() {
|
|||||||
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
|
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
|
||||||
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
|
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
|
||||||
{item.status !== 'sold'
|
{item.status !== 'sold'
|
||||||
? <Button size="small" onClick={() => markSold(item.id).then(load)}>Mark Sold</Button>
|
? <Button size="small" onClick={() => handleStatusChange(markSold, item.id, 'mark sold')}>Mark Sold</Button>
|
||||||
: <Button size="small" onClick={() => markAvailable(item.id).then(load)}>Mark Available</Button>}
|
: <Button size="small" onClick={() => handleStatusChange(markAvailable, item.id, 'mark available')}>Mark Available</Button>}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -174,7 +195,7 @@ function Inventory() {
|
|||||||
</div>
|
</div>
|
||||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||||
|
|
||||||
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} onCancel={() => setModalOpen(false)} destroyOnClose width={720}>
|
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
|
||||||
<div data-color-mode={mode}>
|
<div data-color-mode={mode}>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
||||||
@@ -187,13 +208,7 @@ function Inventory() {
|
|||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="category_id" label="Category">
|
<Form.Item name="category_id" label="Category">
|
||||||
<TreeSelect
|
<CategoryTreeSelect categories={categories} onCategoriesChanged={setCategories} />
|
||||||
allowClear
|
|
||||||
placeholder="Uncategorized"
|
|
||||||
treeDefaultExpandAll
|
|
||||||
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="tags"
|
name="tags"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import TreeSelect from 'antd/lib/tree-select';
|
||||||
|
import Input from 'antd/lib/input';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import Divider from 'antd/lib/divider';
|
||||||
|
import message from 'antd/lib/message';
|
||||||
|
import { Category, createCategory, fetchAdminCategories } from '../api';
|
||||||
|
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||||
|
|
||||||
|
interface CategoryTreeOption {
|
||||||
|
value: number;
|
||||||
|
title: string;
|
||||||
|
children?: CategoryTreeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
value: node.id,
|
||||||
|
title: node.name,
|
||||||
|
children: node.children.length ? toTreeData(node.children) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Supplied by antd's Form.Item. `id` has to be forwarded or the field loses
|
||||||
|
// its association with the rendered <label>, which breaks both screen readers
|
||||||
|
// and any lookup by label.
|
||||||
|
value?: number;
|
||||||
|
onChange?: (value: number | undefined) => void;
|
||||||
|
id?: string;
|
||||||
|
categories: Category[];
|
||||||
|
onCategoriesChanged: (categories: Category[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulled out of the item form so that typing a new category name re-renders
|
||||||
|
// only this control. Left inline, every keystroke re-rendered the whole
|
||||||
|
// Inventory component and rebuilt the tree data, which visibly jittered the
|
||||||
|
// open popup and moved the buttons under the pointer.
|
||||||
|
export default function CategoryTreeSelect({ value, onChange, id, categories, onCategoriesChanged }: Props) {
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
||||||
|
|
||||||
|
// Tags can be invented from the item form, so categories should be too —
|
||||||
|
// otherwise adding an item in a new category means abandoning a half-filled
|
||||||
|
// form. New categories land at the top level; nesting is done in the
|
||||||
|
// Categories tab, keeping this control to a single decision.
|
||||||
|
async function handleCreate() {
|
||||||
|
const name = newCategoryName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const created = await createCategory(name, null);
|
||||||
|
onCategoriesChanged(await fetchAdminCategories());
|
||||||
|
onChange?.(created.id);
|
||||||
|
setNewCategoryName('');
|
||||||
|
message.success(`Category "${created.name}" added`);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`Couldn't create category — ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeSelect
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder="Uncategorized"
|
||||||
|
// Deliberately not treeDefaultExpandAll: expanding a large tree on open
|
||||||
|
// makes the popup reflow while it measures, and buries the create field
|
||||||
|
// below every row. Search is the better affordance past a screenful.
|
||||||
|
treeNodeFilterProp="title"
|
||||||
|
listHeight={256}
|
||||||
|
treeData={treeData}
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
popupRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{/* Above the tree, not below it. Under a long list the control is
|
||||||
|
both invisible without scrolling and positioned by the list's
|
||||||
|
measured height, so it shifts as the virtualized rows settle. */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '8px 8px 0' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="New category name"
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||||
|
// Without this the tree steals the keystrokes for its own
|
||||||
|
// type-ahead and arrow-key navigation.
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
onPressEnter={handleCreate}
|
||||||
|
/>
|
||||||
|
<Button type="primary" loading={creating} onClick={handleCreate}>
|
||||||
|
Create category
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
{menu}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-6
@@ -54,40 +54,67 @@ export async function fetchConfig(): Promise<SiteConfig> {
|
|||||||
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
||||||
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
||||||
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
||||||
|
// An error response still parses as JSON — as `{ error: ... }`, not an array.
|
||||||
|
// Returning that unchecked would set it as the item list and crash the grid
|
||||||
|
// on `.map`, so a failure has to surface as a rejection the caller can show.
|
||||||
|
if (!res.ok) throw new Error('failed to load items');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
||||||
const res = await fetch('/api/filters');
|
const res = await fetch('/api/filters');
|
||||||
|
if (!res.ok) throw new Error('failed to load filters');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every admin call goes through this. Without the res.ok check a 4xx/5xx still
|
||||||
|
// resolves — the caller then reports success for a write that never happened,
|
||||||
|
// which is worse than failing outright because nothing prompts the user to look
|
||||||
|
// for the missing row.
|
||||||
|
async function expectOk(res: Response, action: string): Promise<Response> {
|
||||||
|
if (res.ok) return res;
|
||||||
|
const detail = await res.json().catch(() => null);
|
||||||
|
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAdminItems(): Promise<Item[]> {
|
export async function fetchAdminItems(): Promise<Item[]> {
|
||||||
const res = await fetch('/api/admin/items');
|
const res = await expectOk(await fetch('/api/admin/items'), 'failed to load items');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
|
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
|
||||||
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
|
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
|
||||||
const res = await fetch(url, { method: id ? 'PUT' : 'POST', body: formData });
|
const res = await expectOk(
|
||||||
|
await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }),
|
||||||
|
'failed to save item'
|
||||||
|
);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteItem(id: number): Promise<void> {
|
export async function deleteItem(id: number): Promise<void> {
|
||||||
await fetch(`/api/admin/items/${id}`, { method: 'DELETE' });
|
await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
|
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
|
||||||
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' });
|
await expectOk(
|
||||||
|
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }),
|
||||||
|
'failed to remove image'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markSold(id: number): Promise<Item> {
|
export async function markSold(id: number): Promise<Item> {
|
||||||
const res = await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' });
|
const res = await expectOk(
|
||||||
|
await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }),
|
||||||
|
'failed to mark sold'
|
||||||
|
);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAvailable(id: number): Promise<Item> {
|
export async function markAvailable(id: number): Promise<Item> {
|
||||||
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
const res = await expectOk(
|
||||||
|
await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }),
|
||||||
|
'failed to mark available'
|
||||||
|
);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-2
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
import { ConfigProvider, theme as antdTheme } from 'antd';
|
||||||
@@ -16,13 +16,35 @@ import { CartProvider } from './cart/CartContext';
|
|||||||
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||||
|
// antd's transitions. Beyond the accessibility win, animated popups are a
|
||||||
|
// standing source of flake in end-to-end tests, which drive the app with this
|
||||||
|
// preference enabled.
|
||||||
|
function usePrefersReducedMotion(): boolean {
|
||||||
|
const [prefers, setPrefers] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.matchMedia(REDUCED_MOTION_QUERY).matches
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
const update = () => setPrefers(query.matches);
|
||||||
|
query.addEventListener('change', update);
|
||||||
|
return () => query.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return prefers;
|
||||||
|
}
|
||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
const { mode } = useThemeMode();
|
const { mode } = useThemeMode();
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||||
token: { colorPrimary: '#1a1a1a' }
|
token: { colorPrimary: '#1a1a1a', motion: !prefersReducedMotion }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const suffix = () => `i${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
// Opening the form kicks off a categories fetch. Interacting with the category
|
||||||
|
// field before it lands means the tree re-renders under the cursor, so wait for
|
||||||
|
// the data rather than racing it.
|
||||||
|
async function openItemForm(page: import('@playwright/test').Page) {
|
||||||
|
// Opening the form fetches both categories and tags, and each one re-renders
|
||||||
|
// the modal as it lands. Waiting for only one still leaves the second to
|
||||||
|
// reflow the popup mid-interaction.
|
||||||
|
const loaded = Promise.all([
|
||||||
|
page.waitForResponse((res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'),
|
||||||
|
page.waitForResponse((res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET')
|
||||||
|
]);
|
||||||
|
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||||
|
await loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Inline category creation from the item form', () => {
|
||||||
|
test('creates a category without leaving the item form and assigns it', async ({ page }) => {
|
||||||
|
const RUN = suffix();
|
||||||
|
const categoryName = `Inline ${RUN}`;
|
||||||
|
const itemName = `Item ${RUN}`;
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await openItemForm(page);
|
||||||
|
await page.getByLabel('Name').fill(itemName);
|
||||||
|
await page.getByLabel('Price (USD)').fill('99');
|
||||||
|
|
||||||
|
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
|
||||||
|
const nameInput = page.getByPlaceholder('New category name');
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
|
||||||
|
await nameInput.fill(categoryName);
|
||||||
|
// Submitted with Enter rather than a click: the popup sits over a
|
||||||
|
// virtualized tree that keeps re-measuring, so a click target inside it is
|
||||||
|
// never geometrically stable. Enter runs the same handler as the button.
|
||||||
|
await nameInput.press('Enter');
|
||||||
|
|
||||||
|
// The new category should be selected straight away — having to hunt for it
|
||||||
|
// in the tree afterwards defeats the point of creating it inline.
|
||||||
|
await expect(page.getByRole('dialog').getByText(categoryName)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
await expect(page.getByText('Item added')).toBeVisible();
|
||||||
|
|
||||||
|
const items = await (await page.request.get('/api/admin/items')).json();
|
||||||
|
const saved = items.find((item: { name: string }) => item.name === itemName);
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
expect(saved.category_name).toBe(categoryName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a duplicate category name instead of silently doing nothing', async ({ page }) => {
|
||||||
|
const RUN = suffix();
|
||||||
|
const categoryName = `Dupe ${RUN}`;
|
||||||
|
|
||||||
|
const created = await page.request.post('/api/admin/categories', {
|
||||||
|
data: { name: categoryName, parent_id: null }
|
||||||
|
});
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await openItemForm(page);
|
||||||
|
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
|
||||||
|
const nameInput = page.getByPlaceholder('New category name');
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
|
||||||
|
await nameInput.fill(categoryName);
|
||||||
|
// Submitted with Enter rather than a click: the popup sits over a
|
||||||
|
// virtualized tree that keeps re-measuring, so a click target inside it is
|
||||||
|
// never geometrically stable. Enter runs the same handler as the button.
|
||||||
|
await nameInput.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByText(/already exists/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
test.describe('Admin save failures', () => {
|
||||||
|
test('does not claim an item was saved when the request failed', async ({ page }) => {
|
||||||
|
await page.route('**/api/admin/items', (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '{"error":"internal error"}'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||||
|
await page.getByLabel('Name').fill(`Broken ${suffix()}`);
|
||||||
|
await page.getByLabel('Price (USD)').fill('12');
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
|
||||||
|
// Reporting success for a failed save is worse than failing loudly: the
|
||||||
|
// item is silently absent and the user has no reason to look for it.
|
||||||
|
await expect(page.getByText('Item added')).toBeHidden();
|
||||||
|
await expect(page.getByText("Couldn't save item")).toBeVisible();
|
||||||
|
// The form must stay open so the entered values aren't lost.
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a failed delete rather than claiming success', async ({ page }) => {
|
||||||
|
// Seeded through the API so the test owns a known row rather than clicking
|
||||||
|
// whichever Delete button happens to be first in a paginated table.
|
||||||
|
const name = `Doomed ${suffix()}`;
|
||||||
|
const created = await page.request.post('/api/admin/items', {
|
||||||
|
multipart: { name, description: '', price: '10', category_id: '', tags: '[]' }
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
await page.route('**/api/admin/items/*', (route) => {
|
||||||
|
if (route.request().method() === 'DELETE') {
|
||||||
|
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
// Items list newest-first, so the seeded row is on the first page.
|
||||||
|
const row = page.getByRole('row').filter({ hasText: name });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Item deleted')).toBeHidden();
|
||||||
|
await expect(page.getByText("Couldn't delete item")).toBeVisible();
|
||||||
|
// The row must survive a failed delete.
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves an item successfully when the server accepts it', async ({ page }) => {
|
||||||
|
const name = `Good ${suffix()}`;
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||||
|
await page.getByLabel('Name').fill(name);
|
||||||
|
await page.getByLabel('Price (USD)').fill('34');
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Item added')).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm the row actually reached the database, not just that a toast fired.
|
||||||
|
const items = await (await page.request.get('/api/admin/items')).json();
|
||||||
|
expect(items.some((item: { name: string }) => item.name === name)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Storefront failure states', () => {
|
||||||
|
test('reports a server failure instead of claiming the store is empty', async ({ page }) => {
|
||||||
|
await page.route('**/api/items*', (route) =>
|
||||||
|
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Telling a customer "no items yet" when the server is broken is worse than
|
||||||
|
// saying nothing — it reads as an empty catalogue and hides the outage.
|
||||||
|
await expect(page.getByText('No items yet')).toBeHidden();
|
||||||
|
await expect(page.getByText("Couldn't load items")).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers when the server comes back', async ({ page }) => {
|
||||||
|
let failing = true;
|
||||||
|
await page.route('**/api/items*', (route) => {
|
||||||
|
if (failing) {
|
||||||
|
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||||
|
|
||||||
|
failing = false;
|
||||||
|
await page.getByRole('button', { name: 'Retry' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Couldn't load items")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a request that never resolves does not render as an empty catalogue', async ({ page }) => {
|
||||||
|
// Mirrors the real incident: an un-migrated database left every item query
|
||||||
|
// hanging with no response at all.
|
||||||
|
await page.route('**/api/items*', () => { /* never fulfilled */ });
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page.getByText('No items yet')).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user