From 0334e03c3c97b63ed82f8ef53b7b7be45fa109d1 Mon Sep 17 00:00:00 2001 From: gosangam Date: Mon, 8 Jun 2026 15:48:12 +0530 Subject: [PATCH] feat: add webpush and sadhna tracker --- app/(app)/books/va/[chapter]/page.tsx | 3 - app/(app)/c/[id]/page.tsx | 2 - app/(app)/glossary/glossary-client.tsx | 3 +- app/(app)/layout.tsx | 2 + app/(app)/letters/letters-browser.tsx | 1 - app/(app)/page.tsx | 5 + app/(app)/sadhna-streak/chant/page.tsx | 315 +++++++++++---- app/(app)/sadhna-streak/page.tsx | 280 +++++--------- app/(app)/sadhna-streak/preview/page.tsx | 207 ---------- app/(app)/search/filters-menu.tsx | 8 +- app/(app)/search/search-content.tsx | 12 +- app/(app)/settings/page.tsx | 10 + .../[slug]/play-audio-button.tsx | 2 +- .../transcriptions/place/[place]/page.tsx | 2 +- .../type/[type]/type-browser.tsx | 1 - .../year/[year]/year-browser.tsx | 1 - app/(app)/u/[handle]/posts-grid.tsx | 1 - app/(app)/u/[handle]/posts/scroll-feed.tsx | 1 - app/(app)/u/me/bookmarks/page.tsx | 1 - app/(app)/u/me/collections/[id]/page.tsx | 1 - app/(app)/u/me/page.tsx | 1 - app/(app)/u/me/posts/page.tsx | 1 - app/(app)/verify-email/page.tsx | 16 +- app/api/og/post/[id]/route.tsx | 4 + app/api/og/profile/[handle]/route.tsx | 3 + app/api/og/route.tsx | 3 + components/audio-player/floating-player.tsx | 37 -- components/auth/auth-gate.tsx | 3 + .../notifications/notifications-provider.tsx | 34 +- components/push/push-prompt.tsx | 119 ++++++ components/push/push-toggle.tsx | 128 +++++++ components/sadhna/confetti.tsx | 85 +++++ components/sadhna/reminder-toggle.tsx | 104 +++++ components/sadhna/reward-celebration.tsx | 102 +++++ components/sadhna/rewards-gallery.tsx | 177 +++++++++ components/sadhna/streak-calendar.tsx | 361 ++++++++++++++++++ components/sadhna/streak-cta.tsx | 82 ++++ components/sadhna/streak-flame.tsx | 41 ++ components/social/collection-grid-card.tsx | 2 +- components/social/page-bookmarks.tsx | 3 +- components/social/update-background-modal.tsx | 4 +- components/text-selection-toolbar.tsx | 7 +- lib/api.ts | 2 +- lib/bs.ts | 1 - lib/iso.ts | 1 - lib/noi.ts | 1 - lib/push.ts | 137 +++++++ lib/sadhna.ts | 54 +++ lib/use-mounted.ts | 17 + next.config.ts | 5 + public/icons/chanting-beads.svg | 222 +++++++++++ public/sw.js | 48 +++ 52 files changed, 2118 insertions(+), 545 deletions(-) delete mode 100644 app/(app)/sadhna-streak/preview/page.tsx create mode 100644 components/push/push-prompt.tsx create mode 100644 components/push/push-toggle.tsx create mode 100644 components/sadhna/confetti.tsx create mode 100644 components/sadhna/reminder-toggle.tsx create mode 100644 components/sadhna/reward-celebration.tsx create mode 100644 components/sadhna/rewards-gallery.tsx create mode 100644 components/sadhna/streak-calendar.tsx create mode 100644 components/sadhna/streak-cta.tsx create mode 100644 components/sadhna/streak-flame.tsx create mode 100644 lib/push.ts create mode 100644 lib/use-mounted.ts create mode 100644 public/icons/chanting-beads.svg create mode 100644 public/sw.js diff --git a/app/(app)/books/va/[chapter]/page.tsx b/app/(app)/books/va/[chapter]/page.tsx index 6325833c..51de0be2 100644 --- a/app/(app)/books/va/[chapter]/page.tsx +++ b/app/(app)/books/va/[chapter]/page.tsx @@ -6,7 +6,6 @@ import { notFound } from "next/navigation"; import { getVaContent, getVaSidebarItems, - type VaChapter, type VaSidebarItem, } from "@/lib/va"; import { getProseChapter } from "@/lib/api"; @@ -61,8 +60,6 @@ export default async function VaChapterPage({ const next = currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null; - const chapterNum = (content as VaChapter).chapter; - return (
diff --git a/app/(app)/c/[id]/page.tsx b/app/(app)/c/[id]/page.tsx index 5278615b..aca0f963 100644 --- a/app/(app)/c/[id]/page.tsx +++ b/app/(app)/c/[id]/page.tsx @@ -7,7 +7,6 @@ */ import { use, useEffect, useState } from "react"; -import Link from "next/link"; import { notFound } from "next/navigation"; import { Globe, Lock, FolderPlus } from "lucide-react"; import { @@ -29,7 +28,6 @@ export default function PublicCollectionPage(props: { useEffect(() => { let cancelled = false; - setLoading(true); collections .get(id) .then(({ collection, posts }) => { diff --git a/app/(app)/glossary/glossary-client.tsx b/app/(app)/glossary/glossary-client.tsx index aa542c02..4674f406 100644 --- a/app/(app)/glossary/glossary-client.tsx +++ b/app/(app)/glossary/glossary-client.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useRef, useEffect, useCallback } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import Link from "next/link"; import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -289,7 +289,6 @@ export function GlossaryClient({ letters, letterCounts, }: Props) { - const router = useRouter(); const searchParams = useSearchParams(); const [query, setQuery] = useState(""); diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 579ed3df..263cbe4c 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -7,6 +7,7 @@ import { VerifyEmailBanner } from "@/components/auth/verify-email-banner"; import { TextSelectionToolbar } from "@/components/text-selection-toolbar"; import { TextHighlighter } from "@/components/text-highlighter"; import { PageBookmarks } from "@/components/social/page-bookmarks"; +import { PushPrompt } from "@/components/push/push-prompt"; /** * Default app layout — the reading-app chrome that wraps the home page, all @@ -37,6 +38,7 @@ export default function AppLayout({ + ); } diff --git a/app/(app)/letters/letters-browser.tsx b/app/(app)/letters/letters-browser.tsx index a57035da..c707add6 100644 --- a/app/(app)/letters/letters-browser.tsx +++ b/app/(app)/letters/letters-browser.tsx @@ -3,7 +3,6 @@ import { useState, useMemo, useRef, useEffect } from "react"; import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 7048115d..b7b0f6bc 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Compass, LogIn, Sparkles } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; +import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta"; /** * / — the Home (Feed) gate. @@ -79,6 +80,10 @@ export default function HomePage() {

) : null} + {/* Daily sādhana streak — a simple entry point until the feed lands. */} +
+ +
); diff --git a/app/(app)/sadhna-streak/chant/page.tsx b/app/(app)/sadhna-streak/chant/page.tsx index 4ecd99fa..3996ac5e 100644 --- a/app/(app)/sadhna-streak/chant/page.tsx +++ b/app/(app)/sadhna-streak/chant/page.tsx @@ -3,34 +3,98 @@ /** * /sadhna-streak/chant — the japa counter. * - * Shows the Hare Kṛṣṇa mahā-mantra with a big tap-to-count round counter - * running toward the devotee's daily target (their profile's - * daily_chanting_rounds, falling back to 16). Save persists the count; - * reaching the target marks today's chanting discipline complete. + * Shows the full opening sequence — Śrīla Prabhupāda Praṇāma (×1), + * Pañca-tattva Praṇāma (×1), then the Hare Kṛṣṇa mahā-mantra (×108) — with a + * big tap-to-count round counter running toward the devotee's daily target + * (profile's daily_chanting_rounds, falling back to 16). Progress saves + * automatically (debounced); reaching the target marks today's chanting + * complete. The mantra script toggles between English (IAST) and Sanskrit + * (Devanāgarī), and Śrīla Prabhupāda's recorded japa can be played alongside. * - * Auth-gated like the rest of /sadhna-streak. It seeds the counter from - * today's already-saved progress so a devotee can chant in sittings. + * Auth-gated like the rest of /sadhna-streak; seeds from today's saved + * progress so a devotee can chant in sittings. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft, Check, Minus, RotateCcw } from "lucide-react"; +import { + ArrowLeft, + Check, + Loader2, + Minus, + Pause, + Play, + RotateCcw, +} from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; +import { useAudioPlayer, type AudioTrack } from "@/components/audio-player"; import { sadhna } from "@/lib/sadhna"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -const MANTRA = [ - "Hare Kṛṣṇa Hare Kṛṣṇa", - "Kṛṣṇa Kṛṣṇa Hare Hare", - "Hare Rāma Hare Rāma", - "Rāma Rāma Hare Hare", -]; +type Lang = "en" | "sa"; const FALLBACK_TARGET = 16; +const LANG_KEY = "sadhna.mantra.lang"; + +// Śrīla Prabhupāda's recorded japa — played through the global player so it +// survives navigation and pauses any other audio. +const JAPA_TRACK: AudioTrack = { + url: "https://s3.prabhupadasays.com/sp-audio/Japa/Srila%20Prabhupada%20-%20Chanting%20Japa.mp3", + title: "Śrīla Prabhupāda — Japa", + subtitle: "Chanting the Hare Kṛṣṇa mahā-mantra", + type: "kirtan", +}; + +// The opening praṇāma mantras, recited once before the rounds of japa. +const PRELUDE: { title: string; times: string; en: string[]; sa: string[] }[] = + [ + { + title: "Śrīla Prabhupāda Praṇāma", + times: "×1", + en: [ + "nama oṁ viṣṇu-pādāya kṛṣṇa-preṣṭhāya bhū-tale", + "śrīmate bhaktivedānta-svāmin iti nāmine", + "namas te sārasvate deve gaura-vāṇī-pracāriṇe", + "nirviśeṣa-śūnyavādi-pāścātya-deśa-tāriṇe", + ], + sa: [ + "नम ॐ विष्णु-पादाय कृष्ण-प्रेष्ठाय भूतले", + "श्रीमते भक्तिवेदांत-स्वामिन् इति नामिने ।", + "नमस्ते सारस्वते देवे गौर-वाणी-प्रचारिणे", + "निर्विशेष-शून्यवादि-पाश्चात्य-देश-तारिणे ॥", + ], + }, + { + title: "Pañca-tattva Praṇāma", + times: "×1", + en: [ + "(jaya) śrī-kṛṣṇa-caitanya prabhu-nityānanda", + "śrī-advaita gadādhara śrīvāsādi-gaura-bhakta-vṛnda", + ], + sa: [ + "जय श्री कृष्ण-चैतन्य प्रभु-नित्यानंद", + "श्री-अद्वैत गदाधर श्रीवास आदि गौर-भक्त-वृंद ।", + ], + }, + ]; + +const MAHAMANTRA: Record = { + en: [ + "Hare Kṛṣṇa Hare Kṛṣṇa", + "Kṛṣṇa Kṛṣṇa Hare Hare", + "Hare Rāma Hare Rāma", + "Rāma Rāma Hare Hare", + ], + sa: [ + "हरे कृष्ण हरे कृष्ण", + "कृष्ण कृष्ण हरे हरे ।", + "हरे राम हरे राम", + "राम राम हरे हरे ॥", + ], +}; export default function ChantPage() { const { user, loading } = useAuth(); @@ -40,8 +104,24 @@ export default function ChantPage() { const [target, setTarget] = useState(FALLBACK_TARGET); const [count, setCount] = useState(0); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); - const [saving, setSaving] = useState(false); - const [savedAt, setSavedAt] = useState(0); // count value at last save + const [saveStatus, setSaveStatus] = useState< + "idle" | "saving" | "saved" | "error" + >("idle"); + // Lazy initializer reads the saved script preference. Guarded for SSR; the + // mantra UI only renders client-side (after auth resolves), so this can't + // cause a hydration mismatch. + const [lang, setLang] = useState(() => { + if (typeof window === "undefined") return "en"; + const stored = window.localStorage.getItem(LANG_KEY); + return stored === "sa" || stored === "en" ? stored : "en"; + }); + + const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer(); + const japaActive = currentTrack?.url === JAPA_TRACK.url; + const japaPlaying = japaActive && isPlaying; + + const savedRef = useRef(0); // last count persisted to the server + const completedRef = useRef(false); // so we toast completion only once // Auth gate. useEffect(() => { @@ -66,7 +146,8 @@ export default function ChantPage() { if (cancelled) return; setTarget(d.target_rounds || FALLBACK_TARGET); setCount(d.rounds_completed || 0); - setSavedAt(d.rounds_completed || 0); + savedRef.current = d.rounds_completed || 0; + completedRef.current = !!d.chant_done; setState("ready"); }) .catch(() => !cancelled && setState("error")); @@ -75,29 +156,46 @@ export default function ChantPage() { }; }, [user]); - const inc = useCallback(() => { - setCount((c) => Math.min(target, c + 1)); - }, [target]); + const persist = useCallback(async (value: number) => { + setSaveStatus("saving"); + try { + const d = await sadhna.saveChant(value); + savedRef.current = value; + setTarget(d.target_rounds || FALLBACK_TARGET); + setSaveStatus("saved"); + if (d.chant_done && !completedRef.current) { + completedRef.current = true; + toast.success("Today's chanting is complete 🙏"); + } + if (!d.chant_done) completedRef.current = false; + } catch { + setSaveStatus("error"); + } + }, []); + + // Auto-save: debounce a save whenever the count drifts from what's stored. + useEffect(() => { + if (state !== "ready" || count === savedRef.current) return; + const t = window.setTimeout(() => void persist(count), 700); + return () => window.clearTimeout(t); + }, [count, state, persist]); + + const inc = useCallback( + () => setCount((c) => Math.min(target, c + 1)), + [target], + ); const dec = useCallback(() => setCount((c) => Math.max(0, c - 1)), []); const reset = useCallback(() => setCount(0), []); - const save = useCallback(async () => { - setSaving(true); - try { - const d = await sadhna.saveChant(count); - setSavedAt(count); - setTarget(d.target_rounds || FALLBACK_TARGET); - toast.success( - d.chant_done - ? "Today's chanting complete 🙏" - : `Saved — ${count} of ${d.target_rounds} rounds`, - ); - } catch { - toast.error("Could not save. Please try again."); - } finally { - setSaving(false); - } - }, [count]); + const chooseLang = useCallback((l: Lang) => { + setLang(l); + window.localStorage.setItem(LANG_KEY, l); + }, []); + + const toggleAudio = useCallback(() => { + if (japaActive) togglePlayPause(); + else play(JAPA_TRACK); + }, [japaActive, togglePlayPause, play]); if (loading || !user) { return ( @@ -110,11 +208,10 @@ export default function ChantPage() { } const complete = count >= target; - const dirty = count !== savedAt; const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0; return ( -
+
- {/* Mantra */} -
-

- The Mahā-mantra + {/* Script toggle + japa audio */} +

+
+ {(["en", "sa"] as Lang[]).map((l) => ( + + ))} +
+ + +
+ + {/* Opening praṇāma mantras */} +
+

+ Before you begin

-
- {MANTRA.map((line) => ( -

{line}

+ {PRELUDE.map((m) => ( +
+
+

+ {m.title} +

+ + {m.times} + +
+
+ {m[lang].map((line, i) => ( +

{line}

+ ))} +
+
+ ))} +
+ + {/* Mahā-mantra */} +
+
+

+ The Mahā-mantra +

+ + ×108 + +
+
+ {MAHAMANTRA[lang].map((line, i) => ( +

{line}

))}
{/* Big tap-to-count dial */} -
+
-

- One round is 108 names on the mālā. -

- - {/* Save */} -
- + {/* Auto-save status */} +
{state === "error" ? ( -

- Could not load today`'s progress. -

- ) : null} + + Could not load today's progress. + + ) : saveStatus === "saving" ? ( + <> + Saving… + + ) : saveStatus === "error" ? ( + + Couldn't save — keep chanting, we'll retry. + + ) : saveStatus === "saved" ? ( + <> + Progress + saved + + ) : ( + One round is 108 names on the mālā + )}
); diff --git a/app/(app)/sadhna-streak/page.tsx b/app/(app)/sadhna-streak/page.tsx index 9b451458..e9ad292d 100644 --- a/app/(app)/sadhna-streak/page.tsx +++ b/app/(app)/sadhna-streak/page.tsx @@ -3,12 +3,13 @@ /** * /sadhna-streak — the devotee's daily sādhana home. * - * • a thin 7-day streak strip across the top + * • a streak calendar with week/month views and back/forward paging; each + * day shows its state, today highlighted, and a lotus blooms beneath + * every day the sādhana was completed + * • the current/best streak counter * • two daily disciplines as action cards: finish today's chanting, and * post at least one quote from the Vāṇī — each flips to a "done" state * once complete - * • a plant that grows from a seedling toward a tree as the streak holds, - * and withers back through its stages (eventually dying) when it breaks * * Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote * posted elsewhere (posting is selection-driven while reading) reflects @@ -18,11 +19,14 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { BookOpenText, Check, Flame, BadgePlus } from "lucide-react"; +import Image from "next/image"; +import { BookOpenText, Check } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; -import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna"; -import { SadhnaVine } from "@/components/sadhna/sadhna-vine"; +import { sadhna, type SadhnaDashboard, type SadhnaRewardGrant } from "@/lib/sadhna"; +import { StreakCalendar } from "@/components/sadhna/streak-calendar"; +import { RewardsGallery } from "@/components/sadhna/rewards-gallery"; +import { RewardCelebration } from "@/components/sadhna/reward-celebration"; import { cn } from "@/lib/utils"; export default function SadhnaStreakPage() { @@ -32,6 +36,13 @@ export default function SadhnaStreakPage() { const [dash, setDash] = useState(null); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + const [rewards, setRewards] = useState<{ + collected: SadhnaRewardGrant[]; + total: number; + } | null>(null); + // Guards the celebration from re-opening between dismiss and the dashboard + // refetch (and lets a *new* milestone's gift still celebrate). + const [ackedMilestone, setAckedMilestone] = useState(null); // Auth gate — same pattern as /notifications. useEffect(() => { @@ -56,14 +67,41 @@ export default function SadhnaStreakPage() { .catch(() => setState("error")); }, []); + const loadRewards = useCallback(() => { + sadhna + .rewards() + .then(setRewards) + .catch(() => { + /* gallery is non-critical */ + }); + }, []); + // Load once we have a user, and again whenever the tab regains focus. useEffect(() => { if (!user) return; load(); - const onFocus = () => load(); + loadRewards(); + const onFocus = () => { + load(); + loadRewards(); + }; window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); - }, [user, load]); + }, [user, load, loadRewards]); + + // A freshly earned, not-yet-celebrated gift to show confetti for. + const pending = dash?.pending_reward ?? null; + const showCelebration = !!pending && pending.milestone !== ackedMilestone; + const closeCelebration = useCallback(async () => { + if (pending) setAckedMilestone(pending.milestone); + try { + await sadhna.ackRewards(); + } catch { + /* best-effort */ + } + load(); // clears pending_reward + loadRewards(); // the new gift now appears in the gallery + }, [pending, load, loadRewards]); if (loading || !user) { return ( @@ -76,91 +114,42 @@ export default function SadhnaStreakPage() { } return ( -
- {/* Streak strip */} - - +
-

+

Daily Sādhana

-

{vineCaption(dash)}

+

{caption(dash)}

+ {state === "error" ? ( +

+ Could not load your sādhana. Please try again. +

+ ) : ( + + )} + {/* Action items */}
- {/* The creeper — climbs the pole, one fold per day of the streak */} - + {/* Collected gifts */} + + + {/* Celebration when a gift is freshly earned */} + {showCelebration && pending ? ( + + ) : null}
); } -// ─── Streak strip ──────────────────────────────────────────────────────── - -function StreakStrip({ - dash, - loading, -}: { - dash: SadhnaDashboard | null; - loading: boolean; -}) { - const week = dash?.week ?? placeholderWeek(); - const streak = dash?.current_streak ?? 0; - - return ( -
-
- 0 ? "text-orange-500" : "text-muted-foreground", - )} - /> - - {streak} - - - day{streak === 1 ? "" : "s"} - -
- -
- {week.map((day, i) => ( - - ))} -
-
- ); -} - -function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) { - const partial = !day.completed && (day.chant_done || day.quote_done); - return ( -
-
- {day.completed ? : null} -
- - {weekdayLetter(day.date)} - -
- ); -} - // ─── Action cards ────────────────────────────────────────────────────── function ChantAction({ dash }: { dash: SadhnaDashboard | null }) { @@ -170,15 +159,21 @@ function ChantAction({ dash }: { dash: SadhnaDashboard | null }) { return ( } + icon={ + + } title="Finish today's chanting" subtitle={ - done - ? `${rounds} rounds chanted` - : `${rounds} / ${target} rounds` + done ? `${rounds} rounds chanted` : `${rounds} / ${target} rounds` } done={done} - cta={rounds > 0 ? "Continue" : "Begin"} /> ); } @@ -187,16 +182,15 @@ function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) { const done = dash?.quote_done ?? false; return ( } - title="Post a quote from the Vāṇī" + href="/search" + icon={} + title="Post a quote" subtitle={ done ? "Shared today — thank you" : "Select a passage while reading, then Share to Feed" } done={done} - cta="Find a verse" /> ); } @@ -207,124 +201,50 @@ function ActionCard({ title, subtitle, done, - cta, }: { href: string; icon: React.ReactNode; title: string; subtitle: string; done: boolean; - cta: string; }) { return ( -
- - {done ? : icon} - - {done ? ( - Done - ) : ( - - {cta} → - + -
-

+ > + {done ? : icon} + +

+

{title}

-

{subtitle}

+

{subtitle}

); } -// ─── Vine panel ─────────────────────────────────────────────────────── +// ─── Copy ────────────────────────────────────────────────────────────── -function VinePanel({ - dash, - error, -}: { - dash: SadhnaDashboard | null; - error: boolean; -}) { - return ( -
- {error ? ( -
-

- Could not load your sādhana. Please try again. -

-
- ) : dash ? ( - - ) : ( -
-

Loading your vine…

-
- )} -
- ); -} - -// ─── Copy + helpers ──────────────────────────────────────────────────── - -function vineCaption(dash: SadhnaDashboard | null): string { - if (!dash) return "Two simple offerings each day. Keep them up and watch it climb."; - if (dash.current_streak === 0 && !dash.alive) { - return "Begin today, and a new vine starts climbing the pole."; - } - if (dash.today_completed) { - return "Today's fold is complete. The vine climbs higher."; - } +function caption(dash: SadhnaDashboard | null): string { + if (!dash) return "Two simple offerings each day — a lotus for every one."; + if (dash.today_completed) return "Today's sādhana is complete — a lotus blooms."; if (dash.chant_done || dash.quote_done) { - return "One offering done — finish the other to complete today's fold."; + return "One offering done. Finish the other to complete today."; } - return "Each day's sādhana winds another fold around the pole."; -} - -/** Single-letter weekday (UTC, matching the backend's day buckets). */ -function weekdayLetter(date: string): string { - const d = new Date(`${date}T00:00:00Z`); - if (Number.isNaN(d.getTime())) return ""; - return ["S", "M", "T", "W", "T", "F", "S"][d.getUTCDay()]; -} - -/** A blank 7-day strip for the initial render before data arrives. */ -function placeholderWeek(): SadhnaDay[] { - const out: SadhnaDay[] = []; - const now = new Date(); - const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - for (let i = 6; i >= 0; i--) { - const d = new Date(today - i * 86400000); - out.push({ - date: d.toISOString().slice(0, 10), - chant_done: false, - quote_done: false, - completed: false, - }); - } - return out; + return "Chant your rounds and post a quote to complete today."; } diff --git a/app/(app)/sadhna-streak/preview/page.tsx b/app/(app)/sadhna-streak/preview/page.tsx deleted file mode 100644 index 4fc098c3..00000000 --- a/app/(app)/sadhna-streak/preview/page.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client"; - -/** - * /sadhna-streak/preview — DEV-ONLY sandbox for the sādhana creeper. - * - * Lets you fast-track the vine: scrub the streak length, set today's - * in-progress rounds/posts, and dial the health (growth) score to watch the - * vine climb the pole, sprout folds + blossoms, and wither — all without - * seeding real logs. Returns nothing in production builds. - * - * The synthesized payload matches what the BFF's Dashboard builds, so what - * you see here mirrors live. - */ - -import { useState } from "react"; -import { SadhnaVine } from "@/components/sadhna/sadhna-vine"; -import type { VineDay } from "@/lib/sadhna"; -import { cn } from "@/lib/utils"; - -export default function SadhnaPreviewPage() { - if (process.env.NODE_ENV === "production") { - return ( -
-

Not available.

-
- ); - } - return ; -} - -/** Build a vine_days payload exactly the way the BFF would. */ -function buildDays( - completed: number, - target: number, - todayRounds: number, - todayPosts: number, - todayComplete: boolean, -): VineDay[] { - const out: VineDay[] = []; - const now = new Date(); - const todayMs = Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate(), - ); - const endOffset = todayComplete ? 0 : 1; // days-ago of the last finished fold - for (let i = completed - 1; i >= 0; i--) { - const daysAgo = endOffset + i; - const date = new Date(todayMs - daysAgo * 86400000) - .toISOString() - .slice(0, 10); - out.push({ - date, - rounds: target + (i % 3), - posts: 1 + (i % 2), - completed: true, - in_progress: false, - }); - } - if (!todayComplete) { - out.push({ - date: new Date(todayMs).toISOString().slice(0, 10), - rounds: todayRounds, - posts: todayPosts, - completed: false, - in_progress: true, - }); - } - return out; -} - -function Preview() { - const [completed, setCompleted] = useState(3); - const [target] = useState(16); - const [todayComplete, setTodayComplete] = useState(false); - const [todayRounds, setTodayRounds] = useState(8); - const [todayPosts, setTodayPosts] = useState(0); - const [growth, setGrowth] = useState(60); - - const days = buildDays(completed, target, todayRounds, todayPosts, todayComplete); - const streak = completed; // current streak = finished folds - const alive = growth > 0; - - return ( -
-
- - Dev - -

- Vine preview -

-
- - {/* Controls */} -
- - - - - -
- setGrowth(Math.min(100, completed * 4))}> - sync health → streak - - setGrowth(0)}>kill -
-
- - {/* Live vine — same panel chrome as the real page */} -
- -
-
- ); -} - -function Slider({ - label, - value, - min, - max, - onChange, - disabled, -}: { - label: string; - value: number; - min: number; - max: number; - onChange: (v: number) => void; - disabled?: boolean; -}) { - return ( -
-
- {label} - {value} -
- onChange(Number(e.target.value))} - className="w-full accent-primary" - /> -
- ); -} - -function Chip({ - children, - onClick, -}: { - children: React.ReactNode; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/app/(app)/search/filters-menu.tsx b/app/(app)/search/filters-menu.tsx index ea36ad31..026dd960 100644 --- a/app/(app)/search/filters-menu.tsx +++ b/app/(app)/search/filters-menu.tsx @@ -66,12 +66,10 @@ export function SearchFiltersMenu({ // active count is one OR the other — never their sum. const activeCount = isUserChipActive ? 1 : selectedTypes.size; - // Desktop popover positioning — below the trigger, left-aligned. + // Desktop popover positioning — below the trigger, left-aligned. (Stale + // pos while closed is harmless — the popover only renders when open.) useEffect(() => { - if (!open || isMobile) { - setPos(null); - return; - } + if (!open || isMobile) return; function recompute() { const btn = triggerRef.current; if (!btn) return; diff --git a/app/(app)/search/search-content.tsx b/app/(app)/search/search-content.tsx index fda4b7c0..bacbd4d3 100644 --- a/app/(app)/search/search-content.tsx +++ b/app/(app)/search/search-content.tsx @@ -283,6 +283,12 @@ function SearchInner() { // Fetch search results — branches between user search (`@`-prefixed) // and content search. We clear the *other* state slot on every run // so the UI doesn't show stale results from a previous mode. + // + // This effect synchronizes results with the debounced query; the + // synchronous "show loading + clear stale" updates are intentional (they + // prevent flashing previous results), so set-state-in-effect is disabled + // for this block. + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { const query = debouncedQuery.trim(); @@ -363,6 +369,7 @@ function SearchInner() { isUserQueryEmpty, userQuery, ]); + /* eslint-enable react-hooks/set-state-in-effect */ // Auto-focus the input on mount useEffect(() => { @@ -404,11 +411,6 @@ function SearchInner() { } }; - const isTypeActive = (type: string): boolean => { - if (type === "all") return selectedTypes.size === 0; - return selectedTypes.has(type); - }; - const handlePageChange = (page: number) => { setCurrentPage(page); window.scrollTo({ top: 0, behavior: "smooth" }); diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 7375fac7..63b6e5fe 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -29,6 +29,8 @@ import { useAuth } from "@/components/auth/auth-provider"; import { AddEmailModal } from "@/components/auth/add-email-modal"; import { ChangePasswordModal } from "@/components/auth/change-password-modal"; import { ReadingSettings } from "@/components/reading-settings"; +import { PushToggle } from "@/components/push/push-toggle"; +import { ReminderToggle } from "@/components/sadhna/reminder-toggle"; const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; @@ -56,6 +58,14 @@ export default function SettingsPage() { +
+ + +
+
{/* Suspense boundary required because LinkResult reads useSearchParams — Next.js bails out of SSR otherwise. */} diff --git a/app/(app)/transcriptions/[slug]/play-audio-button.tsx b/app/(app)/transcriptions/[slug]/play-audio-button.tsx index 73662407..dbeb7abb 100644 --- a/app/(app)/transcriptions/[slug]/play-audio-button.tsx +++ b/app/(app)/transcriptions/[slug]/play-audio-button.tsx @@ -6,7 +6,7 @@ import { AudioWaveAnimation, } from "@/components/audio-player"; import { cn } from "@/lib/utils"; -import { Play, Pause } from "lucide-react"; +import { Play } from "lucide-react"; type PlayAudioButtonProps = { audioUrl: string; diff --git a/app/(app)/transcriptions/place/[place]/page.tsx b/app/(app)/transcriptions/place/[place]/page.tsx index 93136e37..58d742c8 100644 --- a/app/(app)/transcriptions/place/[place]/page.tsx +++ b/app/(app)/transcriptions/place/[place]/page.tsx @@ -8,7 +8,7 @@ import { buildOgMeta } from "@/lib/og"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Clock, MapPin } from "lucide-react"; +import { Clock } from "lucide-react"; import { PlayAudioButton } from "@/app/(app)/transcriptions/[slug]/play-audio-button"; export async function generateMetadata({ diff --git a/app/(app)/transcriptions/type/[type]/type-browser.tsx b/app/(app)/transcriptions/type/[type]/type-browser.tsx index 08aef431..44b71221 100644 --- a/app/(app)/transcriptions/type/[type]/type-browser.tsx +++ b/app/(app)/transcriptions/type/[type]/type-browser.tsx @@ -422,7 +422,6 @@ function MonthSection({ export function TranscriptionTypeBrowser({ transcriptions, - type, typeLabel, totalCount, }: { diff --git a/app/(app)/transcriptions/year/[year]/year-browser.tsx b/app/(app)/transcriptions/year/[year]/year-browser.tsx index efbdcf14..8962e60a 100644 --- a/app/(app)/transcriptions/year/[year]/year-browser.tsx +++ b/app/(app)/transcriptions/year/[year]/year-browser.tsx @@ -42,7 +42,6 @@ const MONTH_NAMES = [ ]; function MonthSection({ - month, label, items, defaultOpen, diff --git a/app/(app)/u/[handle]/posts-grid.tsx b/app/(app)/u/[handle]/posts-grid.tsx index 5af04399..fd6255f5 100644 --- a/app/(app)/u/[handle]/posts-grid.tsx +++ b/app/(app)/u/[handle]/posts-grid.tsx @@ -29,7 +29,6 @@ export function PostsGrid({ useEffect(() => { let cancelled = false; - setLoading(true); profile .feed(handle, 30) .then((rows) => { diff --git a/app/(app)/u/[handle]/posts/scroll-feed.tsx b/app/(app)/u/[handle]/posts/scroll-feed.tsx index b6791f50..42b507c9 100644 --- a/app/(app)/u/[handle]/posts/scroll-feed.tsx +++ b/app/(app)/u/[handle]/posts/scroll-feed.tsx @@ -32,7 +32,6 @@ export function ScrollFeed({ useEffect(() => { let cancelled = false; - setLoading(true); profile .feed(handle, 60) .then((rows) => { diff --git a/app/(app)/u/me/bookmarks/page.tsx b/app/(app)/u/me/bookmarks/page.tsx index de5b1627..0dec095b 100644 --- a/app/(app)/u/me/bookmarks/page.tsx +++ b/app/(app)/u/me/bookmarks/page.tsx @@ -18,7 +18,6 @@ export default function MyBookmarksPage() { const [error, setError] = useState(null); useEffect(() => { - setLoading(true); bookmarks .list() .then((r) => setList(r ?? [])) diff --git a/app/(app)/u/me/collections/[id]/page.tsx b/app/(app)/u/me/collections/[id]/page.tsx index 04e4a45d..5d1c96e2 100644 --- a/app/(app)/u/me/collections/[id]/page.tsx +++ b/app/(app)/u/me/collections/[id]/page.tsx @@ -35,7 +35,6 @@ export default function CollectionDetailPage(props: { useEffect(() => { let cancelled = false; - setLoading(true); collections .get(id) .then(({ collection, posts }) => { diff --git a/app/(app)/u/me/page.tsx b/app/(app)/u/me/page.tsx index 7a8f0334..92904eb1 100644 --- a/app/(app)/u/me/page.tsx +++ b/app/(app)/u/me/page.tsx @@ -51,7 +51,6 @@ function MyProfilePageInner() { useEffect(() => { if (!user) return; - setLoading(true); posts .mine() .then((rows) => setFeed(rows ?? [])) diff --git a/app/(app)/u/me/posts/page.tsx b/app/(app)/u/me/posts/page.tsx index 2179ce4c..fe5b6ef3 100644 --- a/app/(app)/u/me/posts/page.tsx +++ b/app/(app)/u/me/posts/page.tsx @@ -26,7 +26,6 @@ export default function MyPostsFeedPage() { useEffect(() => { if (!user) return; - setLoading(true); posts .mine() .then((rows) => setFeed(rows ?? [])) diff --git a/app/(app)/verify-email/page.tsx b/app/(app)/verify-email/page.tsx index 3ac92cdd..cdcb19da 100644 --- a/app/(app)/verify-email/page.tsx +++ b/app/(app)/verify-email/page.tsx @@ -17,15 +17,17 @@ function VerifyEmailContent() { const params = useSearchParams(); const token = params.get("token") ?? ""; const { refresh } = useAuth(); - const [status, setStatus] = useState<"pending" | "ok" | "error">("pending"); - const [message, setMessage] = useState(""); + // Seed straight from the token's presence so the missing-token case isn't + // a synchronous setState inside the effect below. + const [status, setStatus] = useState<"pending" | "ok" | "error">( + token ? "pending" : "error", + ); + const [message, setMessage] = useState( + token ? "" : "Verification link is missing a token.", + ); useEffect(() => { - if (!token) { - setStatus("error"); - setMessage("Verification link is missing a token."); - return; - } + if (!token) return; void (async () => { try { await auth.verifyEmail(token); diff --git a/app/api/og/post/[id]/route.tsx b/app/api/og/post/[id]/route.tsx index 96d9321e..6fbae81e 100644 --- a/app/api/og/post/[id]/route.tsx +++ b/app/api/og/post/[id]/route.tsx @@ -1,3 +1,5 @@ +/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only + renders raw ; next/image doesn't work inside OG image generation. */ /** * OG card for a single post. * @@ -107,6 +109,7 @@ export async function GET( {hasImage ? ( <> {post.author_avatar_url ? ( ; next/image doesn't work inside OG image generation. */ /** * OG card for a user profile (`/u/`). * @@ -109,6 +111,7 @@ export async function GET( > {profile.avatar_url ? ( ; next/image doesn't work inside OG image generation. */ import { ImageResponse } from "next/og"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -79,6 +81,7 @@ export async function GET(request: Request) { > {/* Prabhupāda image — already contains quote mark and branding */} void): () => void { - const mq = window.matchMedia(MOBILE_QUERY); - mq.addEventListener("change", callback); - return () => mq.removeEventListener("change", callback); -} - -function getMobileSnapshot(): boolean { - return window.matchMedia(MOBILE_QUERY).matches; -} - -function getMobileServerSnapshot(): boolean { - return false; -} - -function useIsMobile() { - return useSyncExternalStore( - subscribeMobile, - getMobileSnapshot, - getMobileServerSnapshot, - ); -} - /** * Returns [minimized, setMinimized, resetTimer]. * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call @@ -460,7 +424,6 @@ export function FloatingPlayer() { const playerRef = useRef(null); const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false); - const isMobile = useIsMobile(); const { minimized, setMinimized, resetTimer } = useAutoMinimize(!!currentTrack); diff --git a/components/auth/auth-gate.tsx b/components/auth/auth-gate.tsx index 1fbbe5e0..2fd94606 100644 --- a/components/auth/auth-gate.tsx +++ b/components/auth/auth-gate.tsx @@ -72,6 +72,9 @@ export function AuthGateProvider({ children }: { children: React.ReactNode }) { if (user && (open || resolversRef.current.length > 0)) { const pending = resolversRef.current; resolversRef.current = []; + // Intentional: react to the user becoming authenticated by closing the + // gate and resolving the queued promises. + // eslint-disable-next-line react-hooks/set-state-in-effect setOpen(false); pending.forEach((r) => r(true)); } diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index 53ec6dd1..004a9425 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -27,6 +27,7 @@ import { import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; import { notifications } from "@/lib/social"; +import { sadhna } from "@/lib/sadhna"; type NotificationsContextValue = { /** Live unread count for the header bell. */ @@ -86,13 +87,26 @@ export function NotificationsProvider({ // Seed the count whenever the signed-in user changes (async — no // synchronous setState in the effect body). useEffect(() => { - if (!user) return; + if (!user?.id) return; refreshUnread(); }, [user?.id, refreshUnread]); - // Open the realtime connection while signed in. + // Report the browser's timezone (once per signed-in session) so sādhana + // reminders fire at the user's local clock time. Best-effort. useEffect(() => { - if (!user) return; + if (!user?.id) return; + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (tz) void sadhna.setTimezone(tz).catch(() => {}); + } catch { + /* Intl unavailable — leave the server default (UTC) */ + } + }, [user?.id]); + + // Open the realtime connection while signed in. + const userId = user?.id; + useEffect(() => { + if (!userId) return; const key = process.env.NEXT_PUBLIC_SOKETI_KEY; const host = process.env.NEXT_PUBLIC_SOKETI_HOST; if (!key || !host) return; // realtime not configured @@ -100,7 +114,7 @@ export function NotificationsProvider({ const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443"); const forceTLS = (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; - const channelName = `notifications.${user.id}`; + const channelName = `notifications.${userId}`; let cancelled = false; // Dynamic import keeps pusher-js out of SSR (it touches window) and the @@ -124,6 +138,16 @@ export function NotificationsProvider({ setLastEventAt(Date.now()); toast(messageFor(data ?? {}), { duration: 4000 }); }); + // Sādhana reminders are ephemeral nudges — toast only, no unread bump + // (they aren't persisted to the notification center). + channel.bind( + "reminder", + (data: { title?: string; body?: string }) => { + toast(data?.body || data?.title || "A gentle reminder", { + duration: 6000, + }); + }, + ); cleanup = () => { channel.unbind_all(); pusher.unsubscribe(channelName); @@ -135,7 +159,7 @@ export function NotificationsProvider({ cancelled = true; cleanup?.(); }; - }, [user?.id]); + }, [userId]); return ( { + if (loading || !userId) return; + let cancelled = false; + + // Async eligibility check — never setState synchronously in the effect. + (async () => { + if (!pushSupported() || pushPermission() !== "default") return; + const dismissed = Number(window.localStorage.getItem(DISMISS_KEY) || 0); + if (dismissed && Date.now() - dismissed < COOLDOWN_MS) return; + if (!(await pushConfigured())) return; + if (await isPushEnabled()) return; + if (!cancelled) setShow(true); + })(); + + return () => { + cancelled = true; + }; + }, [userId, loading]); + + function dismiss() { + window.localStorage.setItem(DISMISS_KEY, String(Date.now())); + setShow(false); + } + + async function enable() { + setBusy(true); + try { + await enablePush(); + toast.success("Notifications enabled 🙏"); + setShow(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Couldn't enable notifications."); + // A denial / dismissal shouldn't keep re-prompting. + dismiss(); + } finally { + setBusy(false); + } + } + + if (!show) return null; + + return ( +
+
+ + + +
+

+ Stay with your sādhana +

+

+ Turn on notifications for daily reminders, follow requests, and + more — even when the app is closed. +

+
+ + +
+
+ +
+
+ ); +} diff --git a/components/push/push-toggle.tsx b/components/push/push-toggle.tsx new file mode 100644 index 00000000..85e069e4 --- /dev/null +++ b/components/push/push-toggle.tsx @@ -0,0 +1,128 @@ +"use client"; + +/** + * PushToggle — the settings control for browser push notifications. Reads the + * current state on mount (supported? permission? server-configured? + * subscribed?) and lets the user enable/disable. Renders as a settings row + * matching the rest of /settings. + */ + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + disablePush, + enablePush, + isPushEnabled, + pushConfigured, + pushPermission, + pushSupported, +} from "@/lib/push"; + +type State = + | "loading" + | "unsupported" + | "unavailable" // server has no VAPID keys + | "denied" // browser permission blocked + | "on" + | "off"; + +export function PushToggle() { + const [state, setState] = useState("loading"); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + if (!pushSupported()) return finish("unsupported"); + if (pushPermission() === "denied") return finish("denied"); + if (!(await pushConfigured())) return finish("unavailable"); + finish((await isPushEnabled()) ? "on" : "off"); + function finish(s: State) { + if (!cancelled) setState(s); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + async function toggle() { + setBusy(true); + try { + if (state === "on") { + await disablePush(); + setState("off"); + toast.success("Push notifications turned off"); + } else { + await enablePush(); + setState("on"); + toast.success("Push notifications turned on"); + } + } catch (err) { + const msg = + err instanceof Error ? err.message : "Something went wrong."; + // A fresh block surfaces as a permission error — reflect it. + if (pushPermission() === "denied") setState("denied"); + toast.error(msg); + } finally { + setBusy(false); + } + } + + const description = describe(state); + + return ( +
    +
  • +
    + + Push notifications + +

    {description}

    +
    +
    + {state === "on" || state === "off" ? ( + + ) : ( + + {state === "loading" ? "…" : "Unavailable"} + + )} +
    +
  • +
+ ); +} + +function describe(state: State): string { + switch (state) { + case "on": + return "On — you'll be notified about follow requests and more, even when the app is closed."; + case "off": + return "Get notified about follow requests and more, even when the app is closed."; + case "denied": + return "Blocked in your browser. Enable notifications for this site in your browser settings, then reload."; + case "unsupported": + return "This browser doesn't support push notifications."; + case "unavailable": + return "Push notifications aren't available right now."; + default: + return "Checking…"; + } +} diff --git a/components/sadhna/confetti.tsx b/components/sadhna/confetti.tsx new file mode 100644 index 00000000..0269a0e9 --- /dev/null +++ b/components/sadhna/confetti.tsx @@ -0,0 +1,85 @@ +"use client"; + +/** + * Confetti — a lightweight, dependency-free confetti shower for the reward + * celebration. Spawns a fixed set of colored pieces that fall with a little + * horizontal drift and spin, then fade. Pointer-events-none so it never + * blocks the dialog beneath it. Honors prefers-reduced-motion. + */ + +import { useMemo } from "react"; + +const COLORS = [ + "#f59e0b", + "#fb7185", + "#34d399", + "#60a5fa", + "#fbbf24", + "#f472b6", + "#a78bfa", + "#facc15", +]; + +// Deterministic pseudo-random in [0,1) from a seed. Math.sin is pure, so this +// keeps the confetti layout stable across renders (no Math.random impurity). +function rand(seed: number): number { + const x = Math.sin(seed * 127.1) * 43758.5453; + return x - Math.floor(x); +} + +export function Confetti({ pieces = 140 }: { pieces?: number }) { + // Computed once per mount; varying by index keeps it deterministic-ish + // without Date/Math.random concerns (this is a normal component). + const items = useMemo( + () => + Array.from({ length: pieces }, (_, i) => { + const r = (k: number) => rand(i * 9.17 + k * 3.13); + return { + left: r(1) * 100, + delay: r(2) * 0.5, + dur: 2.4 + r(3) * 1.8, + rot: r(4) * 360, + drift: (r(5) * 2 - 1) * 70, + size: 6 + r(6) * 7, + color: COLORS[i % COLORS.length], + round: r(7) < 0.3, + }; + }), + [pieces], + ); + + return ( +
+ + {items.map((p, i) => ( + + ))} +
+ ); +} diff --git a/components/sadhna/reminder-toggle.tsx b/components/sadhna/reminder-toggle.tsx new file mode 100644 index 00000000..2fb32cd5 --- /dev/null +++ b/components/sadhna/reminder-toggle.tsx @@ -0,0 +1,104 @@ +"use client"; + +/** + * ReminderToggle — the settings control for daily sādhana reminders (email + + * push + websocket). Reads the current preference and lets the user opt out. + * Renders as a settings row matching the rest of /settings. + */ + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { sadhna } from "@/lib/sadhna"; +import { + enablePush, + isPushEnabled, + pushPermission, + pushSupported, +} from "@/lib/push"; + +export function ReminderToggle() { + const [enabled, setEnabled] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + sadhna + .reminderPrefs() + .then((r) => { + if (!cancelled) setEnabled(r.enabled); + }) + .catch(() => { + if (!cancelled) setEnabled(true); // default-on; toggle still works + }); + return () => { + cancelled = true; + }; + }, []); + + async function toggle() { + if (enabled === null) return; + const next = !enabled; + setBusy(true); + try { + // Turning reminders ON also offers push permission on this device, so + // the reminder can actually reach a closed app. Best-effort: if the + // user declines or push isn't available, reminders still go by email + // and in-app. Done before saving the pref so the permission request + // stays close to the toggle gesture. + if ( + next && + pushSupported() && + pushPermission() !== "denied" && + !(await isPushEnabled()) + ) { + await enablePush().catch(() => { + /* declined / unavailable — reminders still work elsewhere */ + }); + } + await sadhna.setReminderPrefs(next); + setEnabled(next); + toast.success(next ? "Reminders turned on" : "Reminders turned off"); + } catch { + toast.error("Could not update reminders"); + } finally { + setBusy(false); + } + } + + return ( +
    +
  • +
    + Sādhana reminders +

    + Daily nudges to chant your rounds and share a verse — by email, + push, and in-app. +

    +
    +
    + {enabled === null ? ( + … + ) : ( + + )} +
    +
  • +
+ ); +} diff --git a/components/sadhna/reward-celebration.tsx b/components/sadhna/reward-celebration.tsx new file mode 100644 index 00000000..747cae8b --- /dev/null +++ b/components/sadhna/reward-celebration.tsx @@ -0,0 +1,102 @@ +"use client"; + +/** + * RewardCelebration — a full-screen confetti celebration shown when the + * devotee earns a streak gift. Centered card with the reward image, its + * cosmic name, and symbolic meaning. Dismissing it acknowledges the gift + * (via the caller) so it surfaces exactly once. + * + * Scrolls internally on small screens (max-h + overflow) and reads in both + * themes (card/foreground tokens). Esc or backdrop-click closes. + */ + +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import Image from "next/image"; +import { motion } from "framer-motion"; +import { Confetti } from "./confetti"; +import type { SadhnaRewardGrant } from "@/lib/sadhna"; +import { useMounted } from "@/lib/use-mounted"; + +export function RewardCelebration({ + grant, + onClose, +}: { + grant: SadhnaRewardGrant; + onClose: () => void; +}) { + const mounted = useMounted(); + + // Esc to close + lock background scroll while open. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (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( +
+
+ + +

+ A gift for your sādhana +

+

+ You've earned a reward! +

+

+ For {grant.milestone} days of unbroken sādhana 🙏 +

+ +
+
+ {r.name} +
+ +

+ {r.name} +

+ {r.meaning ? ( +

+ {r.meaning} +

+ ) : null} + + + +
, + document.body, + ); +} diff --git a/components/sadhna/rewards-gallery.tsx b/components/sadhna/rewards-gallery.tsx new file mode 100644 index 00000000..9ceb9a74 --- /dev/null +++ b/components/sadhna/rewards-gallery.tsx @@ -0,0 +1,177 @@ +"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 } from "lucide-react"; +import type { SadhnaRewardGrant } from "@/lib/sadhna"; +import { useMounted } from "@/lib/use-mounted"; + +export function RewardsGallery({ + collected, + nextRewardDate, +}: { + collected: SadhnaRewardGrant[]; + nextRewardDate: string | null; +}) { + const [detail, setDetail] = useState(null); + + // 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); + } + } + + 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} +
+ ); +} + +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} +

+ Earned on day {grant.milestone} of your streak +

+ +
+
, + 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", + }); +} diff --git a/components/sadhna/streak-calendar.tsx b/components/sadhna/streak-calendar.tsx new file mode 100644 index 00000000..7f27d75c --- /dev/null +++ b/components/sadhna/streak-calendar.tsx @@ -0,0 +1,361 @@ +"use client"; + +/** + * StreakCalendar — the sādhana streak calendar with week/month views. + * + * Shows the current/best streak and a grid of days; a lotus blooms beneath + * every day the sādhana was completed (both disciplines). The user can + * switch between a single-week view and a full-month grid, and page + * backward/forward through time. "Next" stops at the current period — there + * is nothing to show in the future. + * + * Streak counters come from the dashboard payload; the grid itself is fed by + * the /sadhna/history range endpoint, fetched per visible period and cached + * so paging back to a period you've seen is instant. Everything is bucketed + * by UTC calendar day to match the backend. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronLeft, ChevronRight, Gift } from "lucide-react"; +import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna"; +import { StreakFlame } from "@/components/sadhna/streak-flame"; +import { cn } from "@/lib/utils"; + +const DAY = 86400000; +const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; + +type View = "week" | "month"; + +// ── UTC date helpers (operate on epoch-ms at UTC midnight) ── +function ymd(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} +function startOfDayUTC(d: Date): number { + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} +function startOfWeek(ms: number): number { + return ms - new Date(ms).getUTCDay() * DAY; +} + +export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) { + const todayMs = startOfDayUTC(new Date()); + const todayStr = ymd(todayMs); + + const [view, setView] = useState("week"); + // A day inside the visible period. Starts on today. + const [anchorMs, setAnchorMs] = useState(todayMs); + + // Visible grid range + metadata for the chosen view. + const grid = useMemo(() => { + if (view === "week") { + const start = startOfWeek(anchorMs); + return { start, weeks: 1, monthIndex: -1 }; + } + const d = new Date(anchorMs); + const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + const monthEnd = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0); + const start = startOfWeek(monthStart); + const end = startOfWeek(monthEnd) + 6 * DAY; + const weeks = Math.round((end - start) / (7 * DAY)) + 1; + return { start, weeks, monthIndex: d.getUTCMonth() }; + }, [view, anchorMs]); + + const fromStr = ymd(grid.start); + const toStr = ymd(grid.start + (grid.weeks * 7 - 1) * DAY); + + // Fetch the visible range, cached by range key; race-safe. + const cacheRef = useRef>>(new Map()); + const reqRef = useRef(0); + const [byDate, setByDate] = useState>(new Map()); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const key = `${fromStr}_${toStr}`; + const cached = cacheRef.current.get(key); + if (cached) { + setByDate(cached); + return; + } + const token = ++reqRef.current; + setLoading(true); + sadhna + .history(fromStr, toStr) + .then((days) => { + const m = new Map((days ?? []).map((d) => [d.date, d] as const)); + cacheRef.current.set(key, m); + if (reqRef.current === token) setByDate(m); + }) + .catch(() => { + /* keep whatever's shown */ + }) + .finally(() => { + if (reqRef.current === token) setLoading(false); + }); + }, [fromStr, toStr]); + + // The dashboard's trailing-7-day `week` is the freshest authoritative + // source for recent days (it reflects a completion the moment dash + // reloads on mount/focus). Overlay it on top of the fetched history so the + // current week's cells — especially today — always agree with the streak + // counter, no matter how the history fetch raced. + const dashByDate = useMemo( + () => new Map((dash?.week ?? []).map((d) => [d.date, d] as const)), + [dash], + ); + const dayFor = (date: string) => dashByDate.get(date) ?? byDate.get(date); + + // Navigation bounds: never page past the current period. + const currentPeriodStart = + view === "week" + ? startOfWeek(todayMs) + : Date.UTC(new Date(todayMs).getUTCFullYear(), new Date(todayMs).getUTCMonth(), 1); + const visiblePeriodStart = + view === "week" + ? grid.start + : Date.UTC(new Date(anchorMs).getUTCFullYear(), new Date(anchorMs).getUTCMonth(), 1); + const atCurrent = visiblePeriodStart >= currentPeriodStart; + + const shift = (dir: -1 | 1) => { + if (view === "week") { + setAnchorMs((ms) => ms + dir * 7 * DAY); + } else { + const d = new Date(anchorMs); + setAnchorMs(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + dir, 1)); + } + }; + + const streak = dash?.current_streak ?? 0; + const longest = dash?.longest_streak ?? 0; + const giftDate = dash?.next_reward_date ?? null; + const cells = Array.from({ length: grid.weeks * 7 }, (_, i) => grid.start + i * DAY); + + return ( +
+ {/* Streak + view toggle */} +
+
+ 0 ? "bg-orange-500/15" : "bg-muted", + )} + > + + +
+

+ {streak} day{streak === 1 ? "" : "s"} +

+

+ {streak > 0 ? "current streak" : "start your streak today"} + {longest > 0 ? ` · best ${longest}` : ""} +

+
+
+ +
+ {(["week", "month"] as View[]).map((v) => ( + + ))} +
+
+ + {/* Period nav */} +
+ shift(-1)}> + + +
+ + {view === "week" + ? weekRangeLabel(grid.start) + : monthLabel(anchorMs)} + + {!atCurrent ? ( + + ) : null} +
+ shift(1)} disabled={atCurrent}> + + +
+ + {/* Weekday header (month view) */} + {view === "month" ? ( +
+ {WEEKDAYS.map((w) => ( + + {w} + + ))} +
+ ) : null} + +
+ {cells.map((ms) => { + const date = ymd(ms); + return ( + todayMs} + isGift={!!giftDate && date === giftDate} + inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex} + /> + ); + })} +
+
+ ); +} + +function NavButton({ + label, + onClick, + disabled, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +function DayCell({ + date, + day, + view, + isToday, + isFuture, + isGift, + inMonth, +}: { + date: string; + day: SadhnaDay | undefined; + view: View; + isToday: boolean; + isFuture: boolean; + isGift: boolean; + inMonth: boolean; +}) { + const completed = !!day?.completed; + const partial = !!day && !completed && (day.chant_done || day.quote_done); + const isWeek = view === "week"; + + return ( +
+ {isWeek ? ( + + {WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]} + + ) : null} +
+ {dayOfMonth(date)} +
+ {/* Marker slot — fixed height keeps every column's baseline aligned. */} +
+ {completed ? ( + + ) : isGift ? ( + + ) : null} +
+
+ ); +} + +// ── labels ── +function dayOfMonth(date: string): number | string { + const d = new Date(`${date}T00:00:00Z`); + return Number.isNaN(d.getTime()) ? "" : d.getUTCDate(); +} +function monthLabel(ms: number): string { + return new Date(ms).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + timeZone: "UTC", + }); +} +function weekRangeLabel(startMs: number): string { + const f = new Date(startMs); + const l = new Date(startMs + 6 * DAY); + const fm = f.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" }); + const lm = l.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" }); + return fm === lm + ? `${fm} ${f.getUTCDate()} – ${l.getUTCDate()}` + : `${fm} ${f.getUTCDate()} – ${lm} ${l.getUTCDate()}`; +} diff --git a/components/sadhna/streak-cta.tsx b/components/sadhna/streak-cta.tsx new file mode 100644 index 00000000..edac1669 --- /dev/null +++ b/components/sadhna/streak-cta.tsx @@ -0,0 +1,82 @@ +"use client"; + +/** + * SadhnaStreakCTA — a compact streak card for the homepage. Tapping it opens + * /sadhna-streak. For a signed-in devotee it shows their live streak and + * today's status; for visitors it's a gentle invitation to begin. The feed + * will sit alongside this later. + */ + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import { useAuth } from "@/components/auth/auth-provider"; +import { sadhna, type SadhnaDashboard } from "@/lib/sadhna"; +import { StreakFlame } from "@/components/sadhna/streak-flame"; + +export function SadhnaStreakCTA() { + const { user, loading } = useAuth(); + const authed = !loading && !!user; + const [dash, setDash] = useState(null); + + useEffect(() => { + // Only fetch when signed in; never setState synchronously here. A stale + // dash from a previous session is ignored in render via `data` below. + if (!authed) return; + let cancelled = false; + sadhna + .dashboard() + .then((d) => { + if (!cancelled) setDash(d); + }) + .catch(() => { + /* card stays in its invitation state */ + }); + return () => { + cancelled = true; + }; + }, [authed]); + + // Ignore any leftover dashboard when not authed (e.g. after sign-out). + const data = authed ? dash : null; + const streak = data?.current_streak ?? 0; + + const title = !authed + ? "Daily Sādhana" + : streak > 0 + ? `${streak} day${streak === 1 ? "" : "s"} sadhna streak` + : "Start your streak"; + + const subtitle = !authed + ? "Chant your rounds and share a verse each day" + : data?.today_completed + ? "Today's sādhana is complete 🌸" + : data?.chant_done || data?.quote_done + ? "One offering done — finish today" + : "Chant and post a quote to complete today"; + + return ( + + 0 ? "bg-orange-500/15" : "bg-muted") + } + > + + +
+

+ {title} +

+

+ {subtitle} +

+
+ + + ); +} diff --git a/components/sadhna/streak-flame.tsx b/components/sadhna/streak-flame.tsx new file mode 100644 index 00000000..939fa47c --- /dev/null +++ b/components/sadhna/streak-flame.tsx @@ -0,0 +1,41 @@ +/** + * StreakFlame — a vivid two-tone flame for the sādhana streak badge: a warm + * orange body with an inner gold flame. Pass `muted` for a zero / broken + * streak (greyed out). Size it with a height/size class. + */ +export function StreakFlame({ + className, + muted = false, +}: { + className?: string; + muted?: boolean; +}) { + const outer = muted + ? ["#c2c6cd", "#9aa0a8", "#868c95"] + : ["#ff9d2e", "#ff5722", "#e23a0e"]; + const inner = muted ? ["#dde0e4", "#bcc0c6"] : ["#ffe08a", "#ffb01f"]; + // One flame renders at a time, so fixed gradient ids are fine. + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/components/social/collection-grid-card.tsx b/components/social/collection-grid-card.tsx index f480f469..01898c08 100644 --- a/components/social/collection-grid-card.tsx +++ b/components/social/collection-grid-card.tsx @@ -16,7 +16,7 @@ import Image from "next/image"; import Link from "next/link"; -import { Globe, Lock } from "lucide-react"; +import { Lock } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Collection } from "@/lib/social"; import { quoteCardBgStyle } from "./post-card-shared"; diff --git a/components/social/page-bookmarks.tsx b/components/social/page-bookmarks.tsx index a1f5b56e..5e8f3915 100644 --- a/components/social/page-bookmarks.tsx +++ b/components/social/page-bookmarks.tsx @@ -155,9 +155,10 @@ export function PageBookmarks() { // Belt-and-braces: when this component unmounts (route change), tear // down every mark so the next page starts clean. useEffect(() => { + const appliedIds = appliedIdsRef.current; return () => { removeBookmarkMarks(); - appliedIdsRef.current.clear(); + appliedIds.clear(); }; }, []); diff --git a/components/social/update-background-modal.tsx b/components/social/update-background-modal.tsx index 7aa213e9..38b43b1b 100644 --- a/components/social/update-background-modal.tsx +++ b/components/social/update-background-modal.tsx @@ -218,8 +218,8 @@ export function UpdateBackgroundModal({ Update Background

- Pick an image to set as the card's background, or remove the - current one. + Pick an image to set as the card's background, or remove + the current one.