feat: add webpush and sadhna tracker

This commit is contained in:
gosangam
2026-06-08 15:48:12 +05:30
parent ecb32d2e85
commit 0334e03c3c
52 changed files with 2118 additions and 545 deletions
-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 {
+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>
);
+242 -65
View File
@@ -3,34 +3,98 @@
/**
* /sadhna-streak/chant — the japa counter.
*
* Shows the Hare Kṛṣṇa mahā-mantra with a big tap-to-count round counter
* running toward the devotee's daily target (their profile's
* daily_chanting_rounds, falling back to 16). Save persists the count;
* reaching the target marks today's chanting discipline complete.
* 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. It seeds the counter from
* today's already-saved progress so a devotee can chant in sittings.
* Auth-gated like the rest of /sadhna-streak; seeds from today's saved
* progress so a devotee can chant in sittings.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Check, Minus, RotateCcw } from "lucide-react";
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const MANTRA = [
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",
];
const FALLBACK_TARGET = 16;
],
sa: [
"हरे कृष्ण हरे कृष्ण",
"कृष्ण कृष्ण हरे हरे ।",
"हरे राम हरे राम",
"राम राम हरे हरे ॥",
],
};
export default function ChantPage() {
const { user, loading } = useAuth();
@@ -40,8 +104,24 @@ export default function ChantPage() {
const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState(0); // count value at last save
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(() => {
@@ -66,7 +146,8 @@ export default function ChantPage() {
if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0);
setSavedAt(d.rounds_completed || 0);
savedRef.current = d.rounds_completed || 0;
completedRef.current = !!d.chant_done;
setState("ready");
})
.catch(() => !cancelled && setState("error"));
@@ -75,29 +156,46 @@ export default function ChantPage() {
};
}, [user]);
const inc = useCallback(() => {
setCount((c) => Math.min(target, c + 1));
}, [target]);
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 save = useCallback(async () => {
setSaving(true);
try {
const d = await sadhna.saveChant(count);
setSavedAt(count);
setTarget(d.target_rounds || FALLBACK_TARGET);
toast.success(
d.chant_done
? "Today's chanting complete 🙏"
: `Saved — ${count} of ${d.target_rounds} rounds`,
);
} catch {
toast.error("Could not save. Please try again.");
} finally {
setSaving(false);
}
}, [count]);
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 (
@@ -110,11 +208,10 @@ export default function ChantPage() {
}
const complete = count >= target;
const dirty = count !== savedAt;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return (
<main className="flex-1 w-full max-w-md mx-auto px-4 sm:px-6 py-5 flex flex-col">
<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"
@@ -133,20 +230,87 @@ export default function ChantPage() {
</button>
</div>
{/* Mantra */}
{/* 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">
<p className="text-xs uppercase tracking-widest text-muted-foreground">
<div className="flex items-center justify-center gap-2">
<p className="text-[11px] uppercase tracking-widest text-muted-foreground">
The Mahā-mantra
</p>
<div className="mt-3 space-y-1 grid grid-cols-1 md:grid-cols-2 font-serif text-lg sm:text-xl text-foreground leading-relaxed">
{MANTRA.map((line) => (
<p key={line}>{line}</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-8 flex flex-col items-center">
<div className="mt-7 flex flex-col items-center">
<button
onClick={inc}
disabled={state !== "ready" || complete}
@@ -160,9 +324,19 @@ export default function ChantPage() {
)}
aria-label="Count one round"
>
{/* progress ring */}
<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" />
<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"
@@ -205,25 +379,28 @@ export default function ChantPage() {
</button>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
One round is 108 names on the mālā.
</p>
{/* Save */}
<div className="mt-auto pt-8">
<Button
onClick={save}
disabled={saving || !dirty || state !== "ready"}
size="lg"
className="w-full"
>
{saving ? "Saving…" : dirty ? "Save progress" : "Saved"}
</Button>
{/* Auto-save status */}
<div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
{state === "error" ? (
<p className="mt-2 text-center text-xs text-destructive">
Could not load today`&apos;s progress.
</p>
) : null}
<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>
);
+92 -172
View File
@@ -3,12 +3,13 @@
/**
* /sadhna-streak — the devotee's daily sādhana home.
*
* • a thin 7-day streak strip across the top
* • a streak calendar with week/month views and back/forward paging; each
* day shows its state, today highlighted, and a lotus blooms beneath
* every day the sādhana was completed
* • the current/best streak counter
* • two daily disciplines as action cards: finish today's chanting, and
* post at least one quote from the Vāṇī — each flips to a "done" state
* once complete
* • a plant that grows from a seedling toward a tree as the streak holds,
* and withers back through its stages (eventually dying) when it breaks
*
* Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
* posted elsewhere (posting is selection-driven while reading) reflects
@@ -18,11 +19,14 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { BookOpenText, Check, Flame, BadgePlus } from "lucide-react";
import Image from "next/image";
import { BookOpenText, Check } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
import { sadhna, type SadhnaDashboard, type SadhnaRewardGrant } from "@/lib/sadhna";
import { StreakCalendar } from "@/components/sadhna/streak-calendar";
import { RewardsGallery } from "@/components/sadhna/rewards-gallery";
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
import { cn } from "@/lib/utils";
export default function SadhnaStreakPage() {
@@ -32,6 +36,13 @@ export default function SadhnaStreakPage() {
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [rewards, setRewards] = useState<{
collected: SadhnaRewardGrant[];
total: number;
} | null>(null);
// Guards the celebration from re-opening between dismiss and the dashboard
// refetch (and lets a *new* milestone's gift still celebrate).
const [ackedMilestone, setAckedMilestone] = useState<number | null>(null);
// Auth gate — same pattern as /notifications.
useEffect(() => {
@@ -56,14 +67,41 @@ export default function SadhnaStreakPage() {
.catch(() => setState("error"));
}, []);
const loadRewards = useCallback(() => {
sadhna
.rewards()
.then(setRewards)
.catch(() => {
/* gallery is non-critical */
});
}, []);
// Load once we have a user, and again whenever the tab regains focus.
useEffect(() => {
if (!user) return;
load();
const onFocus = () => load();
loadRewards();
const onFocus = () => {
load();
loadRewards();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [user, load]);
}, [user, load, loadRewards]);
// A freshly earned, not-yet-celebrated gift to show confetti for.
const pending = dash?.pending_reward ?? null;
const showCelebration = !!pending && pending.milestone !== ackedMilestone;
const closeCelebration = useCallback(async () => {
if (pending) setAckedMilestone(pending.milestone);
try {
await sadhna.ackRewards();
} catch {
/* best-effort */
}
load(); // clears pending_reward
loadRewards(); // the new gift now appears in the gallery
}, [pending, load, loadRewards]);
if (loading || !user) {
return (
@@ -76,88 +114,39 @@ export default function SadhnaStreakPage() {
}
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
{/* Streak strip */}
<StreakStrip dash={dash} loading={state === "loading"} />
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5">
<header className="text-center space-y-0.5">
<h1 className="font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold text-foreground">
<h1 className="font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold text-foreground">
Daily Sādhana
</h1>
<p className="text-sm text-muted-foreground">{vineCaption(dash)}</p>
<p className="text-sm text-muted-foreground">{caption(dash)}</p>
</header>
{state === "error" ? (
<p className="rounded-2xl border border-border bg-card p-4 text-center text-sm text-destructive">
Could not load your sādhana. Please try again.
</p>
) : (
<StreakCalendar dash={dash} />
)}
{/* Action items */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChantAction dash={dash} />
<QuoteAction dash={dash} />
</div>
{/* The creeper — climbs the pole, one fold per day of the streak */}
<VinePanel dash={dash} error={state === "error"} />
</main>
);
}
// ─── Streak strip ────────────────────────────────────────────────────────
function StreakStrip({
dash,
loading,
}: {
dash: SadhnaDashboard | null;
loading: boolean;
}) {
const week = dash?.week ?? placeholderWeek();
const streak = dash?.current_streak ?? 0;
return (
<div className="flex items-center justify-between gap-3 rounded-full border border-border bg-card/60 px-4 py-2">
<div className="flex items-center gap-1.5 shrink-0">
<Flame
className={cn(
"size-4",
streak > 0 ? "text-orange-500" : "text-muted-foreground",
)}
{/* Collected gifts */}
<RewardsGallery
collected={rewards?.collected ?? []}
nextRewardDate={dash?.next_reward_date ?? null}
/>
<span className="text-sm font-semibold text-foreground tabular-nums">
{streak}
</span>
<span className="text-xs text-muted-foreground hidden sm:inline">
day{streak === 1 ? "" : "s"}
</span>
</div>
<div className={cn("flex items-center gap-2", loading && "opacity-40")}>
{week.map((day, i) => (
<DayDot key={day.date} day={day} isToday={i === week.length - 1} />
))}
</div>
</div>
);
}
function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) {
const partial = !day.completed && (day.chant_done || day.quote_done);
return (
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"size-5 rounded-full flex items-center justify-center transition-colors",
day.completed
? "bg-primary text-primary-foreground"
: partial
? "bg-primary/20 ring-1 ring-primary/50"
: "bg-muted",
isToday && !day.completed && "ring-2 ring-primary/60",
)}
>
{day.completed ? <Check className="size-3" strokeWidth={3} /> : null}
</div>
<span className="text-[10px] leading-none text-muted-foreground">
{weekdayLetter(day.date)}
</span>
</div>
{/* Celebration when a gift is freshly earned */}
{showCelebration && pending ? (
<RewardCelebration grant={pending} onClose={closeCelebration} />
) : null}
</main>
);
}
@@ -170,15 +159,21 @@ function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
return (
<ActionCard
href="/sadhna-streak/chant"
icon={<BadgePlus className="size-5" />}
icon={
<Image
src="/icons/chanting-beads.svg"
alt=""
width={48}
height={48}
className="size-12"
unoptimized
/>
}
title="Finish today's chanting"
subtitle={
done
? `${rounds} rounds chanted`
: `${rounds} / ${target} rounds`
done ? `${rounds} rounds chanted` : `${rounds} / ${target} rounds`
}
done={done}
cta={rounds > 0 ? "Continue" : "Begin"}
/>
);
}
@@ -187,16 +182,15 @@ function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.quote_done ?? false;
return (
<ActionCard
href="/books"
icon={<BookOpenText className="size-5" />}
title="Post a quote from the Vāṇī"
href="/search"
icon={<BookOpenText className="size-8" />}
title="Post a quote"
subtitle={
done
? "Shared today — thank you"
: "Select a passage while reading, then Share to Feed"
}
done={done}
cta="Find a verse"
/>
);
}
@@ -207,124 +201,50 @@ function ActionCard({
title,
subtitle,
done,
cta,
}: {
href: string;
icon: React.ReactNode;
title: string;
subtitle: string;
done: boolean;
cta: string;
}) {
return (
<Link
href={href}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 transition-colors",
"group relative flex items-center gap-3.5 rounded-2xl border p-4 transition-colors",
done
? "border-primary/40 bg-primary/5"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"inline-flex size-9 items-center justify-center rounded-xl",
"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-5" strokeWidth={3} /> : icon}
{done ? <Check className="size-8" strokeWidth={3} /> : icon}
</span>
{done ? (
<span className="text-xs font-medium text-primary">Done</span>
) : (
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
{cta} →
</span>
)}
</div>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground leading-snug">
<div className="min-w-0 space-y-0.5">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="text-xs text-muted-foreground leading-snug">{subtitle}</p>
<p className="text-sm text-muted-foreground leading-snug">{subtitle}</p>
</div>
</Link>
);
}
// ─── Vine panel ───────────────────────────────────────────────────────
// ─── Copy ──────────────────────────────────────────────────────────────
function VinePanel({
dash,
error,
}: {
dash: SadhnaDashboard | null;
error: boolean;
}) {
return (
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
{error ? (
<div className="absolute inset-0 flex items-center justify-center px-4">
<p className="text-sm text-destructive text-center">
Could not load your sādhana. Please try again.
</p>
</div>
) : dash ? (
<SadhnaVine
days={dash.vine_days}
targetRounds={dash.target_rounds}
growth={dash.growth}
alive={dash.alive}
totalStreak={dash.current_streak}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading your vine…</p>
</div>
)}
</section>
);
}
// ─── Copy + helpers ────────────────────────────────────────────────────
function vineCaption(dash: SadhnaDashboard | null): string {
if (!dash) return "Two simple offerings each day. Keep them up and watch it climb.";
if (dash.current_streak === 0 && !dash.alive) {
return "Begin today, and a new vine starts climbing the pole.";
}
if (dash.today_completed) {
return "Today's fold is complete. The vine climbs higher.";
}
function caption(dash: SadhnaDashboard | null): string {
if (!dash) return "Two simple offerings each day — a lotus for every one.";
if (dash.today_completed) return "Today's sādhana is complete — a lotus blooms.";
if (dash.chant_done || dash.quote_done) {
return "One offering done — finish the other to complete today's fold.";
return "One offering done. Finish the other to complete today.";
}
return "Each day's sādhana winds another fold around the pole.";
}
/** Single-letter weekday (UTC, matching the backend's day buckets). */
function weekdayLetter(date: string): string {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return ["S", "M", "T", "W", "T", "F", "S"][d.getUTCDay()];
}
/** A blank 7-day strip for the initial render before data arrives. */
function placeholderWeek(): SadhnaDay[] {
const out: SadhnaDay[] = [];
const now = new Date();
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
for (let i = 6; i >= 0; i--) {
const d = new Date(today - i * 86400000);
out.push({
date: d.toISOString().slice(0, 10),
chant_done: false,
quote_done: false,
completed: false,
});
}
return out;
return "Chant your rounds and post a quote to complete today.";
}
-207
View File
@@ -1,207 +0,0 @@
"use client";
/**
* /sadhna-streak/preview — DEV-ONLY sandbox for the sādhana creeper.
*
* Lets you fast-track the vine: scrub the streak length, set today's
* in-progress rounds/posts, and dial the health (growth) score to watch the
* vine climb the pole, sprout folds + blossoms, and wither — all without
* seeding real logs. Returns nothing in production builds.
*
* The synthesized payload matches what the BFF's Dashboard builds, so what
* you see here mirrors live.
*/
import { useState } from "react";
import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
import type { VineDay } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
export default function SadhnaPreviewPage() {
if (process.env.NODE_ENV === "production") {
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">Not available.</p>
</main>
);
}
return <Preview />;
}
/** Build a vine_days payload exactly the way the BFF would. */
function buildDays(
completed: number,
target: number,
todayRounds: number,
todayPosts: number,
todayComplete: boolean,
): VineDay[] {
const out: VineDay[] = [];
const now = new Date();
const todayMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
);
const endOffset = todayComplete ? 0 : 1; // days-ago of the last finished fold
for (let i = completed - 1; i >= 0; i--) {
const daysAgo = endOffset + i;
const date = new Date(todayMs - daysAgo * 86400000)
.toISOString()
.slice(0, 10);
out.push({
date,
rounds: target + (i % 3),
posts: 1 + (i % 2),
completed: true,
in_progress: false,
});
}
if (!todayComplete) {
out.push({
date: new Date(todayMs).toISOString().slice(0, 10),
rounds: todayRounds,
posts: todayPosts,
completed: false,
in_progress: true,
});
}
return out;
}
function Preview() {
const [completed, setCompleted] = useState(3);
const [target] = useState(16);
const [todayComplete, setTodayComplete] = useState(false);
const [todayRounds, setTodayRounds] = useState(8);
const [todayPosts, setTodayPosts] = useState(0);
const [growth, setGrowth] = useState(60);
const days = buildDays(completed, target, todayRounds, todayPosts, todayComplete);
const streak = completed; // current streak = finished folds
const alive = growth > 0;
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
Dev
</span>
<h1 className="font-[family-name:var(--font-title)] text-xl font-semibold text-foreground">
Vine preview
</h1>
</div>
{/* Controls */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 rounded-2xl border border-border bg-card p-4">
<Slider
label="Streak (finished folds)"
value={completed}
min={0}
max={30}
onChange={setCompleted}
/>
<Slider
label="Health (growth)"
value={growth}
min={0}
max={100}
onChange={setGrowth}
/>
<Slider
label="Today's rounds"
value={todayRounds}
min={0}
max={target}
disabled={todayComplete}
onChange={setTodayRounds}
/>
<Slider
label="Today's posts"
value={todayPosts}
min={0}
max={3}
disabled={todayComplete}
onChange={setTodayPosts}
/>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={todayComplete}
onChange={(e) => setTodayComplete(e.target.checked)}
className="size-4 accent-primary"
/>
Today already complete
</label>
<div className="flex flex-wrap gap-2">
<Chip onClick={() => setGrowth(Math.min(100, completed * 4))}>
sync health → streak
</Chip>
<Chip onClick={() => setGrowth(0)}>kill</Chip>
</div>
</div>
{/* Live vine — same panel chrome as the real page */}
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
<SadhnaVine
days={days}
targetRounds={target}
growth={growth}
alive={alive}
totalStreak={streak}
/>
</section>
</main>
);
}
function Slider({
label,
value,
min,
max,
onChange,
disabled,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<div className={cn("space-y-1", disabled && "opacity-40")}>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums text-foreground">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
function Chip({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
{children}
</button>
);
}
+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}
@@ -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));
}
@@ -27,6 +27,7 @@ import {
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. */
@@ -86,13 +87,26 @@ export function NotificationsProvider({
// Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body).
useEffect(() => {
if (!user) return;
if (!user?.id) return;
refreshUnread();
}, [user?.id, refreshUnread]);
// Open the realtime connection while signed in.
// 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) return;
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
@@ -100,7 +114,7 @@ export function NotificationsProvider({
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${user.id}`;
const channelName = `notifications.${userId}`;
let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
@@ -124,6 +138,16 @@ export function NotificationsProvider({
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);
@@ -135,7 +159,7 @@ export function NotificationsProvider({
cancelled = true;
cleanup?.();
};
}, [user?.id]);
}, [userId]);
return (
<NotificationsContext.Provider
+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",
});
}
+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";
+2 -1
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();
};
}, []);
@@ -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;
}
+54
View File
@@ -91,6 +91,29 @@ export type SadhnaDashboard = {
// 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 ───────────────────────────────────────────────────────────────
@@ -99,10 +122,41 @@ 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,
);
}
+5
View File
@@ -5,6 +5,11 @@ const nextConfig: NextConfig = {
turbopack: {
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 [
{
+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);
}),
);
});