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
+246 -69
View File
@@ -3,34 +3,98 @@
/**
* /sadhna-streak/chant — the japa counter.
*
* Shows the Hare Kṛṣṇa mahā-mantra with a big tap-to-count round counter
* running toward the devotee's daily target (their profile's
* daily_chanting_rounds, falling back to 16). Save persists the count;
* reaching the target marks today's chanting discipline complete.
* Shows the full opening sequence — Śrīla Prabhupāda Praṇāma (×1),
* Pañca-tattva Praṇāma (×1), then the Hare Kṛṣṇa mahā-mantra (×108) — with a
* big tap-to-count round counter running toward the devotee's daily target
* (profile's daily_chanting_rounds, falling back to 16). Progress saves
* automatically (debounced); reaching the target marks today's chanting
* complete. The mantra script toggles between English (IAST) and Sanskrit
* (Devanāgarī), and Śrīla Prabhupāda's recorded japa can be played alongside.
*
* Auth-gated like the rest of /sadhna-streak. It seeds the counter from
* today's already-saved progress so a devotee can chant in sittings.
* Auth-gated like the rest of /sadhna-streak; seeds from today's saved
* progress so a devotee can chant in sittings.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Check, Minus, RotateCcw } from "lucide-react";
import {
ArrowLeft,
Check,
Loader2,
Minus,
Pause,
Play,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
import { sadhna } from "@/lib/sadhna";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const MANTRA = [
"Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare",
];
type Lang = "en" | "sa";
const FALLBACK_TARGET = 16;
const LANG_KEY = "sadhna.mantra.lang";
// Śrīla Prabhupāda's recorded japa — played through the global player so it
// survives navigation and pauses any other audio.
const JAPA_TRACK: AudioTrack = {
url: "https://s3.prabhupadasays.com/sp-audio/Japa/Srila%20Prabhupada%20-%20Chanting%20Japa.mp3",
title: "Śrīla Prabhupāda — Japa",
subtitle: "Chanting the Hare Kṛṣṇa mahā-mantra",
type: "kirtan",
};
// The opening praṇāma mantras, recited once before the rounds of japa.
const PRELUDE: { title: string; times: string; en: string[]; sa: string[] }[] =
[
{
title: "Śrīla Prabhupāda Praṇāma",
times: "×1",
en: [
"nama oṁ viṣṇu-pādāya kṛṣṇa-preṣṭhāya bhū-tale",
"śrīmate bhaktivedānta-svāmin iti nāmine",
"namas te sārasvate deve gaura-vāṇī-pracāriṇe",
"nirviśeṣa-śūnyavādi-pāścātya-deśa-tāriṇe",
],
sa: [
"नम ॐ विष्णु-पादाय कृष्ण-प्रेष्ठाय भूतले",
"श्रीमते भक्तिवेदांत-स्वामिन् इति नामिने ।",
"नमस्ते सारस्वते देवे गौर-वाणी-प्रचारिणे",
"निर्विशेष-शून्यवादि-पाश्चात्य-देश-तारिणे ॥",
],
},
{
title: "Pañca-tattva Praṇāma",
times: "×1",
en: [
"(jaya) śrī-kṛṣṇa-caitanya prabhu-nityānanda",
"śrī-advaita gadādhara śrīvāsādi-gaura-bhakta-vṛnda",
],
sa: [
"जय श्री कृष्ण-चैतन्य प्रभु-नित्यानंद",
"श्री-अद्वैत गदाधर श्रीवास आदि गौर-भक्त-वृंद ।",
],
},
];
const MAHAMANTRA: Record<Lang, string[]> = {
en: [
"Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare",
],
sa: [
"हरे कृष्ण हरे कृष्ण",
"कृष्ण कृष्ण हरे हरे ।",
"हरे राम हरे राम",
"राम राम हरे हरे ॥",
],
};
export default function ChantPage() {
const { user, loading } = useAuth();
@@ -40,8 +104,24 @@ export default function ChantPage() {
const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState(0); // count value at last save
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
// Lazy initializer reads the saved script preference. Guarded for SSR; the
// mantra UI only renders client-side (after auth resolves), so this can't
// cause a hydration mismatch.
const [lang, setLang] = useState<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.
useEffect(() => {
@@ -66,7 +146,8 @@ export default function ChantPage() {
if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0);
setSavedAt(d.rounds_completed || 0);
savedRef.current = d.rounds_completed || 0;
completedRef.current = !!d.chant_done;
setState("ready");
})
.catch(() => !cancelled && setState("error"));
@@ -75,29 +156,46 @@ export default function ChantPage() {
};
}, [user]);
const inc = useCallback(() => {
setCount((c) => Math.min(target, c + 1));
}, [target]);
const persist = useCallback(async (value: number) => {
setSaveStatus("saving");
try {
const d = await sadhna.saveChant(value);
savedRef.current = value;
setTarget(d.target_rounds || FALLBACK_TARGET);
setSaveStatus("saved");
if (d.chant_done && !completedRef.current) {
completedRef.current = true;
toast.success("Today's chanting is complete 🙏");
}
if (!d.chant_done) completedRef.current = false;
} catch {
setSaveStatus("error");
}
}, []);
// Auto-save: debounce a save whenever the count drifts from what's stored.
useEffect(() => {
if (state !== "ready" || count === savedRef.current) return;
const t = window.setTimeout(() => void persist(count), 700);
return () => window.clearTimeout(t);
}, [count, state, persist]);
const inc = useCallback(
() => setCount((c) => Math.min(target, c + 1)),
[target],
);
const dec = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(0), []);
const save = useCallback(async () => {
setSaving(true);
try {
const d = await sadhna.saveChant(count);
setSavedAt(count);
setTarget(d.target_rounds || FALLBACK_TARGET);
toast.success(
d.chant_done
? "Today's chanting complete 🙏"
: `Saved — ${count} of ${d.target_rounds} rounds`,
);
} catch {
toast.error("Could not save. Please try again.");
} finally {
setSaving(false);
}
}, [count]);
const chooseLang = useCallback((l: Lang) => {
setLang(l);
window.localStorage.setItem(LANG_KEY, l);
}, []);
const toggleAudio = useCallback(() => {
if (japaActive) togglePlayPause();
else play(JAPA_TRACK);
}, [japaActive, togglePlayPause, play]);
if (loading || !user) {
return (
@@ -110,11 +208,10 @@ export default function ChantPage() {
}
const complete = count >= target;
const dirty = count !== savedAt;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return (
<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">
<Link
href="/sadhna-streak"
@@ -133,20 +230,87 @@ export default function ChantPage() {
</button>
</div>
{/* Mantra */}
<div className="mt-6 text-center">
<p className="text-xs uppercase tracking-widest text-muted-foreground">
The Mahā-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>
<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">
{MANTRA.map((line) => (
<p key={line}>{line}</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="flex items-center justify-center gap-2">
<p className="text-[11px] uppercase tracking-widest text-muted-foreground">
The Mahā-mantra
</p>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
×108
</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>
{/* Big tap-to-count dial */}
<div className="mt-8 flex flex-col items-center">
<div className="mt-7 flex flex-col items-center">
<button
onClick={inc}
disabled={state !== "ready" || complete}
@@ -160,9 +324,19 @@ export default function ChantPage() {
)}
aria-label="Count one round"
>
{/* progress ring */}
<svg className="absolute inset-0 size-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="46" fill="none" stroke="currentColor" strokeOpacity="0.08" strokeWidth="4" />
<svg
className="absolute inset-0 size-full -rotate-90"
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="currentColor"
strokeOpacity="0.08"
strokeWidth="4"
/>
<circle
cx="50"
cy="50"
@@ -205,25 +379,28 @@ export default function ChantPage() {
</button>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
One round is 108 names on the mālā.
</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>
{/* Auto-save status */}
<div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
{state === "error" ? (
<p className="mt-2 text-center text-xs text-destructive">
Could not load today`&apos;s progress.
</p>
) : null}
<span className="text-destructive">
Could not load today&apos;s progress.
</span>
) : 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>
</main>
);