Compare commits

...
5 Commits
Author SHA1 Message Date
bermudalamb ab7484cee8 docs: adopt Conventional Branch naming alongside Conventional Commits
SonarQube Analysis / sonarqube (pull_request) Successful in 2m33s
Tests / backend-unit (pull_request) Successful in 39s
Tests / frontend-e2e (pull_request) Failing after 7m56s
Branches follow Conventional Branch — `<type>/<description>` with types feature, bugfix, hotfix, release, chore — carrying the issue number so the work is identifiable from `git branch`: `feature/48-my-account-modal`. That is the same `feature/` prefix this repo has always used, so no existing branch was named wrongly. Commits stay Conventional Commits with the issue number appended to the subject and a `Closes #N` line in the body.

Spells out which half does what, because it is easy to assume the branch name links the work: Gitea builds the reference from a `#N` in a commit message or PR and never from the branch name. The name is for humans; the reference is what ties the work to the issue.

Also records the backdrop-location arrangement in `AppRoutes`, since it is the pattern the sibling navigational dead-end issues should copy rather than each inventing their own, and folds the unwrapped-commit-body rule in with the rest of the commit conventions.
2026-08-18 15:58:23 -05:00
bermudalamb cd5de63f96 feat(account): open My Account as a modal over the page behind it (#48)
/account had no site header and no links of any kind, so once a customer opened it the only way out was the browser's back button or editing the URL.

It is now a modal rendered over whatever the customer was looking at, while staying a real route. Opening it from the header pushes /account and names the current page as the backdrop, so closing returns there with filters and scroll position intact, and the browser's Back button does the same thing as the close control. Entering /account directly — a bookmark, the link in a verification email, or the redirect after registering — has no page behind it, so it falls back to rendering the storefront as the backdrop. Closing therefore always lands somewhere real rather than on nothing.

Keeping it a route rather than view state means the URL still works: it can be bookmarked, shared, and refreshed with the account view still open, which is what the existing header link and the four post-authentication redirects already depend on.

Also scopes the account switch locator in the favorites spec to the modal. The storefront now renders behind the account view and has a theme switch of its own, so an unscoped switch locator was only picking the right control by DOM accident.

Verified with 68 end-to-end tests, 5 of them new, all passing, and type checking clean. No backend changes.

Closes #48
2026-08-18 15:57:59 -05:00
bermudalamb 0f04cd25cd Merge pull request 'Feature/favorites filter' (#55) from feature/favorites-filter into main
SonarQube Analysis / sonarqube (push) Successful in 2m47s
Tests / backend-unit (push) Successful in 44s
Tests / frontend-e2e (push) Failing after 14m43s
Reviewed-on: #55
2026-08-18 15:17:34 -05:00
bermudalamb 1249f9a311 docs: record the favorites filter and the Node version trap
SonarQube Analysis / sonarqube (pull_request) Successful in 3m8s
Tests / backend-unit (pull_request) Successful in 41s
Tests / frontend-e2e (pull_request) Failing after 8m36s
Notes that the default local Node (18.16.1) cannot run either the integration suite or Playwright, and that neither failure names the version as the cause — the integration one reads like a broken lru-cache dependency. Records the newer version's path so a single command can be run against it without switching what the user has active.

Adds two e2e lessons from this change: isVisible() does not wait, so guarding an optional dialog with it loses the race and leaves an antd modal open to intercept every later click; and under fullyParallel a test that mutates a shared fixture races its siblings.

Records that the storefront listing sold items is now load-bearing rather than merely tolerated, since the favorites filter deliberately shows sold favorites, and points at the favorites filter as the pattern for any future filter dimension that depends on who is asking.
2026-08-18 15:12:14 -05:00
bermudalamb d4e2abe743 feat: filter the storefront by favorited items (#35)
Adds favorites as another dimension of the existing storefront filter rather than a separate view, so it lives in the URL, shows up as a removable chip, and combines with category, tags, and price by AND like everything else. A customer can ask for "my favorites under $500 in Furniture" instead of only "my favorites".

Sold favorites are included. The storefront shows sold items everywhere else, and a favorite that has just sold is often exactly what the customer came back to look at after being emailed about it in #34. Hiding them would make items disappear from a list the customer curated themselves. Anyone wanting only what they can still buy can combine the toggle with the status filter.

Which customer "my favorites" means comes from the session, never from the query string, so a hand-edited URL cannot name someone else's favorites. A signed-out visitor sees the toggle and gets the same inline register/login prompt the heart button and Add to Cart already use; signing in resolves the gate and the filter applies on its own. A bookmarked favorites link whose session has expired says so rather than rendering an empty grid, which would tell the visitor they have no favorites instead of that we do not know who they are. The API answers 401 for the same reason, and the admin inventory refuses the filter outright rather than ignoring it.

The shared SQL builder now requires callers to say whose favorites they mean, even when that is nobody, and throws instead of dropping the clause — a future caller that forgets the guard fails loudly rather than quietly returning the whole catalogue.

Verified with 59 unit tests, 134 backend integration tests, and 70 end-to-end tests, all passing, with type checking clean on both sides.
2026-08-18 15:10:45 -05:00
15 changed files with 662 additions and 44 deletions
+27 -4
View File
@@ -213,8 +213,12 @@ sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c
## Conventions
- **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, open a PR, merge; the branch auto-deletes (repo setting is on).
- **Branches follow [Conventional Branch](https://conventional-branch.github.io/), with the issue number carried for Gitea:** `<type>/<issue-number>-<short-slug>`, e.g. `feature/48-my-account-modal`, `bugfix/57-cart-total-wrong`. Types are `feature`, `bugfix`, `hotfix`, `release`, `chore` — the same `feature/` prefix this repo has always used, so nothing in the existing history is wrong. Drop the number when there is no issue behind the work (`chore/tidy-dead-routes`). The type should agree with the Conventional Commit type of the work it carries.
- **Commits follow [Conventional Commits](https://www.conventionalcommits.org/)** — `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `ci:`, `refactor:` — with the issue number appended to the subject: `feat(account): open My Account as a modal (#48)`.
- **Put `Closes #48` in the commit body**, on its own line, for the commit that completes the issue (`Refs #48` when it only contributes). This is what actually closes the issue on merge, independently of whether the PR description repeats it.
- **Be clear about which part does the linking.** Gitea creates the reference from a `#48` appearing in a *commit message or PR* — never from the branch name. The branch name is for humans reading `git branch`; the reference is what ties the work to the issue. Both are wanted, but only one of them links.
- **Commit bodies are unwrapped paragraphs** — no hard line breaks inside a paragraph.
- 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.
@@ -229,6 +233,21 @@ 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`
- 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.
### The local Node version will not run the integration or e2e suites
`nvm4w` has both **18.16.1 (active by default)** and **24.13.1** installed, and the active one is too old for two of the three suites:
- Integration tests fail in `globalSetup` with `(0 , U.tracingChannel) is not a function` — a transitive `lru-cache` needs `diagnostics_channel.tracingChannel`, added in Node 20.2.
- Playwright refuses outright: "Playwright requires Node.js 20 or higher."
Neither failure mentions the Node version as the cause, and the first one reads like a broken dependency. Rather than switching the user's active version, prepend the newer one for the single command:
```bash
export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH"
```
Unit tests and `tsc` run fine on 18, so a green `npm test` says nothing about whether the other two suites can even start.
### 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:
@@ -237,6 +256,8 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- **`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.
- **`isVisible()` does not wait.** It answers about *this instant*, so guarding an optional dialog with `if (await x.isVisible())` loses the race whenever the dialog is still on its way — and an antd modal left open then intercepts every later click, which surfaces as an unrelated element "not found" thirty seconds later. If the dialog is deterministic, click it unconditionally and let the locator auto-wait; only use `isVisible()` when it genuinely may never appear, and even then give it something to wait on first.
- **`fullyParallel: true` means a test that mutates a shared fixture races every other test in the file.** A spec that marked an item sold broke the sibling tests reading that same item. Give any test that changes an item's state its own fixture.
## Known gaps / natural next steps
@@ -249,7 +270,7 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- **`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.
- **The storefront still lists sold items** and the price-range bounds are computed across all items regardless of status. Deliberately left as-is, and now load-bearing: the favorites filter (#35) shows sold favorites on purpose, since an item that just sold is often what the customer came back to look at after the #34 email. Anyone wanting only purchasable stock combines the favorites toggle with the status filter. Worth revisiting if sold stock ever outnumbers available stock — but changing the default would change what a favorites view means.
- **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
@@ -259,7 +280,9 @@ Integration tests truncate between cases (`resetDb()` in `tests/integration/setu
- 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 how a customer route is framed (modal vs page) → `frontend/src/main.tsx`. `AppRoutes` renders the route table against a *backdrop* location rather than the real one: `/account` is a modal over the page named in `location.state.background`, falling back to the storefront when there is none (a bookmark, an email link, a post-registration redirect). This is the pattern to copy for the sibling dead-end issues (#49, #50) — it keeps the URL real and linkable while making sure closing always lands somewhere. The link that opens it must pass `state={{ background: location }}`, or closing goes to the fallback instead of where the customer was.
- 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`, `frontend/src/components/ActiveFilterChips.tsx`
- Add a filter dimension that depends on who is asking → follow the favorites filter (#35). The identity comes from `req.customerId` (`attachCustomer` runs globally, so it is available on the public `/api/items` too) and is passed into `buildItemFilterSql` as an explicit argument — never parsed from the query string, or a hand-edited URL could name another customer. Each route decides what to do when it cannot satisfy the filter: the storefront answers 401, the admin inventory 400, and the builder throws rather than silently dropping the clause and returning everything.
- 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
+45 -2
View File
@@ -10,6 +10,10 @@ export interface ItemFilters {
minPriceCents: number | null;
maxPriceCents: number | null;
status: ItemStatus | null;
// Storefront only: "just the items I have favorited". Which customer that
// means is not part of the parsed filter — it comes from the session at build
// time, so a query string can never name someone else's favorites.
favoritesOnly: boolean;
}
export type ItemStatus = 'available' | 'reserved' | 'sold';
@@ -29,6 +33,12 @@ export interface BuiltFilter {
// '10.5' are caller mistakes worth surfacing rather than silently coercing.
const NON_NEGATIVE_INTEGER = /^\d+$/;
// Accepts both spellings because these URLs get hand-edited and shared, but
// nothing else: '?favorites=yes' is a mistake worth reporting rather than
// treating as either on or off.
const TRUE_VALUES: readonly string[] = ['1', 'true'];
const FALSE_VALUES: readonly string[] = ['0', 'false'];
function singleValue(value: unknown, name: string): string | null {
if (value === undefined || value === null) {
return null;
@@ -107,12 +117,33 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
status = statusRaw as ItemStatus;
}
return { categoryId, tagIds, minPriceCents, maxPriceCents, status };
const favoritesRaw = singleValue(query.favorites, 'favorites');
let favoritesOnly = false;
if (favoritesRaw !== null && favoritesRaw !== '') {
if (TRUE_VALUES.includes(favoritesRaw)) {
favoritesOnly = true;
} else if (!FALSE_VALUES.includes(favoritesRaw)) {
throw new FilterError('invalid favorites');
}
}
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
}
// Returns WHERE fragments plus their parameters, with placeholders numbered
// from `startIndex` so the caller can splice these in after its own params.
export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter {
//
// `favoritesCustomerId` is required rather than optional so a caller has to say
// whose favorites it means, even when it means nobody's. Both routes already
// reject a favorites filter they cannot satisfy, so reaching the throw below is
// a programming error — but it is here so that a future caller which forgets
// the guard fails loudly instead of quietly ignoring the filter and listing the
// whole catalogue.
export function buildItemFilterSql(
filters: ItemFilters,
startIndex: number,
favoritesCustomerId: number | null
): BuiltFilter {
const clauses: string[] = [];
const params: unknown[] = [];
let next = startIndex;
@@ -164,5 +195,17 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
next++;
}
if (filters.favoritesOnly) {
if (favoritesCustomerId === null) {
throw new Error('favorites filter requires a customer id');
}
params.push(favoritesCustomerId);
// EXISTS rather than a join: an item is favorited by a customer at most
// once, but joining would still risk multiplying rows if that ever changed,
// and this reads as the membership test it is.
clauses.push(`EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $${next})`);
next++;
}
return { clauses, params };
}
+7 -1
View File
@@ -128,7 +128,13 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
throw err;
}
const { clauses, params } = buildItemFilterSql(filters, 1);
// Favorites belong to a customer, and the admin inventory view is not
// browsing as one. Refused rather than ignored so the mistake is visible.
if (filters.favoritesOnly) {
return res.status(400).json({ error: 'favorites is not a valid inventory filter' });
}
const { clauses, params } = buildItemFilterSql(filters, 1, null);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows);
+10 -1
View File
@@ -19,7 +19,16 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
throw err;
}
const { clauses, params } = buildItemFilterSql(filters, 1);
// 401 rather than an empty list: a signed-out visitor asking for "my
// favorites" has no favorites to be empty of, and answering with [] would
// render as "no items match these filters" — a plausible-looking lie. The
// storefront prompts for sign-in instead of sending this, so reaching here
// means a bookmarked link outlived its session.
if (filters.favoritesOnly && !req.customerId) {
return res.status(401).json({ error: 'sign in to filter by favorites' });
}
const { clauses, params } = buildItemFilterSql(filters, 1, req.customerId ?? null);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows);
@@ -304,3 +304,89 @@ describe('notifying when a favorited item is deleted', () => {
expect(rows[0].n).toBe(0);
});
});
describe('filtering the storefront by favorites', () => {
it('returns only the favorited items', async () => {
const favorited = await createItem('Oak table');
await createItem('Elm bench');
const { agent } = await register('filter@example.com');
await agent.post(`/api/customers/me/favorites/${favorited}`);
const res = await agent.get('/api/items?favorites=1');
expect(res.status).toBe(200);
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('keeps one customer out of another customer\'s favorites', async () => {
const mine = await createItem('Oak table');
const theirs = await createItem('Elm bench');
const { agent: me } = await register('mine@example.com');
const { agent: them } = await register('theirs@example.com');
await me.post(`/api/customers/me/favorites/${mine}`);
await them.post(`/api/customers/me/favorites/${theirs}`);
const res = await me.get('/api/items?favorites=1');
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('still shows a favorite that has sold', async () => {
const itemId = await createItem('Oak table');
const { agent } = await register('sold@example.com');
await agent.post(`/api/customers/me/favorites/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
const res = await agent.get('/api/items?favorites=1');
// The storefront shows sold items everywhere else, and a favorite that has
// just sold is often exactly what the customer came back to look at.
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('combines with the other filters rather than replacing them', async () => {
const cheap = await createItem('Oak table');
const dear = await createItem('Elm bench');
await pool.query(`UPDATE items SET price_cents = 90000 WHERE id = $1`, [dear]);
const { agent } = await register('combined@example.com');
await agent.post(`/api/customers/me/favorites/${cheap}`);
await agent.post(`/api/customers/me/favorites/${dear}`);
const res = await agent.get('/api/items?favorites=1&max_price=50000');
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('answers 401 rather than an empty list when nobody is signed in', async () => {
await createItem('Oak table');
const res = await request(app).get('/api/items?favorites=1');
// An empty array would render as "no items match these filters", telling a
// signed-out visitor they have no favorites instead of that we do not know
// who they are.
expect(res.status).toBe(401);
});
it('rejects a favorites value that is neither on nor off', async () => {
const { agent } = await register('bogus@example.com');
expect((await agent.get('/api/items?favorites=yes')).status).toBe(400);
});
it('leaves the catalogue alone when the flag is off', async () => {
await createItem('Oak table');
await createItem('Elm bench');
const { agent } = await register('off@example.com');
const res = await agent.get('/api/items?favorites=0');
expect(res.body).toHaveLength(2);
});
it('refuses the favorites filter on the admin inventory', async () => {
const res = await request(app).get('/api/admin/items?favorites=1');
expect(res.status).toBe(400);
});
});
+49 -7
View File
@@ -7,7 +7,8 @@ describe('parseItemFilters', () => {
tagIds: [],
minPriceCents: null,
maxPriceCents: null,
status: null
status: null,
favoritesOnly: false
});
});
@@ -85,6 +86,26 @@ describe('parseItemFilters', () => {
expect(parseItemFilters({ status: '' }).status).toBeNull();
});
it('parses the favorites flag in both spellings', () => {
expect(parseItemFilters({ favorites: '1' }).favoritesOnly).toBe(true);
expect(parseItemFilters({ favorites: 'true' }).favoritesOnly).toBe(true);
});
it('treats an explicit off value as off', () => {
expect(parseItemFilters({ favorites: '0' }).favoritesOnly).toBe(false);
expect(parseItemFilters({ favorites: 'false' }).favoritesOnly).toBe(false);
expect(parseItemFilters({ favorites: '' }).favoritesOnly).toBe(false);
});
it('defaults favorites to off', () => {
expect(parseItemFilters({}).favoritesOnly).toBe(false);
});
it('rejects a favorites value that is neither on nor off', () => {
expect(() => parseItemFilters({ favorites: 'yes' })).toThrow(FilterError);
expect(() => parseItemFilters({ favorites: 'mine' })).toThrow(FilterError);
});
it('rejects a status outside the known set', () => {
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
});
@@ -96,19 +117,19 @@ describe('parseItemFilters', () => {
describe('buildItemFilterSql', () => {
it('produces no clauses and no params when nothing is filtered', () => {
const built = buildItemFilterSql(parseItemFilters({}), 1);
const built = buildItemFilterSql(parseItemFilters({}), 1, null);
expect(built.clauses).toEqual([]);
expect(built.params).toEqual([]);
});
it('matches a category and all of its descendants', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1);
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null);
expect(built.clauses.join(' ')).toContain('RECURSIVE');
expect(built.params).toEqual([4]);
});
it('requires every listed tag rather than any of them', () => {
const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1);
const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1, null);
// The count of matched tag rows must equal the number of tags requested —
// an ANY/IN match alone would return items carrying just one of them.
expect(built.clauses.join(' ')).toContain('COUNT(*)');
@@ -116,20 +137,41 @@ describe('buildItemFilterSql', () => {
});
it('numbers placeholders from the given starting index', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3);
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3, null);
expect(built.clauses.join(' ')).toContain('$3');
});
it('filters on status', () => {
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1);
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null);
expect(built.clauses.join(' ')).toContain('i.status');
expect(built.params).toEqual(['reserved']);
});
it('restricts to the favorites of the given customer', () => {
const built = buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, 42);
expect(built.clauses.join(' ')).toContain('EXISTS');
expect(built.clauses.join(' ')).toContain('favorites f');
expect(built.params).toEqual([42]);
});
it('does not restrict to favorites when the flag is off, even given a customer', () => {
const built = buildItemFilterSql(parseItemFilters({}), 1, 42);
expect(built.clauses).toEqual([]);
expect(built.params).toEqual([]);
});
// Both routes reject this before reaching the builder, so it can only happen
// through a new caller that forgot to. Failing loudly beats dropping the
// clause and returning the whole catalogue as if it were someone's favorites.
it('throws rather than ignore a favorites filter with no customer', () => {
expect(() => buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, null)).toThrow();
});
it('continues numbering across multiple filters', () => {
const built = buildItemFilterSql(
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
1
1,
null
);
expect(built.params).toEqual([4, 100, 900]);
const sql = built.clauses.join(' ');
+46 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
import ItemCard from './components/ItemCard';
import FilterDrawer from './components/FilterDrawer';
@@ -13,6 +13,7 @@ import {
filtersToSearchParams,
hasActiveFilters
} from './filters';
import AuthPromptModal from './customer/AuthPromptModal';
import { useThemeMode } from './theme/ThemeContext';
import { useCustomerAuth } from './customer/CustomerAuthContext';
import { useCart } from './cart/CartContext';
@@ -30,9 +31,11 @@ export default function App() {
const [failed, setFailed] = useState(false);
const [options, setOptions] = useState<FilterOptions | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const { mode, toggle } = useThemeMode();
const { customer } = useCustomerAuth();
const { customer, loading: authLoading } = useCustomerAuth();
const { items: cartItems } = useCart();
const { token } = theme.useToken();
@@ -41,6 +44,12 @@ export default function App() {
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
const filterKey = filtersToSearchParams(filters).toString();
// "Only my favorites" needs to know who is asking. Until the session has
// resolved we hold rather than guess: firing the request early would 401 and
// show the outage banner to someone who is in fact signed in.
const awaitingAuth = filters.favoritesOnly && authLoading;
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
const applyFilters = useCallback(
(next: ItemFilters) => {
// replace, not push: dragging a slider shouldn't bury the previous page
@@ -68,10 +77,24 @@ export default function App() {
}, [filterKey]);
useEffect(() => {
if (awaitingAuth) {
setLoading(true);
return;
}
// Prompt instead of requesting. The server would answer 401, and rendering
// that as "no items match these filters" would tell a signed-out visitor
// they have no favorites rather than that we do not know who they are.
if (needsFavoritesAuth) {
setItems([]);
setFailed(false);
setLoading(false);
setAuthModalOpen(true);
return;
}
setLoading(true);
const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [load]);
}, [load, awaitingAuth, needsFavoritesAuth]);
useEffect(() => {
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
@@ -105,8 +128,13 @@ export default function App() {
<Button icon={<ShoppingCartOutlined />} />
</Badge>
</Link>
{/* The account link names the page to render behind the modal, so
closing it comes back here filters and all rather than to a
default. */}
{customer ? (
<Link to="/account"><Button>My Account</Button></Link>
<Link to="/account" state={{ background: location }}>
<Button>My Account</Button>
</Link>
) : (
<>
<Link to="/login"><Button>Log in</Button></Link>
@@ -141,6 +169,11 @@ export default function App() {
description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
/>
) : needsFavoritesAuth ? (
<Empty description="Sign in to see the items you have favorited">
<Button type="primary" onClick={() => setAuthModalOpen(true)}>Sign in</Button>
<Button style={{ marginInlineStart: 8 }} onClick={clearFilters}>Browse everything</Button>
</Empty>
) : !loading && !items.length ? (
<Empty
description={
@@ -174,6 +207,15 @@ export default function App() {
onClear={clearFilters}
resultCount={items.length}
/>
{/* The same prompt the heart button and Add to Cart use. Signing in
resolves the gate above, and the filter then applies on its own the
customer never has to set it a second time. */}
<AuthPromptModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={() => setAuthModalOpen(false)}
/>
</Layout>
);
}
@@ -18,6 +18,16 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
const chips: { key: string; label: string; onRemove: () => void }[] = [];
// Listed first so it matches the drawer's ordering, and because it is the
// chip most worth noticing when a customer wonders why the grid looks short.
if (filters.favoritesOnly) {
chips.push({
key: 'favorites',
label: 'My favorites',
onRemove: () => onChange({ ...filters, favoritesOnly: false })
});
}
if (filters.categoryId !== null) {
const path = categoryPath(categories, filters.categoryId);
// Falls back to the raw id while /api/filters is still loading, so the chip
+23
View File
@@ -5,6 +5,7 @@ import Tag from 'antd/es/tag';
import Slider from 'antd/es/slider';
import InputNumber from 'antd/es/input-number';
import Empty from 'antd/es/empty';
import Switch from 'antd/es/switch';
import Grid from 'antd/es/grid';
import type { DataNode } from 'antd/es/tree';
import type { FilterOptions } from '../api';
@@ -81,6 +82,28 @@ export default function FilterDrawer({
</div>
}
>
{/* First because it is the broadest cut, and because a customer who came
here for their favorites should not have to scroll past the catalogue
controls to find it. Shown to signed-out visitors too: switching it on
prompts them to sign in, which is how they learn favorites exist. */}
<section style={{ marginBottom: 28 }}>
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
Favorites
</h4>
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
button, which is labelable, so a wrapping label can forward a click
the switch already handled and toggle it twice. The accessible name
comes from aria-label instead. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch
checked={filters.favoritesOnly}
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
aria-label="Only my favorites"
/>
<span>Only my favorites</span>
</div>
</section>
<section style={{ marginBottom: 28 }}>
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
Category
+22 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
import { Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
@@ -7,7 +7,13 @@ import { useCustomerAuth } from './CustomerAuthContext';
const { Title, Text } = Typography;
export default function Account() {
interface Props {
// Supplied by the route, which decides where closing lands: back to the page
// the customer came from, or to the storefront when they arrived directly.
onClose: () => void;
}
export default function Account({ onClose }: Props) {
const { customer, loading, refresh, logout } = useCustomerAuth();
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
const navigate = useNavigate();
@@ -66,9 +72,18 @@ export default function Account() {
}
return (
<div style={{ maxWidth: 700, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>My Account</Title>
<Modal
open
// The account view is a place a customer can be sent by an email link or
// a bookmark, so it is titled and closable rather than relying on the
// page behind it to say where they are.
title="My Account"
onCancel={onClose}
footer={null}
width={700}
destroyOnHidden
>
<div>
<Text>{customer.email}</Text>
{!customer.email_verified && (
<div style={{ marginTop: 8 }}>
@@ -112,7 +127,7 @@ export default function Account() {
<Button onClick={handleLogout}>Log out</Button>
<Button danger onClick={handleDelete}>Delete my account</Button>
</Space>
</Card>
</div>
</div>
</Modal>
);
}
+12 -2
View File
@@ -10,6 +10,10 @@ export interface ItemFilters {
// Only the admin Inventory tab sets this; the storefront leaves it null and
// shows every status, as it always has.
status: ItemStatus | null;
// Storefront only, and only meaningful when signed in. Sold favorites are
// included: the storefront shows sold items everywhere else, and a favorite
// that has just sold is often exactly what the customer came to look at.
favoritesOnly: boolean;
}
export const EMPTY_FILTERS: ItemFilters = {
@@ -17,7 +21,8 @@ export const EMPTY_FILTERS: ItemFilters = {
tagIds: [],
minPriceCents: null,
maxPriceCents: null,
status: null
status: null,
favoritesOnly: false
};
// Filters live in the URL so a filtered view can be linked, bookmarked, and
@@ -30,6 +35,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
if (filters.status !== null) params.set('status', filters.status);
if (filters.favoritesOnly) params.set('favorites', '1');
return params;
}
@@ -50,12 +56,15 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
? rawStatus
: null;
const favorites = params.get('favorites');
return {
categoryId: readInt(params.get('category')),
tagIds: tags,
minPriceCents: readInt(params.get('min_price')),
maxPriceCents: readInt(params.get('max_price')),
status
status,
favoritesOnly: favorites === '1' || favorites === 'true'
};
}
@@ -67,6 +76,7 @@ export function activeFilterCount(filters: ItemFilters): number {
count += filters.tagIds.length;
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
if (filters.status !== null) count++;
if (filters.favoritesOnly) count++;
return count;
}
+50 -13
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme } from 'antd';
import 'antd/dist/reset.css';
import App from './App';
@@ -26,6 +27,11 @@ const DARK_ACCENT = '#f0f0f0';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
// What /account renders over when it was entered directly — a bookmark, a link
// in an email, or a redirect after signing in. There is no page behind in that
// case, and a modal floating on nothing has nowhere to close back to.
const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash: '' };
// 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
@@ -45,6 +51,48 @@ function usePrefersReducedMotion(): boolean {
return prefers;
}
// /account is a route that renders as a modal over whatever the customer was
// looking at, rather than a page of its own. It stays a real, linkable URL —
// bookmarkable, refreshable, and closed by the browser's Back button — while
// never being a place with no way out of it.
function AppRoutes() {
const location = useLocation();
const navigate = useNavigate();
const state = location.state as { background?: Location } | null;
const isAccount = location.pathname === '/account';
// In-app navigation names the page to render behind. Anything else — a
// bookmark, an email link, the redirect after registering — falls back to the
// storefront, so closing always lands somewhere real.
const background = state?.background;
const backdrop = isAccount ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
function closeAccount() {
// Back, when there is somewhere to go back to, so closing the modal and
// pressing Back do the same thing and neither leaves a dead entry behind.
if (background) navigate(-1);
else navigate('/', { replace: true });
}
return (
<>
<Routes location={backdrop as Location}>
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/cart" element={<Cart />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
</Routes>
{/* Rendered outside the Routes above, which are showing the backdrop. */}
{isAccount && <Account onClose={closeAccount} />}
</>
);
}
function Root() {
const { mode } = useThemeMode();
const prefersReducedMotion = usePrefersReducedMotion();
@@ -66,18 +114,7 @@ function Root() {
}}
>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/account" element={<Account />} />
<Route path="/cart" element={<Cart />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
</Routes>
<AppRoutes />
</BrowserRouter>
</ConfigProvider>
);
+98
View File
@@ -0,0 +1,98 @@
import { test, expect, Page } from '@playwright/test';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `account-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
// Registering lands on /account, which is now the modal over the storefront.
// Returns the address so a test can assert the right account is shown.
async function registerAndCloseAccount(page: Page): Promise<string> {
const email = uniqueEmail();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
await closeAccount(page);
return email;
}
const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' });
async function closeAccount(page: Page) {
await accountModal(page).getByRole('button', { name: 'Close' }).click();
await expect(accountModal(page)).toBeHidden();
}
test.describe('My Account opens as a modal', () => {
test('opens over the storefront and closes back to it, filters and all', async ({ page }) => {
await registerAndCloseAccount(page);
// A filtered view, to prove closing restores where the customer actually
// was rather than a bare storefront.
await page.goto('/?max_price=50000');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
await page.getByRole('button', { name: 'My Account' }).click();
await expect(accountModal(page)).toBeVisible();
// Still a real, linkable URL rather than hidden view state.
await expect(page).toHaveURL(/\/account/);
await closeAccount(page);
await expect(page).toHaveURL(/max_price=50000/);
});
test('the browser back button closes it, the same as the close control', async ({ page }) => {
await registerAndCloseAccount(page);
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'My Account' }).click();
await expect(accountModal(page)).toBeVisible();
await page.goBack();
await expect(accountModal(page)).toBeHidden();
await expect(page).toHaveURL(/max_price=50000/);
});
test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ page }) => {
await registerAndCloseAccount(page);
// A bookmark, or the link in a verification email. There is no page behind
// in this case, which is what used to make /account a dead end.
await page.goto('/account');
await expect(accountModal(page)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
await closeAccount(page);
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
});
test('survives a reload, since it is a route rather than view state', async ({ page }) => {
const email = await registerAndCloseAccount(page);
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'My Account' }).click();
await expect(accountModal(page)).toBeVisible();
await page.reload();
await expect(accountModal(page)).toBeVisible();
await expect(accountModal(page)).toContainText(email);
});
test('shows the signed-in account and its settings', async ({ page }) => {
const email = await registerAndCloseAccount(page);
await page.goto('/account');
const modal = accountModal(page);
await expect(modal).toContainText(email);
await expect(modal).toContainText('Order History');
// Scoped to the modal: the storefront behind it has a theme switch of its
// own, so an unscoped switch locator would be ambiguous.
await expect(modal.getByRole('switch')).toHaveCount(2);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { test, expect, Page } from '@playwright/test';
const PASSWORD = 'supersecret123';
// The storefront runs against a shared database that is never reset, so every
// name has to be unique to this run or a rerun would match the last one's rows.
const RUN = `ff${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const KEPT = `Kept ${RUN}`;
const OTHER = `Other ${RUN}`;
// Its own item because this run marks it sold, and the suite is fullyParallel:
// mutating an item the other tests read would make them race.
const SELLS = `Sells ${RUN}`;
const uniqueEmail = () => `favfilter-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
// Two items, priced apart so one test can prove favorites combines with the
// price filter rather than replacing it.
for (const [name, price] of [[KEPT, '60'], [OTHER, '900'], [SELLS, '70']] as const) {
const res = await api.post('/api/admin/items', {
multipart: { name, description: '', price, category_id: '', tags: '[]' }
});
expect(res.ok()).toBeTruthy();
}
await api.dispose();
});
async function register(page: Page) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
}
// The session is still resolving for a moment after a remount, and the
// favorites filter deliberately waits it out rather than guessing. Waiting for
// the account link is what a real customer sees settle.
async function gotoStorefrontSignedIn(page: Page) {
await page.goto('/');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
}
async function favorite(page: Page, itemName: string) {
await page.getByRole('button', { name: `Add ${itemName} to favorites` }).click();
// The alert opt-in is offered on every favorite until it is accepted, so it
// is always there to decline. Clicked rather than probed with isVisible():
// that check does not wait, so it loses the race with the modal appearing and
// leaves it open to block everything the test does next.
const decline = page.getByRole('dialog').getByRole('button', { name: 'No thanks' });
await decline.click();
// Its wrapper goes on intercepting pointer events while it fades out.
await expect(decline).toBeHidden();
await expect(page.getByRole('button', { name: `Remove ${itemName} from favorites` })).toBeVisible();
}
async function openFilters(page: Page) {
await page.getByRole('button', { name: /Filters/ }).click();
await expect(favoritesSwitch(page)).toBeVisible();
}
const favoritesSwitch = (page: Page) => page.getByRole('switch', { name: 'Only my favorites' });
test.describe('Filtering the storefront by favorites', () => {
test('narrows the grid to favorited items and puts it in the URL', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await openFilters(page);
await favoritesSwitch(page).click();
// Close the drawer before reading the grid behind it, as the other filter
// tests do.
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
// In the URL so the view can be linked, bookmarked, and reloaded.
await expect(page).toHaveURL(/favorites=1/);
});
test('survives a reload, since the URL is the source of truth', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await page.goto('/?favorites=1');
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
});
test('shows a removable chip that restores the full catalogue', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await page.goto('/?favorites=1');
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
const chips = page.getByRole('group', { name: 'Active filters' });
await expect(chips).toContainText('My favorites');
await chips.getByRole('button', { name: 'Remove filter My favorites' }).click();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeVisible();
await expect(page).not.toHaveURL(/favorites/);
});
test('combines with the price filter rather than replacing it', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await favorite(page, OTHER);
// Both are favorited; only one is under the price cap.
await page.goto('/?favorites=1&max_price=50000');
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
await expect(page.getByRole('button', { name: `Remove ${OTHER} from favorites` })).toBeHidden();
});
test('prompts a signed-out visitor to sign in, then applies the filter', async ({ page }) => {
await page.goto('/');
await openFilters(page);
await favoritesSwitch(page).click();
// The same inline prompt the heart and Add to Cart use, rather than an
// empty grid implying the visitor has no favorites.
const prompt = page.getByRole('dialog', { name: /Create an account/ });
await expect(prompt).toBeVisible();
await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
await prompt.getByLabel('Password').fill(PASSWORD);
await prompt.getByRole('button', { name: 'Create account' }).click();
// Signing in resolves the gate and the filter applies on its own — the
// customer never sets it twice. A brand-new account has no favorites yet.
await expect(page.getByText('No items match these filters')).toBeVisible();
await expect(page).toHaveURL(/favorites=1/);
});
test('explains itself when a favorites link is opened without a session', async ({ page }) => {
// A bookmarked filtered view whose session has since expired. The grid must
// not claim there are no matching items, which would read as "you have no
// favorites" rather than "we do not know who you are".
await page.goto('/?favorites=1');
await expect(page.getByText('Sign in to see the items you have favorited')).toBeVisible();
await expect(page.getByText('No items match these filters')).toBeHidden();
});
test('keeps showing a favorite after it sells', async ({ page, playwright }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, SELLS);
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
const items = await (await api.get('/api/items')).json();
const sells = items.find((item: { name: string }) => item.name === SELLS);
expect(await (await api.post(`/api/admin/items/${sells.id}/mark-sold`)).ok()).toBeTruthy();
await api.dispose();
await page.goto('/?favorites=1');
// Hiding it would make an item the customer curated vanish without
// explanation, right after they were emailed to say it had sold.
await expect(page.getByRole('button', { name: `Remove ${SELLS} from favorites` })).toBeVisible();
// Scoped to this item's cell: the ribbon sits outside the card, and other
// sold items from earlier runs are on the same page.
await expect(page.locator('.ant-col').filter({ hasText: SELLS })).toContainText('SOLD');
});
});
+6 -3
View File
@@ -130,10 +130,13 @@ test.describe('Favoriting items', () => {
await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } });
await page.goto('/account');
const toggle = page.getByText('Email me when an item I favorited is sold');
await expect(toggle).toBeVisible();
// Scoped to the account modal. The storefront renders behind it and has a
// theme switch of its own, so an unscoped switch locator only picks the
// right control by DOM accident.
const account = page.getByRole('dialog', { name: 'My Account' });
await expect(account.getByText('Email me when an item I favorited is sold')).toBeVisible();
await page.getByRole('switch').last().click();
await account.getByRole('switch').last().click();
await expect(page.getByText('Turned off')).toBeVisible();
const me = await (await page.request.get('/api/customers/me')).json();