From 36948434ec0ea2cb06611b5e557515d0441ccdca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 20:20:48 +0000 Subject: [PATCH] Swap the wishlist and favourite-roaster icons: bookmark for wishlist, heart for favourites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bean wishlist used a red ♥/♡ heart and roaster favourites used a bookmark — inverted semantics. A wishlist is "buy this later" (with restock alerts attached): a bookmark, the same glyph Vivino and Untappd use for "want". Pinning a roaster is affection: that's the heart. - WishlistHeart: bookmark SVG from the shared Icon set instead of a text glyph; accent colour instead of red so it matches the rest of the UI; adds aria-label to match FavoriteRoasterButton. - FavoriteRoasterButton, the "Pinned" directory filter, and the account-page hint: heart. - Wishlist nav tab and empty-state hint: bookmark. No behaviour change; labels ("Add to wishlist", "Pin X") unchanged. Adds a WishlistHeart test (it had none) covering the toggle, the glyph choice, click containment and the signed-out state. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WQPoqZvpFtcjR76PFyVv99 --- src/App.jsx | 316 +++++++++++++---------- src/components/FavoriteRoasterButton.jsx | 12 +- src/components/WishlistHeart.jsx | 19 +- src/components/WishlistHeart.test.jsx | 78 ++++++ src/pages/AccountSettings.jsx | 125 +++++++-- src/pages/RoastersPage.jsx | 196 ++++++++++---- src/pages/Wishlist.jsx | 40 ++- 7 files changed, 562 insertions(+), 224 deletions(-) create mode 100644 src/components/WishlistHeart.test.jsx diff --git a/src/App.jsx b/src/App.jsx index f4dd921..fb2a7dc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -43,15 +43,15 @@ export default function App() { const routePath = useLocation().pathname; return ( - - - - {/* Global a11y polish. + + + + {/* Global a11y polish. - focus-visible: keyboard users get a 2px terracotta ring; mouse clicks don't. - skip-link: hidden until focused, jumps past the nav. - prefers-reduced-motion: kills card-expand transitions and animations. - forced-colors: high-contrast modes get a system-color outline. */} - - Skip to main content - {/* Full-bleed on phone (no outer padding / card chrome) so it reads as a + + Skip to main content + + {/* Full-bleed on phone (no outer padding / card chrome) so it reads as a native app; the boxed 1400px card returns at sm:+. */} -
- {/* No overflow-hidden here (previously clipped to the rounded corners) -- +
+ {/* No overflow-hidden here (previously clipped to the rounded corners) -- it would break `position: sticky` for any descendant, e.g. the Beans page filter bar. Header/footer each round + clip their own corners below instead, so the boxed card still looks seamless at sm:+. */} -
- {/* Modern light header: brand bar + scrollable icon nav. Replaces the +
+ {/* Modern light header: brand bar + scrollable icon nav. Replaces the former brown gradient hero so the directory leads, not the chrome. */} -
-
-
- - - - - {/* Location moved to the Roasters page (near the search); the +
+
+
+ + + + + + + + + {/* Location moved to the Roasters page (near the search); the header just carries theme + auth now, both compact. */} -
- - -
-
-
+
+ -
-
+ > + + Roasters + + + Beans + + + Map + + + My Reviews + + + Wishlist + + +
+ - + -
- {/* Per-route boundary: a render error in one page shows a recover card +
+ {/* Per-route boundary: a render error in one page shows a recover card but keeps the header/nav/footer alive and navigable. resetKey on the pathname auto-clears the error when the user navigates elsewhere. */} - ( -
-

This page hit a snag

-

- Something went wrong rendering this view. You can try again, or use the navigation above to go elsewhere. -

- -
- )} - > - {/* Reserve a tall min-height while a lazy route chunk streams in so the + ( +
+

This page hit a snag

+

+ Something went wrong rendering this view. You can try again, or use the + navigation above to go elsewhere. +

+ +
+ )} + > + {/* Reserve a tall min-height while a lazy route chunk streams in so the footer doesn't paint high then jump down (CLS). */} - - - -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- } - > - - {/* The roaster directory is the home AND keeps its /roasters URL so + + + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ } + > + + {/* The roaster directory is the home AND keeps its /roasters URL so existing deep links (and the map's "not on map" pills) still work. */} - } /> - } /> - } /> - } /> - {/* Old detail/roaster pages redirect into the unified /beans card UI. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + } /> + } /> + } /> + } /> + {/* Old detail/roaster pages redirect into the unified /beans card UI. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + -
-
-
- -

- A directory of Canadian specialty-coffee roasters and the beans they're roasting right now. -

-
-
- - +
+
+
+ +

+ A directory of Canadian specialty-coffee roasters and the beans they're + roasting right now. +

+
+
+ + +
+
+
+ © 2026 Roastmap +
+
-
- © 2026 Roastmap -
-
-
- -
-
-
+
+
+
); } @@ -235,7 +275,13 @@ function AuthCorner() { if (user) { return (
- {user.avatar_url && } + {user.avatar_url && ( + + )} - + Sign in - + Sign up
@@ -288,7 +338,11 @@ function NavTab({ to, end, icon, children }) { function SignedInNavTab({ to, icon, children }) { const { user } = useAuth(); if (!user) return null; - return {children}; + return ( + + {children} + + ); } /** diff --git a/src/components/FavoriteRoasterButton.jsx b/src/components/FavoriteRoasterButton.jsx index 87ab705..4f950fe 100644 --- a/src/components/FavoriteRoasterButton.jsx +++ b/src/components/FavoriteRoasterButton.jsx @@ -3,9 +3,11 @@ import { useFavoriteRoasters } from '../hooks/useFavoriteRoasters.jsx'; import Icon from './Icon.jsx'; /** - * Bookmark toggle that pins/unpins a roaster (per-user favorite). + * Heart toggle that pins/unpins a roaster (per-user favorite). * Pinned roasters sort to the top of the directory and appear in the - * account page. Hidden when signed out — same convention as WishlistHeart. + * account page. A favourite is affection, so it gets the heart; the + * bean wishlist ("buy later") gets the bookmark (WishlistHeart). Hidden + * when signed out — same convention as WishlistHeart. */ export default function FavoriteRoasterButton({ roaster, size = 18, className = '' }) { const { user } = useAuth(); @@ -27,12 +29,10 @@ export default function FavoriteRoasterButton({ roaster, size = 18, className = toggle(roaster); }} className={`inline-flex items-center justify-center rounded-md p-1.5 transition-colors ${ - pinned - ? 'text-accent hover:text-accent-hover' - : 'text-fg-subtle hover:text-accent' + pinned ? 'text-accent hover:text-accent-hover' : 'text-fg-subtle hover:text-accent' } ${className}`} > - + ); } diff --git a/src/components/WishlistHeart.jsx b/src/components/WishlistHeart.jsx index bb9096c..db8be96 100644 --- a/src/components/WishlistHeart.jsx +++ b/src/components/WishlistHeart.jsx @@ -1,10 +1,14 @@ import { useAuth } from '../auth.jsx'; import { useWishlist } from '../hooks/useWishlist.jsx'; +import Icon from './Icon.jsx'; /** - * Heart icon that toggles a coffee in the user's wishlist (Q10). - * Hidden when the user isn't logged in — prevents a click-to-redirect - * dead end. + * Bookmark toggle that adds/removes a coffee from the user's wishlist (Q10). + * A wishlist is "come back to this later" — a bookmark, the same glyph + * Vivino/Untappd use for "want" — not "I love this", so it is deliberately + * NOT a heart and NOT red. The heart belongs to roaster favourites + * (FavoriteRoasterButton). Hidden when the user isn't logged in — prevents + * a click-to-redirect dead end. */ export default function WishlistHeart({ coffeeId, size = 'md' }) { const { user } = useAuth(); @@ -13,23 +17,24 @@ export default function WishlistHeart({ coffeeId, size = 'md' }) { const wishlisted = ids.has(coffeeId); const label = wishlisted ? 'Remove from wishlist' : 'Add to wishlist'; - const px = size === 'lg' ? 'text-2xl' : 'text-lg'; + const px = size === 'lg' ? 24 : 20; return ( ); } diff --git a/src/components/WishlistHeart.test.jsx b/src/components/WishlistHeart.test.jsx new file mode 100644 index 0000000..272fec5 --- /dev/null +++ b/src/components/WishlistHeart.test.jsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import WishlistHeart from './WishlistHeart.jsx'; + +const mockAuth = { user: { id: 1, email: 'a@example.com' } }; +vi.mock('../auth.jsx', () => ({ + useAuth: () => mockAuth, +})); + +const add = vi.fn(); +const remove = vi.fn(); +let wishlistIds = new Set(); +vi.mock('../hooks/useWishlist.jsx', () => ({ + useWishlist: () => ({ ids: wishlistIds, add, remove }), +})); + +describe('WishlistHeart', () => { + afterEach(() => { + cleanup(); + add.mockClear(); + remove.mockClear(); + wishlistIds = new Set(); + mockAuth.user = { id: 1, email: 'a@example.com' }; + }); + + it('renders an accessible unpressed toggle when not wishlisted', () => { + render(); + const btn = screen.getByRole('button', { name: 'Add to wishlist' }); + expect(btn).toHaveAttribute('aria-pressed', 'false'); + }); + + it('renders a pressed toggle when wishlisted', () => { + wishlistIds = new Set([42]); + render(); + expect(screen.getByRole('button', { name: 'Remove from wishlist' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + it('uses the bookmark glyph, not a heart — a wishlist is "buy later", not "love"', () => { + const { container } = render(); + expect(container.querySelector('svg')).not.toBeNull(); + expect(container.textContent).not.toMatch(/[♥♡]/); + // Favourite roasters own the heart; the two must stay distinct. + expect(container.querySelector('button').className).not.toMatch(/red/); + }); + + it('adds when not wishlisted and removes when wishlisted', () => { + const { unmount } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Add to wishlist' })); + expect(add).toHaveBeenCalledWith(42); + expect(remove).not.toHaveBeenCalled(); + unmount(); + + wishlistIds = new Set([42]); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove from wishlist' })); + expect(remove).toHaveBeenCalledWith(42); + }); + + it('does not bubble the click to a wrapping card', () => { + const onCardClick = vi.fn(); + render( +
+ +
, + ); + fireEvent.click(screen.getByRole('button', { name: 'Add to wishlist' })); + expect(onCardClick).not.toHaveBeenCalled(); + }); + + it('renders nothing when signed out', () => { + mockAuth.user = null; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/pages/AccountSettings.jsx b/src/pages/AccountSettings.jsx index 46b98a2..fa4de22 100644 --- a/src/pages/AccountSettings.jsx +++ b/src/pages/AccountSettings.jsx @@ -34,7 +34,7 @@ function fieldErrors(err) { const errors = err?.body?.errors; if (!errors) return {}; return Object.fromEntries( - Object.entries(errors).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)]) + Object.entries(errors).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)]), ); } @@ -115,11 +115,20 @@ function ProfileSection({ user, token, setUser, snack }) {
- setName(e.target.value)} required /> + setName(e.target.value)} + required + /> {user.avatar_url && (

- + Profile photo is managed by your Google account.

)} @@ -157,8 +170,12 @@ function PinnedRoastersSection() { > {items.length === 0 ? (

- Nothing pinned yet. Hit the on any - roaster in the directory. + Nothing pinned yet. Hit the on + any roaster in the{' '} + + directory + + .

) : (
    @@ -169,10 +186,15 @@ function PinnedRoastersSection() {
  • - + {r.name} - {[r.city, r.region].filter(Boolean).join(', ')} + + {[r.city, r.region].filter(Boolean).join(', ')} +
    diff --git a/src/pages/RoastersPage.jsx b/src/pages/RoastersPage.jsx index 2423788..ca5b71d 100644 --- a/src/pages/RoastersPage.jsx +++ b/src/pages/RoastersPage.jsx @@ -22,7 +22,17 @@ import FavoriteRoasterButton from '../components/FavoriteRoasterButton.jsx'; import { useAuth } from '../auth.jsx'; import { useFavoriteRoasters } from '../hooks/useFavoriteRoasters.jsx'; -const SORT_FIELDS = ['distance', 'name', 'country', 'region', 'city', 'coffees', 'cpg_range', 'shipping_cost', 'free_shipping_over']; +const SORT_FIELDS = [ + 'distance', + 'name', + 'country', + 'region', + 'city', + 'coffees', + 'cpg_range', + 'shipping_cost', + 'free_shipping_over', +]; const ROASTER_SINGLE_KEYS = ['q', 'region', 'country']; const NO_MULTI_KEYS = new Set(); @@ -102,7 +112,8 @@ export default function RoastersPage() { useEffect(() => { // The slim summary payload (~15x smaller than /roasters): this page only // renders per-roaster rollups, never individual beans. - api.listRoasterSummaries() + api + .listRoasterSummaries() .then((d) => setRoasters(d.roasters)) .catch((e) => setError(e.message)); }, []); @@ -119,7 +130,7 @@ export default function RoastersPage() { const totalShipping = useMemo( () => (roasters ? roasters.filter((r) => r.has_shipping).length : 0), - [roasters] + [roasters], ); const rows = useMemo(() => { @@ -136,7 +147,7 @@ export default function RoastersPage() { r.name.toLowerCase().includes(q) || (r.region || '').toLowerCase().includes(q) || (r.city || '').toLowerCase().includes(q) || - (r.search_terms || '').includes(q) + (r.search_terms || '').includes(q), ); } @@ -145,9 +156,10 @@ export default function RoastersPage() { list = list .map((r) => { - const distanceKm = (location && r.latitude != null && r.longitude != null) - ? haversineKm(location, { lat: r.latitude, lng: r.longitude }) - : null; + const distanceKm = + location && r.latitude != null && r.longitude != null + ? haversineKm(location, { lat: r.latitude, lng: r.longitude }) + : null; return { ...r, _inStockCount: r.in_stock_count ?? 0, @@ -167,13 +179,20 @@ export default function RoastersPage() { const bx = b._distanceKm ?? Infinity; return (ax - bx) * mult; } - case 'country': return countryName(a.country_code).localeCompare(countryName(b.country_code)) * mult; - case 'region': return (a.region || '').localeCompare(b.region || '') * mult; - case 'city': return (a.city || '').localeCompare(b.city || '') * mult; - case 'coffees': return ((a.coffees_count ?? 0) - (b.coffees_count ?? 0)) * mult; - case 'cpg_range': return ((a._range.min ?? Infinity) - (b._range.min ?? Infinity)) * mult; - case 'shipping_cost': return ((a.shipping_cost ?? Infinity) - (b.shipping_cost ?? Infinity)) * mult; - case 'free_shipping_over': return ((a.free_shipping_over ?? Infinity) - (b.free_shipping_over ?? Infinity)) * mult; + case 'country': + return countryName(a.country_code).localeCompare(countryName(b.country_code)) * mult; + case 'region': + return (a.region || '').localeCompare(b.region || '') * mult; + case 'city': + return (a.city || '').localeCompare(b.city || '') * mult; + case 'coffees': + return ((a.coffees_count ?? 0) - (b.coffees_count ?? 0)) * mult; + case 'cpg_range': + return ((a._range.min ?? Infinity) - (b._range.min ?? Infinity)) * mult; + case 'shipping_cost': + return ((a.shipping_cost ?? Infinity) - (b.shipping_cost ?? Infinity)) * mult; + case 'free_shipping_over': + return ((a.free_shipping_over ?? Infinity) - (b.free_shipping_over ?? Infinity)) * mult; case 'name': default: return a.name.localeCompare(b.name) * mult; @@ -190,12 +209,27 @@ export default function RoastersPage() { ]; } return list; - }, [roasters, search, region, country, sort, dir, showOutOfStock, location, user, favoriteIds, pinnedOnly]); + }, [ + roasters, + search, + region, + country, + sort, + dir, + showOutOfStock, + location, + user, + favoriteIds, + pinnedOnly, + ]); function toggleSort(field) { if (!SORT_FIELDS.includes(field)) return; if (sort === field) setDir(dir === 'asc' ? 'desc' : 'asc'); - else { setSort(field); setDir('asc'); } + else { + setSort(field); + setDir('asc'); + } } const hasFilters = search || region || country; @@ -211,7 +245,8 @@ export default function RoastersPage() { ['free_shipping_over', 'Free over'], ]; - const selectClass = 'px-3 py-2.5 min-h-[44px] rounded-lg text-sm bg-surface border border-border text-fg focus:outline-none focus:border-accent'; + const selectClass = + 'px-3 py-2.5 min-h-[44px] rounded-lg text-sm bg-surface border border-border text-fg focus:outline-none focus:border-accent'; if (error) { return ( @@ -231,17 +266,25 @@ export default function RoastersPage() { return ( <>

    Canadian specialty-coffee roasters

    -

    {rows.length} {rows.length === 1 ? 'roaster' : 'roasters'} match your search

    +

    + {rows.length} {rows.length === 1 ? 'roaster' : 'roasters'} match your search +

    {/* Search hero — the list's primary entry point (search is the CTA). */}
    -

    Find a Canadian roaster

    +

    + Find a Canadian roaster +

    {roasters ? `${totalShipping} roasters` : 'Loading…'} · live beans, prices & shipping

    - + {allCountries.length > 1 && ( - setFilter('country', e.target.value)} + aria-label="Filter by country" + className={selectClass} + > - {allCountries.map((c) => )} + {allCountries.map((c) => ( + + ))} )} {/* Province select — always rendered (options just populate once roaster data loads) so it doesn't appear late and shift the row. */} - setFilter('region', e.target.value)} + aria-label="Filter by province" + className={selectClass} + > - {allRegions.map((r) => )} + {allRegions.map((r) => ( + + ))}