// 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 { useEffect, 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([]) } let cached: AppNotification[] | null = null function subscribe(cb: () => void): () => void { const onChange = () => { cached = null 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[] { if (!cached) cached = readFromStorage() return cached } function getServerSnapshot(): AppNotification[] { return [] } export function useNotifications(): AppNotification[] { const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) useEffect(() => { cached = null }, []) return value } export function unreadCount(items: AppNotification[]): number { return items.filter((n) => !n.readAt).length } /** Seed a few demo notifications on first load so the bell isn't empty. */ export function seedIfEmpty() { if (typeof window === "undefined") return if (localStorage.getItem(STORAGE_KEY)) return const now = Date.now() const seed: AppNotification[] = [ { id: newId(), kind: "info", title: "Welcome", body: "Tag elements with data-action and the assistant can drive them.", href: "/assistant", createdAt: now - 60_000, }, { id: newId(), kind: "success", title: "Profile saved", body: "Your display name and avatar are live across the app.", href: "/profile", createdAt: now - 5 * 60_000, }, ] writeToStorage(seed) }