feat: add webpush and sadhna tracker
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
|
||||
+137
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user