E01-S06 — Favorites (heart with a label) + login next
Backlog:
.ai/specs/backlog-przebudowa.md(E01-S06). Risk: medium (new table + RLS, a login flow change). Prerequisite for S03a (#199). Builds on S01 (tokens), S02 (guest pages) and the views, all live.
📝 TLDR
Today nobody can bookmark a recipe, and every login lands on / or /recipes whatever page you came from. Proposed (future behavior):
- A signed-in user marks any recipe, their own or someone else’s, with a labelled heart on the recipe page. It updates optimistically and shows “Dodano do ulubionych · Cofnij”.
- Their favorites are listed at
/ulubione(also in MENU). - A guest who taps the heart logs in and comes back to the same recipe with the favorite already added.
- Login (password and magic link) and the middleware’s redirect to
/logincarry a validatednext.
✅ Decisions (Open Questions gate, 2026-10-16)
- Q1: the heart is only on the recipe page; the card bar comes with the S04/S05 card redesign.
- Q2: a minimal
/ulubionepage (a grid of the existing cards) + “Ulubione” in MENU. Full integration in S05 (the search scope) and S09 (menu counters). - Q3: after login the guest’s favorite is added automatically (a POST from the page, never a GET), with the “Cofnij” toast.
- Own recipes can be favorited (handoff: “zakładki na dowolne przepisy”). S03a (#199) follow-up: its collapse must keep a favorite whose user owns the canonical recipe, not delete it.
📝 Data Model
Migration …_favorites:
favorites(user_id uuid → auth.users on delete cascade, recipe_id bigint → recipes on delete cascade, created_at timestamptz default now(), primary key (user_id, recipe_id)), plus an index onrecipe_id(S03a’s delete guard counts by recipe).- RLS on. A single policy for
authenticated:user_id = public.current_user_id()for select / insert / delete.anongets nothing. - Grants: Supabase’s default
ALLis narrowed toselect, insert, deleteforauthenticated; nothing foranon. There’s noupdatepath, since a favorite is a pair. - Foreign key vs RLS: the FK check runs as the table owner, so a user can favorite any recipe id even though
recipesis owner-only, which is what we want. Those ids are public throughpublic_recipesanyway.
📝 Contracts
addFavorite(recipeId),removeFavorite(recipeId)(server actions,src/app/favorites/actions.ts):- validate a positive integer id
- require a session
- add is an upsert with
ignoreDuplicates, so it’s idempotent - return
{ ok: true } | { ok: false, error } revalidatePath('/ulubione')
- Login
next:/login?next=<path>: the page passes it as a hidden field in both forms.- The actions resolve it with the existing
safeNextagainst the site origin; an invalidnextbecomes the default.- password →
redirect(next ?? '/recipes')(today’s default kept) - magic link →
emailRedirectTo …/auth/confirm?next=<encoded safe path>(/auth/confirmalready honoursnext) - error redirects keep
next, so a typo doesn’t lose the destination
- password →
- middleware: a guest on a gated route →
/login?next=<pathname+search>; a signed-in user on/login→ their safenext(or/).
- Guest heart: a link to
/login?next=/recipes/{slug}?fav=1. On return, the recipe page seesfav=1+ a session + not yet favorited → the client callsaddFavoriteonce, shows the toast, and stripsfavfrom the URL withrouter.replace.
📝 UI/UX (handoff, recipe page)
- The button, 58 px:
- inactive: a 2.5 px
#c2410cborder (border-action),text-action-text“Dodaj do ulubionych” - active:
bg-action, white, “W ulubionych · usuń” - an outline / filled heart icon,
aria-pressed
- inactive: a 2.5 px
- The toast after adding: “Dodano do ulubionych · Cofnij” (
role="status", ~5 s); “Cofnij” removes the favorite. - On error: the optimistic state is rolled back + an inline
role="alert"message “Nie udało się zapisać. Spróbuj ponownie.” /ulubione: the heading “Ulubione”, a grid ofRecipeCards (newest favorite first), and the empty state “Nie masz jeszcze ulubionych przepisów. Otwórz przepis i kliknij „Dodaj do ulubionych”.”
📝 Edge Cases
- A double click / a retried POST: the upsert is idempotent, and a remove of a missing row is a no-op.
fav=1when it’s already favorited: no second add, no toast; the param is stripped.fav=1without a session (a stale link): the page shows the guest heart; nothing happens.- Open redirect through
next: blocked bysafeNext(unit-tested already, reused unchanged). - The magic link opened in another browser:
nexttravels inside the email link, so the user still lands on the recipe there. - The recipe is deleted: the cascade removes the favorites (S03a adds the guard that blocks deleting shared recipes).
📋 Implementation Plan
- Migration
favorites+ RLS + grants. Checked against production in a rolled-back transaction: anon is denied; a user sees, inserts and deletes only their own rows. src/lib/favorites.ts: a pureparseRecipeId+ unit tests. Server actionsaddFavorite/removeFavorite+ unit tests with a mocked client (session required, idempotent upsert).- Login
next:src/lib/login-redirect.ts(pure):loginPath(next)andresolveLoginNext(raw, origin, fallback)(wrappingsafeNext) + unit tests- page, form and actions wiring
- middleware:
nexton the redirect, and a signed-in user on/login→next
- A
FavoriteButtonclient component (optimistic, toast, auto-add onfav=1) and its wiring on the recipe page. /ulubionepage + the MENU item.- Gates (typecheck, lint, test) + a browser check of the guest → login → return flow and the heart states.