feat: add webpush and sadhna tracker
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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,
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user