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 with aria-expanded
+ * when the group is collapsible (more than PINNED_COLLAPSE_THRESHOLD pins);
+ * otherwise a static heading — a short list is always open, so a toggle
+ * would be noise. Collapsed, it previews the first three names so the bar
+ * reads as content rather than an empty control.
+ */
+function PinnedGroupHeader({ id, controls, count, names, collapsible, expanded, onToggle }) {
+ const label = `Pinned roasters (${count})`;
+ const preview = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3}` : '');
+ const heading = (
+
+
+
+ {label}
+
+
+ );
+
+ if (!collapsible) {
+ return (
+
+ {heading}
+
+ );
+ }
+
+ return (
+
+
+
+ {heading}
+ {!expanded && {preview} }
+
+
+
+
+ );
+}
+
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
+
+ )}
+
+ {mainRows.map(renderMobileCard)}
+
+ >
+ )}
{/* 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();
});
});