Blocked by #199.
The client side of saving: a save button on cards and detail pages, the public count next to it, and a /competitions/saved page.
Files
src/lib/competition-saves.ts - new, mirroring src/lib/user-places.ts
src/components/competitions/save-button.tsx - 'use client'
src/app/competitions/saved/page.tsx
Follow the existing Supabase client pattern exactly
src/lib/user-places.ts is the model (141 lines): requireClient() at :61-65 throws when the client is null, currentUserId() at :67-72, then thin fetch/create/delete wrappers. Copy that structure.
Functions needed:
fetchSavedCompetitionIds(): Promise<string[]>
saveCompetition(competitionId: string): Promise<void>
unsaveCompetition(competitionId: string): Promise<void>
fetchSaveCounts(): Promise<Record<string, number>> // reads competition_stats, works signed out
The count rendering rule
Show the number only when it is 3 or more. Below that, render the save icon alone.
Do not reserve empty layout space for the hidden number. A permanently blank slot on every card for the first few months is worse than no slot at all. The button should be visibly narrower when there is no count, not a gap where a count would go.
Preview mode is a supported path, not an edge case
src/lib/supabase/config.ts:9-14 gates every call site and both client.ts and server.ts return null when the env vars are absent. Self-hosting without Supabase is first-class: the map, filters and calendar all work without it.
So: with no Supabase configured, the save button does not render at all, counts do not render, and /competitions/saved either redirects or shows the same "preview mode" card that src/app/login/login-form.tsx:29-44 uses. Nothing throws.
Signed out but Supabase configured: the button renders and prompts sign-in on click rather than silently doing nothing.
Optimistic update
Toggle the icon immediately, then reconcile. On failure, revert and surface a sonner toast; the repo already has sonner 2.0.7 wired up.
The saved page
Same card component as browse (#196), filtered to saved ids, sorted by soonest deadline. Empty state links to /competitions rather than being a dead end.
Acceptance criteria
Blocked by #199.
The client side of saving: a save button on cards and detail pages, the public count next to it, and a
/competitions/savedpage.Files
src/lib/competition-saves.ts- new, mirroringsrc/lib/user-places.tssrc/components/competitions/save-button.tsx-'use client'src/app/competitions/saved/page.tsxFollow the existing Supabase client pattern exactly
src/lib/user-places.tsis the model (141 lines):requireClient()at:61-65throws when the client is null,currentUserId()at:67-72, then thin fetch/create/delete wrappers. Copy that structure.Functions needed:
The count rendering rule
Show the number only when it is 3 or more. Below that, render the save icon alone.
Do not reserve empty layout space for the hidden number. A permanently blank slot on every card for the first few months is worse than no slot at all. The button should be visibly narrower when there is no count, not a gap where a count would go.
Preview mode is a supported path, not an edge case
src/lib/supabase/config.ts:9-14gates every call site and bothclient.tsandserver.tsreturnnullwhen the env vars are absent. Self-hosting without Supabase is first-class: the map, filters and calendar all work without it.So: with no Supabase configured, the save button does not render at all, counts do not render, and
/competitions/savedeither redirects or shows the same "preview mode" card thatsrc/app/login/login-form.tsx:29-44uses. Nothing throws.Signed out but Supabase configured: the button renders and prompts sign-in on click rather than silently doing nothing.
Optimistic update
Toggle the icon immediately, then reconcile. On failure, revert and surface a
sonnertoast; the repo already hassonner2.0.7 wired up.The saved page
Same card component as browse (#196), filtered to saved ids, sorted by soonest deadline. Empty state links to
/competitionsrather than being a dead end.Acceptance criteria
npm run buildstill passes/competitions/savedempty state is useful