Author SHA1 Message Date
gosangam 0334e03c3c feat: add webpush and sadhna tracker 2026-06-08 15:48:12 +05:30
gosangam ecb32d2e85 wip: sadhna streak 2026-06-07 22:32:34 +05:30
gosangam d5fb350652 fix: rybbit blocking using next rewrite 2026-06-07 00:52:49 +05:30
gosangam bcffe9684f feat: add websocket and analytics 2026-06-07 00:23:54 +05:30
gosangam 0db339254d fix: text selection 2026-06-05 14:13:13 +05:30
66 changed files with 3504 additions and 166 deletions
+18
View File
@@ -0,0 +1,18 @@
# BFF API Server (server-side fetches)
BFF_URL=http://localhost:8080
# BFF API Server (browser-side fetches; must be publicly reachable in prod)
NEXT_PUBLIC_BFF_URL=http://localhost:8080
# RTF Viewer (development only)
ALLOW_RTF=true
NEXT_PUBLIC_OAUTH_ENABLED=true
# SOKETI
NEXT_PUBLIC_SOKETI_KEY=spsays
NEXT_PUBLIC_SOKETI_HOST=ws.prabhupadasays.org
NEXT_PUBLIC_SOKETI_PORT=443
NEXT_PUBLIC_SOKETI_FORCE_TLS=true
# Analytics
NEXT_PUBLIC_RYBBIT_HOST=https://analytics.krispire.cloud
+1 -1
View File
@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.env
# vercel
.vercel
-3
View File
@@ -6,7 +6,6 @@ import { notFound } from "next/navigation";
import {
getVaContent,
getVaSidebarItems,
type VaChapter,
type VaSidebarItem,
} from "@/lib/va";
import { getProseChapter } from "@/lib/api";
@@ -61,8 +60,6 @@ export default async function VaChapterPage({
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as VaChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
-2
View File
@@ -7,7 +7,6 @@
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Globe, Lock, FolderPlus } from "lucide-react";
import {
@@ -29,7 +28,6 @@ export default function PublicCollectionPage(props: {
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
+1 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -289,7 +289,6 @@ export function GlossaryClient({
letters,
letterCounts,
}: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState("");
+2
View File
@@ -7,6 +7,7 @@ import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
import { TextHighlighter } from "@/components/text-highlighter";
import { PageBookmarks } from "@/components/social/page-bookmarks";
import { PushPrompt } from "@/components/push/push-prompt";
/**
* Default app layout — the reading-app chrome that wraps the home page, all
@@ -37,6 +38,7 @@ export default function AppLayout({
<TextHighlighter />
</Suspense>
<PageBookmarks />
<PushPrompt />
</>
);
}
-1
View File
@@ -3,7 +3,6 @@
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
+12 -5
View File
@@ -19,11 +19,13 @@ import Link from "next/link";
import { Bell } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { notifications, type AppNotification } from "@/lib/social";
export default function NotificationsPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const { lastEventAt, clearUnread } = useNotifications();
const router = useRouter();
const [items, setItems] = useState<AppNotification[]>([]);
@@ -42,7 +44,9 @@ export default function NotificationsPage() {
};
}, [loading, user, requireAuth, router]);
// Load the feed + mark everything read once we have a user.
// Load the feed + mark everything read once we have a user, and again
// whenever a realtime notification arrives while the page is open
// (lastEventAt bumps). Marking read here also clears the header badge.
useEffect(() => {
if (!user) return;
let cancelled = false;
@@ -57,13 +61,16 @@ export default function NotificationsPage() {
.catch(() => {
if (!cancelled) setState("error");
});
notifications.markAllRead().catch(() => {
/* non-critical */
});
notifications
.markAllRead()
.then(() => clearUnread())
.catch(() => {
/* non-critical */
});
return () => {
cancelled = true;
};
}, [user]);
}, [user, lastEventAt, clearUnread]);
if (loading || !user) {
return (
+5
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Compass, LogIn, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
/**
* / — the Home (Feed) gate.
@@ -79,6 +80,10 @@ export default function HomePage() {
</p>
) : null}
{/* Daily sādhana streak — a simple entry point until the feed lands. */}
<div className="mt-6 w-full max-w-md">
<SadhnaStreakCTA />
</div>
</div>
</main>
);
+407
View File
@@ -0,0 +1,407 @@
"use client";
/**
* /sadhna-streak/chant — the japa counter.
*
* Shows the full opening sequence — Śrīla Prabhupāda Praṇāma (×1),
* Pañca-tattva Praṇāma (×1), then the Hare Kṛṣṇa mahā-mantra (×108) — with a
* big tap-to-count round counter running toward the devotee's daily target
* (profile's daily_chanting_rounds, falling back to 16). Progress saves
* automatically (debounced); reaching the target marks today's chanting
* complete. The mantra script toggles between English (IAST) and Sanskrit
* (Devanāgarī), and Śrīla Prabhupāda's recorded japa can be played alongside.
*
* Auth-gated like the rest of /sadhna-streak; seeds from today's saved
* progress so a devotee can chant in sittings.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
Check,
Loader2,
Minus,
Pause,
Play,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
import { sadhna } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
type Lang = "en" | "sa";
const FALLBACK_TARGET = 16;
const LANG_KEY = "sadhna.mantra.lang";
// Śrīla Prabhupāda's recorded japa — played through the global player so it
// survives navigation and pauses any other audio.
const JAPA_TRACK: AudioTrack = {
url: "https://s3.prabhupadasays.com/sp-audio/Japa/Srila%20Prabhupada%20-%20Chanting%20Japa.mp3",
title: "Śrīla Prabhupāda — Japa",
subtitle: "Chanting the Hare Kṛṣṇa mahā-mantra",
type: "kirtan",
};
// The opening praṇāma mantras, recited once before the rounds of japa.
const PRELUDE: { title: string; times: string; en: string[]; sa: string[] }[] =
[
{
title: "Śrīla Prabhupāda Praṇāma",
times: "×1",
en: [
"nama oṁ viṣṇu-pādāya kṛṣṇa-preṣṭhāya bhū-tale",
"śrīmate bhaktivedānta-svāmin iti nāmine",
"namas te sārasvate deve gaura-vāṇī-pracāriṇe",
"nirviśeṣa-śūnyavādi-pāścātya-deśa-tāriṇe",
],
sa: [
"नम ॐ विष्णु-पादाय कृष्ण-प्रेष्ठाय भूतले",
"श्रीमते भक्तिवेदांत-स्वामिन् इति नामिने ।",
"नमस्ते सारस्वते देवे गौर-वाणी-प्रचारिणे",
"निर्विशेष-शून्यवादि-पाश्चात्य-देश-तारिणे ॥",
],
},
{
title: "Pañca-tattva Praṇāma",
times: "×1",
en: [
"(jaya) śrī-kṛṣṇa-caitanya prabhu-nityānanda",
"śrī-advaita gadādhara śrīvāsādi-gaura-bhakta-vṛnda",
],
sa: [
"जय श्री कृष्ण-चैतन्य प्रभु-नित्यानंद",
"श्री-अद्वैत गदाधर श्रीवास आदि गौर-भक्त-वृंद ।",
],
},
];
const MAHAMANTRA: Record<Lang, string[]> = {
en: [
"Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare",
],
sa: [
"हरे कृष्ण हरे कृष्ण",
"कृष्ण कृष्ण हरे हरे ।",
"हरे राम हरे राम",
"राम राम हरे हरे ॥",
],
};
export default function ChantPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
// Lazy initializer reads the saved script preference. Guarded for SSR; the
// mantra UI only renders client-side (after auth resolves), so this can't
// cause a hydration mismatch.
const [lang, setLang] = useState<Lang>(() => {
if (typeof window === "undefined") return "en";
const stored = window.localStorage.getItem(LANG_KEY);
return stored === "sa" || stored === "en" ? stored : "en";
});
const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer();
const japaActive = currentTrack?.url === JAPA_TRACK.url;
const japaPlaying = japaActive && isPlaying;
const savedRef = useRef(0); // last count persisted to the server
const completedRef = useRef(false); // so we toast completion only once
// Auth gate.
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]);
// Seed from today's saved progress.
useEffect(() => {
if (!user) return;
let cancelled = false;
sadhna
.dashboard()
.then((d) => {
if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0);
savedRef.current = d.rounds_completed || 0;
completedRef.current = !!d.chant_done;
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [user]);
const persist = useCallback(async (value: number) => {
setSaveStatus("saving");
try {
const d = await sadhna.saveChant(value);
savedRef.current = value;
setTarget(d.target_rounds || FALLBACK_TARGET);
setSaveStatus("saved");
if (d.chant_done && !completedRef.current) {
completedRef.current = true;
toast.success("Today's chanting is complete 🙏");
}
if (!d.chant_done) completedRef.current = false;
} catch {
setSaveStatus("error");
}
}, []);
// Auto-save: debounce a save whenever the count drifts from what's stored.
useEffect(() => {
if (state !== "ready" || count === savedRef.current) return;
const t = window.setTimeout(() => void persist(count), 700);
return () => window.clearTimeout(t);
}, [count, state, persist]);
const inc = useCallback(
() => setCount((c) => Math.min(target, c + 1)),
[target],
);
const dec = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(0), []);
const chooseLang = useCallback((l: Lang) => {
setLang(l);
window.localStorage.setItem(LANG_KEY, l);
}, []);
const toggleAudio = useCallback(() => {
if (japaActive) togglePlayPause();
else play(JAPA_TRACK);
}, [japaActive, togglePlayPause, play]);
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 chant."}
</p>
</main>
);
}
const complete = count >= target;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return (
<main className="flex-1 w-full max-w-lg mx-auto px-4 sm:px-6 py-5 flex flex-col">
<div className="flex items-center justify-between">
<Link
href="/sadhna-streak"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Sādhana
</Link>
<button
onClick={reset}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Reset counter"
>
<RotateCcw className="size-3.5" />
Reset
</button>
</div>
{/* Script toggle + japa audio */}
<div className="mt-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["en", "sa"] as Lang[]).map((l) => (
<button
key={l}
type="button"
onClick={() => chooseLang(l)}
className={cn(
"rounded-md px-3 py-1 transition-colors",
lang === l
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{l === "en" ? "English" : "Sanskrit"}
</button>
))}
</div>
<button
type="button"
onClick={toggleAudio}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
aria-pressed={japaPlaying}
>
{japaPlaying ? (
<Pause className="size-3.5 text-primary" />
) : (
<Play className="size-3.5 text-primary" />
)}
{japaPlaying ? "Pause japa" : "Play japa"}
</button>
</div>
{/* Opening praṇāma mantras */}
<section className="mt-5 space-y-3">
<p className="text-center text-[11px] uppercase tracking-widest text-muted-foreground">
Before you begin
</p>
{PRELUDE.map((m) => (
<div
key={m.title}
className="rounded-2xl border border-border bg-card/60 p-4 text-center"
>
<div className="flex items-center justify-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
{m.title}
</h3>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{m.times}
</span>
</div>
<div className="mt-2 space-y-0.5 font-serif text-[15px] sm:text-base leading-relaxed text-foreground/90">
{m[lang].map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
))}
</section>
{/* Mahā-mantra */}
<div className="mt-6 text-center">
<div className="flex items-center justify-center gap-2">
<p className="text-[11px] uppercase tracking-widest text-muted-foreground">
The Mahā-mantra
</p>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
×108
</span>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-1 font-serif text-lg sm:text-xl leading-relaxed text-foreground md:grid-cols-2">
{MAHAMANTRA[lang].map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
{/* Big tap-to-count dial */}
<div className="mt-7 flex flex-col items-center">
<button
onClick={inc}
disabled={state !== "ready" || complete}
className={cn(
"relative grid place-items-center size-56 rounded-full select-none transition-transform active:scale-[0.97]",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
complete
? "bg-primary/10"
: "bg-card hover:bg-muted/50 cursor-pointer",
"border border-border",
)}
aria-label="Count one round"
>
<svg
className="absolute inset-0 size-full -rotate-90"
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="currentColor"
strokeOpacity="0.08"
strokeWidth="4"
/>
<circle
cx="50"
cy="50"
r="46"
fill="none"
className="text-primary transition-[stroke-dashoffset] duration-500 ease-out"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 46}
strokeDashoffset={(1 - pct / 100) * 2 * Math.PI * 46}
/>
</svg>
<div className="text-center">
<div className="text-6xl font-semibold tabular-nums text-foreground leading-none">
{count}
</div>
<div className="mt-1 text-sm text-muted-foreground">
of {target} rounds
</div>
{complete ? (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary">
<Check className="size-3.5" strokeWidth={3} /> Complete
</div>
) : (
<div className="mt-2 text-[11px] text-muted-foreground">
tap for each round
</div>
)}
</div>
</button>
<button
onClick={dec}
disabled={count === 0}
className="mt-4 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors disabled:opacity-40"
aria-label="Remove one round"
>
<Minus className="size-4" /> One round
</button>
</div>
{/* Auto-save status */}
<div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
{state === "error" ? (
<span className="text-destructive">
Could not load today&apos;s progress.
</span>
) : saveStatus === "saving" ? (
<>
<Loader2 className="size-3.5 animate-spin" /> Saving…
</>
) : saveStatus === "error" ? (
<span className="text-destructive">
Couldn&apos;t save — keep chanting, we&apos;ll retry.
</span>
) : saveStatus === "saved" ? (
<>
<Check className="size-3.5 text-primary" strokeWidth={3} /> Progress
saved
</>
) : (
<span>One round is 108 names on the mālā</span>
)}
</div>
</main>
);
}
+250
View File
@@ -0,0 +1,250 @@
"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.";
}
+3 -5
View File
@@ -66,12 +66,10 @@ export function SearchFiltersMenu({
// active count is one OR the other — never their sum.
const activeCount = isUserChipActive ? 1 : selectedTypes.size;
// Desktop popover positioning — below the trigger, left-aligned.
// Desktop popover positioning — below the trigger, left-aligned. (Stale
// pos while closed is harmless — the popover only renders when open.)
useEffect(() => {
if (!open || isMobile) {
setPos(null);
return;
}
if (!open || isMobile) return;
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
+7 -5
View File
@@ -283,6 +283,12 @@ function SearchInner() {
// Fetch search results — branches between user search (`@`-prefixed)
// and content search. We clear the *other* state slot on every run
// so the UI doesn't show stale results from a previous mode.
//
// This effect synchronizes results with the debounced query; the
// synchronous "show loading + clear stale" updates are intentional (they
// prevent flashing previous results), so set-state-in-effect is disabled
// for this block.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const query = debouncedQuery.trim();
@@ -363,6 +369,7 @@ function SearchInner() {
isUserQueryEmpty,
userQuery,
]);
/* eslint-enable react-hooks/set-state-in-effect */
// Auto-focus the input on mount
useEffect(() => {
@@ -404,11 +411,6 @@ function SearchInner() {
}
};
const isTypeActive = (type: string): boolean => {
if (type === "all") return selectedTypes.size === 0;
return selectedTypes.has(type);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: "smooth" });
+10
View File
@@ -29,6 +29,8 @@ import { useAuth } from "@/components/auth/auth-provider";
import { AddEmailModal } from "@/components/auth/add-email-modal";
import { ChangePasswordModal } from "@/components/auth/change-password-modal";
import { ReadingSettings } from "@/components/reading-settings";
import { PushToggle } from "@/components/push/push-toggle";
import { ReminderToggle } from "@/components/sadhna/reminder-toggle";
const BFF_BASE =
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
@@ -56,6 +58,14 @@ export default function SettingsPage() {
<ReadingSettings variant="bare" />
</Section>
<Section
title="Notifications"
description="Receive alerts on this device — even when the app is closed."
>
<PushToggle />
<ReminderToggle />
</Section>
<Section title="Authentication">
{/* Suspense boundary required because LinkResult reads
useSearchParams — Next.js bails out of SSR otherwise. */}
@@ -6,7 +6,7 @@ import {
AudioWaveAnimation,
} from "@/components/audio-player";
import { cn } from "@/lib/utils";
import { Play, Pause } from "lucide-react";
import { Play } from "lucide-react";
type PlayAudioButtonProps = {
audioUrl: string;
@@ -8,7 +8,7 @@ import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Clock, MapPin } from "lucide-react";
import { Clock } from "lucide-react";
import { PlayAudioButton } from "@/app/(app)/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({
@@ -422,7 +422,6 @@ function MonthSection({
export function TranscriptionTypeBrowser({
transcriptions,
type,
typeLabel,
totalCount,
}: {
@@ -42,7 +42,6 @@ const MONTH_NAMES = [
];
function MonthSection({
month,
label,
items,
defaultOpen,
-1
View File
@@ -29,7 +29,6 @@ export function PostsGrid({
useEffect(() => {
let cancelled = false;
setLoading(true);
profile
.feed(handle, 30)
.then((rows) => {
@@ -32,7 +32,6 @@ export function ScrollFeed({
useEffect(() => {
let cancelled = false;
setLoading(true);
profile
.feed(handle, 60)
.then((rows) => {
-1
View File
@@ -18,7 +18,6 @@ export default function MyBookmarksPage() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
bookmarks
.list()
.then((r) => setList(r ?? []))
-1
View File
@@ -35,7 +35,6 @@ export default function CollectionDetailPage(props: {
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
-1
View File
@@ -51,7 +51,6 @@ function MyProfilePageInner() {
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
-1
View File
@@ -26,7 +26,6 @@ export default function MyPostsFeedPage() {
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
+9 -7
View File
@@ -17,15 +17,17 @@ function VerifyEmailContent() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const { refresh } = useAuth();
const [status, setStatus] = useState<"pending" | "ok" | "error">("pending");
const [message, setMessage] = useState<string>("");
// Seed straight from the token's presence so the missing-token case isn't
// a synchronous setState inside the effect below.
const [status, setStatus] = useState<"pending" | "ok" | "error">(
token ? "pending" : "error",
);
const [message, setMessage] = useState<string>(
token ? "" : "Verification link is missing a token.",
);
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Verification link is missing a token.");
return;
}
if (!token) return;
void (async () => {
try {
await auth.verifyEmail(token);
+4
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/**
* OG card for a single post.
*
@@ -107,6 +109,7 @@ export async function GET(
{hasImage ? (
<>
<img
alt=""
src={bgImageUrl}
width={1200}
height={630}
@@ -191,6 +194,7 @@ export async function GET(
>
{post.author_avatar_url ? (
<img
alt=""
src={post.author_avatar_url}
width={64}
height={64}
+3
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/**
* OG card for a user profile (`/u/<handle>`).
*
@@ -109,6 +111,7 @@ export async function GET(
>
{profile.avatar_url ? (
<img
alt=""
src={profile.avatar_url}
width={220}
height={220}
+3
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -79,6 +81,7 @@ export async function GET(request: Request) {
>
{/* Prabhupāda image — already contains quote mark and branding */}
<img
alt=""
src={imgSrc}
width={420}
height={590}
+24 -12
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
import { ogDefault } from "@/lib/og";
import { ThemeProvider } from "@/components/theme-provider";
@@ -6,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate";
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
@@ -73,22 +75,32 @@ export default function RootLayout({
disableTransitionOnChange
>
<AuthProvider>
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<SharePostProvider>
{children}
{/* Toaster and FloatingPlayer are global — toasts and
<NotificationsProvider>
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<SharePostProvider>
{children}
{/* Toaster and FloatingPlayer are global — toasts and
currently-playing audio survive navigation between
route groups. */}
<FloatingPlayer />
<Toaster />
</SharePostProvider>
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>
<FloatingPlayer />
<Toaster />
</SharePostProvider>
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>
</NotificationsProvider>
</AuthProvider>
</ThemeProvider>
{/* Rybbit analytics — loads after hydration (afterInteractive is
next/script's defer-equivalent). Site-wide via the root layout,
loaded once across navigations. */}
<Script
src="/api/script.js"
data-site-id="1"
strategy="afterInteractive"
/>
</body>
</html>
);
@@ -6,7 +6,6 @@ import {
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave";
@@ -35,41 +34,6 @@ import {
const MINIMIZE_DELAY = 8000;
/**
* `useSyncExternalStore` is the React-recommended way to expose values
* from external systems (window, media-queries, etc.) without a
* mirror-into-state effect. The previous useEffect-+-setState version
* tripped React 19's "setState synchronously inside an effect" warning
* — this variant has no effect at all.
*
* On the server we default to false (desktop). The player only mounts
* when a track is playing, which is always after hydration, so the
* initial-paint value never matters for SSR markup.
*/
const MOBILE_QUERY = "(max-width: 767px)";
function subscribeMobile(callback: () => void): () => void {
const mq = window.matchMedia(MOBILE_QUERY);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function getMobileSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getMobileServerSnapshot(): boolean {
return false;
}
function useIsMobile() {
return useSyncExternalStore(
subscribeMobile,
getMobileSnapshot,
getMobileServerSnapshot,
);
}
/**
* Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
@@ -460,7 +424,6 @@ export function FloatingPlayer() {
const playerRef = useRef<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
+3
View File
@@ -72,6 +72,9 @@ export function AuthGateProvider({ children }: { children: React.ReactNode }) {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
// Intentional: react to the user becoming authenticated by closing the
// gate and resolving the queued promises.
// eslint-disable-next-line react-hooks/set-state-in-effect
setOpen(false);
pending.forEach((r) => r(true));
}
+7 -21
View File
@@ -2,33 +2,19 @@
/**
* NotificationBell — header affordance for signed-in users. Links to the
* /notifications page and shows a dot when there are unread items. The
* unread count is fetched once on mount (good enough until websocket/push
* delivery lands); visiting the page marks everything read.
* /notifications page and shows a dot when there are unread items.
*
* The unread count is read from NotificationsProvider, which seeds it from
* the REST endpoint and keeps it live over the Soketi WebSocket. Visiting
* the page marks everything read and clears the badge.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bell } from "lucide-react";
import { notifications } from "@/lib/social";
import { useNotifications } from "@/components/notifications/notifications-provider";
export function NotificationBell() {
const [unread, setUnread] = useState(0);
useEffect(() => {
let cancelled = false;
notifications
.unreadCount()
.then((r) => {
if (!cancelled) setUnread(r?.count ?? 0);
})
.catch(() => {
/* badge is non-critical — ignore */
});
return () => {
cancelled = true;
};
}, []);
const { unread } = useNotifications();
return (
<Link
@@ -0,0 +1,181 @@
"use client";
/**
* NotificationsProvider — holds the live unread-notification count and a
* WebSocket connection to our Soketi (Pusher-compatible) server so the
* bell badge + a toast update in realtime.
*
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and
* binds the `notification` event the BFF triggers on each follow-request /
* follow-accept. The event is a lightweight nudge — we bump the unread
* count locally and toast — while the authoritative list/count still come
* from the REST endpoints. (Public channel for now; once Soketi App auth
* is configured, switch to a `private-` channel + a /broadcasting/auth
* endpoint — only the channel name and a Pusher `authEndpoint` change.)
*
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
* realtime silently disables and the bell falls back to its fetched count.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { notifications } from "@/lib/social";
import { sadhna } from "@/lib/sadhna";
type NotificationsContextValue = {
/** Live unread count for the header bell. */
unread: number;
/** Bumps on each realtime event; pages can depend on it to refetch. */
lastEventAt: number;
/** Re-fetch the authoritative unread count from the BFF. */
refreshUnread: () => void;
/** Zero the badge locally (after the page marks everything read). */
clearUnread: () => void;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(
null,
);
type IncomingEvent = {
kind?: string;
actor_name?: string;
actor_handle?: string;
actor_avatar_url?: string | null;
};
function messageFor(e: IncomingEvent): string {
const who =
e.actor_name || (e.actor_handle ? `@${e.actor_handle}` : "Someone");
switch (e.kind) {
case "follow_request":
return `${who} requested to follow you`;
case "follow_accepted":
return `${who} accepted your follow request`;
default:
return "You have a new notification";
}
}
export function NotificationsProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useAuth();
const [unread, setUnread] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0);
const refreshUnread = useCallback(() => {
notifications
.unreadCount()
.then((r) => setUnread(r?.count ?? 0))
.catch(() => {
/* badge is non-critical */
});
}, []);
const clearUnread = useCallback(() => setUnread(0), []);
// Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body).
useEffect(() => {
if (!user?.id) return;
refreshUnread();
}, [user?.id, refreshUnread]);
// Report the browser's timezone (once per signed-in session) so sādhana
// reminders fire at the user's local clock time. Best-effort.
useEffect(() => {
if (!user?.id) return;
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz) void sadhna.setTimezone(tz).catch(() => {});
} catch {
/* Intl unavailable — leave the server default (UTC) */
}
}, [user?.id]);
// Open the realtime connection while signed in.
const userId = user?.id;
useEffect(() => {
if (!userId) return;
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
if (!key || !host) return; // realtime not configured
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${userId}`;
let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
// initial bundle.
let cleanup: (() => void) | undefined;
void import("pusher-js").then(({ default: Pusher }) => {
if (cancelled) return;
const pusher = new Pusher(key, {
wsHost: host,
wsPort: port,
wssPort: port,
forceTLS,
enabledTransports: ["ws", "wss"],
disableStats: true,
// Ignored when wsHost is set, but required by the type.
cluster: "",
});
const channel = pusher.subscribe(channelName);
channel.bind("notification", (data: IncomingEvent) => {
setUnread((u) => u + 1);
setLastEventAt(Date.now());
toast(messageFor(data ?? {}), { duration: 4000 });
});
// Sādhana reminders are ephemeral nudges — toast only, no unread bump
// (they aren't persisted to the notification center).
channel.bind(
"reminder",
(data: { title?: string; body?: string }) => {
toast(data?.body || data?.title || "A gentle reminder", {
duration: 6000,
});
},
);
cleanup = () => {
channel.unbind_all();
pusher.unsubscribe(channelName);
pusher.disconnect();
};
});
return () => {
cancelled = true;
cleanup?.();
};
}, [userId]);
return (
<NotificationsContext.Provider
value={{ unread, lastEventAt, refreshUnread, clearUnread }}
>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications(): NotificationsContextValue {
const ctx = useContext(NotificationsContext);
if (!ctx) {
throw new Error(
"useNotifications must be used inside <NotificationsProvider>",
);
}
return ctx;
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* PushPrompt — a gentle on-load invitation to enable browser notifications,
* shown to signed-in users who haven't decided yet.
*
* Browsers require a user gesture to request notification permission, so this
* is a *soft* ask: the real `Notification.requestPermission()` only fires
* when the user taps "Enable". We show it when push is supported, the server
* has push configured, permission is still "default", the user isn't already
* subscribed, and they haven't recently dismissed it (7-day cooldown).
*/
import { useEffect, useState } from "react";
import { Bell, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
enablePush,
isPushEnabled,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
const DISMISS_KEY = "push.prompt.dismissedAt";
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
export function PushPrompt() {
const { user, loading } = useAuth();
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const userId = user?.id;
useEffect(() => {
if (loading || !userId) return;
let cancelled = false;
// Async eligibility check — never setState synchronously in the effect.
(async () => {
if (!pushSupported() || pushPermission() !== "default") return;
const dismissed = Number(window.localStorage.getItem(DISMISS_KEY) || 0);
if (dismissed && Date.now() - dismissed < COOLDOWN_MS) return;
if (!(await pushConfigured())) return;
if (await isPushEnabled()) return;
if (!cancelled) setShow(true);
})();
return () => {
cancelled = true;
};
}, [userId, loading]);
function dismiss() {
window.localStorage.setItem(DISMISS_KEY, String(Date.now()));
setShow(false);
}
async function enable() {
setBusy(true);
try {
await enablePush();
toast.success("Notifications enabled 🙏");
setShow(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Couldn't enable notifications.");
// A denial / dismissal shouldn't keep re-prompting.
dismiss();
} finally {
setBusy(false);
}
}
if (!show) return null;
return (
<div className="fixed inset-x-0 bottom-20 z-40 flex justify-center px-4 md:bottom-4">
<div className="flex w-full max-w-md items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-lg">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Bell className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
Stay with your sādhana
</p>
<p className="mt-0.5 text-sm text-muted-foreground leading-snug">
Turn on notifications for daily reminders, follow requests, and
more — even when the app is closed.
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={enable}
disabled={busy}
className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
>
{busy ? "Enabling…" : "Enable"}
</button>
<button
type="button"
onClick={dismiss}
className="inline-flex h-9 items-center justify-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Not now
</button>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
/**
* PushToggle — the settings control for browser push notifications. Reads the
* current state on mount (supported? permission? server-configured?
* subscribed?) and lets the user enable/disable. Renders as a settings row
* matching the rest of /settings.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
disablePush,
enablePush,
isPushEnabled,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
type State =
| "loading"
| "unsupported"
| "unavailable" // server has no VAPID keys
| "denied" // browser permission blocked
| "on"
| "off";
export function PushToggle() {
const [state, setState] = useState<State>("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
if (!pushSupported()) return finish("unsupported");
if (pushPermission() === "denied") return finish("denied");
if (!(await pushConfigured())) return finish("unavailable");
finish((await isPushEnabled()) ? "on" : "off");
function finish(s: State) {
if (!cancelled) setState(s);
}
})();
return () => {
cancelled = true;
};
}, []);
async function toggle() {
setBusy(true);
try {
if (state === "on") {
await disablePush();
setState("off");
toast.success("Push notifications turned off");
} else {
await enablePush();
setState("on");
toast.success("Push notifications turned on");
}
} catch (err) {
const msg =
err instanceof Error ? err.message : "Something went wrong.";
// A fresh block surfaces as a permission error — reflect it.
if (pushPermission() === "denied") setState("denied");
toast.error(msg);
} finally {
setBusy(false);
}
}
const description = describe(state);
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">
Push notifications
</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">
{state === "on" || state === "off" ? (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={state === "on"}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
state === "on" ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
state === "on" ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{state === "loading" ? "…" : "Unavailable"}
</span>
)}
</div>
</li>
</ul>
);
}
function describe(state: State): string {
switch (state) {
case "on":
return "On — you'll be notified about follow requests and more, even when the app is closed.";
case "off":
return "Get notified about follow requests and more, even when the app is closed.";
case "denied":
return "Blocked in your browser. Enable notifications for this site in your browser settings, then reload.";
case "unsupported":
return "This browser doesn't support push notifications.";
case "unavailable":
return "Push notifications aren't available right now.";
default:
return "Checking…";
}
}
+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",
});
}
+682
View File
@@ -0,0 +1,682 @@
"use client";
/**
* SadhnaVine — the sādhana creeper.
*
* A realistic crawler vine twines up a pole, gaining one full fold (a 360°
* wrap) for every day of the current streak — oldest at the base, climbing
* toward the top. An unfinished today is the soft growing tip above the last
* finished fold, tipped with a tendril and reaching a little higher as the
* day's two disciplines are met. Each finished fold carries a chip showing
* how many rounds were chanted and how many quotes were posted that day.
*
* The pole stands its full height from the start — the practice is to climb
* it. When the vine fills the pole a saffron flag crowns it. Lose the streak
* and the living vine you built is gone: you begin the climb again, and a
* faltering practice yellows, then browns, the foliage (the `growth` health
* score drives the palette).
*
* Rendering: the stem is a tapered, tube-shaded ribbon following a helix; its
* far half is drawn behind the pole and near half in front, so it wraps the
* pole convincingly. Heart-shaped leaves (veined, gradient-shaded, on
* petioles) and coiled tendrils dress the stem. Geometry is in real pixels,
* sized to the container via a ResizeObserver, so the day chips (HTML) line
* up with the SVG. A gentle CSS breeze sways leaves, tendrils and the tip;
* the stem stays put so the chips track it.
*
* The drawing math mirrors the standalone SVG study iterated with the
* render-svg tool, kept parametric so it scales with the container and the
* streak length.
*/
import { useEffect, useRef, useState } from "react";
import { CircleDot, MessageSquareQuote, Sparkles } from "lucide-react";
import type { VineDay } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
const TAU = Math.PI * 2;
type Palette = {
stemFrontTop: string;
stemFrontMid: string;
stemFrontBot: string;
stemBack: string;
sheen: string;
leafFront: [string, string, string];
leafBack: [string, string, string];
vein: string;
tendril: string;
droop: number; // extra rotation toward the ground when wilting
dim: boolean;
};
function palette(growth: number, alive: boolean): Palette {
if (!alive || growth <= 0) {
return {
stemFrontTop: "#a98f63",
stemFrontMid: "#8c7149",
stemFrontBot: "#6f5836",
stemBack: "#5f4a2e",
sheen: "#cdb085",
leafFront: ["#bfa46f", "#9c8451", "#6f5a35"],
leafBack: ["#9c8451", "#7d683f", "#54462a"],
vein: "#5f4a2e",
tendril: "#8c7149",
droop: 22,
dim: true,
};
}
if (growth < 30) {
return {
stemFrontTop: "#9fb04a",
stemFrontMid: "#84983a",
stemFrontBot: "#5f7029",
stemBack: "#566b27",
sheen: "#cfe07f",
leafFront: ["#c2d85e", "#9bbf45", "#6f8a30"],
leafBack: ["#9bbf45", "#7ea136", "#5b7327"],
vein: "#566b27",
tendril: "#84983a",
droop: 8,
dim: false,
};
}
if (growth < 60) {
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#26602a",
sheen: "#7bbf5e",
leafFront: ["#74c044", "#45a23b", "#27702c"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#235f27",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#235a27",
sheen: "#8fce6a",
leafFront: ["#7cc848", "#46a23c", "#27732f"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#1f5a23",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
// Heart-shaped crawler leaf outline, tip up at -L, petiole at origin.
function leafOutline(L: number): string {
const w = L * 0.66;
return `M 0 ${-L * 0.2}
C ${w * 0.16} ${-L * 0.1} ${w * 0.36} 0 ${w * 0.52} ${-L * 0.02}
C ${w * 0.8} ${-L * 0.05} ${w} ${-L * 0.22} ${w} ${-L * 0.41}
C ${w} ${-L * 0.61} ${w * 0.64} ${-L * 0.81} ${w * 0.3} ${-L * 0.92}
C ${w * 0.18} ${-L * 0.96} ${w * 0.08} ${-L * 0.98} 0 ${-L}
C ${-w * 0.08} ${-L * 0.98} ${-w * 0.18} ${-L * 0.96} ${-w * 0.3} ${-L * 0.92}
C ${-w * 0.64} ${-L * 0.81} ${-w} ${-L * 0.61} ${-w} ${-L * 0.41}
C ${-w} ${-L * 0.22} ${-w * 0.8} ${-L * 0.05} ${-w * 0.52} ${-L * 0.02}
C ${-w * 0.36} 0 ${-w * 0.16} ${-L * 0.1} 0 ${-L * 0.2} Z`;
}
function leafVeins(L: number): string {
const w = L * 0.66;
const lat = (yf: number, xf: number, s: number) =>
`M0 ${-L * yf} Q ${s * w * xf * 0.55} ${-L * (yf + 0.1)} ${s * w * xf} ${-L * (yf + 0.2)}`;
return [
`M0 ${-L * 0.2} L0 ${-L * 0.95}`,
lat(0.3, 0.74, 1),
lat(0.3, 0.74, -1),
lat(0.48, 0.6, 1),
lat(0.48, 0.6, -1),
lat(0.64, 0.44, 1),
lat(0.64, 0.44, -1),
lat(0.78, 0.28, 1),
lat(0.78, 0.28, -1),
].join(" ");
}
export function SadhnaVine({
days,
targetRounds,
growth,
alive,
totalStreak,
className,
}: {
days: VineDay[];
targetRounds: number;
growth: number;
alive: boolean;
totalStreak: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const cr = entries[0].contentRect;
setSize({ w: cr.width, h: cr.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const W = size.w;
const H = size.h;
const ready = W > 40 && H > 140;
const pal = palette(growth, alive);
// ── Geometry ──
const centerX = W / 2;
const baseY = H - 34;
const topPad = 24;
const availH = Math.max(120, baseY - topPad);
const amp = Math.min(46, Math.max(22, W * 0.17));
const pitch = Math.min(86, Math.max(54, availH / 12));
const poleW = Math.min(14, Math.max(9, amp * 0.3));
const wBase = Math.min(15, Math.max(8, amp * 0.3));
const wTip = 3;
const maxWraps = Math.max(1, Math.floor(availH / pitch));
// Growing tip — today, still unfinished — extends with the day's progress.
const tip =
days.length > 0 && days[days.length - 1].in_progress
? days[days.length - 1]
: null;
const chantPortion =
targetRounds > 0
? Math.min(1, (tip?.rounds ?? 0) / targetRounds)
: (tip?.rounds ?? 0) > 0
? 1
: 0;
const quotePortion = (tip?.posts ?? 0) > 0 ? 1 : 0;
const partialFrac = tip ? 0.18 + 0.78 * ((chantPortion + quotePortion) / 2) : 0;
// Trim oldest folds beyond the pole's capacity.
const completedAll = days.filter((d) => d.completed).length;
const unitsNeeded = completedAll + (tip ? 1 : 0);
const hidden = Math.max(0, unitsNeeded - maxWraps);
const render = hidden > 0 ? days.slice(hidden) : days;
const renderCompleted = render.filter((d) => d.completed).length;
const renderTip =
render.length > 0 && render[render.length - 1].in_progress
? render[render.length - 1]
: null;
const topPhase =
renderCompleted * TAU + (renderTip ? partialFrac * TAU : 0);
// ── Helix helpers (closures over the measured geometry) ──
const ptOf = (ph: number) => ({
x: centerX + amp * Math.sin(ph),
y: baseY - (ph / TAU) * pitch,
depth: Math.cos(ph),
});
const tanOf = (ph: number) => {
const dx = amp * Math.cos(ph);
const dy = -(pitch / TAU);
const l = Math.hypot(dx, dy) || 1;
return { tx: dx / l, ty: dy / l };
};
const widthOf = (t: number) => wBase + (wTip - wBase) * t;
const ribbon = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const left: string[] = [];
const right: string[] = [];
for (let i = 0; i <= steps; i++) {
const ph = p0 + ((p1 - p0) * i) / steps;
const c = ptOf(ph);
const { tx, ty } = tanOf(ph);
const nx = -ty;
const ny = tx;
const w = widthOf(topPhase > 0 ? ph / topPhase : 0) / 2;
left.push(`${(c.x + nx * w).toFixed(1)} ${(c.y + ny * w).toFixed(1)}`);
right.push(`${(c.x - nx * w).toFixed(1)} ${(c.y - ny * w).toFixed(1)}`);
}
return `M${left.join(" L")} L${right.reverse().join(" L")} Z`;
};
const centerPath = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const pts: string[] = [];
for (let i = 0; i <= steps; i++) {
const c = ptOf(p0 + ((p1 - p0) * i) / steps);
pts.push(`${c.x.toFixed(1)} ${c.y.toFixed(1)}`);
}
return `M${pts.join(" L")}`;
};
const frontRuns = () => {
const runs: [number, number][] = [];
let start: number | null = null;
const step = TAU / 80;
for (let ph = 0; ph <= topPhase + 1e-6; ph += step) {
const d = Math.cos(ph);
if (d >= 0 && start === null) start = ph;
if (d < 0 && start !== null) {
runs.push([start, ph]);
start = null;
}
}
if (start !== null) runs.push([start, topPhase]);
return runs;
};
const tendrilPath = (x: number, y: number, dir: number, scale: number) => {
const loops = 2.3;
const R = 11 * scale;
const a0 = dir > 0 ? 0 : Math.PI;
const ex = x + dir * 15 * scale;
const ey = y - 5 * scale;
const steps = 56;
const pts: string[] = [`${x.toFixed(1)} ${y.toFixed(1)}`];
for (let i = 0; i <= steps; i++) {
const a = a0 + (i / steps) * loops * TAU;
const r = R * (1 - 0.62 * (i / steps));
pts.push(
`${(ex + dir * r * Math.cos(a)).toFixed(1)} ${(ey + r * Math.sin(a)).toFixed(1)}`,
);
}
return `M${pts.join(" L")}`;
};
// ── Build foliage ──
type LeafSpec = {
key: string;
x: number;
y: number;
rot: number;
L: number;
back: boolean;
delay: number;
};
const leaves: LeafSpec[] = [];
const petioles: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
let k = 0;
for (let ph = Math.PI / 2; ph < topPhase - 0.25; ph += Math.PI, k++) {
const c = ptOf(ph);
const right = Math.sin(ph) > 0;
const t = ph / topPhase;
const L = (54 - 22 * t) * (k % 3 === 0 ? 0.82 : 1);
const petLen = 11 * (1 - 0.4 * t);
const rot = (right ? 56 : -56) + (k % 2 ? 8 : -6) + (right ? pal.droop : -pal.droop);
const aa = ((rot - 90) * Math.PI) / 180;
const lx = c.x + Math.cos(aa) * petLen;
const ly = c.y + Math.sin(aa) * petLen;
petioles.push({
key: `p${k}`,
d: `M${c.x.toFixed(1)} ${c.y.toFixed(1)} q ${(Math.cos(aa) * petLen * 0.4).toFixed(1)} ${(Math.sin(aa) * petLen * 0.4).toFixed(1)} ${(Math.cos(aa) * petLen).toFixed(1)} ${(Math.sin(aa) * petLen).toFixed(1)}`,
});
leaves.push({
key: `l${k}`,
x: lx,
y: ly,
rot,
L,
back: c.depth < -0.1,
delay: (k % 5) * 0.45,
});
}
}
const backLeaves = leaves.filter((l) => l.back);
const frontLeaves = leaves.filter((l) => !l.back);
// Tendrils: always one at the growing tip, plus a couple along the way.
const tendrils: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
const t = ptOf(topPhase);
tendrils.push({
key: "t-tip",
d: tendrilPath(t.x, t.y, Math.sin(topPhase) >= 0 ? 1 : -1, 1.1),
});
for (let ph = Math.PI * 1.5; ph < topPhase - 0.4; ph += TAU) {
const c = ptOf(ph);
tendrils.push({
key: `t${ph.toFixed(0)}`,
d: tendrilPath(c.x, c.y, -1, 0.85),
});
}
}
// ── Day nodes (chips anchor here, at each fold's front-centre top) ──
type Node = {
key: string;
x: number;
y: number;
day: VineDay;
kind: "fold" | "tip";
sideRight: boolean;
};
const nodes: Node[] = [];
if (ready) {
for (let i = 0; i < renderCompleted; i++) {
const phase = (i + 1) * TAU;
const c = ptOf(phase);
nodes.push({
key: render[i].date,
x: c.x,
y: c.y,
day: render[i],
kind: "fold",
sideRight: i % 2 === 0,
});
}
if (renderTip) {
const c = ptOf(topPhase);
nodes.push({
key: `${renderTip.date}-tip`,
x: c.x,
y: c.y,
day: renderTip,
kind: "tip",
sideRight: renderCompleted % 2 === 0,
});
}
}
const filledToTop = ready && unitsNeeded >= maxWraps && renderCompleted >= 1;
const runs = ready ? frontRuns() : [];
return (
<div ref={ref} className={cn("relative w-full h-full overflow-hidden", className)}>
<style>{`
@keyframes sv-leaf { 0%,100%{transform:rotate(-5deg)} 50%{transform:rotate(5deg)} }
@keyframes sv-flag { 0%,100%{transform:scaleX(1)} 50%{transform:scaleX(0.82)} }
@keyframes sv-tip { 0%,100%{transform:rotate(-6deg)} 50%{transform:rotate(7deg)} }
.sv-leaf { transform-box:fill-box; transform-origin:50% 100%; animation:sv-leaf 4.5s ease-in-out infinite; }
.sv-flag { transform-box:fill-box; transform-origin:0% 50%; animation:sv-flag 2.6s ease-in-out infinite; }
.sv-tip { transform-box:fill-box; transform-origin:50% 100%; animation:sv-tip 3.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce){ .sv-leaf,.sv-flag,.sv-tip{ animation:none } }
`}</style>
{ready ? (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
className="absolute inset-0"
role="img"
aria-label={`Your sādhana vine — ${totalStreak} day streak`}
>
<defs>
<linearGradient id="sv-pole" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#caa066" />
<stop offset="0.5" stopColor="#a87a40" />
<stop offset="1" stopColor="#724d24" />
</linearGradient>
<linearGradient
id="sv-stem"
gradientUnits="userSpaceOnUse"
x1={centerX - amp}
y1="0"
x2={centerX + amp}
y2="0"
>
<stop offset="0" stopColor={pal.stemFrontTop} />
<stop offset="0.5" stopColor={pal.stemFrontMid} />
<stop offset="1" stopColor={pal.stemFrontBot} />
</linearGradient>
<linearGradient id="sv-leaf-front" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafFront[0]} />
<stop offset="0.5" stopColor={pal.leafFront[1]} />
<stop offset="1" stopColor={pal.leafFront[2]} />
</linearGradient>
<linearGradient id="sv-leaf-back" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafBack[0]} />
<stop offset="0.5" stopColor={pal.leafBack[1]} />
<stop offset="1" stopColor={pal.leafBack[2]} />
</linearGradient>
<linearGradient id="sv-soil" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#9c7350" />
<stop offset="1" stopColor="#6b4a31" />
</linearGradient>
</defs>
{/* Back-facing leaves */}
{backLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-back)" />
))}
{/* Vine far half — behind the pole */}
{topPhase > 0 ? <path d={ribbon(0, topPhase)} fill={pal.stemBack} /> : null}
{/* Pole */}
<rect
x={centerX - poleW / 2}
y={topPad}
width={poleW}
height={baseY - topPad}
rx={poleW / 2}
fill="url(#sv-pole)"
/>
<rect
x={centerX - poleW / 2 + 1.5}
y={topPad}
width={1.8}
height={baseY - topPad}
rx={1}
fill="#ecd6ad"
opacity={0.5}
/>
{/* bamboo node rings */}
{Array.from({ length: Math.max(2, Math.floor((baseY - topPad) / 90)) }).map(
(_, i) => {
const y = baseY - (i + 1) * 90;
if (y < topPad + 8) return null;
return (
<rect
key={`ring${i}`}
x={centerX - poleW / 2 - 1}
y={y}
width={poleW + 2}
height={3}
rx={1.5}
fill="#6b4a26"
opacity={0.45}
/>
);
},
)}
{/* Finial + saffron flag */}
<circle cx={centerX} cy={topPad} r={poleW * 0.55} fill="#b8842f" />
<circle cx={centerX - 1} cy={topPad - 1} r={poleW * 0.22} fill="#f0d9a8" opacity={0.6} />
{filledToTop ? (
<g className="sv-flag">
<path
d={`M${centerX + poleW * 0.4} ${topPad - 2}
L${centerX + poleW * 0.4 + 32} ${topPad + 5}
L${centerX + poleW * 0.4} ${topPad + 12} Z`}
fill="#ef8a35"
/>
</g>
) : null}
{/* Vine near half — over the pole, with a tube sheen */}
{runs.map(([a, b], i) => (
<path key={`fr${i}`} d={ribbon(a, b)} fill="url(#sv-stem)" />
))}
{runs.map(([a, b], i) => (
<path
key={`sh${i}`}
d={centerPath(a, b)}
fill="none"
stroke={pal.sheen}
strokeWidth={Math.max(2, wBase * 0.22)}
strokeLinecap="round"
opacity={pal.dim ? 0.25 : 0.45}
/>
))}
{/* Soil */}
<ellipse cx={centerX} cy={baseY + 8} rx={Math.min(70, W * 0.3)} ry={12} fill="url(#sv-soil)" />
<ellipse cx={centerX} cy={baseY + 3} rx={Math.min(58, W * 0.25)} ry={6} fill="#9c7350" />
{/* Tendrils */}
{tendrils.map((td) => (
<path
key={td.key}
className="sv-tip"
d={td.d}
fill="none"
stroke={pal.tendril}
strokeWidth={2.2}
strokeLinecap="round"
/>
))}
{/* Petioles + front leaves */}
{petioles.map((p) => (
<path
key={p.key}
d={p.d}
fill="none"
stroke={pal.stemFrontMid}
strokeWidth={2.4}
strokeLinecap="round"
/>
))}
{frontLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-front)" />
))}
</svg>
) : null}
{/* Day chips (HTML overlay, aligned to the SVG nodes) */}
{ready
? nodes.map((n) => (
<DayChip
key={`c-${n.key}`}
x={n.x}
y={n.y}
W={W}
sideRight={n.sideRight}
day={n.day}
isTip={n.kind === "tip"}
dim={pal.dim}
/>
))
: null}
{/* Hidden-folds hint at the base */}
{ready && hidden > 0 ? (
<div
className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border bg-card/90 px-2.5 py-0.5 text-[10px] text-muted-foreground shadow-sm"
style={{ bottom: 6 }}
>
+{hidden} earlier {hidden === 1 ? "day" : "days"}
</div>
) : null}
</div>
);
}
function Leaf({
lf,
pal,
fill,
}: {
lf: { x: number; y: number; rot: number; L: number; delay: number };
pal: Palette;
fill: string;
}) {
return (
<g
className="sv-leaf"
style={{ animationDelay: `${lf.delay}s` }}
transform={`translate(${lf.x.toFixed(1)} ${lf.y.toFixed(1)}) rotate(${lf.rot.toFixed(1)})`}
>
<path d={leafOutline(lf.L)} fill={fill} stroke={pal.vein} strokeWidth={0.7} />
<path
d={leafVeins(lf.L)}
fill="none"
stroke={pal.vein}
strokeWidth={Math.max(0.5, lf.L * 0.013)}
strokeLinecap="round"
opacity={0.45}
/>
<path
d={`M ${-lf.L * 0.1} ${-lf.L * 0.3} Q ${-lf.L * 0.28} ${-lf.L * 0.55} ${-lf.L * 0.12} ${-lf.L * 0.8}`}
fill="none"
stroke="#d8f2b0"
strokeWidth={lf.L * 0.045}
strokeLinecap="round"
opacity={pal.dim ? 0.12 : 0.25}
/>
</g>
);
}
function DayChip({
x,
y,
W,
sideRight,
day,
isTip,
dim,
}: {
x: number;
y: number;
W: number;
sideRight: boolean;
day: VineDay;
isTip: boolean;
dim: boolean;
}) {
const gap = 18;
const style: React.CSSProperties = sideRight
? { left: x + gap, top: y, transform: "translateY(-50%)" }
: { right: W - x + gap, top: y, transform: "translateY(-50%)" };
const blank = isTip && day.rounds === 0 && day.posts === 0;
return (
<div
className={cn(
"pointer-events-none absolute flex max-w-[42%] flex-col gap-0.5 rounded-xl border bg-card/90 px-2 py-1 shadow-sm backdrop-blur-sm",
isTip ? "border-primary/50 ring-1 ring-primary/20" : "border-border",
dim && !isTip && "opacity-70",
)}
style={style}
>
<span className="text-[10px] font-medium leading-none text-muted-foreground">
{isTip ? "Today" : fmtDate(day.date)}
</span>
{blank ? (
<span className="flex items-center gap-1 text-[11px] font-medium leading-none text-primary">
<Sparkles className="size-3" /> Begin
</span>
) : (
<span className="flex items-center gap-2 text-[11px] font-semibold leading-none text-foreground tabular-nums">
<span className="flex items-center gap-0.5" title="rounds chanted">
<CircleDot className="size-3 text-primary" />
{day.rounds}
</span>
<span className="flex items-center gap-0.5" title="quotes posted">
<MessageSquareQuote className="size-3 text-primary" />
{day.posts}
</span>
</span>
)}
</div>
);
}
function fmtDate(iso: string): string {
const d = new Date(`${iso}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>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@
import Image from "next/image";
import Link from "next/link";
import { Globe, Lock } from "lucide-react";
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "./post-card-shared";
+1 -1
View File
@@ -119,7 +119,7 @@ export function CollectionScrollCard({
const showArrows = slideCount > 1;
return (
<article ref={cardRef} className="space-y-3">
<article ref={cardRef} className="space-y-3 select-none">
<div className="relative max-w-xl mx-auto">
<div
ref={scrollRef}
+4 -4
View File
@@ -208,7 +208,7 @@ export function EditCollectionDrawer({
: "max-w-md sm:max-w-lg"
}
>
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2 select-none">
<DrawerTitle>Edit collection</DrawerTitle>
<DrawerClose asChild>
<button
@@ -222,7 +222,7 @@ export function EditCollectionDrawer({
</DrawerClose>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5 select-none">
{/* Cover */}
<section className="flex items-start gap-4">
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
@@ -283,7 +283,7 @@ export function EditCollectionDrawer({
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 select-text"
/>
</section>
@@ -297,7 +297,7 @@ export function EditCollectionDrawer({
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="Optional"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none select-text"
/>
</section>
+3 -2
View File
@@ -155,9 +155,10 @@ export function PageBookmarks() {
// Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean.
useEffect(() => {
const appliedIds = appliedIdsRef.current;
return () => {
removeBookmarkMarks();
appliedIdsRef.current.clear();
appliedIds.clear();
};
}, []);
@@ -195,7 +196,7 @@ export function PageBookmarks() {
left: m.left,
zIndex: 5,
}}
className="pointer-events-none"
className="pointer-events-none select-none"
>
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
<Bookmark className="size-3.5 text-white" />
+31 -28
View File
@@ -19,7 +19,7 @@
* elsewhere in the post action UI.
*/
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Ban, Flag, MoreVertical, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -90,37 +90,35 @@ export function PostCardMenu({
: post.author_name || "this user";
// Desktop popover positioning: anchor below the trigger, right-aligned.
// Recompute every time we open + on resize so a viewport change between
// renders doesn't strand the popover at a stale coordinate.
// We measure in event handlers (the open click + resize/scroll), never
// synchronously inside an effect body, so opening can't trigger a
// cascading render. The effect below only wires the resize/scroll
// listeners while the popover is open.
const recompute = useCallback(() => {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const popoverW = 240;
const pad = 8;
// Right-align the popover to the trigger's right edge — feels natural
// for an overflow menu sitting in the top-right corner.
let left = rect.right - popoverW;
if (left < pad) left = pad;
if (left + popoverW > window.innerWidth - pad) {
left = window.innerWidth - popoverW - pad;
}
setPopPos({ top: rect.bottom + 4, left });
}, []);
useEffect(() => {
if (!open || isMobile) {
setPopPos(null);
return;
}
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const popoverW = 240;
const pad = 8;
// Right-align the popover to the trigger's right edge — feels
// natural for an overflow menu sitting in the top-right corner.
let left = rect.right - popoverW;
if (left < pad) left = pad;
if (left + popoverW > window.innerWidth - pad) {
left = window.innerWidth - popoverW - pad;
}
const top = rect.bottom + 4;
setPopPos({ top, left });
}
recompute();
if (!open || isMobile) return;
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
return () => {
window.removeEventListener("resize", recompute);
window.removeEventListener("scroll", recompute, true);
};
}, [open, isMobile]);
}, [open, isMobile, recompute]);
// Click-outside + Escape to close (desktop only; the drawer's own
// overlay handles dismissal on mobile).
@@ -202,7 +200,12 @@ export function PostCardMenu({
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
onClick={() => {
// Measure synchronously in the click (an event handler — safe) so
// the popover renders at the right spot on first paint.
if (!open && !isMobile) recompute();
setOpen((v) => !v);
}}
aria-label="Post options"
aria-haspopup="menu"
aria-expanded={open}
@@ -219,7 +222,7 @@ export function PostCardMenu({
// Menu surface: bottom drawer on mobile, floating popover on desktop.
const menu = isMobile ? (
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
<DrawerContent className="bg-card">
<DrawerContent className="bg-card select-none">
<DrawerHeader className="text-left border-b border-border pb-3">
<DrawerTitle>Post options</DrawerTitle>
</DrawerHeader>
@@ -242,7 +245,7 @@ export function PostCardMenu({
zIndex: 60,
}}
className={cn(
"w-60 rounded-lg border border-border bg-card shadow-lg py-1",
"w-60 rounded-lg border border-border bg-card shadow-lg py-1 select-none",
"animate-in fade-in-0 zoom-in-95",
)}
>
+1 -1
View File
@@ -45,7 +45,7 @@ export function PostCard({
const authorHandle = post.author_handle ?? null;
return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5 select-none">
{/* Reshare attribution — only on rows that are themselves
reshares. The link goes to the original author's profile;
their handle is rendered inline so the source is obvious.
+1 -1
View File
@@ -73,7 +73,7 @@ export function PostScrollCard({
return (
<article
id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24 select-none"
>
{/* Reshare attribution — only on rows that are themselves
reshares. Tappable link → original author's profile. The card
+3 -3
View File
@@ -125,7 +125,7 @@ export function ReportDialog({
rows={3}
maxLength={2000}
placeholder="Add any context that will help us review this"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 select-text"
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
@@ -144,7 +144,7 @@ export function ReportDialog({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && !working && onClose()}>
<DrawerContent className="bg-card">
<DrawerContent className="bg-card select-none">
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
@@ -168,7 +168,7 @@ export function ReportDialog({
aria-modal="true"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden select-none">
<div className="flex items-start gap-3 px-5 pt-5 pb-1">
<div className="flex-1 min-w-0">
<h2 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground">
+1 -1
View File
@@ -445,7 +445,7 @@ export function SharePostSheet({
onOpenChange={(o) => !o && onClose()}
direction={direction}
>
<DrawerContent direction={direction} className="bg-card">
<DrawerContent direction={direction} className="bg-card select-none">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle>Share post</DrawerTitle>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
@@ -202,7 +202,7 @@ export function UpdateBackgroundModal({
aria-labelledby="update-bg-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden select-none">
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
<span
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
@@ -218,8 +218,8 @@ export function UpdateBackgroundModal({
Update Background
</h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the
current one.
Pick an image to set as the card&apos;s background, or remove
the current one.
</p>
</div>
<button
+3 -4
View File
@@ -54,10 +54,9 @@ export function TextSelectionToolbar() {
// after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => {
if (!user) {
setBookmarkCount(0);
return;
}
// `limitReached` below already guards on `!!user`, so a stale count
// while signed out is harmless — no need to reset it here.
if (!user) return;
let cancelled = false;
const refetch = () => {
bookmarks
+1 -1
View File
@@ -5,7 +5,7 @@
* directly. The BFF handles sanitization, pagination, and caching.
*/
import { bff, bffList, bffOrThrow } from "@/lib/bff";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
-1
View File
@@ -9,7 +9,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
+137
View File
@@ -0,0 +1,137 @@
/**
* Web Push client helpers.
*
* Flow: read the VAPID public key from the BFF → register the service worker
* → request notification permission → subscribe via PushManager → send the
* subscription to the BFF (which stores it and pushes to it on each
* notification). Disabling unsubscribes locally and on the server.
*
* All BFF calls ride the auth cookie (credentials: "include"), matching the
* other lib clients.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/** True when the browser can do Web Push at all. */
export function pushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/** The browser's current notification permission, or "unsupported". */
export function pushPermission(): NotificationPermission | "unsupported" {
if (!pushSupported()) return "unsupported";
return Notification.permission;
}
type VapidKey = { public_key: string; enabled: boolean };
async function getVapidKey(): Promise<VapidKey> {
const res = await fetch(`${BFF_BASE}/api/v1/push/key`, {
credentials: "include",
});
if (!res.ok) throw new Error("Could not load push configuration");
const body = (await res.json()) as { data?: VapidKey };
return body.data ?? { public_key: "", enabled: false };
}
async function registration(): Promise<ServiceWorkerRegistration | null> {
if (!pushSupported()) return null;
return (await navigator.serviceWorker.getRegistration()) ?? null;
}
/** Whether push is currently enabled (permission granted + a live sub). */
export async function isPushEnabled(): Promise<boolean> {
if (!pushSupported() || Notification.permission !== "granted") return false;
const reg = await registration();
if (!reg) return false;
return !!(await reg.pushManager.getSubscription());
}
/** Whether the server has push configured (VAPID keys present). */
export async function pushConfigured(): Promise<boolean> {
try {
return (await getVapidKey()).enabled;
} catch {
return false;
}
}
/**
* Enable push: requests permission, subscribes, and registers the
* subscription with the BFF. Throws a user-facing message on failure.
*/
export async function enablePush(): Promise<void> {
if (!pushSupported()) {
throw new Error("This browser doesn't support push notifications.");
}
const { public_key, enabled } = await getVapidKey();
if (!enabled || !public_key) {
throw new Error("Push notifications aren't available right now.");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Notification permission was not granted.");
}
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
}
const json = sub.toJSON() as {
endpoint?: string;
keys?: { p256dh?: string; auth?: string };
};
const res = await fetch(`${BFF_BASE}/api/v1/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
});
if (!res.ok) throw new Error("Could not save your subscription.");
}
/** Disable push: unsubscribe locally and remove the subscription server-side. */
export async function disablePush(): Promise<void> {
const reg = await registration();
const sub = reg ? await reg.pushManager.getSubscription() : null;
if (!sub) return;
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {
/* best-effort */
});
}
/** VAPID public keys are base64url; PushManager wants a Uint8Array. */
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
// Back with a concrete ArrayBuffer so the type is Uint8Array<ArrayBuffer>,
// which PushManager.subscribe's applicationServerKey requires.
const out = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Browser-side BFF client for the sādhana streak (/sadhna-streak).
*
* Mirrors lib/social.ts: same `/api/v1` base, same `credentials: "include"`
* so the auth cookie rides along, same envelope unwrapping. Kept in its
* own module so the streak feature doesn't bloat the social client.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class SadhnaError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "SadhnaError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new SadhnaError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
/** Plant growth stage, mirrors the BFF's stageFor(). */
export type SadhnaStage =
| "dead"
| "sprout"
| "seedling"
| "sapling"
| "young"
| "mature";
/** One cell of the 7-day streak strip. */
export type SadhnaDay = {
date: string; // YYYY-MM-DD (UTC)
chant_done: boolean;
quote_done: boolean;
completed: boolean;
};
/**
* One fold of the sādhana creeper — a single day the vine has wound around
* its pole. The vine grows a fresh fold for each day of the current streak
* (oldest first); the final fold may be today, still in progress (the
* growing tip). `rounds`/`posts` annotate what was offered that day.
*/
export type VineDay = {
date: string; // YYYY-MM-DD (UTC)
rounds: number;
posts: number;
completed: boolean;
in_progress: boolean;
};
export type SadhnaDashboard = {
// Today's two disciplines.
target_rounds: number;
rounds_completed: number;
chant_done: boolean;
quote_done: boolean;
today_completed: boolean;
// Streak counters.
current_streak: number;
longest_streak: number;
// Plant state (growth 0–100).
growth: number;
stage: SadhnaStage;
alive: boolean;
// Trailing 7 days, oldest first.
week: SadhnaDay[];
// The creeper: one fold per day of the current streak (oldest first),
// plus an in-progress fold for an unfinished today. Capped server-side;
// current_streak holds the true length.
vine_days: VineDay[];
// Rewards. `next_reward_date` is the projected UTC date (YYYY-MM-DD) of the
// next gift if the streak continues. `pending_reward` is a freshly earned
// gift to celebrate now (null when there's nothing to show).
next_reward_date: string | null;
pending_reward: SadhnaRewardGrant | null;
};
/** A gift item in the streak-reward catalog. */
export type SadhnaReward = {
id: string;
key: string;
name: string;
meaning: string;
image_url: string;
active: boolean;
sort: number;
};
/** A reward earned at a streak milestone (a multiple of 7 days). */
export type SadhnaRewardGrant = {
milestone: number;
granted_at: string;
reward: SadhnaReward;
};
// ─── API ───────────────────────────────────────────────────────────────
export const sadhna = {
/** Today's progress + streak + plant state. */
dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"),
/** Per-day state for an inclusive UTC date range (YYYY-MM-DD), for the
* calendar's week/month views. Range is bounded to a year server-side. */
history: (from: string, to: string) =>
call<SadhnaDay[]>(
`/sadhna/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
),
/** Save today's chanting round count; returns the recomputed dashboard. */
saveChant: (roundsCompleted: number) =>
call<SadhnaDashboard>("/sadhna/chant", {
method: "POST",
body: JSON.stringify({ rounds_completed: roundsCompleted }),
}),
/** The user's collected gifts (newest first) + giftable catalog size. */
rewards: () =>
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
/** Acknowledge pending celebrations after showing the confetti. */
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
/** Whether daily sādhana reminders are enabled for the caller. */
reminderPrefs: () => call<{ enabled: boolean }>("/sadhna/reminders"),
/** Toggle daily sādhana reminders. */
setReminderPrefs: (enabled: boolean) =>
call<{ enabled: boolean }>("/sadhna/reminders", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
/** Report the browser's IANA timezone so reminders fire at local time. */
setTimezone: (timezone: string) =>
call<void>("/sadhna/timezone", {
method: "POST",
body: JSON.stringify({ timezone }),
}),
};
+17
View File
@@ -0,0 +1,17 @@
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
/**
* useMounted — false during SSR and the first client render, true thereafter.
* Uses useSyncExternalStore (not an effect), so it's safe for gating
* client-only work like createPortal without tripping the
* set-state-in-effect rule. Mirrors the pattern in components/logo.tsx.
*/
export function useMounted(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}
+19 -2
View File
@@ -3,8 +3,25 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
turbopack: {
root: "/"
}
root: "/",
},
images: {
// R2 public bucket — lets next/image optimize/resize the (large) reward
// images instead of serving the multi-MB originals.
remotePatterns: [{ protocol: "https", hostname: "s3.prabhupadasays.com" }],
},
async rewrites() {
return [
{
source: "/api/script.js",
destination: `${process.env.NEXT_PUBLIC_RYBBIT_HOST}/api/script.js`,
},
{
source: "/api/track",
destination: `${process.env.NEXT_PUBLIC_RYBBIT_HOST}/api/track`,
},
];
},
};
export default nextConfig;
+1
View File
@@ -16,6 +16,7 @@
"lucide-react": "^1.8.0",
"next": "16.2.2",
"next-themes": "^0.4.6",
"pusher-js": "^8.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-icons": "^5.6.0",
+222
View File
@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="utf-8"?>
␍<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--noto" preserveAspectRatio="xMidYMid meet">
␍<ellipse transform="rotate(-1.507 101.145 63.828)" cx="101.21" cy="63.87" rx="6.14" ry="6.39" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-1.507 100.561 63.136)" cx="100.63" cy="63.18" rx="5.24" ry="5.45" fill="#d1593f">
␍</ellipse>
␍<path d="M101.06 60.5c.42 1.56-1.14 2.85-2.44 2.71c-1.18-.13-2.28-1.41-1.45-2.96c1.09-2.01 3.49-1.23 3.89.25z" fill="#ff7759">
␍</path>
␍<ellipse transform="rotate(-2.922 110.772 56.615)" cx="110.83" cy="56.63" rx="6.08" ry="6.33" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-2.922 110.074 56.01)" cx="110.13" cy="56.03" rx="5.19" ry="5.41" fill="#d1593f">
␍</ellipse>
␍<path d="M110.11 53.12c.78 1.33-.47 2.97-1.8 3.2c-1.2.2-2.6-.69-2.13-2.35c.61-2.18 3.18-2.12 3.93-.85z" fill="#ff7759">
␍</path>
␍<ellipse cx="118.01" cy="34.86" rx="5.99" ry="6.24" fill="#874d36">
␍</ellipse>
␍<ellipse cx="117.19" cy="34.5" rx="5.11" ry="5.32" fill="#d1593f">
␍</ellipse>
␍<path d="M117.29 32.43c.46 1.55-1.08 2.88-2.38 2.77c-1.18-.1-2.31-1.36-1.51-2.92c1.03-2.05 3.45-1.32 3.89.15z" fill="#ff7759">
␍</path>
␍<ellipse cx="112.86" cy="24.02" rx="5.95" ry="6.2" fill="#874d36">
␍</ellipse>
␍<ellipse cx="112.3" cy="23.48" rx="5.08" ry="5.29" fill="#d1593f">
␍</ellipse>
␍<path d="M112.33 21.78c.56 1.6-.86 3.05-2.15 2.99c-1.17-.06-2.37-1.32-1.69-2.99c.88-2.17 3.31-1.51 3.84 0z" fill="#ff7759">
␍</path>
␍<ellipse cx="103.47" cy="16.53" rx="5.92" ry="6.17" fill="#874d36">
␍</ellipse>
␍<ellipse cx="102.95" cy="16.03" rx="5.05" ry="5.26" fill="#d1593f">
␍</ellipse>
␍<path d="M102.72 13.94c.72 1.5-.57 3.09-1.88 3.19c-1.19.08-2.53-1.01-2.01-2.73c.68-2.23 3.21-1.88 3.89-.46z" fill="#ff7759">
␍</path>
␍<ellipse cx="92.61" cy="12.34" rx="5.89" ry="6.14" fill="#874d36">
␍</ellipse>
␍<ellipse cx="92.12" cy="11.78" rx="5.03" ry="5.24" fill="#d1593f">
␍</ellipse>
␍<path d="M92.08 9.96c.28 1.59-.82 2.77-2.38 2.77c-1.27 0-2.29-1.76-1.33-3.23c1.16-1.79 3.41-1.23 3.71.46z" fill="#ff7759">
␍</path>
␍<ellipse cx="80.99" cy="10.78" rx="5.87" ry="6.12" fill="#874d36">
␍</ellipse>
␍<ellipse cx="80.17" cy="10.17" rx="5.01" ry="5.22" fill="#d1593f">
␍</ellipse>
␍<path d="M80.3 8.44c.47 1.61-1.05 2.98-2.35 2.85c-1.18-.12-2.31-1.43-1.53-3.04c1.02-2.1 3.44-1.33 3.88.19z" fill="#ff7759">
␍</path>
␍<ellipse cx="69.29" cy="10.75" rx="5.86" ry="6.1" fill="#874d36">
␍</ellipse>
␍<ellipse cx="68.85" cy="10.05" rx="5" ry="5.21" fill="#d1593f">
␍</ellipse>
␍<path d="M68.66 8.08c.68 1.46-.64 3.01-1.94 3.09c-1.19.07-2.49-1-1.93-2.66c.72-2.17 3.23-1.82 3.87-.43z" fill="#ff7759">
␍</path>
␍<ellipse cx="58" cy="12.21" rx="5.85" ry="6.09" fill="#874d36">
␍</ellipse>
␍<ellipse cx="57.26" cy="11.65" rx="4.99" ry="5.2" fill="#d1593f">
␍</ellipse>
␍<path d="M57.48 9.69c.46 1.55-1.08 2.88-2.38 2.77c-1.18-.1-2.31-1.36-1.51-2.92c1.04-2.05 3.46-1.32 3.89.15z" fill="#ff7759">
␍</path>
␍<g>
␍<ellipse cx="46.91" cy="14.49" rx="5.85" ry="6.09" fill="#874d36">
␍</ellipse>
␍<ellipse cx="46.48" cy="13.65" rx="4.99" ry="5.2" fill="#d1593f">
␍</ellipse>
␍<path d="M42.54 13.31c-.36-1.75 1.38-2.82 2.6-2.72c1.12.09 2.16 1.55 1.17 3.08c-1.36 2.09-3.46 1.17-3.77-.36z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="36.38" cy="19.27" rx="5.85" ry="6.09" fill="#874d36">
␍</ellipse>
␍<ellipse cx="35.83" cy="18.48" rx="4.99" ry="5.2" fill="#d1593f">
␍</ellipse>
␍<path d="M36.37 16.83c.08 1.67-1.58 2.73-2.87 2.37c-1.12-.31-2.1-1.79-1.03-3.21c1.33-1.77 3.83-.74 3.9.84z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="26.97" cy="26.42" rx="5.86" ry="6.1" fill="#874d36">
␍</ellipse>
␍<ellipse cx="26.52" cy="25.79" rx="5" ry="5.21" fill="#d1593f">
␍</ellipse>
␍<path d="M28.04 23.12c.26 1.66-1.74 2.54-3.08 2.37c-1.24-.15-2.27-1.58-1.05-2.95c1.65-1.86 3.9-.87 4.13.58z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse transform="rotate(-16.544 19.35 35.557)" cx="19.35" cy="35.56" rx="5.87" ry="6.12" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-16.544 18.876 34.764)" cx="18.87" cy="34.76" rx="5.01" ry="5.22" fill="#d1593f">
␍</ellipse>
␍<path d="M20.16 32.37c.08.89-.45 1.77-1.17 2.26c-.7.48-1.62.58-2.42.33a2.047 2.047 0 0 1-.78-3.1c1.07-1.63 4.15-1.93 4.37.51z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse transform="rotate(-16.096 13.825 46.146)" cx="13.82" cy="46.15" rx="5.89" ry="6.14" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-16.096 13.377 45.34)" cx="13.37" cy="45.34" rx="5.03" ry="5.24" fill="#d1593f">
␍</ellipse>
␍<path d="M15.17 43.56c-.2.87-.98 1.54-1.82 1.78c-.81.23-1.72.05-2.4-.44c-.5-.36-1.31-1.98.22-3.19s4.55-.54 4 1.85z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse transform="rotate(-9.97 10.57 57.697)" cx="10.56" cy="57.7" rx="5.92" ry="6.17" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-9.97 10.188 56.916)" cx="10.18" cy="56.92" rx="5.05" ry="5.26" fill="#d1593f">
␍</ellipse>
␍<path d="M12.47 55.13c-.37.81-1.27 1.31-2.14 1.38c-.84.06-1.69-.3-2.26-.92c-.41-.45-.88-2.21.86-3.08s4.56.39 3.54 2.62z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="9.95" cy="70.1" rx="5.95" ry="6.2" fill="#874d36">
␍</ellipse>
␍<ellipse cx="9.88" cy="69.1" rx="5.08" ry="5.29" fill="#d1593f">
␍</ellipse>
␍<path d="M12.2 67.4c-.27.86-1.09 1.46-1.95 1.63c-.83.16-1.72-.1-2.37-.64c-.49-.41-1.13-2.16.53-3.32c1.56-1.11 4.54-.09 3.79 2.33z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="12.39" cy="81.97" rx="5.99" ry="6.24" fill="#874d36">
␍</ellipse>
␍<ellipse cx="12.53" cy="81.01" rx="5.11" ry="5.32" fill="#d1593f">
␍</ellipse>
␍<path d="M14.44 78.62c-.29 1.41-1.4 1.71-2.26 1.91c-2.1.49-3.97-1.31-2.1-3.13c1.13-1.11 4.89-1.36 4.36 1.22z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="18.32" cy="91.72" rx="6.03" ry="6.28" fill="#874d36">
␍</ellipse>
␍<ellipse cx="18.39" cy="90.8" rx="5.15" ry="5.36" fill="#d1593f">
␍</ellipse>
␍<path d="M20.8 88.6c-.08.9-.83 1.72-1.69 1.9c-1.46.31-4.2-.32-2.53-3.15c.96-1.64 4.48-1.67 4.22 1.25z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="28.28" cy="98.84" rx="6.08" ry="6.33" fill="#874d36">
␍</ellipse>
␍<ellipse cx="28.11" cy="97.87" rx="5.19" ry="5.41" fill="#d1593f">
␍</ellipse>
␍<path d="M29.93 95.63c-.13.89-.86 1.58-1.68 1.91c-1.21.49-4.26.03-2.74-3.1c.81-1.67 4.82-1.58 4.42 1.19z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="39.61" cy="101.12" rx="6.14" ry="6.39" fill="#874d36">
␍</ellipse>
␍<ellipse cx="39.5" cy="100.13" rx="5.24" ry="5.45" fill="#d1593f">
␍</ellipse>
␍<path d="M41.47 97.55c-.12.89-.58 1.34-1.66 1.92c-1.64.89-4.33-.63-2.93-3.08c.96-1.67 4.97-1.69 4.59 1.16z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="51.06" cy="98.89" rx="6.19" ry="6.45" fill="#874d36">
␍</ellipse>
␍<ellipse cx="50.76" cy="97.92" rx="5.29" ry="5.51" fill="#d1593f">
␍</ellipse>
␍<path d="M53.13 95.85c-.11.9-.82 1.65-1.65 1.93c-.82.28-1.75.13-2.5-.31c-.64-.37-1.17-1.03-1.18-1.79c0-.41.28-.89.53-.9c1.05-1.95 5.17-1.89 4.8 1.07z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="63.02" cy="95.73" rx="6.26" ry="6.52" fill="#874d36">
␍</ellipse>
␍<ellipse cx="62.6" cy="94.96" rx="5.34" ry="5.56" fill="#d1593f">
␍</ellipse>
␍<path d="M64.26 92.59c-.01.91-.62 1.74-1.41 2.11c-.79.37-1.73.32-2.54-.02c-.7-.3-1.42-1.47-1.18-2.38c.67-2.52 5.15-2.78 5.13.29z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="74.83" cy="95.53" rx="6.33" ry="6.59" fill="#874d36">
␍</ellipse>
␍<ellipse cx="74.45" cy="94.58" rx="5.4" ry="5.63" fill="#d1593f">
␍</ellipse>
␍<path d="M76.5 92.39c-.15.9-.79 1.67-1.62 1.96s-1.83.41-2.81-.28c-.64-.45-1.28-1.58-.75-2.61c1.1-2.18 5.66-1.97 5.18.93z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="87.06" cy="82.42" rx="6.26" ry="6.52" fill="#874d36">
␍</ellipse>
␍<ellipse transform="rotate(-71.119 86.409 81.782)" cx="86.41" cy="81.78" rx="5.56" ry="5.34" fill="#d1593f">
␍</ellipse>
␍<path d="M86.71 80.38c-.36 1.77-2.31 2.37-3.36 1.65c-.95-.66-1.29-2.45.14-3.64c1.87-1.55 3.56.32 3.22 1.99z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="117.83" cy="46.79" rx="6.03" ry="6.28" fill="#874d36">
␍</ellipse>
␍<ellipse cx="117.19" cy="46.5" rx="5.15" ry="5.36" fill="#d1593f">
␍</ellipse>
␍<path d="M116.94 44.2c.46 1.55-.91 3.27-2.21 3.16c-1.18-.1-2.31-1.36-1.51-2.92c1.04-2.04 3.28-1.71 3.72-.24z" fill="#ff7759">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="86.64" cy="94.67" rx="6.33" ry="6.59" fill="#874d36">
␍</ellipse>
␍<ellipse cx="86.06" cy="93.7" rx="5.4" ry="5.63" fill="#d1593f">
␍</ellipse>
␍<path d="M86.97 90.73c.3.84.01 1.86-.61 2.51c-.62.65-1.53.97-2.4.97c-.78 0-1.85-.62-1.79-2.15c.08-2.46 3.75-4.27 4.8-1.33z" fill="#ff7759">
␍</path>
␍<path d="M82.48 97.94c.47.51 1.75.88 2.39.63c1.27-.5 1.23-1.67 3.18-2.84c1.25-.75 1.78-.58 2.82-1.06c.75-.35.57-1.29.51-1.9c0 0 2.35 3.66-2.22 6.4c-4.53 2.71-6.68-1.23-6.68-1.23z" fill="#874d36">
␍</path>
␍</g>
␍<g>
␍<ellipse cx="92.6" cy="71.76" rx="6.19" ry="6.45" fill="#874d36">
␍</ellipse>
␍<ellipse cx="91.75" cy="71.3" rx="5.29" ry="5.51" fill="#d1593f">
␍</ellipse>
␍<path d="M92.16 68.53c.26 1.59-1.07 2.76-2.38 2.77c-1.29.01-2.51-1.48-1.51-2.92c1.39-2.02 3.65-1.37 3.89.15z" fill="#ff7759">
␍</path>
␍</g>
␍<path d="M91.19 95.91c-2.41-.26-4.56 1.98-4.79 4.29c-.47 4.73 4.78 6.41 4.78 6.41c.45.21 1.61.75 2.92 2.83c4.54 7.24 11.83 12.96 20.33 13.88c2.76.3 9.35-14.38 4.15-16.31c-1.56-.58-3.29-.32-4.93-.11c-3.02.39-6.09.6-9.1.13c-2.23-.35-7.07-2.6-8.16-4.9c0-.01-.27-5.69-5.2-6.22z" fill="#ffca28">
␍</path>
␍<path d="M94.44 103.33c3.66 2.46 8.37 3.27 12.64 2.19c4.04-1.03 7.93-3.68 12.01-2.83" fill="none" stroke="#ffca28" stroke-width="2.523" stroke-linecap="round" stroke-miterlimit="10">
␍</path>
␍<path d="M92.96 106.96c.21 4.56.86 9.29 4.5 12.05c3.35 2.55 7.88 3.34 11.98 1.33" fill="none" stroke="#ffca28" stroke-width="2.523" stroke-linecap="round" stroke-miterlimit="10">
␍</path>
␍<path d="M116.62 107.37c-1.55.87-1.07 5.34-1.15 6.28c-.28 3.33-2.65 2.4-3.64 5.41c-.52 1.57-.29 4.04 1.58 4.24c6.42.69 7.55-6.32 7.71-7.92c.67-6.44-2.09-9.36-4.5-8.01z" fill="#e2a610">
␍</path>
␍<g>
␍<path d="M115.59 114.36c-6.91 0-13.86-2.26-18.62-7.04c-1.94-1.95-.79-2.57-.28-2.11c5.55 4.98 13.68 7.69 21.31 6.98l.19 2.05c-.85.09-1.72.12-2.6.12z" fill="#e2a610">
␍</path>
␍</g>
␍<g>
␍<path d="M115.71 120.34c-5.01-.82-10.92-3.41-13.84-5.81c-1.83-1.5-2.16-2.45-2.16-2.45c4.21 3.42 10.82 5.66 16.17 6.53l-.17 1.73z" fill="#e2a610">
␍</path>
␍</g>
␍<path d="M91.83 106.96s.47-1.22 2.11-2.55c1.95-1.59 2.87-1.63 2.87-1.63c-.29-.45-.4-2.12-1.12-3.46c0 0 .2 2.08-2.58 4.07c-1.82 1.3-5.06 1.28-5.06 1.28c1.37 1.42 3.78 2.29 3.78 2.29z" fill="#e2a610">
␍</path>
␍</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+48
View File
@@ -0,0 +1,48 @@
/* Prabhupāda Says — Web Push service worker.
*
* Handles incoming push messages (shows a notification) and clicks (focuses
* an existing tab or opens the target URL). The push payload is the JSON the
* BFF sends: { title, body, url, icon, tag }.
*/
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) =>
event.waitUntil(self.clients.claim()),
);
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { body: event.data && event.data.text ? event.data.text() : "" };
}
const title = data.title || "Prabhupāda Says";
const options = {
body: data.body || "",
icon: data.icon || "/logo_dark.png",
badge: "/logo_dark.png",
tag: data.tag || undefined,
data: { url: data.url || "/" },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url =
(event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((list) => {
for (const client of list) {
if ("focus" in client) {
if ("navigate" in client) client.navigate(url);
return client.focus();
}
}
if (self.clients.openWindow) return self.clients.openWindow(url);
}),
);
});
+12
View File
@@ -4188,6 +4188,13 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
pusher-js@^8.5.0:
version "8.5.0"
resolved "https://registry.yarnpkg.com/pusher-js/-/pusher-js-8.5.0.tgz#329d4fb4b5e4ae0a5e0a558b6da1a852c4badae4"
integrity sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==
dependencies:
tweetnacl "^1.0.3"
qs@^6.14.0, qs@^6.14.1:
version "6.15.1"
resolved "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz"
@@ -4938,6 +4945,11 @@ tw-animate-css@^1.4.0:
resolved "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz"
integrity sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==
tweetnacl@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"