251 lines
7.8 KiB
TypeScript
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 select-none">
|
|
<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.";
|
|
}
|