feat: add webpush and sadhna tracker
This commit is contained in:
+100
-180
@@ -3,12 +3,13 @@
|
||||
/**
|
||||
* /sadhna-streak — the devotee's daily sādhana home.
|
||||
*
|
||||
* • a thin 7-day streak strip across the top
|
||||
* • 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
|
||||
* • a plant that grows from a seedling toward a tree as the streak holds,
|
||||
* and withers back through its stages (eventually dying) when it breaks
|
||||
*
|
||||
* Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
|
||||
* posted elsewhere (posting is selection-driven while reading) reflects
|
||||
@@ -18,11 +19,14 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { BookOpenText, Check, Flame, BadgePlus } from "lucide-react";
|
||||
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 SadhnaDay } from "@/lib/sadhna";
|
||||
import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
|
||||
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() {
|
||||
@@ -32,6 +36,13 @@ export default function SadhnaStreakPage() {
|
||||
|
||||
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(() => {
|
||||
@@ -56,14 +67,41 @@ export default function SadhnaStreakPage() {
|
||||
.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();
|
||||
const onFocus = () => load();
|
||||
loadRewards();
|
||||
const onFocus = () => {
|
||||
load();
|
||||
loadRewards();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [user, load]);
|
||||
}, [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 (
|
||||
@@ -76,91 +114,42 @@ export default function SadhnaStreakPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
|
||||
{/* Streak strip */}
|
||||
<StreakStrip dash={dash} loading={state === "loading"} />
|
||||
|
||||
<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:var(--font-title)] text-2xl sm:text-3xl font-semibold text-foreground">
|
||||
<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">{vineCaption(dash)}</p>
|
||||
<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>
|
||||
|
||||
{/* The creeper — climbs the pole, one fold per day of the streak */}
|
||||
<VinePanel dash={dash} error={state === "error"} />
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Streak strip ────────────────────────────────────────────────────────
|
||||
|
||||
function StreakStrip({
|
||||
dash,
|
||||
loading,
|
||||
}: {
|
||||
dash: SadhnaDashboard | null;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const week = dash?.week ?? placeholderWeek();
|
||||
const streak = dash?.current_streak ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-full border border-border bg-card/60 px-4 py-2">
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Flame
|
||||
className={cn(
|
||||
"size-4",
|
||||
streak > 0 ? "text-orange-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{streak}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
day{streak === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex items-center gap-2", loading && "opacity-40")}>
|
||||
{week.map((day, i) => (
|
||||
<DayDot key={day.date} day={day} isToday={i === week.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) {
|
||||
const partial = !day.completed && (day.chant_done || day.quote_done);
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"size-5 rounded-full flex items-center justify-center transition-colors",
|
||||
day.completed
|
||||
? "bg-primary text-primary-foreground"
|
||||
: partial
|
||||
? "bg-primary/20 ring-1 ring-primary/50"
|
||||
: "bg-muted",
|
||||
isToday && !day.completed && "ring-2 ring-primary/60",
|
||||
)}
|
||||
>
|
||||
{day.completed ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</div>
|
||||
<span className="text-[10px] leading-none text-muted-foreground">
|
||||
{weekdayLetter(day.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Action cards ──────────────────────────────────────────────────────
|
||||
|
||||
function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
|
||||
@@ -170,15 +159,21 @@ function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
|
||||
return (
|
||||
<ActionCard
|
||||
href="/sadhna-streak/chant"
|
||||
icon={<BadgePlus className="size-5" />}
|
||||
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 ? `${rounds} rounds chanted` : `${rounds} / ${target} rounds`
|
||||
}
|
||||
done={done}
|
||||
cta={rounds > 0 ? "Continue" : "Begin"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -187,16 +182,15 @@ function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
|
||||
const done = dash?.quote_done ?? false;
|
||||
return (
|
||||
<ActionCard
|
||||
href="/books"
|
||||
icon={<BookOpenText className="size-5" />}
|
||||
title="Post a quote from the Vāṇī"
|
||||
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}
|
||||
cta="Find a verse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -207,124 +201,50 @@ function ActionCard({
|
||||
title,
|
||||
subtitle,
|
||||
done,
|
||||
cta,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
done: boolean;
|
||||
cta: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"group relative flex flex-col gap-2 rounded-2xl border p-4 transition-colors",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-9 items-center justify-center rounded-xl",
|
||||
done
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-5" strokeWidth={3} /> : icon}
|
||||
</span>
|
||||
{done ? (
|
||||
<span className="text-xs font-medium text-primary">Done</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
|
||||
{cta} →
|
||||
</span>
|
||||
<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",
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold text-foreground leading-snug">
|
||||
>
|
||||
{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-xs text-muted-foreground leading-snug">{subtitle}</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug">{subtitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Vine panel ───────────────────────────────────────────────────────
|
||||
// ─── Copy ──────────────────────────────────────────────────────────────
|
||||
|
||||
function VinePanel({
|
||||
dash,
|
||||
error,
|
||||
}: {
|
||||
dash: SadhnaDashboard | null;
|
||||
error: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
|
||||
{error ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4">
|
||||
<p className="text-sm text-destructive text-center">
|
||||
Could not load your sādhana. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
) : dash ? (
|
||||
<SadhnaVine
|
||||
days={dash.vine_days}
|
||||
targetRounds={dash.target_rounds}
|
||||
growth={dash.growth}
|
||||
alive={dash.alive}
|
||||
totalStreak={dash.current_streak}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading your vine…</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Copy + helpers ────────────────────────────────────────────────────
|
||||
|
||||
function vineCaption(dash: SadhnaDashboard | null): string {
|
||||
if (!dash) return "Two simple offerings each day. Keep them up and watch it climb.";
|
||||
if (dash.current_streak === 0 && !dash.alive) {
|
||||
return "Begin today, and a new vine starts climbing the pole.";
|
||||
}
|
||||
if (dash.today_completed) {
|
||||
return "Today's fold is complete. The vine climbs higher.";
|
||||
}
|
||||
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's fold.";
|
||||
return "One offering done. Finish the other to complete today.";
|
||||
}
|
||||
return "Each day's sādhana winds another fold around the pole.";
|
||||
}
|
||||
|
||||
/** Single-letter weekday (UTC, matching the backend's day buckets). */
|
||||
function weekdayLetter(date: string): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return ["S", "M", "T", "W", "T", "F", "S"][d.getUTCDay()];
|
||||
}
|
||||
|
||||
/** A blank 7-day strip for the initial render before data arrives. */
|
||||
function placeholderWeek(): SadhnaDay[] {
|
||||
const out: SadhnaDay[] = [];
|
||||
const now = new Date();
|
||||
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(today - i * 86400000);
|
||||
out.push({
|
||||
date: d.toISOString().slice(0, 10),
|
||||
chant_done: false,
|
||||
quote_done: false,
|
||||
completed: false,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
return "Chant your rounds and post a quote to complete today.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user