408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* /sadhna-streak/chant — the japa counter.
|
||
*
|
||
* 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; seeds from today's saved
|
||
* progress so a devotee can chant in sittings.
|
||
*/
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
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 { cn } from "@/lib/utils";
|
||
|
||
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();
|
||
const { requireAuth } = useAuthGate();
|
||
const router = useRouter();
|
||
|
||
const [target, setTarget] = useState(FALLBACK_TARGET);
|
||
const [count, setCount] = useState(0);
|
||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||
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(() => {
|
||
if (loading || user) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
const ok = await requireAuth();
|
||
if (!ok && !cancelled) router.replace("/");
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [loading, user, requireAuth, router]);
|
||
|
||
// Seed from today's saved progress.
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
let cancelled = false;
|
||
sadhna
|
||
.dashboard()
|
||
.then((d) => {
|
||
if (cancelled) return;
|
||
setTarget(d.target_rounds || FALLBACK_TARGET);
|
||
setCount(d.rounds_completed || 0);
|
||
savedRef.current = d.rounds_completed || 0;
|
||
completedRef.current = !!d.chant_done;
|
||
setState("ready");
|
||
})
|
||
.catch(() => !cancelled && setState("error"));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [user]);
|
||
|
||
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 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 (
|
||
<main className="flex-1 flex items-center justify-center px-4 py-12">
|
||
<p className="text-sm text-muted-foreground">
|
||
{loading ? "Loading…" : "Sign in to chant."}
|
||
</p>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
const complete = count >= target;
|
||
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
|
||
|
||
return (
|
||
<main className="flex-1 w-full max-w-lg mx-auto px-4 sm:px-6 py-5 flex flex-col select-none">
|
||
<div className="flex items-center justify-between">
|
||
<Link
|
||
href="/sadhna-streak"
|
||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<ArrowLeft className="size-4" />
|
||
Sādhana
|
||
</Link>
|
||
<button
|
||
onClick={reset}
|
||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||
aria-label="Reset counter"
|
||
>
|
||
<RotateCcw className="size-3.5" />
|
||
Reset
|
||
</button>
|
||
</div>
|
||
|
||
{/* 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="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-7 flex flex-col items-center">
|
||
<button
|
||
onClick={inc}
|
||
disabled={state !== "ready" || complete}
|
||
className={cn(
|
||
"relative grid place-items-center size-56 rounded-full select-none transition-transform active:scale-[0.97]",
|
||
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
complete
|
||
? "bg-primary/10"
|
||
: "bg-card hover:bg-muted/50 cursor-pointer",
|
||
"border border-border",
|
||
)}
|
||
aria-label="Count one round"
|
||
>
|
||
<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"
|
||
r="46"
|
||
fill="none"
|
||
className="text-primary transition-[stroke-dashoffset] duration-500 ease-out"
|
||
stroke="currentColor"
|
||
strokeWidth="4"
|
||
strokeLinecap="round"
|
||
strokeDasharray={2 * Math.PI * 46}
|
||
strokeDashoffset={(1 - pct / 100) * 2 * Math.PI * 46}
|
||
/>
|
||
</svg>
|
||
<div className="text-center">
|
||
<div className="text-6xl font-semibold tabular-nums text-foreground leading-none">
|
||
{count}
|
||
</div>
|
||
<div className="mt-1 text-sm text-muted-foreground">
|
||
of {target} rounds
|
||
</div>
|
||
{complete ? (
|
||
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary">
|
||
<Check className="size-3.5" strokeWidth={3} /> Complete
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||
tap for each round
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={dec}
|
||
disabled={count === 0}
|
||
className="mt-4 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors disabled:opacity-40"
|
||
aria-label="Remove one round"
|
||
>
|
||
<Minus className="size-4" /> One round
|
||
</button>
|
||
</div>
|
||
|
||
{/* Auto-save status */}
|
||
<div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||
{state === "error" ? (
|
||
<span className="text-destructive">
|
||
Could not load today's progress.
|
||
</span>
|
||
) : saveStatus === "saving" ? (
|
||
<>
|
||
<Loader2 className="size-3.5 animate-spin" /> Saving…
|
||
</>
|
||
) : saveStatus === "error" ? (
|
||
<span className="text-destructive">
|
||
Couldn't save — keep chanting, we'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>
|
||
);
|
||
}
|