// Notifications — small reactive store for in-app toasts/inbox items. // Pair with @crema/notification-ui's for transient toasts; // this store is for the appbar bell's persistent inbox. import { useSyncExternalStore } from "react" export type NotificationKind = "info" | "success" | "warning" | "error" export type AppNotification = { id: string kind: NotificationKind title: string body?: string // Optional href to open when the row is clicked. href?: string createdAt: number readAt?: number } const STORAGE_KEY = "crema.notifications" const CHANGE_EVENT = "crema:notifications-change" const MAX_ITEMS = 200 function newId(): string { return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` } function readFromStorage(): AppNotification[] { if (typeof window === "undefined") return [] try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return [] const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed.filter( (n): n is AppNotification => n && typeof n.id === "string" && typeof n.title === "string" && typeof n.createdAt === "number" && ["info", "success", "warning", "error"].includes(n.kind), ) } catch { return [] } } function writeToStorage(items: AppNotification[]) { if (typeof window === "undefined") return const trimmed = items.slice(0, MAX_ITEMS) try { localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)) window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) } catch { /* quota — drop silently */ } } export function loadNotifications(): AppNotification[] { return readFromStorage() } export function addNotification( n: Omit, ): AppNotification { const next: AppNotification = { ...n, id: newId(), createdAt: Date.now(), } writeToStorage([next, ...readFromStorage()]) return next } export function markRead(id: string) { const items = readFromStorage().map((n) => n.id === id ? { ...n, readAt: Date.now() } : n, ) writeToStorage(items) } export function markAllRead() { const now = Date.now() const items = readFromStorage().map((n) => n.readAt ? n : { ...n, readAt: now }, ) writeToStorage(items) } export function dismiss(id: string) { writeToStorage(readFromStorage().filter((n) => n.id !== id)) } export function dismissAll() { writeToStorage([]) } // Cache keyed on the raw stored string so the snapshot stays referentially // stable — `useSyncExternalStore` requires that getSnapshot return the same // reference until the value genuinely changes. (This used to clear a flag on // every mount without notifying subscribers, the same identity-churn bug that // was fixed in session.ts.) let cached: AppNotification[] = [] let cachedRaw: string | null = null let primed = false function readRaw(): string | null { if (typeof window === "undefined") return null try { return localStorage.getItem(STORAGE_KEY) } catch { return null } } function subscribe(cb: () => void): () => void { const onChange = () => { primed = false cb() } window.addEventListener(CHANGE_EVENT, onChange) window.addEventListener("storage", (e) => { if (e.key === STORAGE_KEY) onChange() }) return () => window.removeEventListener(CHANGE_EVENT, onChange) } function getSnapshot(): AppNotification[] { const raw = readRaw() if (!primed || raw !== cachedRaw) { cachedRaw = raw cached = readFromStorage() primed = true } return cached } function getServerSnapshot(): AppNotification[] { return [] } export function useNotifications(): AppNotification[] { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } export function unreadCount(items: AppNotification[]): number { return items.filter((n) => !n.readAt).length }