diff --git a/src/hooks/usePinnedSectionOpen.js b/src/hooks/usePinnedSectionOpen.js new file mode 100644 index 0000000..dbbe774 --- /dev/null +++ b/src/hooks/usePinnedSectionOpen.js @@ -0,0 +1,32 @@ +import { useCallback, useState } from 'react'; + +export const PINNED_SECTION_OPEN_KEY = 'roastmap:pinnedRoastersOpen'; + +/** + * Whether the visitor has explicitly opened or closed the pinned-roasters + * group on the directory. Tri-state: true / false / null (never chosen). + * The page decides the default from the pin count; this only remembers an + * explicit choice, in localStorage so it sticks across visits. Storage + * failures (Safari private mode, blocked site data) degrade to session-only. + */ +export function usePinnedSectionOpen() { + const [pref, setPref] = useState(() => { + try { + const v = localStorage.getItem(PINNED_SECTION_OPEN_KEY); + return v === '1' ? true : v === '0' ? false : null; + } catch { + return null; + } + }); + + const set = useCallback((open) => { + setPref(open); + try { + localStorage.setItem(PINNED_SECTION_OPEN_KEY, open ? '1' : '0'); + } catch { + // localStorage unavailable — the choice lasts for this session only. + } + }, []); + + return [pref, set]; +} diff --git a/src/pages/RoastersPage.jsx b/src/pages/RoastersPage.jsx index ca5b71d..96d4427 100644 --- a/src/pages/RoastersPage.jsx +++ b/src/pages/RoastersPage.jsx @@ -21,6 +21,7 @@ import RoasterAvatar from '../components/RoasterAvatar.jsx'; import FavoriteRoasterButton from '../components/FavoriteRoasterButton.jsx'; import { useAuth } from '../auth.jsx'; import { useFavoriteRoasters } from '../hooks/useFavoriteRoasters.jsx'; +import { usePinnedSectionOpen } from '../hooks/usePinnedSectionOpen.js'; const SORT_FIELDS = [ 'distance', @@ -37,6 +38,12 @@ const SORT_FIELDS = [ const ROASTER_SINGLE_KEYS = ['q', 'region', 'country']; const NO_MULTI_KEYS = new Set(); +// Pinned roasters get their own group above the directory. Up to this many +// the group is always open (it's a short list); past it, it starts collapsed +// so a long run of favourites doesn't push the directory below the fold. +// The visitor's explicit open/closed choice is remembered. +const PINNED_COLLAPSE_THRESHOLD = 3; + // ¢/g range for a roaster row. The /roasters/summary endpoint precomputes both // spans server-side (cpg_min/max = in-stock only, cpg_min_all/max_all = every // variant) — this just picks the one matching the stock toggle. @@ -69,6 +76,56 @@ function shipLabel(r) { return Number(r.shipping_cost) === 0 ? 'Free ship' : `${formatCAD(r.shipping_cost)} ship`; } +/** + * Heading for the pinned-roasters group. A real + + ); +} + export default function RoastersPage() { useSeo({ title: 'Roasters', @@ -199,15 +256,6 @@ export default function RoastersPage() { } }); - // Pinned roasters float to the top, keeping the active sort order within - // each group (stable partition — not a sort key, so column sorting still - // reads naturally inside both groups). - if (user && favoriteIds.size) { - list = [ - ...list.filter((r) => favoriteIds.has(r.id)), - ...list.filter((r) => !favoriteIds.has(r.id)), - ]; - } return list; }, [ roasters, @@ -223,6 +271,32 @@ export default function RoastersPage() { pinnedOnly, ]); + // Pinned roasters render as their own group above the directory instead of + // being partitioned into the same list: with more than a handful pinned, + // the first screen was all favourites and the directory started below the + // fold. When the Pinned chip is on the whole list IS the favourites, so no + // separate group. Both halves keep the active sort order, and the group + // honours the active filters (a BC filter shows only BC pins). + const showPinnedGroup = Boolean(user && favoriteIds.size && !pinnedOnly); + const pinnedRows = useMemo( + () => (showPinnedGroup ? rows.filter((r) => favoriteIds.has(r.id)) : []), + [rows, favoriteIds, showPinnedGroup], + ); + const mainRows = useMemo( + () => (showPinnedGroup ? rows.filter((r) => !favoriteIds.has(r.id)) : rows), + [rows, favoriteIds, showPinnedGroup], + ); + const [pinnedOpenPref, setPinnedOpenPref] = usePinnedSectionOpen(); + const pinnedCollapsible = pinnedRows.length > PINNED_COLLAPSE_THRESHOLD; + const pinnedExpanded = !pinnedCollapsible || pinnedOpenPref === true; + const pinnedHeaderProps = { + count: pinnedRows.length, + names: pinnedRows.map((r) => r.name), + collapsible: pinnedCollapsible, + expanded: pinnedExpanded, + onToggle: () => setPinnedOpenPref(!pinnedExpanded), + }; + function toggleSort(field) { if (!SORT_FIELDS.includes(field)) return; if (sort === field) setDir(dir === 'asc' ? 'desc' : 'asc'); @@ -263,6 +337,133 @@ export default function RoastersPage() { ); } + // Both list renderers are shared by the pinned group and the main list so + // the two can never drift apart visually. + const renderMobileCard = (r) => { + const beanCount = showOutOfStock ? (r.coffees_count ?? 0) : r._inStockCount; + const cityRegion = [r.city, r.region].filter(Boolean).join(', '); + const ship = shipLabel(r); + return ( +
  • + +
    + +
    +
    {r.name}
    + {r.is_online_only ? ( + + Online only + + ) : ( +
    + + {cityRegion || '—'} +
    + )} +
    + {location && r._distanceKm != null && ( + + {formatKm(r._distanceKm)} + + )} + +
    +
    + + + + {beanCount} {beanCount === 1 ? 'bean' : 'beans'} + + + + {priceLabel(r._range)} + + {ship && ( + + + {ship} + + )} +
    + +
  • + ); + }; + + const renderDesktopRow = (r) => ( + navigate(`/beans?roaster=${r.slug}`)} + className="hover:bg-surface-muted border-b border-border last:border-b-0 cursor-pointer transition-colors" + > + +
    + + + e.stopPropagation()} + className="font-semibold text-fg hover:text-accent hover:underline" + > + {r.name} + +
    + + {location && ( + + {r._distanceKm != null ? ( + formatKm(r._distanceKm) + ) : ( + — + )} + + )} + {allCountries.length > 1 && ( + {countryName(r.country_code)} + )} + {r.region || —} + + {r.is_online_only ? ( + Online only + ) : ( + r.city || — + )} + + + {showOutOfStock + ? `${r.coffees_count ?? 0} ${(r.coffees_count ?? 0) === 1 ? 'bean' : 'beans'}` + : `${r._inStockCount} ${r._inStockCount === 1 ? 'bean' : 'beans'}`} + + + {priceLabel(r._range)} + + + {r.shipping_cost != null ? ( + Number(r.shipping_cost) === 0 ? ( + 'Free' + ) : ( + formatCAD(r.shipping_cost) + ) + ) : ( + — + )} + + + {r.free_shipping_over != null ? ( + formatCAD(r.free_shipping_over) + ) : ( + — + )} + + + ); + + // Fixed columns plus the two optional ones, for the full-width group rows. + const tableColumnCount = 7 + (location ? 1 : 0) + (allCountries.length > 1 ? 1 : 0); + return ( <>

    Canadian specialty-coffee roasters

    @@ -426,61 +627,36 @@ export default function RoastersPage() { - + {pinnedRows.length > 0 && ( +
    + + {pinnedExpanded && ( +
      + {pinnedRows.map(renderMobileCard)} +
    + )} +
    + )} + {mainRows.length > 0 && ( + <> + {pinnedRows.length > 0 && ( +

    + All roasters +

    + )} + + + )} {/* Desktop (md:+): editorial sortable table. */} @@ -518,78 +694,33 @@ export default function RoastersPage() { })} - - {rows.map((r) => ( - navigate(`/beans?roaster=${r.slug}`)} - className="hover:bg-surface-muted border-b border-border last:border-b-0 cursor-pointer transition-colors" - > - -
    - - - e.stopPropagation()} - className="font-semibold text-fg hover:text-accent hover:underline" - > - {r.name} - -
    - - {location && ( - - {r._distanceKm != null ? ( - formatKm(r._distanceKm) - ) : ( - — - )} - - )} - {allCountries.length > 1 && ( - {countryName(r.country_code)} - )} - - {r.region || —} - - - {r.is_online_only ? ( - Online only - ) : ( - r.city || — - )} - - - {showOutOfStock - ? `${r.coffees_count ?? 0} ${(r.coffees_count ?? 0) === 1 ? 'bean' : 'beans'}` - : `${r._inStockCount} ${r._inStockCount === 1 ? 'bean' : 'beans'}`} + {pinnedRows.length > 0 && ( + + + + - + {pinnedExpanded && pinnedRows.map(renderDesktopRow)} + + )} + + {pinnedRows.length > 0 && mainRows.length > 0 && ( + + - {priceLabel(r._range)} - - - {r.shipping_cost != null ? ( - Number(r.shipping_cost) === 0 ? ( - 'Free' - ) : ( - formatCAD(r.shipping_cost) - ) - ) : ( - — - )} - - - {r.free_shipping_over != null ? ( - formatCAD(r.free_shipping_over) - ) : ( - — - )} - + All roasters + - ))} + )} + {mainRows.map(renderDesktopRow)} diff --git a/src/pages/RoastersPage.pins.test.jsx b/src/pages/RoastersPage.pins.test.jsx index ae8f5ba..fd90539 100644 --- a/src/pages/RoastersPage.pins.test.jsx +++ b/src/pages/RoastersPage.pins.test.jsx @@ -1,12 +1,15 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import RoastersPage from './RoastersPage.jsx'; +import { PINNED_SECTION_OPEN_KEY } from '../hooks/usePinnedSectionOpen.js'; /** - * Pinned-roaster behavior on the directory: favorites float to the top - * (stable partition), the "Pinned" chip narrows to favorites, and neither - * appears when signed out. + * Pinned-roaster behaviour on the directory. Favourites render as their own + * group above the list (never duplicated below it): always open up to three, + * collapsed by default past that with a remembered toggle. The "Pinned" chip + * still narrows the whole list to favourites, and nothing pin-related shows + * when signed out. */ const mkRoaster = (o = {}) => ({ @@ -35,6 +38,9 @@ const roastersFixture = [ mkRoaster({ id: 1, slug: 'alpha', name: 'Alpha' }), mkRoaster({ id: 2, slug: 'bravo', name: 'Bravo' }), mkRoaster({ id: 3, slug: 'charlie', name: 'Charlie' }), + mkRoaster({ id: 4, slug: 'delta', name: 'Delta' }), + mkRoaster({ id: 5, slug: 'echo', name: 'Echo', region: 'AB', city: 'Calgary' }), + mkRoaster({ id: 6, slug: 'foxtrot', name: 'Foxtrot' }), ]; vi.mock('../api.js', () => ({ @@ -62,52 +68,153 @@ vi.mock('../components/LocationChip.jsx', () => ({ default: () => null, })); -function renderPage() { +function renderPage(path = '/roasters') { return render( - + - + , ); } -// Roaster names in the desktop table, in row order. +// All roaster names in the desktop table, in row order (pinned group first). async function tableNames() { await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); - const table = screen.getByRole('table'); - return within(table).getAllByRole('link').map((a) => a.textContent); + return within(screen.getByRole('table')) + .getAllByRole('link') + .map((a) => a.textContent); } +const desktopPinned = () => screen.queryByTestId('pinned-roasters-desktop'); +const pinnedNames = () => + desktopPinned() + ? within(desktopPinned()) + .queryAllByRole('link') + .map((a) => a.textContent) + : []; +const mainNames = () => + within(screen.getByTestId('roasters-desktop')) + .getAllByRole('link') + .map((a) => a.textContent); +const pinnedToggle = () => + within(desktopPinned()).queryByRole('button', { name: /pinned roasters/i }); + describe('RoastersPage pinning', () => { + beforeEach(() => { + localStorage.clear(); + }); afterEach(() => { cleanup(); favoriteIds = new Set(); mockAuth.user = { id: 1, email: 'a@example.com' }; }); - it('floats favorited roasters to the top, alphabetical within groups', async () => { + it('renders no pinned group and plain alphabetical order with no favourites', async () => { + renderPage(); + expect(await tableNames()).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); + expect(desktopPinned()).not.toBeInTheDocument(); + expect(screen.queryByText(/all roasters/i)).not.toBeInTheDocument(); + }); + + it('groups a single favourite above the list, always open, without duplicating it', async () => { favoriteIds = new Set([3]); // Charlie renderPage(); - expect(await tableNames()).toEqual(['Charlie', 'Alpha', 'Bravo']); + expect(await tableNames()).toEqual(['Charlie', 'Alpha', 'Bravo', 'Delta', 'Echo', 'Foxtrot']); + + expect(screen.getAllByRole('heading', { name: 'Pinned roasters (1)' }).length).toBeGreaterThan( + 0, + ); + expect(pinnedToggle()).toBeNull(); // ≤3 pins: no toggle + expect(pinnedNames()).toEqual(['Charlie']); + expect(mainNames()).not.toContain('Charlie'); + expect(screen.getAllByText('All roasters').length).toBeGreaterThan(0); + }); + + it('stays open with exactly three favourites', async () => { + favoriteIds = new Set([1, 2, 3]); + renderPage(); + await tableNames(); + expect(pinnedToggle()).toBeNull(); + expect(pinnedNames()).toEqual(['Alpha', 'Bravo', 'Charlie']); + expect(mainNames()).toEqual(['Delta', 'Echo', 'Foxtrot']); + }); + + it('collapses by default past three favourites, previews names, and remembers the toggle', async () => { + favoriteIds = new Set([1, 2, 3, 4]); + renderPage(); + await tableNames(); + + const toggle = pinnedToggle(); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveTextContent('Pinned roasters (4)'); + expect(toggle).toHaveTextContent('Alpha, Bravo, Charlie +1'); + expect(pinnedNames()).toEqual([]); // rows not rendered while collapsed + expect(mainNames()).toEqual(['Echo', 'Foxtrot']); + + fireEvent.click(toggle); + expect(pinnedToggle()).toHaveAttribute('aria-expanded', 'true'); + expect(pinnedNames()).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']); + expect(pinnedToggle()).not.toHaveTextContent('+1'); // preview only while collapsed + expect(localStorage.getItem(PINNED_SECTION_OPEN_KEY)).toBe('1'); + + fireEvent.click(pinnedToggle()); + expect(pinnedToggle()).toHaveAttribute('aria-expanded', 'false'); + expect(localStorage.getItem(PINNED_SECTION_OPEN_KEY)).toBe('0'); + }); + + it('opens on load when the visitor previously expanded it', async () => { + localStorage.setItem(PINNED_SECTION_OPEN_KEY, '1'); + favoriteIds = new Set([1, 2, 3, 4]); + renderPage(); + await tableNames(); + expect(pinnedToggle()).toHaveAttribute('aria-expanded', 'true'); + expect(pinnedNames()).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']); }); - it('keeps plain alphabetical order with no favorites', async () => { + it('honours the active filters inside the pinned group', async () => { + favoriteIds = new Set([3, 5]); // Charlie (BC), Echo (AB) + renderPage('/roasters?region=AB'); + expect(await tableNames()).toEqual(['Echo']); + expect(screen.getAllByRole('heading', { name: 'Pinned roasters (1)' }).length).toBeGreaterThan( + 0, + ); + expect(pinnedNames()).toEqual(['Echo']); + // Nothing unpinned matches the filter, so no "All roasters" divider. + expect(screen.queryByText('All roasters')).not.toBeInTheDocument(); + }); + + it('mirrors the group on the mobile list', async () => { + favoriteIds = new Set([1, 2, 3, 4]); renderPage(); - expect(await tableNames()).toEqual(['Alpha', 'Bravo', 'Charlie']); + await tableNames(); + const mobile = screen.getByTestId('pinned-roasters-mobile'); + const toggle = within(mobile).getByRole('button', { name: /pinned roasters \(4\)/i }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(toggle); + expect( + within(mobile) + .getAllByRole('link') + .map((a) => a.textContent.trim()), + ).toHaveLength(4); + // One shared state: the desktop group opened too. + expect(pinnedToggle()).toHaveAttribute('aria-expanded', 'true'); }); - it('the Pinned chip narrows the list to favorites only', async () => { + it('the Pinned chip narrows the whole list to favourites and drops the group', async () => { favoriteIds = new Set([2]); renderPage(); await tableNames(); - fireEvent.click(screen.getByRole('button', { name: /pinned/i })); + fireEvent.click(screen.getByRole('button', { name: /^pinned$/i })); expect(await tableNames()).toEqual(['Bravo']); + expect(desktopPinned()).not.toBeInTheDocument(); }); - it('hides the Pinned chip and pin buttons when signed out', async () => { + it('hides the Pinned chip, pin buttons and group when signed out', async () => { mockAuth.user = null; + favoriteIds = new Set([1, 2, 3, 4]); // stale ids must not leak a group renderPage(); await tableNames(); - expect(screen.queryByRole('button', { name: /pinned/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^pinned$/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /^Pin / })).not.toBeInTheDocument(); + expect(desktopPinned()).not.toBeInTheDocument(); }); });