import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import type { ReactNode } from 'react'; import { ZinoClient } from './client'; import type { User } from './types'; interface ZinoContextValue { client: ZinoClient; user: User | null; login: (email: string, password: string, orgId?: string) => Promise; logout: () => void; } const ZinoContext = createContext(null); export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) { // One client per provider instance. onAuthError clears the user so routes // bounce back to /login. const clientRef = useRef(); if (!clientRef.current) { clientRef.current = new ZinoClient(baseUrl); } const client = clientRef.current; // Restore the session synchronously from the persisted JWT so a hard // navigation to a protected route doesn't flash through /login. const [user, setUser] = useState(() => client.currentUser()); useEffect(() => { client.setAuthErrorHandler(() => setUser(null)); }, [client]); const value = useMemo( () => ({ client, user, login: async (email, password, orgId) => { const res = await client.login(email, password, orgId); setUser(res.user ?? client.currentUser()); }, logout: () => { client.logout(); setUser(null); }, }), [client, user], ); return {children}; } export function useZino(): ZinoContextValue { const ctx = useContext(ZinoContext); if (!ctx) throw new Error('useZino must be used within '); return ctx; } // --- minimal data-fetching hook (no extra deps) --- export interface QueryState { data: T | null; loading: boolean; error: string | null; refetch: () => void; } /** * Fire `fn` whenever a dependency in `deps` changes. Tracks loading/error and * ignores results from a stale call. `enabled=false` short-circuits. * `refetchMs` (opt-in) polls on that interval; the interval is cleared on * unmount, dep change, or when disabled. */ export function useQuery( fn: () => Promise, deps: unknown[], enabled = true, refetchMs?: number, ): QueryState { const [data, setData] = useState(null); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); const [tick, setTick] = useState(0); const refetch = useCallback(() => setTick((t) => t + 1), []); useEffect(() => { if (!enabled) { setLoading(false); return; } let live = true; setLoading(true); setError(null); fn() .then((d) => { if (live) setData(d); }) .catch((e: unknown) => { if (live) setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Request failed')); }) .finally(() => { if (live) setLoading(false); }); return () => { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, tick, enabled]); // Opt-in background polling. Kept separate from the fetch effect so the // interval doesn't restart on every refetch; ticking triggers it instead. useEffect(() => { if (!enabled || !refetchMs || refetchMs <= 0) return; const id = setInterval(() => setTick((t) => t + 1), refetchMs); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, enabled, refetchMs]); return { data, loading, error, refetch }; }