// 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
}