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.
This commit is contained in:
+38
-2
@@ -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,10 @@ 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 { mode, toggle } = useThemeMode();
|
||||
const { customer } = useCustomerAuth();
|
||||
const { customer, loading: authLoading } = useCustomerAuth();
|
||||
const { items: cartItems } = useCart();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
@@ -41,6 +43,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 +76,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));
|
||||
@@ -141,6 +163,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 +201,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user