Files
spsays-training/app/(app)/sadhna-streak/page.tsx
T

251 lines
7.8 KiB
TypeScript

"use client";
/**
* /sadhna-streak — the devotee's daily sādhana home.
*
* • a streak calendar with week/month views and back/forward paging; each
* day shows its state, today highlighted, and a lotus blooms beneath
* every day the sādhana was completed
* • the current/best streak counter
* • two daily disciplines as action cards: finish today's chanting, and
* post at least one quote from the Vāṇī — each flips to a "done" state
* once complete
*
* Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
* posted elsewhere (posting is selection-driven while reading) reflects
* here when the devotee returns.
*/
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { BookOpenText, Check } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { sadhna, type SadhnaDashboard, type SadhnaRewardGrant } from "@/lib/sadhna";
import { StreakCalendar } from "@/components/sadhna/streak-calendar";
import { RewardsGallery } from "@/components/sadhna/rewards-gallery";
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
import { cn } from "@/lib/utils";
export default function SadhnaStreakPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [rewards, setRewards] = useState<{
collected: SadhnaRewardGrant[];
total: number;
} | null>(null);
// Guards the celebration from re-opening between dismiss and the dashboard
// refetch (and lets a *new* milestone's gift still celebrate).
const [ackedMilestone, setAckedMilestone] = useState<number | null>(null);
// Auth gate — same pattern as /notifications.
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]);
const load = useCallback(() => {
sadhna
.dashboard()
.then((d) => {
setDash(d);
setState("ready");
})
.catch(() => setState("error"));
}, []);
const loadRewards = useCallback(() => {
sadhna
.rewards()
.then(setRewards)
.catch(() => {
/* gallery is non-critical */
});
}, []);
// Load once we have a user, and again whenever the tab regains focus.
useEffect(() => {
if (!user) return;
load();
loadRewards();
const onFocus = () => {
load();
loadRewards();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [user, load, loadRewards]);
// A freshly earned, not-yet-celebrated gift to show confetti for.
const pending = dash?.pending_reward ?? null;
const showCelebration = !!pending && pending.milestone !== ackedMilestone;
const closeCelebration = useCallback(async () => {
if (pending) setAckedMilestone(pending.milestone);
try {
await sadhna.ackRewards();
} catch {
/* best-effort */
}
load(); // clears pending_reward
loadRewards(); // the new gift now appears in the gallery
}, [pending, load, loadRewards]);
if (loading || !user) {
return (
<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 track your sādhana."}
</p>
</main>
);
}
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5">
<header className="text-center space-y-0.5">
<h1 className="font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold text-foreground">
Daily Sādhana
</h1>
<p className="text-sm text-muted-foreground">{caption(dash)}</p>
</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 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChantAction dash={dash} />
<QuoteAction dash={dash} />
</div>
{/* Collected gifts */}
<RewardsGallery
collected={rewards?.collected ?? []}
nextRewardDate={dash?.next_reward_date ?? null}
/>
{/* Celebration when a gift is freshly earned */}
{showCelebration && pending ? (
<RewardCelebration grant={pending} onClose={closeCelebration} />
) : null}
</main>
);
}
// ─── Action cards ──────────────────────────────────────────────────────
function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.chant_done ?? false;
const target = dash?.target_rounds ?? 16;
const rounds = dash?.rounds_completed ?? 0;
return (
<ActionCard
href="/sadhna-streak/chant"
icon={
<Image
src="/icons/chanting-beads.svg"
alt=""
width={48}
height={48}
className="size-12"
unoptimized
/>
}
title="Finish today's chanting"
subtitle={
done ? `${rounds} rounds chanted` : `${rounds} / ${target} rounds`
}
done={done}
/>
);
}
function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.quote_done ?? false;
return (
<ActionCard
href="/search"
icon={<BookOpenText className="size-8" />}
title="Post a quote"
subtitle={
done
? "Shared today — thank you"
: "Select a passage while reading, then Share to Feed"
}
done={done}
/>
);
}
function ActionCard({
href,
icon,
title,
subtitle,
done,
}: {
href: string;
icon: React.ReactNode;
title: string;
subtitle: string;
done: boolean;
}) {
return (
<Link
href={href}
className={cn(
"group relative flex items-center gap-3.5 rounded-2xl border p-4 transition-colors",
done
? "border-primary/40 bg-primary/5"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
)}
>
<span
className={cn(
"inline-flex size-14 shrink-0 items-center justify-center rounded-xl",
done
? "bg-primary text-primary-foreground"
: "bg-primary/10 text-primary",
)}
>
{done ? <Check className="size-8" strokeWidth={3} /> : icon}
</span>
<div className="min-w-0 space-y-0.5">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="text-sm text-muted-foreground leading-snug">{subtitle}</p>
</div>
</Link>
);
}
// ─── Copy ──────────────────────────────────────────────────────────────
function caption(dash: SadhnaDashboard | null): string {
if (!dash) return "Two simple offerings each day — a lotus for every one.";
if (dash.today_completed) return "Today's sādhana is complete — a lotus blooms.";
if (dash.chant_done || dash.quote_done) {
return "One offering done. Finish the other to complete today.";
}
return "Chant your rounds and post a quote to complete today.";
}