This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent d9f159a8c3
commit cda5d21d0d
53 changed files with 2711 additions and 162 deletions
@@ -0,0 +1,124 @@
'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;
}