128 lines
3.6 KiB
TypeScript
128 lines
3.6 KiB
TypeScript
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<void>;
|
|
logout: () => void;
|
|
}
|
|
|
|
const ZinoContext = createContext<ZinoContextValue | null>(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<ZinoClient>();
|
|
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<User | null>(() => client.currentUser());
|
|
|
|
useEffect(() => {
|
|
client.setAuthErrorHandler(() => setUser(null));
|
|
}, [client]);
|
|
|
|
const value = useMemo<ZinoContextValue>(
|
|
() => ({
|
|
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 <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>;
|
|
}
|
|
|
|
export function useZino(): ZinoContextValue {
|
|
const ctx = useContext(ZinoContext);
|
|
if (!ctx) throw new Error('useZino must be used within <ZinoProvider>');
|
|
return ctx;
|
|
}
|
|
|
|
// --- minimal data-fetching hook (no extra deps) ---
|
|
|
|
export interface QueryState<T> {
|
|
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<T>(
|
|
fn: () => Promise<T>,
|
|
deps: unknown[],
|
|
enabled = true,
|
|
refetchMs?: number,
|
|
): QueryState<T> {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [loading, setLoading] = useState(enabled);
|
|
const [error, setError] = useState<string | null>(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 };
|
|
}
|