From 53e00863f76360a306c8355f73170488c16e9cb5 Mon Sep 17 00:00:00 2001 From: gosangam Date: Mon, 31 Aug 2026 12:15:19 +0530 Subject: [PATCH] feat: edit Vani of the day --- app/(dashboard)/layout.tsx | 2 + app/(dashboard)/vani/page.tsx | 217 ++++++++++++++++++++++++++++++++++ lib/api.ts | 69 +++++++++++ 3 files changed, 288 insertions(+) create mode 100644 app/(dashboard)/vani/page.tsx diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 8950d0c..aa8539e 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -7,6 +7,7 @@ import { Flag, Gift, Megaphone, + Quote, FolderClosed, LayoutDashboard, LayoutGrid, @@ -30,6 +31,7 @@ const NAV = [ { href: "/reports", label: "Reports", icon: Flag }, { href: "/broadcasts", label: "Announcements", icon: Megaphone }, { href: "/reward-gifts", label: "Gifts", icon: Gift }, + { href: "/vani", label: "Vāṇī of the Day", icon: Quote }, ]; export default function DashboardLayout({ diff --git a/app/(dashboard)/vani/page.tsx b/app/(dashboard)/vani/page.tsx new file mode 100644 index 0000000..aacf7d1 --- /dev/null +++ b/app/(dashboard)/vani/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +/** + * /vani — choose the Vāṇī of the Day. + * + * Days without a pinned pick are left alone: the BFF chooses one at random on + * the day's first request, avoiding anything served in the last 90 days. This + * page only overrides — so an empty schedule is the normal, healthy state, not + * something to fill in. + * + * Pinning sends only a reference. The BFF rebuilds the display blob with the + * same builders the random picker uses, so a pinned pick renders exactly like + * an automatic one. + */ + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + vani, + vaniKindFor, + type VaniCandidate, + type VaniPick, +} from "@/lib/api"; + +/** Today in UTC — picks are keyed on a UTC calendar day. */ +function todayUTC(): string { + return new Date().toISOString().slice(0, 10); +} + +export default function VaniPage() { + const [schedule, setSchedule] = useState(null); + const [day, setDay] = useState(todayUTC()); + const [query, setQuery] = useState(""); + const [results, setResults] = useState<{ q: string; rows: VaniCandidate[] }>({ + q: "", + rows: [], + }); + const [busy, setBusy] = useState(false); + + const refresh = () => + vani + .schedule() + .then(setSchedule) + .catch(() => { + toast.error("Could not load the schedule"); + setSchedule([]); + }); + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const q = query.trim(); + const searchable = q.length >= 2; + // Results carry the query they belong to, so a stale page simply stops + // matching rather than needing to be cleared. + const visible = results.q === q ? results.rows : []; + + useEffect(() => { + if (!searchable) return; + const timer = setTimeout(() => { + vani + .search(q) + .then((rows) => setResults({ q, rows })) + .catch(() => setResults({ q, rows: [] })); + }, 300); + return () => clearTimeout(timer); + }, [q, searchable]); + + const pin = async (candidate: VaniCandidate) => { + const kind = vaniKindFor(candidate.collection); + if (!kind) return; + setBusy(true); + try { + await vani.set({ day, kind, ref_id: candidate.doc_id }); + toast.success(`Set for ${day}`); + setQuery(""); + setResults({ q: "", rows: [] }); + await refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not set it"); + } finally { + setBusy(false); + } + }; + + const clear = async (pick: VaniPick) => { + if ( + !window.confirm( + `Clear ${pick.day}? A random pick will be chosen instead.`, + ) + ) { + return; + } + setBusy(true); + try { + await vani.clear(pick.day); + toast.success("Cleared"); + await refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not clear"); + } finally { + setBusy(false); + } + }; + + const field = + "rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"; + + return ( +
+
+

+ Vāṇī of the Day +

+

+ Pin a verse, chapter or lecture to a date. Days you leave alone get a + random pick that avoids anything shown in the last 90 days. +

+
+ +
+
+ + setQuery(e.target.value)} + placeholder="Search verses, chapters and lectures" + className={`${field} min-w-0 flex-1`} + /> +
+ + {!searchable ? ( +

+ Type at least two characters to search. +

+ ) : visible.length === 0 ? ( +

Nothing found.

+ ) : ( +
    + {visible.map((c) => ( +
  • +
    +

    {c.title}

    +

    + {vaniKindFor(c.collection)} · {c.snippet} +

    +
    + +
  • + ))} +
+ )} +
+ +
+

+ Pinned from today onward +

+ {schedule === null ? ( +

Loading…

+ ) : schedule.length === 0 ? ( +

+ Nothing pinned — every day is chosen at random. +

+ ) : ( +
    + {schedule.map((p) => ( +
  • + + {p.day} + +
    +

    + {p.payload.title} +

    +

    + {p.payload.label} + {p.payload.book_title ? ` · ${p.payload.book_title}` : ""} +

    +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/lib/api.ts b/lib/api.ts index b844d70..8a9c461 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -480,3 +480,72 @@ export const rewardGifts = { body: JSON.stringify({ reward_key: rewardKey }), }).then((r) => r.data), }; + +// ─── Vāṇī of the Day ──────────────────────────────────────────────────────── + +/** One decided day. `payload` is the blob the home-page CTA renders. */ +export type VaniPick = { + day: string; + kind: "verse" | "prose" | "transcript"; + ref_id: string; + payload: { + kind: string; + label: string; + title: string; + subtitle: string; + href: string; + book_title?: string; + date?: string; + }; +}; + +/** A search hit, narrowed to what pinning needs. */ +export type VaniCandidate = { + collection: string; + doc_id: string; + title: string; + snippet: string; + url: string; +}; + +/** Typesense collection → the kind the vani picker uses. */ +const VANI_KINDS: Record = { + verses: "verse", + prose: "prose", + transcriptions: "transcript", +}; + +export function vaniKindFor(collection: string): VaniPick["kind"] | null { + return VANI_KINDS[collection] ?? null; +} + +export const vani = { + /** Decided days from today onward. */ + schedule: () => call("/admin/vani?limit=30").then((r) => r.data), + + /** Pin one item to a day. Only the reference travels — the BFF rebuilds the + * display blob, so a pinned pick renders exactly like an automatic one. */ + set: (input: { day: string; kind: VaniPick["kind"]; ref_id: string }) => + call("/admin/vani", { + method: "POST", + body: JSON.stringify(input), + }).then((r) => r.data), + + /** Hand the day back to the random picker. */ + clear: (day: string) => + call(`/admin/vani/${encodeURIComponent(day)}`, { method: "DELETE" }), + + /** + * Search the three vāṇī-eligible collections. Uses the public search + * endpoint — the same index the app searches — and keeps only hits that map + * to a pinnable kind. + */ + search: async (q: string): Promise => { + const { data } = await call<{ results: VaniCandidate[] }>( + `/search?type=all&limit=20&q=${encodeURIComponent(q)}`, + ); + return (data?.results ?? []).filter( + (r) => r.doc_id && vaniKindFor(r.collection), + ); + }, +};