/** * AuthenticatedUserStore * * Single global store for the signed-in user's identity + billing state, * backed by localStorage (`twwim_authenticated_user`). Components subscribe * via `useAuthenticatedUser()` (useSyncExternalStore) so any `set()` or * `update()` re-renders every reader without React Context or Query round- * trips. * * Why not React Query? This is client-owned state — hydrated at login, * patched by mutation onSuccess handlers, persisted across reloads. A * server-cache abstraction would fight the "seed once, patch locally" * model. The store purposefully mirrors localStorage and nothing else. * * Legacy `archer_*` token keys stay in `LocalTokenStorage`; this store * replaces the old `archer_user` blob with a twwim-prefixed payload that * carries the full AuthenticatedUser (including the account snapshot). * * (c) 2026 TWWIM UG. All rights reserved. (www.twwim.com) */ import { AuthenticatedUser } from '@/domain/entities/AuthenticatedUser'; import { AuthenticatedUserMapper } from '@/infrastructure/http/api/auth/mappers/AuthenticatedUserMapper'; const STORAGE_KEY = 'twwim_authenticated_user'; type UpdatePatch = Parameters[0]; class AuthenticatedUserStoreImpl { private listeners = new Set<() => void>(); // useSyncExternalStore demands a stable snapshot reference — React bails // out of renders only when getSnapshot returns identical identity. We hold // the hydrated instance here and swap it on writes. private snapshot: AuthenticatedUser | null = null; private hydrated = false; private hydrate(): void { if (this.hydrated) return; this.snapshot = AuthenticatedUserMapper.fromStorage( typeof window === 'undefined' ? null : window.localStorage.getItem(STORAGE_KEY), ); this.hydrated = true; } /** Sync, cheap — safe to call from route guards. */ get = (): AuthenticatedUser | null => { this.hydrate(); return this.snapshot; }; set(user: AuthenticatedUser): void { this.snapshot = user; this.hydrated = true; try { window.localStorage.setItem(STORAGE_KEY, AuthenticatedUserMapper.toStorage(user)); } catch { /* storage quota / private mode — in-memory snapshot still wins */ } this.notify(); } /** Merge-patch (same semantics as AuthenticatedUser.with). No-op if unset. */ update(patch: UpdatePatch): void { this.hydrate(); if (!this.snapshot) return; this.set(this.snapshot.with(patch)); } clear(): void { this.snapshot = null; this.hydrated = true; try { window.localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ } this.notify(); } subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; private notify(): void { for (const listener of this.listeners) listener(); } } export const authenticatedUserStore = new AuthenticatedUserStoreImpl();