feat: add webpush and sadhna tracker

This commit is contained in:
gosangam
2026-06-08 15:48:12 +05:30
parent ecb32d2e85
commit 0334e03c3c
52 changed files with 2118 additions and 545 deletions
-3
View File
@@ -6,7 +6,6 @@ import { notFound } from "next/navigation";
import { import {
getVaContent, getVaContent,
getVaSidebarItems, getVaSidebarItems,
type VaChapter,
type VaSidebarItem, type VaSidebarItem,
} from "@/lib/va"; } from "@/lib/va";
import { getProseChapter } from "@/lib/api"; import { getProseChapter } from "@/lib/api";
@@ -61,8 +60,6 @@ export default async function VaChapterPage({
const next = const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null; currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as VaChapter).chapter;
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<PageHeader sticky> <PageHeader sticky>
-2
View File
@@ -7,7 +7,6 @@
*/ */
import { use, useEffect, useState } from "react"; import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Globe, Lock, FolderPlus } from "lucide-react"; import { Globe, Lock, FolderPlus } from "lucide-react";
import { import {
@@ -29,7 +28,6 @@ export default function PublicCollectionPage(props: {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
collections collections
.get(id) .get(id)
.then(({ collection, posts }) => { .then(({ collection, posts }) => {
+1 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react"; 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 Link from "next/link";
import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react"; import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -289,7 +289,6 @@ export function GlossaryClient({
letters, letters,
letterCounts, letterCounts,
}: Props) { }: Props) {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
+2
View File
@@ -7,6 +7,7 @@ import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { TextSelectionToolbar } from "@/components/text-selection-toolbar"; import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
import { TextHighlighter } from "@/components/text-highlighter"; import { TextHighlighter } from "@/components/text-highlighter";
import { PageBookmarks } from "@/components/social/page-bookmarks"; 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 * Default app layout — the reading-app chrome that wraps the home page, all
@@ -37,6 +38,7 @@ export default function AppLayout({
<TextHighlighter /> <TextHighlighter />
</Suspense> </Suspense>
<PageBookmarks /> <PageBookmarks />
<PushPrompt />
</> </>
); );
} }
-1
View File
@@ -3,7 +3,6 @@
import { useState, useMemo, useRef, useEffect } from "react"; import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
+5
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { Compass, LogIn, Sparkles } from "lucide-react"; import { Compass, LogIn, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
/** /**
* / — the Home (Feed) gate. * / — the Home (Feed) gate.
@@ -79,6 +80,10 @@ export default function HomePage() {
</p> </p>
) : null} ) : null}
{/* Daily sādhana streak — a simple entry point until the feed lands. */}
<div className="mt-6 w-full max-w-md">
<SadhnaStreakCTA />
</div>
</div> </div>
</main> </main>
); );
+242 -65
View File
@@ -3,34 +3,98 @@
/** /**
* /sadhna-streak/chant — the japa counter. * /sadhna-streak/chant — the japa counter.
* *
* Shows the Hare Kṛṣṇa mahā-mantra with a big tap-to-count round counter * Shows the full opening sequence — Śrīla Prabhupāda Praṇāma (×1),
* running toward the devotee's daily target (their profile's * Pañca-tattva Praṇāma (×1), then the Hare Kṛṣṇa mahā-mantra (×108) — with a
* daily_chanting_rounds, falling back to 16). Save persists the count; * big tap-to-count round counter running toward the devotee's daily target
* reaching the target marks today's chanting discipline complete. * (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 * Auth-gated like the rest of /sadhna-streak; seeds from today's saved
* today's already-saved progress so a devotee can chant in sittings. * 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 { useRouter } from "next/navigation";
import Link from "next/link"; 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 { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate"; import { useAuthGate } from "@/components/auth/auth-gate";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
import { sadhna } from "@/lib/sadhna"; import { sadhna } from "@/lib/sadhna";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MANTRA = [ 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<Lang, string[]> = {
en: [
"Hare Kṛṣṇa Hare Kṛṣṇa", "Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare", "Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma", "Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare", "Rāma Rāma Hare Hare",
]; ],
sa: [
const FALLBACK_TARGET = 16; "हरे कृष्ण हरे कृष्ण",
"कृष्ण कृष्ण हरे हरे ।",
"हरे राम हरे राम",
"राम राम हरे हरे ॥",
],
};
export default function ChantPage() { export default function ChantPage() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -40,8 +104,24 @@ export default function ChantPage() {
const [target, setTarget] = useState(FALLBACK_TARGET); const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<
const [savedAt, setSavedAt] = useState(0); // count value at last save "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<Lang>(() => {
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. // Auth gate.
useEffect(() => { useEffect(() => {
@@ -66,7 +146,8 @@ export default function ChantPage() {
if (cancelled) return; if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET); setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0); setCount(d.rounds_completed || 0);
setSavedAt(d.rounds_completed || 0); savedRef.current = d.rounds_completed || 0;
completedRef.current = !!d.chant_done;
setState("ready"); setState("ready");
}) })
.catch(() => !cancelled && setState("error")); .catch(() => !cancelled && setState("error"));
@@ -75,29 +156,46 @@ export default function ChantPage() {
}; };
}, [user]); }, [user]);
const inc = useCallback(() => { const persist = useCallback(async (value: number) => {
setCount((c) => Math.min(target, c + 1)); setSaveStatus("saving");
}, [target]); 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 dec = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(0), []); const reset = useCallback(() => setCount(0), []);
const save = useCallback(async () => { const chooseLang = useCallback((l: Lang) => {
setSaving(true); setLang(l);
try { window.localStorage.setItem(LANG_KEY, l);
const d = await sadhna.saveChant(count); }, []);
setSavedAt(count);
setTarget(d.target_rounds || FALLBACK_TARGET); const toggleAudio = useCallback(() => {
toast.success( if (japaActive) togglePlayPause();
d.chant_done else play(JAPA_TRACK);
? "Today's chanting complete 🙏" }, [japaActive, togglePlayPause, play]);
: `Saved — ${count} of ${d.target_rounds} rounds`,
);
} catch {
toast.error("Could not save. Please try again.");
} finally {
setSaving(false);
}
}, [count]);
if (loading || !user) { if (loading || !user) {
return ( return (
@@ -110,11 +208,10 @@ export default function ChantPage() {
} }
const complete = count >= target; const complete = count >= target;
const dirty = count !== savedAt;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0; const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return ( return (
<main className="flex-1 w-full max-w-md mx-auto px-4 sm:px-6 py-5 flex flex-col"> <main className="flex-1 w-full max-w-lg mx-auto px-4 sm:px-6 py-5 flex flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link <Link
href="/sadhna-streak" href="/sadhna-streak"
@@ -133,20 +230,87 @@ export default function ChantPage() {
</button> </button>
</div> </div>
{/* Mantra */} {/* Script toggle + japa audio */}
<div className="mt-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["en", "sa"] as Lang[]).map((l) => (
<button
key={l}
type="button"
onClick={() => chooseLang(l)}
className={cn(
"rounded-md px-3 py-1 transition-colors",
lang === l
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{l === "en" ? "English" : "Sanskrit"}
</button>
))}
</div>
<button
type="button"
onClick={toggleAudio}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
aria-pressed={japaPlaying}
>
{japaPlaying ? (
<Pause className="size-3.5 text-primary" />
) : (
<Play className="size-3.5 text-primary" />
)}
{japaPlaying ? "Pause japa" : "Play japa"}
</button>
</div>
{/* Opening praṇāma mantras */}
<section className="mt-5 space-y-3">
<p className="text-center text-[11px] uppercase tracking-widest text-muted-foreground">
Before you begin
</p>
{PRELUDE.map((m) => (
<div
key={m.title}
className="rounded-2xl border border-border bg-card/60 p-4 text-center"
>
<div className="flex items-center justify-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
{m.title}
</h3>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{m.times}
</span>
</div>
<div className="mt-2 space-y-0.5 font-serif text-[15px] sm:text-base leading-relaxed text-foreground/90">
{m[lang].map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
))}
</section>
{/* Mahā-mantra */}
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-xs uppercase tracking-widest text-muted-foreground"> <div className="flex items-center justify-center gap-2">
<p className="text-[11px] uppercase tracking-widest text-muted-foreground">
The Mahā-mantra The Mahā-mantra
</p> </p>
<div className="mt-3 space-y-1 grid grid-cols-1 md:grid-cols-2 font-serif text-lg sm:text-xl text-foreground leading-relaxed"> <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{MANTRA.map((line) => ( ×108
<p key={line}>{line}</p> </span>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-1 font-serif text-lg sm:text-xl leading-relaxed text-foreground md:grid-cols-2">
{MAHAMANTRA[lang].map((line, i) => (
<p key={i}>{line}</p>
))} ))}
</div> </div>
</div> </div>
{/* Big tap-to-count dial */} {/* Big tap-to-count dial */}
<div className="mt-8 flex flex-col items-center"> <div className="mt-7 flex flex-col items-center">
<button <button
onClick={inc} onClick={inc}
disabled={state !== "ready" || complete} disabled={state !== "ready" || complete}
@@ -160,9 +324,19 @@ export default function ChantPage() {
)} )}
aria-label="Count one round" aria-label="Count one round"
> >
{/* progress ring */} <svg
<svg className="absolute inset-0 size-full -rotate-90" viewBox="0 0 100 100"> className="absolute inset-0 size-full -rotate-90"
<circle cx="50" cy="50" r="46" fill="none" stroke="currentColor" strokeOpacity="0.08" strokeWidth="4" /> viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="currentColor"
strokeOpacity="0.08"
strokeWidth="4"
/>
<circle <circle
cx="50" cx="50"
cy="50" cy="50"
@@ -205,25 +379,28 @@ export default function ChantPage() {
</button> </button>
</div> </div>
<p className="mt-6 text-center text-xs text-muted-foreground"> {/* Auto-save status */}
One round is 108 names on the mālā. <div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
</p>
{/* Save */}
<div className="mt-auto pt-8">
<Button
onClick={save}
disabled={saving || !dirty || state !== "ready"}
size="lg"
className="w-full"
>
{saving ? "Saving…" : dirty ? "Save progress" : "Saved"}
</Button>
{state === "error" ? ( {state === "error" ? (
<p className="mt-2 text-center text-xs text-destructive"> <span className="text-destructive">
Could not load today`&apos;s progress. Could not load today&apos;s progress.
</p> </span>
) : null} ) : saveStatus === "saving" ? (
<>
<Loader2 className="size-3.5 animate-spin" /> Saving…
</>
) : saveStatus === "error" ? (
<span className="text-destructive">
Couldn&apos;t save — keep chanting, we&apos;ll retry.
</span>
) : saveStatus === "saved" ? (
<>
<Check className="size-3.5 text-primary" strokeWidth={3} /> Progress
saved
</>
) : (
<span>One round is 108 names on the mālā</span>
)}
</div> </div>
</main> </main>
); );
+92 -172
View File
@@ -3,12 +3,13 @@
/** /**
* /sadhna-streak — the devotee's daily sādhana home. * /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 * • 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 * post at least one quote from the Vāṇī — each flips to a "done" state
* once complete * 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 * Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
* posted elsewhere (posting is selection-driven while reading) reflects * posted elsewhere (posting is selection-driven while reading) reflects
@@ -18,11 +19,14 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; 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 { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate"; import { useAuthGate } from "@/components/auth/auth-gate";
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna"; import { sadhna, type SadhnaDashboard, type SadhnaRewardGrant } from "@/lib/sadhna";
import { SadhnaVine } from "@/components/sadhna/sadhna-vine"; 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"; import { cn } from "@/lib/utils";
export default function SadhnaStreakPage() { export default function SadhnaStreakPage() {
@@ -32,6 +36,13 @@ export default function SadhnaStreakPage() {
const [dash, setDash] = useState<SadhnaDashboard | null>(null); const [dash, setDash] = useState<SadhnaDashboard | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading"); 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<number | null>(null);
// Auth gate — same pattern as /notifications. // Auth gate — same pattern as /notifications.
useEffect(() => { useEffect(() => {
@@ -56,14 +67,41 @@ export default function SadhnaStreakPage() {
.catch(() => setState("error")); .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. // Load once we have a user, and again whenever the tab regains focus.
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
load(); load();
const onFocus = () => load(); loadRewards();
const onFocus = () => {
load();
loadRewards();
};
window.addEventListener("focus", onFocus); window.addEventListener("focus", onFocus);
return () => window.removeEventListener("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) { if (loading || !user) {
return ( return (
@@ -76,88 +114,39 @@ export default function SadhnaStreakPage() {
} }
return ( return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4"> <main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5">
{/* Streak strip */}
<StreakStrip dash={dash} loading={state === "loading"} />
<header className="text-center space-y-0.5"> <header className="text-center space-y-0.5">
<h1 className="font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold text-foreground"> <h1 className="font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold text-foreground">
Daily Sādhana Daily Sādhana
</h1> </h1>
<p className="text-sm text-muted-foreground">{vineCaption(dash)}</p> <p className="text-sm text-muted-foreground">{caption(dash)}</p>
</header> </header>
{state === "error" ? (
<p className="rounded-2xl border border-border bg-card p-4 text-center text-sm text-destructive">
Could not load your sādhana. Please try again.
</p>
) : (
<StreakCalendar dash={dash} />
)}
{/* Action items */} {/* Action items */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChantAction dash={dash} /> <ChantAction dash={dash} />
<QuoteAction dash={dash} /> <QuoteAction dash={dash} />
</div> </div>
{/* The creeper — climbs the pole, one fold per day of the streak */} {/* Collected gifts */}
<VinePanel dash={dash} error={state === "error"} /> <RewardsGallery
</main> collected={rewards?.collected ?? []}
); nextRewardDate={dash?.next_reward_date ?? null}
}
// ─── Streak strip ────────────────────────────────────────────────────────
function StreakStrip({
dash,
loading,
}: {
dash: SadhnaDashboard | null;
loading: boolean;
}) {
const week = dash?.week ?? placeholderWeek();
const streak = dash?.current_streak ?? 0;
return (
<div className="flex items-center justify-between gap-3 rounded-full border border-border bg-card/60 px-4 py-2">
<div className="flex items-center gap-1.5 shrink-0">
<Flame
className={cn(
"size-4",
streak > 0 ? "text-orange-500" : "text-muted-foreground",
)}
/> />
<span className="text-sm font-semibold text-foreground tabular-nums">
{streak}
</span>
<span className="text-xs text-muted-foreground hidden sm:inline">
day{streak === 1 ? "" : "s"}
</span>
</div>
<div className={cn("flex items-center gap-2", loading && "opacity-40")}> {/* Celebration when a gift is freshly earned */}
{week.map((day, i) => ( {showCelebration && pending ? (
<DayDot key={day.date} day={day} isToday={i === week.length - 1} /> <RewardCelebration grant={pending} onClose={closeCelebration} />
))} ) : null}
</div> </main>
</div>
);
}
function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) {
const partial = !day.completed && (day.chant_done || day.quote_done);
return (
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"size-5 rounded-full flex items-center justify-center transition-colors",
day.completed
? "bg-primary text-primary-foreground"
: partial
? "bg-primary/20 ring-1 ring-primary/50"
: "bg-muted",
isToday && !day.completed && "ring-2 ring-primary/60",
)}
>
{day.completed ? <Check className="size-3" strokeWidth={3} /> : null}
</div>
<span className="text-[10px] leading-none text-muted-foreground">
{weekdayLetter(day.date)}
</span>
</div>
); );
} }
@@ -170,15 +159,21 @@ function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
return ( return (
<ActionCard <ActionCard
href="/sadhna-streak/chant" href="/sadhna-streak/chant"
icon={<BadgePlus className="size-5" />} icon={
<Image
src="/icons/chanting-beads.svg"
alt=""
width={48}
height={48}
className="size-12"
unoptimized
/>
}
title="Finish today's chanting" title="Finish today's chanting"
subtitle={ subtitle={
done done ? `${rounds} rounds chanted` : `${rounds} / ${target} rounds`
? `${rounds} rounds chanted`
: `${rounds} / ${target} rounds`
} }
done={done} done={done}
cta={rounds > 0 ? "Continue" : "Begin"}
/> />
); );
} }
@@ -187,16 +182,15 @@ function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.quote_done ?? false; const done = dash?.quote_done ?? false;
return ( return (
<ActionCard <ActionCard
href="/books" href="/search"
icon={<BookOpenText className="size-5" />} icon={<BookOpenText className="size-8" />}
title="Post a quote from the Vāṇī" title="Post a quote"
subtitle={ subtitle={
done done
? "Shared today — thank you" ? "Shared today — thank you"
: "Select a passage while reading, then Share to Feed" : "Select a passage while reading, then Share to Feed"
} }
done={done} done={done}
cta="Find a verse"
/> />
); );
} }
@@ -207,124 +201,50 @@ function ActionCard({
title, title,
subtitle, subtitle,
done, done,
cta,
}: { }: {
href: string; href: string;
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
subtitle: string; subtitle: string;
done: boolean; done: boolean;
cta: string;
}) { }) {
return ( return (
<Link <Link
href={href} href={href}
className={cn( className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 transition-colors", "group relative flex items-center gap-3.5 rounded-2xl border p-4 transition-colors",
done done
? "border-primary/40 bg-primary/5" ? "border-primary/40 bg-primary/5"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40", : "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
)} )}
> >
<div className="flex items-center justify-between">
<span <span
className={cn( className={cn(
"inline-flex size-9 items-center justify-center rounded-xl", "inline-flex size-14 shrink-0 items-center justify-center rounded-xl",
done done
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-primary/10 text-primary", : "bg-primary/10 text-primary",
)} )}
> >
{done ? <Check className="size-5" strokeWidth={3} /> : icon} {done ? <Check className="size-8" strokeWidth={3} /> : icon}
</span> </span>
{done ? ( <div className="min-w-0 space-y-0.5">
<span className="text-xs font-medium text-primary">Done</span> <p className="text-base font-semibold text-foreground leading-snug">
) : (
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
{cta} →
</span>
)}
</div>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground leading-snug">
{title} {title}
</p> </p>
<p className="text-xs text-muted-foreground leading-snug">{subtitle}</p> <p className="text-sm text-muted-foreground leading-snug">{subtitle}</p>
</div> </div>
</Link> </Link>
); );
} }
// ─── Vine panel ─────────────────────────────────────────────────────── // ─── Copy ──────────────────────────────────────────────────────────────
function VinePanel({ function caption(dash: SadhnaDashboard | null): string {
dash, if (!dash) return "Two simple offerings each day — a lotus for every one.";
error, if (dash.today_completed) return "Today's sādhana is complete — a lotus blooms.";
}: {
dash: SadhnaDashboard | null;
error: boolean;
}) {
return (
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
{error ? (
<div className="absolute inset-0 flex items-center justify-center px-4">
<p className="text-sm text-destructive text-center">
Could not load your sādhana. Please try again.
</p>
</div>
) : dash ? (
<SadhnaVine
days={dash.vine_days}
targetRounds={dash.target_rounds}
growth={dash.growth}
alive={dash.alive}
totalStreak={dash.current_streak}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading your vine…</p>
</div>
)}
</section>
);
}
// ─── 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.";
}
if (dash.chant_done || dash.quote_done) { 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."; return "Chant your rounds and post a quote to complete today.";
}
/** 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;
} }
-207
View File
@@ -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 (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">Not available.</p>
</main>
);
}
return <Preview />;
}
/** 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 (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
Dev
</span>
<h1 className="font-[family-name:var(--font-title)] text-xl font-semibold text-foreground">
Vine preview
</h1>
</div>
{/* Controls */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 rounded-2xl border border-border bg-card p-4">
<Slider
label="Streak (finished folds)"
value={completed}
min={0}
max={30}
onChange={setCompleted}
/>
<Slider
label="Health (growth)"
value={growth}
min={0}
max={100}
onChange={setGrowth}
/>
<Slider
label="Today's rounds"
value={todayRounds}
min={0}
max={target}
disabled={todayComplete}
onChange={setTodayRounds}
/>
<Slider
label="Today's posts"
value={todayPosts}
min={0}
max={3}
disabled={todayComplete}
onChange={setTodayPosts}
/>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={todayComplete}
onChange={(e) => setTodayComplete(e.target.checked)}
className="size-4 accent-primary"
/>
Today already complete
</label>
<div className="flex flex-wrap gap-2">
<Chip onClick={() => setGrowth(Math.min(100, completed * 4))}>
sync health → streak
</Chip>
<Chip onClick={() => setGrowth(0)}>kill</Chip>
</div>
</div>
{/* Live vine — same panel chrome as the real page */}
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
<SadhnaVine
days={days}
targetRounds={target}
growth={growth}
alive={alive}
totalStreak={streak}
/>
</section>
</main>
);
}
function Slider({
label,
value,
min,
max,
onChange,
disabled,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<div className={cn("space-y-1", disabled && "opacity-40")}>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums text-foreground">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
function Chip({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
{children}
</button>
);
}
+3 -5
View File
@@ -66,12 +66,10 @@ export function SearchFiltersMenu({
// active count is one OR the other — never their sum. // active count is one OR the other — never their sum.
const activeCount = isUserChipActive ? 1 : selectedTypes.size; 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(() => { useEffect(() => {
if (!open || isMobile) { if (!open || isMobile) return;
setPos(null);
return;
}
function recompute() { function recompute() {
const btn = triggerRef.current; const btn = triggerRef.current;
if (!btn) return; if (!btn) return;
+7 -5
View File
@@ -283,6 +283,12 @@ function SearchInner() {
// Fetch search results — branches between user search (`@`-prefixed) // Fetch search results — branches between user search (`@`-prefixed)
// and content search. We clear the *other* state slot on every run // and content search. We clear the *other* state slot on every run
// so the UI doesn't show stale results from a previous mode. // 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(() => { useEffect(() => {
const query = debouncedQuery.trim(); const query = debouncedQuery.trim();
@@ -363,6 +369,7 @@ function SearchInner() {
isUserQueryEmpty, isUserQueryEmpty,
userQuery, userQuery,
]); ]);
/* eslint-enable react-hooks/set-state-in-effect */
// Auto-focus the input on mount // Auto-focus the input on mount
useEffect(() => { 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) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
+10
View File
@@ -29,6 +29,8 @@ import { useAuth } from "@/components/auth/auth-provider";
import { AddEmailModal } from "@/components/auth/add-email-modal"; import { AddEmailModal } from "@/components/auth/add-email-modal";
import { ChangePasswordModal } from "@/components/auth/change-password-modal"; import { ChangePasswordModal } from "@/components/auth/change-password-modal";
import { ReadingSettings } from "@/components/reading-settings"; import { ReadingSettings } from "@/components/reading-settings";
import { PushToggle } from "@/components/push/push-toggle";
import { ReminderToggle } from "@/components/sadhna/reminder-toggle";
const BFF_BASE = const BFF_BASE =
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
@@ -56,6 +58,14 @@ export default function SettingsPage() {
<ReadingSettings variant="bare" /> <ReadingSettings variant="bare" />
</Section> </Section>
<Section
title="Notifications"
description="Receive alerts on this device — even when the app is closed."
>
<PushToggle />
<ReminderToggle />
</Section>
<Section title="Authentication"> <Section title="Authentication">
{/* Suspense boundary required because LinkResult reads {/* Suspense boundary required because LinkResult reads
useSearchParams — Next.js bails out of SSR otherwise. */} useSearchParams — Next.js bails out of SSR otherwise. */}
@@ -6,7 +6,7 @@ import {
AudioWaveAnimation, AudioWaveAnimation,
} from "@/components/audio-player"; } from "@/components/audio-player";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Play, Pause } from "lucide-react"; import { Play } from "lucide-react";
type PlayAudioButtonProps = { type PlayAudioButtonProps = {
audioUrl: string; audioUrl: string;
@@ -8,7 +8,7 @@ import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; 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"; import { PlayAudioButton } from "@/app/(app)/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({ export async function generateMetadata({
@@ -422,7 +422,6 @@ function MonthSection({
export function TranscriptionTypeBrowser({ export function TranscriptionTypeBrowser({
transcriptions, transcriptions,
type,
typeLabel, typeLabel,
totalCount, totalCount,
}: { }: {
@@ -42,7 +42,6 @@ const MONTH_NAMES = [
]; ];
function MonthSection({ function MonthSection({
month,
label, label,
items, items,
defaultOpen, defaultOpen,
-1
View File
@@ -29,7 +29,6 @@ export function PostsGrid({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
profile profile
.feed(handle, 30) .feed(handle, 30)
.then((rows) => { .then((rows) => {
@@ -32,7 +32,6 @@ export function ScrollFeed({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
profile profile
.feed(handle, 60) .feed(handle, 60)
.then((rows) => { .then((rows) => {
-1
View File
@@ -18,7 +18,6 @@ export default function MyBookmarksPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLoading(true);
bookmarks bookmarks
.list() .list()
.then((r) => setList(r ?? [])) .then((r) => setList(r ?? []))
-1
View File
@@ -35,7 +35,6 @@ export default function CollectionDetailPage(props: {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
collections collections
.get(id) .get(id)
.then(({ collection, posts }) => { .then(({ collection, posts }) => {
-1
View File
@@ -51,7 +51,6 @@ function MyProfilePageInner() {
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
setLoading(true);
posts posts
.mine() .mine()
.then((rows) => setFeed(rows ?? [])) .then((rows) => setFeed(rows ?? []))
-1
View File
@@ -26,7 +26,6 @@ export default function MyPostsFeedPage() {
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
setLoading(true);
posts posts
.mine() .mine()
.then((rows) => setFeed(rows ?? [])) .then((rows) => setFeed(rows ?? []))
+9 -7
View File
@@ -17,15 +17,17 @@ function VerifyEmailContent() {
const params = useSearchParams(); const params = useSearchParams();
const token = params.get("token") ?? ""; const token = params.get("token") ?? "";
const { refresh } = useAuth(); const { refresh } = useAuth();
const [status, setStatus] = useState<"pending" | "ok" | "error">("pending"); // Seed straight from the token's presence so the missing-token case isn't
const [message, setMessage] = useState<string>(""); // a synchronous setState inside the effect below.
const [status, setStatus] = useState<"pending" | "ok" | "error">(
token ? "pending" : "error",
);
const [message, setMessage] = useState<string>(
token ? "" : "Verification link is missing a token.",
);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) return;
setStatus("error");
setMessage("Verification link is missing a token.");
return;
}
void (async () => { void (async () => {
try { try {
await auth.verifyEmail(token); await auth.verifyEmail(token);
+4
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/** /**
* OG card for a single post. * OG card for a single post.
* *
@@ -107,6 +109,7 @@ export async function GET(
{hasImage ? ( {hasImage ? (
<> <>
<img <img
alt=""
src={bgImageUrl} src={bgImageUrl}
width={1200} width={1200}
height={630} height={630}
@@ -191,6 +194,7 @@ export async function GET(
> >
{post.author_avatar_url ? ( {post.author_avatar_url ? (
<img <img
alt=""
src={post.author_avatar_url} src={post.author_avatar_url}
width={64} width={64}
height={64} height={64}
+3
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/** /**
* OG card for a user profile (`/u/<handle>`). * OG card for a user profile (`/u/<handle>`).
* *
@@ -109,6 +111,7 @@ export async function GET(
> >
{profile.avatar_url ? ( {profile.avatar_url ? (
<img <img
alt=""
src={profile.avatar_url} src={profile.avatar_url}
width={220} width={220}
height={220} height={220}
+3
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
import { ImageResponse } from "next/og"; import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@@ -79,6 +81,7 @@ export async function GET(request: Request) {
> >
{/* Prabhupāda image — already contains quote mark and branding */} {/* Prabhupāda image — already contains quote mark and branding */}
<img <img
alt=""
src={imgSrc} src={imgSrc}
width={420} width={420}
height={590} height={590}
@@ -6,7 +6,6 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
useSyncExternalStore,
} from "react"; } from "react";
import { useAudioPlayer } from "./audio-context"; import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave"; import { AudioWaveAnimation } from "./audio-wave";
@@ -35,41 +34,6 @@ import {
const MINIMIZE_DELAY = 8000; const MINIMIZE_DELAY = 8000;
/**
* `useSyncExternalStore` is the React-recommended way to expose values
* from external systems (window, media-queries, etc.) without a
* mirror-into-state effect. The previous useEffect-+-setState version
* tripped React 19's "setState synchronously inside an effect" warning
* — this variant has no effect at all.
*
* On the server we default to false (desktop). The player only mounts
* when a track is playing, which is always after hydration, so the
* initial-paint value never matters for SSR markup.
*/
const MOBILE_QUERY = "(max-width: 767px)";
function subscribeMobile(callback: () => 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]. * Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
@@ -460,7 +424,6 @@ export function FloatingPlayer() {
const playerRef = useRef<HTMLDivElement>(null); const playerRef = useRef<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false); const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } = const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack); useAutoMinimize(!!currentTrack);
+3
View File
@@ -72,6 +72,9 @@ export function AuthGateProvider({ children }: { children: React.ReactNode }) {
if (user && (open || resolversRef.current.length > 0)) { if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current; const pending = resolversRef.current;
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); setOpen(false);
pending.forEach((r) => r(true)); pending.forEach((r) => r(true));
} }
@@ -27,6 +27,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider"; import { useAuth } from "@/components/auth/auth-provider";
import { notifications } from "@/lib/social"; import { notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna";
type NotificationsContextValue = { type NotificationsContextValue = {
/** Live unread count for the header bell. */ /** 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 // Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body). // synchronous setState in the effect body).
useEffect(() => { useEffect(() => {
if (!user) return; if (!user?.id) return;
refreshUnread(); refreshUnread();
}, [user?.id, 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(() => { 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 key = process.env.NEXT_PUBLIC_SOKETI_KEY;
const host = process.env.NEXT_PUBLIC_SOKETI_HOST; const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
if (!key || !host) return; // realtime not configured 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 port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS = const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${user.id}`; const channelName = `notifications.${userId}`;
let cancelled = false; let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the // Dynamic import keeps pusher-js out of SSR (it touches window) and the
@@ -124,6 +138,16 @@ export function NotificationsProvider({
setLastEventAt(Date.now()); setLastEventAt(Date.now());
toast(messageFor(data ?? {}), { duration: 4000 }); 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 = () => { cleanup = () => {
channel.unbind_all(); channel.unbind_all();
pusher.unsubscribe(channelName); pusher.unsubscribe(channelName);
@@ -135,7 +159,7 @@ export function NotificationsProvider({
cancelled = true; cancelled = true;
cleanup?.(); cleanup?.();
}; };
}, [user?.id]); }, [userId]);
return ( return (
<NotificationsContext.Provider <NotificationsContext.Provider
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* PushPrompt — a gentle on-load invitation to enable browser notifications,
* shown to signed-in users who haven't decided yet.
*
* Browsers require a user gesture to request notification permission, so this
* is a *soft* ask: the real `Notification.requestPermission()` only fires
* when the user taps "Enable". We show it when push is supported, the server
* has push configured, permission is still "default", the user isn't already
* subscribed, and they haven't recently dismissed it (7-day cooldown).
*/
import { useEffect, useState } from "react";
import { Bell, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
enablePush,
isPushEnabled,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
const DISMISS_KEY = "push.prompt.dismissedAt";
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
export function PushPrompt() {
const { user, loading } = useAuth();
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const userId = user?.id;
useEffect(() => {
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 (
<div className="fixed inset-x-0 bottom-20 z-40 flex justify-center px-4 md:bottom-4">
<div className="flex w-full max-w-md items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-lg">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Bell className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
Stay with your sādhana
</p>
<p className="mt-0.5 text-sm text-muted-foreground leading-snug">
Turn on notifications for daily reminders, follow requests, and
more — even when the app is closed.
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={enable}
disabled={busy}
className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
>
{busy ? "Enabling…" : "Enable"}
</button>
<button
type="button"
onClick={dismiss}
className="inline-flex h-9 items-center justify-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Not now
</button>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
+128
View File
@@ -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<State>("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 (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">
Push notifications
</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">
{state === "on" || state === "off" ? (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={state === "on"}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
state === "on" ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
state === "on" ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{state === "loading" ? "…" : "Unavailable"}
</span>
)}
</div>
</li>
</ul>
);
}
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…";
}
}
+85
View File
@@ -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 (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<style>{`
@keyframes sadhna-confetti {
0% { transform: translate(0,-10%) rotateZ(0deg); opacity: 1; }
85% { opacity: 1; }
100% { transform: translate(var(--drift), 115vh) rotateZ(720deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sadhna-confetti-piece { display: none; }
}
`}</style>
{items.map((p, i) => (
<span
key={i}
className="sadhna-confetti-piece absolute top-0 block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * (p.round ? 1 : 0.5),
background: p.color,
borderRadius: p.round ? "9999px" : "1px",
transform: `rotateZ(${p.rot}deg)`,
// CSS var consumed by the keyframe's final transform.
["--drift" as string]: `${p.drift}px`,
animation: `sadhna-confetti ${p.dur}s cubic-bezier(0.2,0.6,0.4,1) ${p.delay}s forwards`,
}}
/>
))}
</div>
);
}
+104
View File
@@ -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<boolean | null>(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 (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">Sādhana reminders</span>
<p className="text-xs text-muted-foreground mt-0.5">
Daily nudges to chant your rounds and share a verse — by email,
push, and in-app.
</p>
</div>
<div className="shrink-0">
{enabled === null ? (
<span className="text-xs text-muted-foreground">…</span>
) : (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
enabled ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
)}
</div>
</li>
</ul>
);
}
+102
View File
@@ -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(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<Confetti />
<motion.div
initial={{ scale: 0.85, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl"
>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
A gift for your sādhana
</p>
<h2 className="mt-1 font-(family-name:--font-title) text-2xl font-semibold text-foreground">
You&apos;ve earned a reward!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
For {grant.milestone} days of unbroken sādhana 🙏
</p>
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/15 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={256}
height={256}
sizes="256px"
className="relative size-full object-contain drop-shadow-xl"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<button
type="button"
onClick={onClose}
className="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Hari bol! 🌸
</button>
</motion.div>
</div>,
document.body,
);
}
+177
View File
@@ -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<SadhnaRewardGrant | null>(null);
// De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>();
const unique: SadhnaRewardGrant[] = [];
for (const g of collected) {
if (!seen.has(g.reward.key)) {
seen.add(g.reward.key);
unique.push(g);
}
}
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2">
<Gift className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">
Sacred Rewards
</h2>
</div>
{unique.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<Gift className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No gifts yet. Keep your sādhana streak — a sacred reward arrives
every 7 days.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4">
{unique.map((g) => (
<button
key={g.reward.key}
type="button"
onClick={() => setDetail(g)}
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<div className="relative aspect-square w-full">
<Image
src={g.reward.image_url}
alt={g.reward.name}
fill
sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1"
/>
</div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)}
</span>
</button>
))}
</div>
)}
{nextRewardDate ? (
<p className="mt-3.5 text-center text-xs text-muted-foreground">
Next gift on{" "}
<span className="font-medium text-foreground">
{fmtLong(nextRewardDate)}
</span>
</p>
) : null}
{detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null}
</section>
);
}
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(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl">
<div className="relative mx-auto mb-4 flex size-40 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/10 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={224}
height={224}
sizes="224px"
className="relative size-full object-contain drop-shadow-lg"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak
</p>
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-2.5 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
>
Close
</button>
</div>
</div>,
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",
});
}
+361
View File
@@ -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<View>("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<Map<string, Map<string, SadhnaDay>>>(new Map());
const reqRef = useRef(0);
const [byDate, setByDate] = useState<Map<string, SadhnaDay>>(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 (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
{/* Streak + view toggle */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex size-10 items-center justify-center rounded-full",
streak > 0 ? "bg-orange-500/15" : "bg-muted",
)}
>
<StreakFlame className="size-6" muted={streak === 0} />
</span>
<div className="leading-tight">
<p className="text-lg font-semibold text-foreground tabular-nums">
{streak} day{streak === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground">
{streak > 0 ? "current streak" : "start your streak today"}
{longest > 0 ? ` · best ${longest}` : ""}
</p>
</div>
</div>
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["week", "month"] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={cn(
"rounded-md px-2.5 py-1 capitalize transition-colors",
view === v
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{v}
</button>
))}
</div>
</div>
{/* Period nav */}
<div className="mb-3 flex items-center justify-between">
<NavButton label="Previous" onClick={() => shift(-1)}>
<ChevronLeft className="size-4" />
</NavButton>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-medium text-foreground",
loading && "opacity-60",
)}
>
{view === "week"
? weekRangeLabel(grid.start)
: monthLabel(anchorMs)}
</span>
{!atCurrent ? (
<button
type="button"
onClick={() => setAnchorMs(todayMs)}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
Today
</button>
) : null}
</div>
<NavButton label="Next" onClick={() => shift(1)} disabled={atCurrent}>
<ChevronRight className="size-4" />
</NavButton>
</div>
{/* Weekday header (month view) */}
{view === "month" ? (
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((w) => (
<span
key={w}
className="text-center text-[11px] leading-none text-muted-foreground"
>
{w}
</span>
))}
</div>
) : null}
<div className={cn("grid grid-cols-7 gap-1", loading && "opacity-70")}>
{cells.map((ms) => {
const date = ymd(ms);
return (
<DayCell
key={date}
date={date}
day={dayFor(date)}
view={view}
isToday={date === todayStr}
isFuture={ms > todayMs}
isGift={!!giftDate && date === giftDate}
inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex}
/>
);
})}
</div>
</section>
);
}
function NavButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-30",
)}
>
{children}
</button>
);
}
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 (
<div
className={cn(
"flex flex-col items-center gap-1.5",
!inMonth && "opacity-35",
)}
>
{isWeek ? (
<span
className={cn(
"text-[11px] leading-none",
isToday ? "font-semibold text-primary" : "text-muted-foreground",
)}
>
{WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]}
</span>
) : null}
<div
className={cn(
"flex items-center justify-center rounded-full font-medium transition-colors",
isWeek ? "size-9 text-sm sm:size-10" : "size-8 text-xs",
completed && "bg-primary text-primary-foreground",
!completed && partial && "bg-primary/15 text-foreground ring-1 ring-primary/40",
!completed && !partial && isToday && "text-foreground ring-2 ring-primary",
!completed && !partial && !isToday && isFuture && "text-muted-foreground/40",
!completed && !partial && !isToday && !isFuture && "bg-muted text-muted-foreground",
)}
>
{dayOfMonth(date)}
</div>
{/* Marker slot — fixed height keeps every column's baseline aligned. */}
<div
className={cn(
"flex items-center justify-center",
isWeek ? "h-5" : "h-3.5",
)}
>
{completed ? (
<Check
className={cn("text-primary", isWeek ? "size-4" : "size-3")}
strokeWidth={3}
/>
) : isGift ? (
<Gift
className={cn("text-amber-500", isWeek ? "size-4" : "size-3")}
/>
) : null}
</div>
</div>
);
}
// ── 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()}`;
}
+82
View File
@@ -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<SadhnaDashboard | null>(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 (
<Link
href="/sadhna-streak"
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<span
className={
"inline-flex size-12 shrink-0 items-center justify-center rounded-full " +
(streak > 0 ? "bg-orange-500/15" : "bg-muted")
}
>
<StreakFlame className="size-7" muted={streak === 0} />
</span>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="truncate text-sm text-muted-foreground leading-snug">
{subtitle}
</p>
</div>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}
+41
View File
@@ -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 (
<svg viewBox="0 0 24 24" className={className} role="img" aria-label="Streak">
<defs>
<linearGradient id="streak-flame-outer" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={outer[0]} />
<stop offset="0.55" stopColor={outer[1]} />
<stop offset="1" stopColor={outer[2]} />
</linearGradient>
<linearGradient id="streak-flame-inner" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={inner[0]} />
<stop offset="1" stopColor={inner[1]} />
</linearGradient>
</defs>
<path
fill="url(#streak-flame-outer)"
d="M13.6 1.8c0.2 2.6 2 4.1 3.4 5.9c1.5 1.9 2.5 4 2.5 6.3c0 4.2-3.4 7.2-7.5 7.2c-4.1 0-7.5-3-7.5-7.2c0-2.1 0.9-3.9 2.3-5.3c0.1 1 0.6 1.9 1.4 2.5c-0.4-2.6 0.5-5.2 2.3-7c1-1 2.3-1.8 3.1-3.1c0.2-0.4 0.4-0.9 0.5-1.3z"
/>
<path
fill="url(#streak-flame-inner)"
d="M12.4 9.6c0.7 1 1.9 1.9 2.6 3c0.6 0.9 0.9 1.9 0.9 2.9c0 2.2-1.8 3.8-3.9 3.8c-2.1 0-3.7-1.6-3.7-3.6c0-1.6 0.9-2.7 1.9-3.7c0.1 0.6 0.4 1.1 0.9 1.5c-0.2-1.4 0.2-2.6 1-3.4c0.2-0.2 0.3-0.3 0.4-0.5z"
/>
</svg>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Globe, Lock } from "lucide-react"; import { Lock } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social"; import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "./post-card-shared"; import { quoteCardBgStyle } from "./post-card-shared";
+2 -1
View File
@@ -155,9 +155,10 @@ export function PageBookmarks() {
// Belt-and-braces: when this component unmounts (route change), tear // Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean. // down every mark so the next page starts clean.
useEffect(() => { useEffect(() => {
const appliedIds = appliedIdsRef.current;
return () => { return () => {
removeBookmarkMarks(); removeBookmarkMarks();
appliedIdsRef.current.clear(); appliedIds.clear();
}; };
}, []); }, []);
@@ -218,8 +218,8 @@ export function UpdateBackgroundModal({
Update Background Update Background
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed"> <p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the Pick an image to set as the card&apos;s background, or remove
current one. the current one.
</p> </p>
</div> </div>
<button <button
+3 -4
View File
@@ -54,10 +54,9 @@ export function TextSelectionToolbar() {
// after saving the user's last allowed bookmark. // after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0); const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => { useEffect(() => {
if (!user) { // `limitReached` below already guards on `!!user`, so a stale count
setBookmarkCount(0); // while signed out is harmless — no need to reset it here.
return; if (!user) return;
}
let cancelled = false; let cancelled = false;
const refetch = () => { const refetch = () => {
bookmarks bookmarks
+1 -1
View File
@@ -5,7 +5,7 @@
* directly. The BFF handles sanitization, pagination, and caching. * directly. The BFF handles sanitization, pagination, and caching.
*/ */
import { bff, bffList, bffOrThrow } from "@/lib/bff"; import { bff, bffList } from "@/lib/bff";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta, getBookMeta,
getIntroSegments, getIntroSegments,
getIntroSegment, getIntroSegment,
getChapter,
getContent, getContent,
getSidebarItems, getSidebarItems,
type BookMeta, type BookMeta,
-1
View File
@@ -9,7 +9,6 @@ import {
getBookMeta, getBookMeta,
getIntroSegments, getIntroSegments,
getIntroSegment, getIntroSegment,
getChapter,
getContent, getContent,
getSidebarItems, getSidebarItems,
type BookMeta, type BookMeta,
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta, getBookMeta,
getIntroSegments, getIntroSegments,
getIntroSegment, getIntroSegment,
getChapter,
getContent, getContent,
getSidebarItems, getSidebarItems,
type BookMeta, type BookMeta,
+137
View File
@@ -0,0 +1,137 @@
/**
* Web Push client helpers.
*
* Flow: read the VAPID public key from the BFF → register the service worker
* → request notification permission → subscribe via PushManager → send the
* subscription to the BFF (which stores it and pushes to it on each
* notification). Disabling unsubscribes locally and on the server.
*
* All BFF calls ride the auth cookie (credentials: "include"), matching the
* other lib clients.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/** True when the browser can do Web Push at all. */
export function pushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/** The browser's current notification permission, or "unsupported". */
export function pushPermission(): NotificationPermission | "unsupported" {
if (!pushSupported()) return "unsupported";
return Notification.permission;
}
type VapidKey = { public_key: string; enabled: boolean };
async function getVapidKey(): Promise<VapidKey> {
const res = await fetch(`${BFF_BASE}/api/v1/push/key`, {
credentials: "include",
});
if (!res.ok) throw new Error("Could not load push configuration");
const body = (await res.json()) as { data?: VapidKey };
return body.data ?? { public_key: "", enabled: false };
}
async function registration(): Promise<ServiceWorkerRegistration | null> {
if (!pushSupported()) return null;
return (await navigator.serviceWorker.getRegistration()) ?? null;
}
/** Whether push is currently enabled (permission granted + a live sub). */
export async function isPushEnabled(): Promise<boolean> {
if (!pushSupported() || Notification.permission !== "granted") return false;
const reg = await registration();
if (!reg) return false;
return !!(await reg.pushManager.getSubscription());
}
/** Whether the server has push configured (VAPID keys present). */
export async function pushConfigured(): Promise<boolean> {
try {
return (await getVapidKey()).enabled;
} catch {
return false;
}
}
/**
* Enable push: requests permission, subscribes, and registers the
* subscription with the BFF. Throws a user-facing message on failure.
*/
export async function enablePush(): Promise<void> {
if (!pushSupported()) {
throw new Error("This browser doesn't support push notifications.");
}
const { public_key, enabled } = await getVapidKey();
if (!enabled || !public_key) {
throw new Error("Push notifications aren't available right now.");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Notification permission was not granted.");
}
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
}
const json = sub.toJSON() as {
endpoint?: string;
keys?: { p256dh?: string; auth?: string };
};
const res = await fetch(`${BFF_BASE}/api/v1/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
});
if (!res.ok) throw new Error("Could not save your subscription.");
}
/** Disable push: unsubscribe locally and remove the subscription server-side. */
export async function disablePush(): Promise<void> {
const reg = await registration();
const sub = reg ? await reg.pushManager.getSubscription() : null;
if (!sub) return;
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {
/* best-effort */
});
}
/** VAPID public keys are base64url; PushManager wants a Uint8Array. */
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
// Back with a concrete ArrayBuffer so the type is Uint8Array<ArrayBuffer>,
// which PushManager.subscribe's applicationServerKey requires.
const out = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
+54
View File
@@ -91,6 +91,29 @@ export type SadhnaDashboard = {
// plus an in-progress fold for an unfinished today. Capped server-side; // plus an in-progress fold for an unfinished today. Capped server-side;
// current_streak holds the true length. // current_streak holds the true length.
vine_days: VineDay[]; vine_days: VineDay[];
// Rewards. `next_reward_date` is the projected UTC date (YYYY-MM-DD) of the
// next gift if the streak continues. `pending_reward` is a freshly earned
// gift to celebrate now (null when there's nothing to show).
next_reward_date: string | null;
pending_reward: SadhnaRewardGrant | null;
};
/** A gift item in the streak-reward catalog. */
export type SadhnaReward = {
id: string;
key: string;
name: string;
meaning: string;
image_url: string;
active: boolean;
sort: number;
};
/** A reward earned at a streak milestone (a multiple of 7 days). */
export type SadhnaRewardGrant = {
milestone: number;
granted_at: string;
reward: SadhnaReward;
}; };
// ─── API ─────────────────────────────────────────────────────────────── // ─── API ───────────────────────────────────────────────────────────────
@@ -99,10 +122,41 @@ export const sadhna = {
/** Today's progress + streak + plant state. */ /** Today's progress + streak + plant state. */
dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"), dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"),
/** Per-day state for an inclusive UTC date range (YYYY-MM-DD), for the
* calendar's week/month views. Range is bounded to a year server-side. */
history: (from: string, to: string) =>
call<SadhnaDay[]>(
`/sadhna/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
),
/** Save today's chanting round count; returns the recomputed dashboard. */ /** Save today's chanting round count; returns the recomputed dashboard. */
saveChant: (roundsCompleted: number) => saveChant: (roundsCompleted: number) =>
call<SadhnaDashboard>("/sadhna/chant", { call<SadhnaDashboard>("/sadhna/chant", {
method: "POST", method: "POST",
body: JSON.stringify({ rounds_completed: roundsCompleted }), body: JSON.stringify({ rounds_completed: roundsCompleted }),
}), }),
/** The user's collected gifts (newest first) + giftable catalog size. */
rewards: () =>
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
/** Acknowledge pending celebrations after showing the confetti. */
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
/** Whether daily sādhana reminders are enabled for the caller. */
reminderPrefs: () => call<{ enabled: boolean }>("/sadhna/reminders"),
/** Toggle daily sādhana reminders. */
setReminderPrefs: (enabled: boolean) =>
call<{ enabled: boolean }>("/sadhna/reminders", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
/** Report the browser's IANA timezone so reminders fire at local time. */
setTimezone: (timezone: string) =>
call<void>("/sadhna/timezone", {
method: "POST",
body: JSON.stringify({ timezone }),
}),
}; };
+17
View File
@@ -0,0 +1,17 @@
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
/**
* useMounted — false during SSR and the first client render, true thereafter.
* Uses useSyncExternalStore (not an effect), so it's safe for gating
* client-only work like createPortal without tripping the
* set-state-in-effect rule. Mirrors the pattern in components/logo.tsx.
*/
export function useMounted(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}
+5
View File
@@ -5,6 +5,11 @@ const nextConfig: NextConfig = {
turbopack: { turbopack: {
root: "/", root: "/",
}, },
images: {
// R2 public bucket — lets next/image optimize/resize the (large) reward
// images instead of serving the multi-MB originals.
remotePatterns: [{ protocol: "https", hostname: "s3.prabhupadasays.com" }],
},
async rewrites() { async rewrites() {
return [ return [
{ {
+222
View File
@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="utf-8"?>
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--noto" preserveAspectRatio="xMidYMid meet">
␍<ellipse transform="rotate(-1.507 101.145 63.828)" cx="101.21" cy="63.87" rx="6.14" ry="6.39" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-1.507 100.561 63.136)" cx="100.63" cy="63.18" rx="5.24" ry="5.45" fill="#d1593f">
␍</ellipse>
␍<path d="M101.06 60.5c.42 1.56-1.14 2.85-2.44 2.71c-1.18-.13-2.28-1.41-1.45-2.96c1.09-2.01 3.49-1.23 3.89.25z" fill="#ff7759">
␍</path>
␍<ellipse transform="rotate(-2.922 110.772 56.615)" cx="110.83" cy="56.63" rx="6.08" ry="6.33" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-2.922 110.074 56.01)" cx="110.13" cy="56.03" rx="5.19" ry="5.41" fill="#d1593f">
␍</ellipse>
␍<path d="M110.11 53.12c.78 1.33-.47 2.97-1.8 3.2c-1.2.2-2.6-.69-2.13-2.35c.61-2.18 3.18-2.12 3.93-.85z" fill="#ff7759">
␍</path>
␍<ellipse cx="118.01" cy="34.86" rx="5.99" ry="6.24" fill="#874d36">
␍</ellipse>
␍<ellipse cx="117.19" cy="34.5" rx="5.11" ry="5.32" fill="#d1593f">
␍</ellipse>
␍<path d="M117.29 32.43c.46 1.55-1.08 2.88-2.38 2.77c-1.18-.1-2.31-1.36-1.51-2.92c1.03-2.05 3.45-1.32 3.89.15z" fill="#ff7759">
␍</path>
␍<ellipse cx="112.86" cy="24.02" rx="5.95" ry="6.2" fill="#874d36">
␍</ellipse>
␍<ellipse cx="112.3" cy="23.48" rx="5.08" ry="5.29" fill="#d1593f">
␍</ellipse>
␍<path d="M112.33 21.78c.56 1.6-.86 3.05-2.15 2.99c-1.17-.06-2.37-1.32-1.69-2.99c.88-2.17 3.31-1.51 3.84 0z" fill="#ff7759">
␍</path>
␍<ellipse cx="103.47" cy="16.53" rx="5.92" ry="6.17" fill="#874d36">
␍</ellipse>
␍<ellipse cx="102.95" cy="16.03" rx="5.05" ry="5.26" fill="#d1593f">
␍</ellipse>
␍<path d="M102.72 13.94c.72 1.5-.57 3.09-1.88 3.19c-1.19.08-2.53-1.01-2.01-2.73c.68-2.23 3.21-1.88 3.89-.46z" fill="#ff7759">
␍</path>
␍<ellipse cx="92.61" cy="12.34" rx="5.89" ry="6.14" fill="#874d36">
␍</ellipse>
␍<ellipse cx="92.12" cy="11.78" rx="5.03" ry="5.24" fill="#d1593f">
␍</ellipse>
␍<path d="M92.08 9.96c.28 1.59-.82 2.77-2.38 2.77c-1.27 0-2.29-1.76-1.33-3.23c1.16-1.79 3.41-1.23 3.71.46z" fill="#ff7759">
␍</path>
␍<ellipse cx="80.99" cy="10.78" rx="5.87" ry="6.12" fill="#874d36">
␍</ellipse>
␍<ellipse cx="80.17" cy="10.17" rx="5.01" ry="5.22" fill="#d1593f">
␍</ellipse>
␍<path d="M80.3 8.44c.47 1.61-1.05 2.98-2.35 2.85c-1.18-.12-2.31-1.43-1.53-3.04c1.02-2.1 3.44-1.33 3.88.19z" fill="#ff7759">
␍</path>
␍<ellipse cx="69.29" cy="10.75" rx="5.86" ry="6.1" fill="#874d36">
␍</ellipse>
␍<ellipse cx="68.85" cy="10.05" rx="5" ry="5.21" fill="#d1593f">
␍</ellipse>
␍<path d="M68.66 8.08c.68 1.46-.64 3.01-1.94 3.09c-1.19.07-2.49-1-1.93-2.66c.72-2.17 3.23-1.82 3.87-.43z" fill="#ff7759">
␍</path>
␍<ellipse cx="58" cy="12.21" rx="5.85" ry="6.09" fill="#874d36">
␍</ellipse>
␍<ellipse cx="57.26" cy="11.65" rx="4.99" ry="5.2" fill="#d1593f">
␍</ellipse>
␍<path d="M57.48 9.69c.46 1.55-1.08 2.88-2.38 2.77c-1.18-.1-2.31-1.36-1.51-2.92c1.04-2.05 3.46-1.32 3.89.15z" fill="#ff7759">
␍</path>
␍<g>
␍<ellipse cx="46.91" cy="14.49" rx="5.85" ry="6.09" fill="#874d36">
␍</ellipse>
␍<ellipse cx="46.48" cy="13.65" rx="4.99" ry="5.2" fill="#d1593f">
␍</ellipse>
␍<path d="M42.54 13.31c-.36-1.75 1.38-2.82 2.6-2.72c1.12.09 2.16 1.55 1.17 3.08c-1.36 2.09-3.46 1.17-3.77-.36z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="36.38" cy="19.27" rx="5.85" ry="6.09" fill="#874d36">
␍</ellipse>
␍<ellipse cx="35.83" cy="18.48" rx="4.99" ry="5.2" fill="#d1593f">
␍</ellipse>
␍<path d="M36.37 16.83c.08 1.67-1.58 2.73-2.87 2.37c-1.12-.31-2.1-1.79-1.03-3.21c1.33-1.77 3.83-.74 3.9.84z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="26.97" cy="26.42" rx="5.86" ry="6.1" fill="#874d36">
␍</ellipse>
␍<ellipse cx="26.52" cy="25.79" rx="5" ry="5.21" fill="#d1593f">
␍</ellipse>
␍<path d="M28.04 23.12c.26 1.66-1.74 2.54-3.08 2.37c-1.24-.15-2.27-1.58-1.05-2.95c1.65-1.86 3.9-.87 4.13.58z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse transform="rotate(-16.544 19.35 35.557)" cx="19.35" cy="35.56" rx="5.87" ry="6.12" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-16.544 18.876 34.764)" cx="18.87" cy="34.76" rx="5.01" ry="5.22" fill="#d1593f">
␍</ellipse>
␍<path d="M20.16 32.37c.08.89-.45 1.77-1.17 2.26c-.7.48-1.62.58-2.42.33a2.047 2.047 0 0 1-.78-3.1c1.07-1.63 4.15-1.93 4.37.51z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse transform="rotate(-16.096 13.825 46.146)" cx="13.82" cy="46.15" rx="5.89" ry="6.14" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-16.096 13.377 45.34)" cx="13.37" cy="45.34" rx="5.03" ry="5.24" fill="#d1593f">
␍</ellipse>
␍<path d="M15.17 43.56c-.2.87-.98 1.54-1.82 1.78c-.81.23-1.72.05-2.4-.44c-.5-.36-1.31-1.98.22-3.19s4.55-.54 4 1.85z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse transform="rotate(-9.97 10.57 57.697)" cx="10.56" cy="57.7" rx="5.92" ry="6.17" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-9.97 10.188 56.916)" cx="10.18" cy="56.92" rx="5.05" ry="5.26" fill="#d1593f">
␍</ellipse>
␍<path d="M12.47 55.13c-.37.81-1.27 1.31-2.14 1.38c-.84.06-1.69-.3-2.26-.92c-.41-.45-.88-2.21.86-3.08s4.56.39 3.54 2.62z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="9.95" cy="70.1" rx="5.95" ry="6.2" fill="#874d36">
␍</ellipse>
␍<ellipse cx="9.88" cy="69.1" rx="5.08" ry="5.29" fill="#d1593f">
␍</ellipse>
␍<path d="M12.2 67.4c-.27.86-1.09 1.46-1.95 1.63c-.83.16-1.72-.1-2.37-.64c-.49-.41-1.13-2.16.53-3.32c1.56-1.11 4.54-.09 3.79 2.33z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="12.39" cy="81.97" rx="5.99" ry="6.24" fill="#874d36">
␍</ellipse>
␍<ellipse cx="12.53" cy="81.01" rx="5.11" ry="5.32" fill="#d1593f">
␍</ellipse>
␍<path d="M14.44 78.62c-.29 1.41-1.4 1.71-2.26 1.91c-2.1.49-3.97-1.31-2.1-3.13c1.13-1.11 4.89-1.36 4.36 1.22z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="18.32" cy="91.72" rx="6.03" ry="6.28" fill="#874d36">
␍</ellipse>
␍<ellipse cx="18.39" cy="90.8" rx="5.15" ry="5.36" fill="#d1593f">
␍</ellipse>
␍<path d="M20.8 88.6c-.08.9-.83 1.72-1.69 1.9c-1.46.31-4.2-.32-2.53-3.15c.96-1.64 4.48-1.67 4.22 1.25z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="28.28" cy="98.84" rx="6.08" ry="6.33" fill="#874d36">
␍</ellipse>
␍<ellipse cx="28.11" cy="97.87" rx="5.19" ry="5.41" fill="#d1593f">
␍</ellipse>
␍<path d="M29.93 95.63c-.13.89-.86 1.58-1.68 1.91c-1.21.49-4.26.03-2.74-3.1c.81-1.67 4.82-1.58 4.42 1.19z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="39.61" cy="101.12" rx="6.14" ry="6.39" fill="#874d36">
␍</ellipse>
␍<ellipse cx="39.5" cy="100.13" rx="5.24" ry="5.45" fill="#d1593f">
␍</ellipse>
␍<path d="M41.47 97.55c-.12.89-.58 1.34-1.66 1.92c-1.64.89-4.33-.63-2.93-3.08c.96-1.67 4.97-1.69 4.59 1.16z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="51.06" cy="98.89" rx="6.19" ry="6.45" fill="#874d36">
␍</ellipse>
␍<ellipse cx="50.76" cy="97.92" rx="5.29" ry="5.51" fill="#d1593f">
␍</ellipse>
␍<path d="M53.13 95.85c-.11.9-.82 1.65-1.65 1.93c-.82.28-1.75.13-2.5-.31c-.64-.37-1.17-1.03-1.18-1.79c0-.41.28-.89.53-.9c1.05-1.95 5.17-1.89 4.8 1.07z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="63.02" cy="95.73" rx="6.26" ry="6.52" fill="#874d36">
␍</ellipse>
␍<ellipse cx="62.6" cy="94.96" rx="5.34" ry="5.56" fill="#d1593f">
␍</ellipse>
␍<path d="M64.26 92.59c-.01.91-.62 1.74-1.41 2.11c-.79.37-1.73.32-2.54-.02c-.7-.3-1.42-1.47-1.18-2.38c.67-2.52 5.15-2.78 5.13.29z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="74.83" cy="95.53" rx="6.33" ry="6.59" fill="#874d36">
␍</ellipse>
␍<ellipse cx="74.45" cy="94.58" rx="5.4" ry="5.63" fill="#d1593f">
␍</ellipse>
␍<path d="M76.5 92.39c-.15.9-.79 1.67-1.62 1.96s-1.83.41-2.81-.28c-.64-.45-1.28-1.58-.75-2.61c1.1-2.18 5.66-1.97 5.18.93z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="87.06" cy="82.42" rx="6.26" ry="6.52" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-71.119 86.409 81.782)" cx="86.41" cy="81.78" rx="5.56" ry="5.34" fill="#d1593f">
␍</ellipse>
␍<path d="M86.71 80.38c-.36 1.77-2.31 2.37-3.36 1.65c-.95-.66-1.29-2.45.14-3.64c1.87-1.55 3.56.32 3.22 1.99z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="117.83" cy="46.79" rx="6.03" ry="6.28" fill="#874d36">
␍</ellipse>
␍<ellipse cx="117.19" cy="46.5" rx="5.15" ry="5.36" fill="#d1593f">
␍</ellipse>
␍<path d="M116.94 44.2c.46 1.55-.91 3.27-2.21 3.16c-1.18-.1-2.31-1.36-1.51-2.92c1.04-2.04 3.28-1.71 3.72-.24z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="86.64" cy="94.67" rx="6.33" ry="6.59" fill="#874d36">
␍</ellipse>
␍<ellipse cx="86.06" cy="93.7" rx="5.4" ry="5.63" fill="#d1593f">
␍</ellipse>
␍<path d="M86.97 90.73c.3.84.01 1.86-.61 2.51c-.62.65-1.53.97-2.4.97c-.78 0-1.85-.62-1.79-2.15c.08-2.46 3.75-4.27 4.8-1.33z" fill="#ff7759">
␍</path>
␍<path d="M82.48 97.94c.47.51 1.75.88 2.39.63c1.27-.5 1.23-1.67 3.18-2.84c1.25-.75 1.78-.58 2.82-1.06c.75-.35.57-1.29.51-1.9c0 0 2.35 3.66-2.22 6.4c-4.53 2.71-6.68-1.23-6.68-1.23z" fill="#874d36">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="92.6" cy="71.76" rx="6.19" ry="6.45" fill="#874d36">
␍</ellipse>
␍<ellipse cx="91.75" cy="71.3" rx="5.29" ry="5.51" fill="#d1593f">
␍</ellipse>
␍<path d="M92.16 68.53c.26 1.59-1.07 2.76-2.38 2.77c-1.29.01-2.51-1.48-1.51-2.92c1.39-2.02 3.65-1.37 3.89.15z" fill="#ff7759">
␍</path>
␍</g>
␍<path d="M91.19 95.91c-2.41-.26-4.56 1.98-4.79 4.29c-.47 4.73 4.78 6.41 4.78 6.41c.45.21 1.61.75 2.92 2.83c4.54 7.24 11.83 12.96 20.33 13.88c2.76.3 9.35-14.38 4.15-16.31c-1.56-.58-3.29-.32-4.93-.11c-3.02.39-6.09.6-9.1.13c-2.23-.35-7.07-2.6-8.16-4.9c0-.01-.27-5.69-5.2-6.22z" fill="#ffca28">
␍</path>
␍<path d="M94.44 103.33c3.66 2.46 8.37 3.27 12.64 2.19c4.04-1.03 7.93-3.68 12.01-2.83" fill="none" stroke="#ffca28" stroke-width="2.523" stroke-linecap="round" stroke-miterlimit="10">
␍</path>
␍<path d="M92.96 106.96c.21 4.56.86 9.29 4.5 12.05c3.35 2.55 7.88 3.34 11.98 1.33" fill="none" stroke="#ffca28" stroke-width="2.523" stroke-linecap="round" stroke-miterlimit="10">
␍</path>
␍<path d="M116.62 107.37c-1.55.87-1.07 5.34-1.15 6.28c-.28 3.33-2.65 2.4-3.64 5.41c-.52 1.57-.29 4.04 1.58 4.24c6.42.69 7.55-6.32 7.71-7.92c.67-6.44-2.09-9.36-4.5-8.01z" fill="#e2a610">
␍</path>
␍<g>
␍<path d="M115.59 114.36c-6.91 0-13.86-2.26-18.62-7.04c-1.94-1.95-.79-2.57-.28-2.11c5.55 4.98 13.68 7.69 21.31 6.98l.19 2.05c-.85.09-1.72.12-2.6.12z" fill="#e2a610">
␍</path>
␍</g>
␍<g>
␍<path d="M115.71 120.34c-5.01-.82-10.92-3.41-13.84-5.81c-1.83-1.5-2.16-2.45-2.16-2.45c4.21 3.42 10.82 5.66 16.17 6.53l-.17 1.73z" fill="#e2a610">
␍</path>
␍</g>
␍<path d="M91.83 106.96s.47-1.22 2.11-2.55c1.95-1.59 2.87-1.63 2.87-1.63c-.29-.45-.4-2.12-1.12-3.46c0 0 .2 2.08-2.58 4.07c-1.82 1.3-5.06 1.28-5.06 1.28c1.37 1.42 3.78 2.29 3.78 2.29z" fill="#e2a610">
␍</path>
␍</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+48
View File
@@ -0,0 +1,48 @@
/* Prabhupāda Says — Web Push service worker.
*
* Handles incoming push messages (shows a notification) and clicks (focuses
* an existing tab or opens the target URL). The push payload is the JSON the
* BFF sends: { title, body, url, icon, tag }.
*/
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) =>
event.waitUntil(self.clients.claim()),
);
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { body: event.data && event.data.text ? event.data.text() : "" };
}
const title = data.title || "Prabhupāda Says";
const options = {
body: data.body || "",
icon: data.icon || "/logo_dark.png",
badge: "/logo_dark.png",
tag: data.tag || undefined,
data: { url: data.url || "/" },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url =
(event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((list) => {
for (const client of list) {
if ("focus" in client) {
if ("navigate" in client) client.navigate(url);
return client.focus();
}
}
if (self.clients.openWindow) return self.clients.openWindow(url);
}),
);
});