125 lines
3.8 KiB
TypeScript
125 lines
3.8 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
|
|
import type { CurrentUser } from '@sport/types';
|
|
|
|
import { browserApi, customerTokenStore } from '@/lib/api';
|
|
|
|
interface CustomerSession {
|
|
customer: CurrentUser | null;
|
|
/** Distinguishes "not signed in" from "we have not checked yet". */
|
|
status: 'loading' | 'authenticated' | 'anonymous';
|
|
signIn: (email: string, password: string) => Promise<void>;
|
|
register: (input: {
|
|
email: string;
|
|
password: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
phone?: string;
|
|
acceptsMarketing?: boolean;
|
|
}) => Promise<void>;
|
|
signOut: () => Promise<void>;
|
|
}
|
|
|
|
const CustomerContext = createContext<CustomerSession | null>(null);
|
|
|
|
/**
|
|
* The signed-in shopper, for the whole storefront.
|
|
*
|
|
* Mounted at the root rather than inside `/account`, because the header, the
|
|
* wishlist button on a product card and checkout prefill all need to know who
|
|
* is signed in — and none of those live under `/account`.
|
|
*
|
|
* On mount it attempts one silent refresh. The access token lives only in
|
|
* memory, so a page reload always starts with nothing; the httpOnly cookie is
|
|
* what makes the session survive, and this is where it gets cashed in.
|
|
*/
|
|
export function CustomerProvider({ children }: { children: ReactNode }) {
|
|
const [customer, setCustomer] = useState<CurrentUser | null>(null);
|
|
const [status, setStatus] = useState<'loading' | 'authenticated' | 'anonymous'>('loading');
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function restore() {
|
|
try {
|
|
const refreshed = await browserApi.auth.refresh();
|
|
customerTokenStore.set(refreshed.accessToken);
|
|
|
|
const me = await browserApi.auth.me();
|
|
if (!cancelled) {
|
|
setCustomer(me);
|
|
setStatus('authenticated');
|
|
}
|
|
} catch {
|
|
// No cookie, or it expired. Being signed out is the normal case for a
|
|
// storefront, not an error worth surfacing.
|
|
if (!cancelled) {
|
|
customerTokenStore.set(null);
|
|
setStatus('anonymous');
|
|
}
|
|
}
|
|
}
|
|
|
|
void restore();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Fires when a background refresh finally fails — the session ended while
|
|
// the shopper was doing something else.
|
|
customerTokenStore.onLost(() => {
|
|
setCustomer(null);
|
|
setStatus('anonymous');
|
|
});
|
|
|
|
return () => customerTokenStore.onLost(null);
|
|
}, []);
|
|
|
|
const signIn = useCallback(async (email: string, password: string) => {
|
|
const result = await browserApi.auth.login({ email, password });
|
|
customerTokenStore.set(result.accessToken);
|
|
setCustomer(result.user);
|
|
setStatus('authenticated');
|
|
}, []);
|
|
|
|
const register = useCallback<CustomerSession['register']>(async (input) => {
|
|
const result = await browserApi.auth.register(input);
|
|
customerTokenStore.set(result.accessToken);
|
|
setCustomer(result.user);
|
|
setStatus('authenticated');
|
|
}, []);
|
|
|
|
const signOut = useCallback(async () => {
|
|
try {
|
|
await browserApi.auth.logout();
|
|
} finally {
|
|
// Local state is cleared even if the call failed: a shopper who pressed
|
|
// "sign out" must end up signed out on this device regardless.
|
|
customerTokenStore.set(null);
|
|
setCustomer(null);
|
|
setStatus('anonymous');
|
|
}
|
|
}, []);
|
|
|
|
const value = useMemo<CustomerSession>(
|
|
() => ({ customer, status, signIn, register, signOut }),
|
|
[customer, status, signIn, register, signOut],
|
|
);
|
|
|
|
return <CustomerContext.Provider value={value}>{children}</CustomerContext.Provider>;
|
|
}
|
|
|
|
export function useCustomer(): CustomerSession {
|
|
const context = useContext(CustomerContext);
|
|
if (!context) {
|
|
throw new Error('useCustomer must be used inside <CustomerProvider>');
|
|
}
|
|
|
|
return context;
|
|
}
|