A signed-in customer can narrow the storefront to items they have favorited.
Scope
The storefront already has a filter drawer with category, tags, and price range, all combined with AND and applied server-side, with filter state kept in the URL query string. This adds favorites as another dimension in the same mechanism rather than a separate view.
Design questions to resolve before implementing
Is it a toggle in the existing filter drawer, or its own entry point (a "My favorites" link in the header)?
What does a signed-out visitor see — the control hidden, or shown and prompting sign-in?
Does it combine with the other filters, or replace them?
Should sold favorites still appear when filtering, given the storefront currently shows sold items?
Depends on
#34 — favorites must exist before they can be filtered on.
Split out of #31 (item 5).
## Goal
A signed-in customer can narrow the storefront to items they have favorited.
## Scope
The storefront already has a filter drawer with category, tags, and price range, all combined with AND and applied server-side, with filter state kept in the URL query string. This adds favorites as another dimension in the same mechanism rather than a separate view.
## Design questions to resolve before implementing
- Is it a toggle in the existing filter drawer, or its own entry point (a "My favorites" link in the header)?
- What does a signed-out visitor see — the control hidden, or shown and prompting sign-in?
- Does it combine with the other filters, or replace them?
- Should sold favorites still appear when filtering, given the storefront currently shows sold items?
## Depends on
#34 — favorites must exist before they can be filtered on.
Is it a toggle in the existing filter drawer, or its own entry point (a "My favorites" link in the header)?
A toggle in the existing filter drawer. It becomes one more control alongside category, tags, and price, lives in the URL like the rest, and appears in the active-filter chip row. This keeps a single filtering mechanism and allows combinations like "my favorites under $500 in Furniture" rather than only "my favorites".
What does a signed-out visitor see — the control hidden, or shown and prompting sign-in?
Shown, and using it opens the same inline register/login modal that the heart button and Add to Cart already use. On successful sign-in the filter applies. This matches how favoriting itself behaves, and a visitor discovers the feature exists.
Does it combine with the other filters, or replace them?
It combines, with AND, exactly like every other filter dimension.
Should sold favorites still appear when filtering, given the storefront currently shows sold items?
Yes. The storefront already shows sold items with a SOLD ribbon, and a favorite that has sold is precisely what a customer may be looking for after receiving the notification from #34. Hiding them would make items silently disappear from a list the customer curated themselves.
Note on the status filter
The storefront filter set already includes a status dimension, so a customer who wants only what they can still buy can combine "Only my favorites" with an available-only status filter. That is the supported way to get the actionable-only view, rather than baking the exclusion into the favorites toggle.
## Design questions resolved
**Is it a toggle in the existing filter drawer, or its own entry point (a "My favorites" link in the header)?**
A toggle in the existing filter drawer. It becomes one more control alongside category, tags, and price, lives in the URL like the rest, and appears in the active-filter chip row. This keeps a single filtering mechanism and allows combinations like "my favorites under $500 in Furniture" rather than only "my favorites".
**What does a signed-out visitor see — the control hidden, or shown and prompting sign-in?**
Shown, and using it opens the same inline register/login modal that the heart button and Add to Cart already use. On successful sign-in the filter applies. This matches how favoriting itself behaves, and a visitor discovers the feature exists.
**Does it combine with the other filters, or replace them?**
It combines, with AND, exactly like every other filter dimension.
**Should sold favorites still appear when filtering, given the storefront currently shows sold items?**
Yes. The storefront already shows sold items with a SOLD ribbon, and a favorite that has sold is precisely what a customer may be looking for after receiving the notification from #34. Hiding them would make items silently disappear from a list the customer curated themselves.
## Note on the status filter
The storefront filter set already includes a status dimension, so a customer who wants only what they can still buy can combine "Only my favorites" with an available-only status filter. That is the supported way to get the actionable-only view, rather than baking the exclusion into the favorites toggle.
Favorites became another dimension of the existing filter mechanism rather than a separate view, so it serializes to the URL, appears as a removable chip, and combines with category, tags, and price by AND.
Backend
itemFilters.ts gains favoritesOnly, parsed from ?favorites=. Both 1/true and 0/false are accepted because these URLs get shared and hand-edited; anything else is a FilterError rather than being read as either on or off.
The SQL is EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $n), which the existing favorites primary key already covers.
buildItemFilterSql now takes the customer id as a required third argument, so a caller has to say whose favorites it means even when it means nobody's, and throws if a favorites filter arrives without one. Both routes already reject that case, so the throw is unreachable today — it is there so a future third caller that forgets the guard fails loudly instead of quietly dropping the clause and returning the whole catalogue.
Which customer "my favorites" means comes from the session, never from the query string. A hand-edited URL cannot name someone else's favorites.
GET /api/items?favorites=1 answers 401 when signed out. An empty array would render as "no items match these filters", telling a visitor they have no favorites rather than that we do not know who they are.
GET /api/admin/items?favorites=1 answers 400. The inventory view is not browsing as a customer, and refusing beats ignoring.
Frontend
A "Favorites" section at the top of the filter drawer with an "Only my favorites" switch, above Category — it is the broadest cut, and someone who came for their favorites should not have to scroll past the catalogue controls. 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.
A "My favorites" chip in the active-filter row, removable like the others.
Signed-out visitors see the control. Switching it on opens the same inline register/login modal the heart button and Add to Cart use; signing in resolves the gate and the filter applies on its own, so the customer never sets it twice.
While the session is still resolving the storefront holds rather than guessing. Firing the request early would 401 and show the outage banner to someone who is in fact signed in.
A bookmarked ?favorites=1 link whose session has expired shows "Sign in to see the items you have favorited" with sign-in and browse-everything actions, instead of an empty grid.
Verification
59 backend unit tests (7 new), 134 backend integration tests (8 new), 70 end-to-end tests (7 new). All passing, type checking clean on both sides.
Two bugs in my own new e2e spec were worth fixing rather than working around, since both are shapes that bite elsewhere: probing the alert opt-in modal with isVisible() does not wait, so it lost the race with the modal appearing and left it open to block every later click; and the sold-item test was mutating an item the other tests read, which the fullyParallel config would have raced.
Not done
I still cannot move this card between project columns — the Gitea API exposes no project endpoints, only a projects field when editing an issue. Moving #35 to Done is yours.
## Implemented on `feature/favorites-filter`
Favorites became another dimension of the existing filter mechanism rather than a separate view, so it serializes to the URL, appears as a removable chip, and combines with category, tags, and price by AND.
### Backend
- `itemFilters.ts` gains `favoritesOnly`, parsed from `?favorites=`. Both `1`/`true` and `0`/`false` are accepted because these URLs get shared and hand-edited; anything else is a `FilterError` rather than being read as either on or off.
- The SQL is `EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $n)`, which the existing `favorites` primary key already covers.
- `buildItemFilterSql` now takes the customer id as a required third argument, so a caller has to say whose favorites it means even when it means nobody's, and throws if a favorites filter arrives without one. Both routes already reject that case, so the throw is unreachable today — it is there so a future third caller that forgets the guard fails loudly instead of quietly dropping the clause and returning the whole catalogue.
- Which customer "my favorites" means comes from the session, never from the query string. A hand-edited URL cannot name someone else's favorites.
- `GET /api/items?favorites=1` answers **401** when signed out. An empty array would render as "no items match these filters", telling a visitor they have no favorites rather than that we do not know who they are.
- `GET /api/admin/items?favorites=1` answers **400**. The inventory view is not browsing as a customer, and refusing beats ignoring.
### Frontend
- A "Favorites" section at the top of the filter drawer with an "Only my favorites" switch, above Category — it is the broadest cut, and someone who came for their favorites should not have to scroll past the catalogue controls. 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.
- A "My favorites" chip in the active-filter row, removable like the others.
- Signed-out visitors see the control. Switching it on opens the same inline register/login modal the heart button and Add to Cart use; signing in resolves the gate and the filter applies on its own, so the customer never sets it twice.
- While the session is still resolving the storefront holds rather than guessing. Firing the request early would 401 and show the outage banner to someone who is in fact signed in.
- A bookmarked `?favorites=1` link whose session has expired shows "Sign in to see the items you have favorited" with sign-in and browse-everything actions, instead of an empty grid.
### Verification
- **59** backend unit tests (7 new), **134** backend integration tests (8 new), **70** end-to-end tests (7 new). All passing, type checking clean on both sides.
Two bugs in my own new e2e spec were worth fixing rather than working around, since both are shapes that bite elsewhere: probing the alert opt-in modal with `isVisible()` does not wait, so it lost the race with the modal appearing and left it open to block every later click; and the sold-item test was mutating an item the other tests read, which the `fullyParallel` config would have raced.
### Not done
I still cannot move this card between project columns — the Gitea API exposes no project endpoints, only a `projects` field when editing an issue. Moving #35 to Done is yours.
Verified in QA: the "Only my favorites" toggle applies, the filter appears in the URL as favorites=1, the chip removes it, it combines with the price filter rather than replacing it, and a favorite that has sold still appears with its SOLD ribbon.
**Released to production.**
Verified in QA: the "Only my favorites" toggle applies, the filter appears in the URL as `favorites=1`, the chip removes it, it combines with the price filter rather than replacing it, and a favorite that has sold still appears with its SOLD ribbon.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #31 (item 5).
Goal
A signed-in customer can narrow the storefront to items they have favorited.
Scope
The storefront already has a filter drawer with category, tags, and price range, all combined with AND and applied server-side, with filter state kept in the URL query string. This adds favorites as another dimension in the same mechanism rather than a separate view.
Design questions to resolve before implementing
Depends on
#34 — favorites must exist before they can be filtered on.
Design questions resolved
Is it a toggle in the existing filter drawer, or its own entry point (a "My favorites" link in the header)?
A toggle in the existing filter drawer. It becomes one more control alongside category, tags, and price, lives in the URL like the rest, and appears in the active-filter chip row. This keeps a single filtering mechanism and allows combinations like "my favorites under $500 in Furniture" rather than only "my favorites".
What does a signed-out visitor see — the control hidden, or shown and prompting sign-in?
Shown, and using it opens the same inline register/login modal that the heart button and Add to Cart already use. On successful sign-in the filter applies. This matches how favoriting itself behaves, and a visitor discovers the feature exists.
Does it combine with the other filters, or replace them?
It combines, with AND, exactly like every other filter dimension.
Should sold favorites still appear when filtering, given the storefront currently shows sold items?
Yes. The storefront already shows sold items with a SOLD ribbon, and a favorite that has sold is precisely what a customer may be looking for after receiving the notification from #34. Hiding them would make items silently disappear from a list the customer curated themselves.
Note on the status filter
The storefront filter set already includes a status dimension, so a customer who wants only what they can still buy can combine "Only my favorites" with an available-only status filter. That is the supported way to get the actionable-only view, rather than baking the exclusion into the favorites toggle.
Implemented on
feature/favorites-filterFavorites became another dimension of the existing filter mechanism rather than a separate view, so it serializes to the URL, appears as a removable chip, and combines with category, tags, and price by AND.
Backend
itemFilters.tsgainsfavoritesOnly, parsed from?favorites=. Both1/trueand0/falseare accepted because these URLs get shared and hand-edited; anything else is aFilterErrorrather than being read as either on or off.EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $n), which the existingfavoritesprimary key already covers.buildItemFilterSqlnow takes the customer id as a required third argument, so a caller has to say whose favorites it means even when it means nobody's, and throws if a favorites filter arrives without one. Both routes already reject that case, so the throw is unreachable today — it is there so a future third caller that forgets the guard fails loudly instead of quietly dropping the clause and returning the whole catalogue.GET /api/items?favorites=1answers 401 when signed out. An empty array would render as "no items match these filters", telling a visitor they have no favorites rather than that we do not know who they are.GET /api/admin/items?favorites=1answers 400. The inventory view is not browsing as a customer, and refusing beats ignoring.Frontend
<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.?favorites=1link whose session has expired shows "Sign in to see the items you have favorited" with sign-in and browse-everything actions, instead of an empty grid.Verification
Two bugs in my own new e2e spec were worth fixing rather than working around, since both are shapes that bite elsewhere: probing the alert opt-in modal with
isVisible()does not wait, so it lost the race with the modal appearing and left it open to block every later click; and the sold-item test was mutating an item the other tests read, which thefullyParallelconfig would have raced.Not done
I still cannot move this card between project columns — the Gitea API exposes no project endpoints, only a
projectsfield when editing an issue. Moving #35 to Done is yours.Released to production.
Verified in QA: the "Only my favorites" toggle applies, the filter appears in the URL as
favorites=1, the chip removes it, it combines with the price filter rather than replacing it, and a favorite that has sold still appears with its SOLD ribbon.