feat: add webpush and sadhna tracker

This commit is contained in:
gosangam
2026-06-08 15:48:12 +05:30
parent ecb32d2e85
commit 0334e03c3c
52 changed files with 2118 additions and 545 deletions
+85
View File
@@ -0,0 +1,85 @@
"use client";
/**
* Confetti — a lightweight, dependency-free confetti shower for the reward
* celebration. Spawns a fixed set of colored pieces that fall with a little
* horizontal drift and spin, then fade. Pointer-events-none so it never
* blocks the dialog beneath it. Honors prefers-reduced-motion.
*/
import { useMemo } from "react";
const COLORS = [
"#f59e0b",
"#fb7185",
"#34d399",
"#60a5fa",
"#fbbf24",
"#f472b6",
"#a78bfa",
"#facc15",
];
// Deterministic pseudo-random in [0,1) from a seed. Math.sin is pure, so this
// keeps the confetti layout stable across renders (no Math.random impurity).
function rand(seed: number): number {
const x = Math.sin(seed * 127.1) * 43758.5453;
return x - Math.floor(x);
}
export function Confetti({ pieces = 140 }: { pieces?: number }) {
// Computed once per mount; varying by index keeps it deterministic-ish
// without Date/Math.random concerns (this is a normal component).
const items = useMemo(
() =>
Array.from({ length: pieces }, (_, i) => {
const r = (k: number) => rand(i * 9.17 + k * 3.13);
return {
left: r(1) * 100,
delay: r(2) * 0.5,
dur: 2.4 + r(3) * 1.8,
rot: r(4) * 360,
drift: (r(5) * 2 - 1) * 70,
size: 6 + r(6) * 7,
color: COLORS[i % COLORS.length],
round: r(7) < 0.3,
};
}),
[pieces],
);
return (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<style>{`
@keyframes sadhna-confetti {
0% { transform: translate(0,-10%) rotateZ(0deg); opacity: 1; }
85% { opacity: 1; }
100% { transform: translate(var(--drift), 115vh) rotateZ(720deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sadhna-confetti-piece { display: none; }
}
`}</style>
{items.map((p, i) => (
<span
key={i}
className="sadhna-confetti-piece absolute top-0 block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * (p.round ? 1 : 0.5),
background: p.color,
borderRadius: p.round ? "9999px" : "1px",
transform: `rotateZ(${p.rot}deg)`,
// CSS var consumed by the keyframe's final transform.
["--drift" as string]: `${p.drift}px`,
animation: `sadhna-confetti ${p.dur}s cubic-bezier(0.2,0.6,0.4,1) ${p.delay}s forwards`,
}}
/>
))}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
/**
* ReminderToggle — the settings control for daily sādhana reminders (email +
* push + websocket). Reads the current preference and lets the user opt out.
* Renders as a settings row matching the rest of /settings.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { sadhna } from "@/lib/sadhna";
import {
enablePush,
isPushEnabled,
pushPermission,
pushSupported,
} from "@/lib/push";
export function ReminderToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
sadhna
.reminderPrefs()
.then((r) => {
if (!cancelled) setEnabled(r.enabled);
})
.catch(() => {
if (!cancelled) setEnabled(true); // default-on; toggle still works
});
return () => {
cancelled = true;
};
}, []);
async function toggle() {
if (enabled === null) return;
const next = !enabled;
setBusy(true);
try {
// Turning reminders ON also offers push permission on this device, so
// the reminder can actually reach a closed app. Best-effort: if the
// user declines or push isn't available, reminders still go by email
// and in-app. Done before saving the pref so the permission request
// stays close to the toggle gesture.
if (
next &&
pushSupported() &&
pushPermission() !== "denied" &&
!(await isPushEnabled())
) {
await enablePush().catch(() => {
/* declined / unavailable — reminders still work elsewhere */
});
}
await sadhna.setReminderPrefs(next);
setEnabled(next);
toast.success(next ? "Reminders turned on" : "Reminders turned off");
} catch {
toast.error("Could not update reminders");
} finally {
setBusy(false);
}
}
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">Sādhana reminders</span>
<p className="text-xs text-muted-foreground mt-0.5">
Daily nudges to chant your rounds and share a verse — by email,
push, and in-app.
</p>
</div>
<div className="shrink-0">
{enabled === null ? (
<span className="text-xs text-muted-foreground">…</span>
) : (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
enabled ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
)}
</div>
</li>
</ul>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
/**
* RewardCelebration — a full-screen confetti celebration shown when the
* devotee earns a streak gift. Centered card with the reward image, its
* cosmic name, and symbolic meaning. Dismissing it acknowledges the gift
* (via the caller) so it surfaces exactly once.
*
* Scrolls internally on small screens (max-h + overflow) and reads in both
* themes (card/foreground tokens). Esc or backdrop-click closes.
*/
import { useEffect } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { motion } from "framer-motion";
import { Confetti } from "./confetti";
import type { SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted";
export function RewardCelebration({
grant,
onClose,
}: {
grant: SadhnaRewardGrant;
onClose: () => void;
}) {
const mounted = useMounted();
// Esc to close + lock background scroll while open.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!mounted) return null;
const r = grant.reward;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<Confetti />
<motion.div
initial={{ scale: 0.85, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl"
>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
A gift for your sādhana
</p>
<h2 className="mt-1 font-(family-name:--font-title) text-2xl font-semibold text-foreground">
You&apos;ve earned a reward!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
For {grant.milestone} days of unbroken sādhana 🙏
</p>
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/15 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={256}
height={256}
sizes="256px"
className="relative size-full object-contain drop-shadow-xl"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<button
type="button"
onClick={onClose}
className="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Hari bol! 🌸
</button>
</motion.div>
</div>,
document.body,
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
/**
* RewardsGallery — the collected-gifts section on /sadhna-streak. Shows each
* unique reward the devotee has earned as a tappable tile (image + short
* name); tapping opens a detail dialog with the full name + symbolic
* meaning + which streak day it was earned. Remaining catalog slots show as
* locked tiles to entice completion.
*
* Built to read in both themes (card/foreground tokens), keep names legible
* (line-clamped, never overflowing), and resize the large R2 images via
* next/image.
*/
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { Gift } from "lucide-react";
import type { SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted";
export function RewardsGallery({
collected,
nextRewardDate,
}: {
collected: SadhnaRewardGrant[];
nextRewardDate: string | null;
}) {
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
// De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>();
const unique: SadhnaRewardGrant[] = [];
for (const g of collected) {
if (!seen.has(g.reward.key)) {
seen.add(g.reward.key);
unique.push(g);
}
}
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2">
<Gift className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">
Sacred Rewards
</h2>
</div>
{unique.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<Gift className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No gifts yet. Keep your sādhana streak — a sacred reward arrives
every 7 days.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4">
{unique.map((g) => (
<button
key={g.reward.key}
type="button"
onClick={() => setDetail(g)}
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<div className="relative aspect-square w-full">
<Image
src={g.reward.image_url}
alt={g.reward.name}
fill
sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1"
/>
</div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)}
</span>
</button>
))}
</div>
)}
{nextRewardDate ? (
<p className="mt-3.5 text-center text-xs text-muted-foreground">
Next gift on{" "}
<span className="font-medium text-foreground">
{fmtLong(nextRewardDate)}
</span>
</p>
) : null}
{detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null}
</section>
);
}
function RewardDetail({
grant,
onClose,
}: {
grant: SadhnaRewardGrant;
onClose: () => void;
}) {
const mounted = useMounted();
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!mounted) return null;
const r = grant.reward;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl">
<div className="relative mx-auto mb-4 flex size-40 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/10 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={224}
height={224}
sizes="224px"
className="relative size-full object-contain drop-shadow-lg"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak
</p>
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-2.5 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
>
Close
</button>
</div>
</div>,
document.body,
);
}
/** The evocative term before the em-dash, for compact tile labels. */
function shortName(name: string): string {
const parts = name.split("—");
return (parts[0] ?? name).trim();
}
function fmtLong(date: string): string {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
/**
* StreakCalendar — the sādhana streak calendar with week/month views.
*
* Shows the current/best streak and a grid of days; a lotus blooms beneath
* every day the sādhana was completed (both disciplines). The user can
* switch between a single-week view and a full-month grid, and page
* backward/forward through time. "Next" stops at the current period — there
* is nothing to show in the future.
*
* Streak counters come from the dashboard payload; the grid itself is fed by
* the /sadhna/history range endpoint, fetched per visible period and cached
* so paging back to a period you've seen is instant. Everything is bucketed
* by UTC calendar day to match the backend.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronLeft, ChevronRight, Gift } from "lucide-react";
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
import { StreakFlame } from "@/components/sadhna/streak-flame";
import { cn } from "@/lib/utils";
const DAY = 86400000;
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
type View = "week" | "month";
// ── UTC date helpers (operate on epoch-ms at UTC midnight) ──
function ymd(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
function startOfDayUTC(d: Date): number {
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function startOfWeek(ms: number): number {
return ms - new Date(ms).getUTCDay() * DAY;
}
export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
const todayMs = startOfDayUTC(new Date());
const todayStr = ymd(todayMs);
const [view, setView] = useState<View>("week");
// A day inside the visible period. Starts on today.
const [anchorMs, setAnchorMs] = useState(todayMs);
// Visible grid range + metadata for the chosen view.
const grid = useMemo(() => {
if (view === "week") {
const start = startOfWeek(anchorMs);
return { start, weeks: 1, monthIndex: -1 };
}
const d = new Date(anchorMs);
const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
const monthEnd = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0);
const start = startOfWeek(monthStart);
const end = startOfWeek(monthEnd) + 6 * DAY;
const weeks = Math.round((end - start) / (7 * DAY)) + 1;
return { start, weeks, monthIndex: d.getUTCMonth() };
}, [view, anchorMs]);
const fromStr = ymd(grid.start);
const toStr = ymd(grid.start + (grid.weeks * 7 - 1) * DAY);
// Fetch the visible range, cached by range key; race-safe.
const cacheRef = useRef<Map<string, Map<string, SadhnaDay>>>(new Map());
const reqRef = useRef(0);
const [byDate, setByDate] = useState<Map<string, SadhnaDay>>(new Map());
const [loading, setLoading] = useState(false);
useEffect(() => {
const key = `${fromStr}_${toStr}`;
const cached = cacheRef.current.get(key);
if (cached) {
setByDate(cached);
return;
}
const token = ++reqRef.current;
setLoading(true);
sadhna
.history(fromStr, toStr)
.then((days) => {
const m = new Map((days ?? []).map((d) => [d.date, d] as const));
cacheRef.current.set(key, m);
if (reqRef.current === token) setByDate(m);
})
.catch(() => {
/* keep whatever's shown */
})
.finally(() => {
if (reqRef.current === token) setLoading(false);
});
}, [fromStr, toStr]);
// The dashboard's trailing-7-day `week` is the freshest authoritative
// source for recent days (it reflects a completion the moment dash
// reloads on mount/focus). Overlay it on top of the fetched history so the
// current week's cells — especially today — always agree with the streak
// counter, no matter how the history fetch raced.
const dashByDate = useMemo(
() => new Map((dash?.week ?? []).map((d) => [d.date, d] as const)),
[dash],
);
const dayFor = (date: string) => dashByDate.get(date) ?? byDate.get(date);
// Navigation bounds: never page past the current period.
const currentPeriodStart =
view === "week"
? startOfWeek(todayMs)
: Date.UTC(new Date(todayMs).getUTCFullYear(), new Date(todayMs).getUTCMonth(), 1);
const visiblePeriodStart =
view === "week"
? grid.start
: Date.UTC(new Date(anchorMs).getUTCFullYear(), new Date(anchorMs).getUTCMonth(), 1);
const atCurrent = visiblePeriodStart >= currentPeriodStart;
const shift = (dir: -1 | 1) => {
if (view === "week") {
setAnchorMs((ms) => ms + dir * 7 * DAY);
} else {
const d = new Date(anchorMs);
setAnchorMs(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + dir, 1));
}
};
const streak = dash?.current_streak ?? 0;
const longest = dash?.longest_streak ?? 0;
const giftDate = dash?.next_reward_date ?? null;
const cells = Array.from({ length: grid.weeks * 7 }, (_, i) => grid.start + i * DAY);
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
{/* Streak + view toggle */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex size-10 items-center justify-center rounded-full",
streak > 0 ? "bg-orange-500/15" : "bg-muted",
)}
>
<StreakFlame className="size-6" muted={streak === 0} />
</span>
<div className="leading-tight">
<p className="text-lg font-semibold text-foreground tabular-nums">
{streak} day{streak === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground">
{streak > 0 ? "current streak" : "start your streak today"}
{longest > 0 ? ` · best ${longest}` : ""}
</p>
</div>
</div>
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["week", "month"] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
className={cn(
"rounded-md px-2.5 py-1 capitalize transition-colors",
view === v
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{v}
</button>
))}
</div>
</div>
{/* Period nav */}
<div className="mb-3 flex items-center justify-between">
<NavButton label="Previous" onClick={() => shift(-1)}>
<ChevronLeft className="size-4" />
</NavButton>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-medium text-foreground",
loading && "opacity-60",
)}
>
{view === "week"
? weekRangeLabel(grid.start)
: monthLabel(anchorMs)}
</span>
{!atCurrent ? (
<button
type="button"
onClick={() => setAnchorMs(todayMs)}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
Today
</button>
) : null}
</div>
<NavButton label="Next" onClick={() => shift(1)} disabled={atCurrent}>
<ChevronRight className="size-4" />
</NavButton>
</div>
{/* Weekday header (month view) */}
{view === "month" ? (
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((w) => (
<span
key={w}
className="text-center text-[11px] leading-none text-muted-foreground"
>
{w}
</span>
))}
</div>
) : null}
<div className={cn("grid grid-cols-7 gap-1", loading && "opacity-70")}>
{cells.map((ms) => {
const date = ymd(ms);
return (
<DayCell
key={date}
date={date}
day={dayFor(date)}
view={view}
isToday={date === todayStr}
isFuture={ms > todayMs}
isGift={!!giftDate && date === giftDate}
inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex}
/>
);
})}
</div>
</section>
);
}
function NavButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-30",
)}
>
{children}
</button>
);
}
function DayCell({
date,
day,
view,
isToday,
isFuture,
isGift,
inMonth,
}: {
date: string;
day: SadhnaDay | undefined;
view: View;
isToday: boolean;
isFuture: boolean;
isGift: boolean;
inMonth: boolean;
}) {
const completed = !!day?.completed;
const partial = !!day && !completed && (day.chant_done || day.quote_done);
const isWeek = view === "week";
return (
<div
className={cn(
"flex flex-col items-center gap-1.5",
!inMonth && "opacity-35",
)}
>
{isWeek ? (
<span
className={cn(
"text-[11px] leading-none",
isToday ? "font-semibold text-primary" : "text-muted-foreground",
)}
>
{WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]}
</span>
) : null}
<div
className={cn(
"flex items-center justify-center rounded-full font-medium transition-colors",
isWeek ? "size-9 text-sm sm:size-10" : "size-8 text-xs",
completed && "bg-primary text-primary-foreground",
!completed && partial && "bg-primary/15 text-foreground ring-1 ring-primary/40",
!completed && !partial && isToday && "text-foreground ring-2 ring-primary",
!completed && !partial && !isToday && isFuture && "text-muted-foreground/40",
!completed && !partial && !isToday && !isFuture && "bg-muted text-muted-foreground",
)}
>
{dayOfMonth(date)}
</div>
{/* Marker slot — fixed height keeps every column's baseline aligned. */}
<div
className={cn(
"flex items-center justify-center",
isWeek ? "h-5" : "h-3.5",
)}
>
{completed ? (
<Check
className={cn("text-primary", isWeek ? "size-4" : "size-3")}
strokeWidth={3}
/>
) : isGift ? (
<Gift
className={cn("text-amber-500", isWeek ? "size-4" : "size-3")}
/>
) : null}
</div>
</div>
);
}
// ── labels ──
function dayOfMonth(date: string): number | string {
const d = new Date(`${date}T00:00:00Z`);
return Number.isNaN(d.getTime()) ? "" : d.getUTCDate();
}
function monthLabel(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
timeZone: "UTC",
});
}
function weekRangeLabel(startMs: number): string {
const f = new Date(startMs);
const l = new Date(startMs + 6 * DAY);
const fm = f.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" });
const lm = l.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" });
return fm === lm
? `${fm} ${f.getUTCDate()} – ${l.getUTCDate()}`
: `${fm} ${f.getUTCDate()} – ${lm} ${l.getUTCDate()}`;
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
/**
* SadhnaStreakCTA — a compact streak card for the homepage. Tapping it opens
* /sadhna-streak. For a signed-in devotee it shows their live streak and
* today's status; for visitors it's a gentle invitation to begin. The feed
* will sit alongside this later.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
import { StreakFlame } from "@/components/sadhna/streak-flame";
export function SadhnaStreakCTA() {
const { user, loading } = useAuth();
const authed = !loading && !!user;
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
useEffect(() => {
// Only fetch when signed in; never setState synchronously here. A stale
// dash from a previous session is ignored in render via `data` below.
if (!authed) return;
let cancelled = false;
sadhna
.dashboard()
.then((d) => {
if (!cancelled) setDash(d);
})
.catch(() => {
/* card stays in its invitation state */
});
return () => {
cancelled = true;
};
}, [authed]);
// Ignore any leftover dashboard when not authed (e.g. after sign-out).
const data = authed ? dash : null;
const streak = data?.current_streak ?? 0;
const title = !authed
? "Daily Sādhana"
: streak > 0
? `${streak} day${streak === 1 ? "" : "s"} sadhna streak`
: "Start your streak";
const subtitle = !authed
? "Chant your rounds and share a verse each day"
: data?.today_completed
? "Today's sādhana is complete 🌸"
: data?.chant_done || data?.quote_done
? "One offering done — finish today"
: "Chant and post a quote to complete today";
return (
<Link
href="/sadhna-streak"
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<span
className={
"inline-flex size-12 shrink-0 items-center justify-center rounded-full " +
(streak > 0 ? "bg-orange-500/15" : "bg-muted")
}
>
<StreakFlame className="size-7" muted={streak === 0} />
</span>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="truncate text-sm text-muted-foreground leading-snug">
{subtitle}
</p>
</div>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* StreakFlame — a vivid two-tone flame for the sādhana streak badge: a warm
* orange body with an inner gold flame. Pass `muted` for a zero / broken
* streak (greyed out). Size it with a height/size class.
*/
export function StreakFlame({
className,
muted = false,
}: {
className?: string;
muted?: boolean;
}) {
const outer = muted
? ["#c2c6cd", "#9aa0a8", "#868c95"]
: ["#ff9d2e", "#ff5722", "#e23a0e"];
const inner = muted ? ["#dde0e4", "#bcc0c6"] : ["#ffe08a", "#ffb01f"];
// One flame renders at a time, so fixed gradient ids are fine.
return (
<svg viewBox="0 0 24 24" className={className} role="img" aria-label="Streak">
<defs>
<linearGradient id="streak-flame-outer" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={outer[0]} />
<stop offset="0.55" stopColor={outer[1]} />
<stop offset="1" stopColor={outer[2]} />
</linearGradient>
<linearGradient id="streak-flame-inner" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={inner[0]} />
<stop offset="1" stopColor={inner[1]} />
</linearGradient>
</defs>
<path
fill="url(#streak-flame-outer)"
d="M13.6 1.8c0.2 2.6 2 4.1 3.4 5.9c1.5 1.9 2.5 4 2.5 6.3c0 4.2-3.4 7.2-7.5 7.2c-4.1 0-7.5-3-7.5-7.2c0-2.1 0.9-3.9 2.3-5.3c0.1 1 0.6 1.9 1.4 2.5c-0.4-2.6 0.5-5.2 2.3-7c1-1 2.3-1.8 3.1-3.1c0.2-0.4 0.4-0.9 0.5-1.3z"
/>
<path
fill="url(#streak-flame-inner)"
d="M12.4 9.6c0.7 1 1.9 1.9 2.6 3c0.6 0.9 0.9 1.9 0.9 2.9c0 2.2-1.8 3.8-3.9 3.8c-2.1 0-3.7-1.6-3.7-3.6c0-1.6 0.9-2.7 1.9-3.7c0.1 0.6 0.4 1.1 0.9 1.5c-0.2-1.4 0.2-2.6 1-3.4c0.2-0.2 0.3-0.3 0.4-0.5z"
/>
</svg>
);
}