E01-S06 — Favorites: the heart on cards + fav= at login (gap closure)
Backlog:
.ai/specs/backlog-przebudowa.md(E01-S06), issue #209. Risk: medium (login completion path, a shared card component). Builds on the first favorites spec,.ai/specs/2026-10-16-favorites.md, which is live: thefavoritestable + RLS,addFavorite/removeFavorite,FavoriteButtonon the recipe page,/ulubione, and the menu counter (S09). No database change.
📝 TLDR
What is live today covers the recipe page only. The backlog story asks for more, so three gaps stay open:
- The heart on cards. The acceptance criterion says “na karcie/przepisie”, but cards have no heart yet. The first spec deferred it to the card redesign (S04/S05), and S04 has since shipped.
/login?next={url}&fav={id}, handled at login completion. Today the recipe ID never reaches login. The heart sends the guest to/login?next=/przepis/{slug}?fav=1, and the recipe page adds the favorite in the browser once the guest is back. That works only for a heart on the recipe page. A heart on a card on the home page or a category page has no recipe page to come back to.- One heart component. The same optimistic toggle, toast and guest link serve both the recipe page (full size) and cards (compact).
Proposed (future behavior):
- Every recipe card on the guest home, category pages and
/ulubionehas a labelled heart.- Off: “Ulubione”. On: “W ulubionych”.
- It is a 48 px target, separate from the card’s link.
- A guest who taps any heart goes to
/login?next={current page}&fav={recipe id}.- Whichever way they sign in (password, magic link, Google/Facebook), the favorite is saved on the server as the login completes.
- They land back on the page they started from, and see “Dodano do ulubionych · Cofnij”.
📝 Problem Statement
- Guests browse lists, not only recipe pages. A heart that works only on the recipe page fails the story’s main use, “zakładka na dowolny przepis”, from the list views.
- The browser-side auto-add after the return needs JavaScript to run on the very page that holds the heart. It cannot cover a card heart, whose page may not list the recipe any more by the time the guest is back (the home page’s recent list moves).
📝 Proposed Solution
favrides on/loginand folds intonext.- The login page reads
fav. Whenfavis a valid recipe ID, the page appendsadd_fav={id}to thenextpath (a distinct name, so a retired?fav=1link can never favorite recipe #1) (withFavoriteIntent(next, fav)) before it handsnextto the forms. - Nothing else in the login plumbing changes. The password action, the magic-link
emailRedirectToand the OAuthredirectToalready carrynextend to end and re-validate it withsafeNext.
- The login page reads
- The one login-completion funnel applies it.
postLoginPath(supabase, next)(S12) is the single exit of all three login paths: the password action,/auth/confirmfor a sign-in link, and/auth/callbackfor OAuth.- It first calls
applyFavoriteIntent(supabase, userId, next), which:- reads
add_favfromnext - adds the favorite with the existing idempotent
insertFavorite - rewrites
nextasfav_added={id}on success, or dropsadd_favon failure
- reads
- The
/witajdetour on a first login still works, because the rewrittennextis what it carries.
- A heart that sees
fav_added={its id}on mount shows the toast once and strips the param. A module-level claim stops two hearts for the same recipe (say, a card and a list) from both showing a toast.- A landing page with no heart for that recipe. A guest’s heart on the guest home sends them back to
/, which is the signed-in home once they log in, and it lists no cards. So the signed-in home renders a standalone notice (FavoriteAddedNotice) whenfav_addedis present. It shows the same toast with a working “Cofnij” and strips the param the same way.
- A landing page with no heart for that recipe. A guest’s heart on the guest home sends them back to
- The recipe page drops its browser-side
?fav=1auto-add. It is replaced by steps 1–3. The recipe page’s guest link becomesloginPath(currentPath, recipeId). FavoriteButtongainssize: 'full' | 'compact'.full: the recipe page, unchanged copy (“Dodaj do ulubionych” / “W ulubionych · usuń”, 58 px).compact: cards (“Ulubione” / “W ulubionych”, 48 px).- The guest link is built inside the component from the current URL, so pages don’t pass it.
RecipeCardtakes an optionalfavoriteprop ({ recipeId, favorited, isAuthenticated }).- With the prop, the card renders the heart in a footer.
- Without it, the card shows no heart. That keeps parallel callers (S05 search, S16 similar recipes) working unchanged until they opt in.
- The card becomes an
<article>whose title link is stretched over the whole card (after:absolute after:inset-0), so the card stays clickable and the heart is not nested in a link (no interactive element inside<a>).
📝 Architecture
src/lib/favorites.ts(pure + DB helpers):withFavoriteIntent(next, rawFav)readFavoriteIntent(next)applyFavoriteIntent(supabase, userId, next)getFavoriteIds(supabase, userId, recipeIds): Promise<Set<number>>, a singleinquery for a page of cards
src/lib/first-login.ts:postLoginPathcallsapplyFavoriteIntentafter it reads the user. S12 owns the file. The change is one line plus the import.src/lib/login-redirect.ts:loginPath(next, fav?)builds/login?next=…&fav=….favoriteReturnPathis removed.src/app/login/page.tsx: readsfavand passeswithFavoriteIntent(next, fav)toOAuthButtonsandSignInForm.src/app/components/favorite-button.tsx: thesizevariant, a self-built guest link, thefav_addedtoast, and a persistent live region.src/app/components/recipe-card.tsx: the optionalfavoriteprop and the stretched-link structure.- The pages that list cards look up favorite IDs for a signed-in user:
/(guest home, so guest hearts only),/przepisy/[kategoria], and/ulubione(all on)./recipesstays with S05, which opts in through the prop. - The recipe page:
favandautoAddFavoriteare removed, and the guest link comes from the component. The edits stay inside the favorites slot (recipe-actions.tsxand thefavlines ofpage.tsx).
📝 API Contracts
GET /login?next=<path>&fav=<positive int>- An invalid
fav(0,-1,abc, a float) is ignored. - An unsafe
nextfollows thesafeNextrule (→/).
- An invalid
applyFavoriteIntent(supabase, userId, next) → Promise<string>- It never throws, and login must never fail because of a favorite.
- No
favinnext→nextunchanged. - The insert succeeds or the row is a duplicate →
favis replaced withfav_added={id}. - Any other error →
favis dropped and the error is logged (codeonly).
getFavoriteIds(supabase, userId, ids)- An empty
idsmakes no query and returns an empty set. - On error it returns an empty set, so the hearts render “off” and a click still works.
- An empty
📝 UI/UX
- Card footer (
compact):- A full-width button with min-height 48 px, 16 px bold text, a 2 px border.
- Off:
border-action, white,text-action-text, outline heart, “Ulubione”. - On:
bg-actionwith white text, filled heart, “W ulubionych”. aria-pressed, the focus ring from the global token, andrelative z-10so it sits above the stretched link.
- Guest card heart: a link that looks like the off button and points to
/login?next=…&fav=…. - Toast:
- A fixed bottom bar,
bg-inkwith white text at 17 px, “Dodano do ulubionych ·” followed by a “Cofnij” button (48 px, underlined). - It lives ~5 s.
- It renders inside an always-mounted
role="status"region, so screen readers announce it reliably. - The recipe page uses the same fixed toast, so both sizes behave alike.
- A fixed bottom bar,
- Error: the optimistic state rolls back, plus “Nie udało się zapisać. Spróbuj ponownie.” (
role="alert"). On a card, the message sits under the button. - Never a star. The heart icon always has its text.
📝 Edge Cases & Failure Scenarios
- A signed-in user opens
/login?next&favfrom a stale guest tab. The middleware redirects tonextwithout the favorite. The page then renders the heart as a button, and the user taps again. This is accepted, because the middleware cannot write data. - A
favfor a deleted or non-existent recipe. The foreign key fails,favis dropped, and the user still logs in. - A crafted link from someone else (
/login?next=/&fav=123). Logging in through it favorites recipe 123. The harm is trivial, it shows up as a visible toast with “Cofnij”, and no data leaks. All three completions are POSTs or a code exchange bound to PKCE, so a bare GET cannot trigger it. - A password-recovery link. It skips
postLoginPath, so the intent is ignored. - The
/witajfirst-login detour. The favorite is already saved before the detour, andfav_addedreaches the final page. - Removing a favorite on
/ulubione.revalidatePath('/ulubione')re-renders the page and the card leaves the list. - A
nextwhose query holds a/(a search phrase, say). The intent is added and removed as raw&key=valuetext, never by re-serializing the whole query.URLSearchParamswould turn/into%2F, whichsafeNextrejects, and the user would land on/without their favorite. - Old
?fav=1links mid-flight during the deploy. They are ignored, and the guest lands on the recipe signed in and taps again.
📝 Risks & Impact Review
- The
RecipeCardrestructure touches every list. The props stay backward compatible and the visuals are unchanged apart from the heart footer. Parallel sessions (S05, S16) only need to resolve merge conflicts. S05’s search cards must use this one heart (RecipeCard’sfavoriteprop orFavoriteButton size="compact") and this onegetFavoriteIds, not a second card heart or a second lookup helper. first-login.tsbelongs to S12. The change is additive, and the funnel fails open as before.- No migration, no change to RLS or grants. Favorites need no full access (S11). The existing policy is
user_id = current_user_id()only.
Resolved assumptions (autonomous defaults)
| # | Question | Default | Why |
|---|---|---|---|
| Q1 | Where does the fav intent get applied? | Server side, in postLoginPath (all three login completions) | One funnel; works for card hearts; POST/PKCE-bound, not a GET side effect. Reversible. |
| Q2 | How does fav reach the completion? | Folded into next by the login page | Zero changes to forms, actions, OAuth and confirm plumbing that already carry next. Reversible. |
| Q3 | Card label | “Ulubione” / “W ulubionych” (compact) | Fits a 2-column mobile grid at 16 px; the full “Dodaj do ulubionych” stays on the recipe page. Reversible. |
| Q4 | Which lists get the heart now? | Guest home, /przepisy/[kategoria], /ulubione | /recipes is S05’s search surface, so S05 and S16 opt in via the favorite prop. Reversible. |
| Q5 | Toast after the login round-trip? | Yes, via fav_added={id}, once per page | Keeps the decided “Dodano do ulubionych · Cofnij” (first spec, Q3). Reversible. |
📋 Implementation Plan
src/lib/favorites.ts:withFavoriteIntent,readFavoriteIntent,applyFavoriteIntentandgetFavoriteIds, with unit tests for valid and invalidfav, duplicates, errors and an empty ID list.loginPath(next, fav?)with tests, andfavoriteReturnPathremoved.- Wire
applyFavoriteIntentintopostLoginPath, with tests for: intent + gender set, intent + first login (/witajcarriesfav_added), and no intent. The login page foldsfavintonext. FavoriteButton:- the
sizevariant - a self-built guest link from
usePathname+window.location.search - the
fav_addedtoast with its module-level claim - the fixed toast inside a persistent live region
autoAddremoved
- the
RecipeCard: the<article>+ stretched-link structure and the optionalfavoritefooter.- Wire
getFavoriteIdsinto/,/przepisy/[kategoria]and/ulubione, renderFavoriteAddedNoticeon the signed-in home, and update the recipe page’s favorites slot. - Run the gates (typecheck, lint, test) and check in the browser: card hearts as a guest and signed in, the guest → login → return flow with the toast, and the 48 px target and focus ring.