"use client"; /** * RewardsGallery — the collected-gifts section on /sadhna-streak. Shows each * unique reward the devotee has earned as a tappable tile (image + short * name); tapping opens a detail dialog with the full name + symbolic * meaning + which streak day it was earned. Remaining catalog slots show as * locked tiles to entice completion. * * Built to read in both themes (card/foreground tokens), keep names legible * (line-clamped, never overflowing), and resize the large R2 images via * next/image. */ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import Image from "next/image"; import { Gift, Sparkles } from "lucide-react"; import { sadhna, type SadhnaRewardGrant } from "@/lib/sadhna"; import { useMounted } from "@/lib/use-mounted"; import { RewardCelebration } from "@/components/sadhna/reward-celebration"; import { cn } from "@/lib/utils"; export function RewardsGallery({ collected, nextRewardDate, }: { collected: SadhnaRewardGrant[]; nextRewardDate: string | null; }) { const [detail, setDetail] = useState(null); // The gift currently being opened — shown enlarged, over confetti. const [unwrapping, setUnwrapping] = useState(null); // Gifts opened in this session. Held locally so the tile clears the moment // the celebration is dismissed, rather than waiting for a page refresh. const [justOpened, setJustOpened] = useState>(new Set()); // De-dupe by reward (collected is newest-first, so the first wins). const seen = new Set(); const unique: SadhnaRewardGrant[] = []; for (const g of collected) { if (!seen.has(g.reward.key)) { seen.add(g.reward.key); unique.push(g); } } // A gift stays wrapped until opened. Everything else — every streak // reward — is simply collected. const isWrapped = (g: SadhnaRewardGrant) => g.source === "gift" && !g.opened && !justOpened.has(g.reward.key); const openGift = (g: SadhnaRewardGrant) => { setUnwrapping(null); setJustOpened((prev) => new Set(prev).add(g.reward.key)); // The tile has already cleared; a failed write only means it arrives // wrapped again next load, which is better than blocking the celebration. void sadhna.openGift(g.reward.key).catch(() => {}); }; return (

Sacred Rewards

{unique.length === 0 ? (

No gifts yet. Keep your sādhana streak — a sacred reward arrives every 7 days.

) : (
{unique.map((g) => ( ))}
)} {nextRewardDate ? (

Next gift on{" "} {fmtLong(nextRewardDate)}

) : null} {detail ? ( setDetail(null)} /> ) : null} {/* Reuses the streak celebration wholesale — a gift should feel like any other sacred reward arriving, not like a second mechanism. */} {unwrapping ? ( openGift(unwrapping)} /> ) : null}
); } function RewardDetail({ grant, onClose, }: { grant: SadhnaRewardGrant; onClose: () => void; }) { const mounted = useMounted(); useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; }; }, [onClose]); if (!mounted) return null; const r = grant.reward; return createPortal(
{r.name}

{r.name}

{r.meaning ? (

{r.meaning}

) : null}

{/* A gift has no milestone — saying "day null of your streak" would be worse than saying nothing about where it came from. */} {grant.milestone !== null ? `Earned on day ${grant.milestone} of your streak` : "An occasional gift"}

, document.body, ); } /** The evocative term before the em-dash, for compact tile labels. */ function shortName(name: string): string { const parts = name.split("—"); return (parts[0] ?? name).trim(); } function fmtLong(date: string): string { const d = new Date(`${date}T00:00:00Z`); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC", }); }