wip: sadhna streak
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Check, Minus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
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",
|
||||
];
|
||||
|
||||
const FALLBACK_TARGET = 16;
|
||||
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState(0); // count value at last save
|
||||
|
||||
// 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);
|
||||
setSavedAt(d.rounds_completed || 0);
|
||||
setState("ready");
|
||||
})
|
||||
.catch(() => !cancelled && setState("error"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
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]);
|
||||
|
||||
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 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">
|
||||
<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>
|
||||
|
||||
{/* Mantra */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
||||
The Mahā-mantra
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Big tap-to-count dial */}
|
||||
<div className="mt-8 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"
|
||||
>
|
||||
{/* 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" />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{state === "error" ? (
|
||||
<p className="mt-2 text-center text-xs text-destructive">
|
||||
Could not load today`'s progress.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user