Stage M5 and Stage M6
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Cart, Locale } from '@sport/types';
|
||||
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
interface CartState {
|
||||
readonly cart: Cart | null;
|
||||
/** True until the first load resolves, so the badge can stay quiet. */
|
||||
readonly loading: boolean;
|
||||
readonly pending: boolean;
|
||||
readonly error: string | null;
|
||||
addLine: (variantId: string, quantity?: number) => Promise<boolean>;
|
||||
setQuantity: (variantId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (variantId: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartState | null>(null);
|
||||
|
||||
/**
|
||||
* One cart, shared by the header badge, the PDP button and the cart page.
|
||||
*
|
||||
* The server is the only source of truth: every mutation returns the whole
|
||||
* recomputed cart and that response replaces local state wholesale. There is no
|
||||
* optimistic arithmetic here on purpose — the API adjusts quantities against
|
||||
* live stock, so a locally incremented number would regularly disagree with the
|
||||
* total on the next render, which is the one number a shopper actually checks.
|
||||
*
|
||||
* The bag is identified by an httpOnly cookie, so there is nothing to persist
|
||||
* and nothing to hydrate from storage.
|
||||
*/
|
||||
export function CartProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = useCallback(async (operation: () => Promise<Cart>): Promise<boolean> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setCart(await operation());
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : 'Something went wrong.');
|
||||
return false;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Re-reads the bag after something outside this provider changed it. */
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setCart(await browserApi.commerce.getCart(locale));
|
||||
} catch {
|
||||
// Same reasoning as the initial load: silence beats a banner.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// The first read owns its own lifecycle rather than delegating to `refresh`,
|
||||
// so it can be cancelled if the locale changes mid-flight and it never
|
||||
// resolves into an unmounted provider.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.commerce.getCart(locale);
|
||||
if (!cancelled) setCart(result);
|
||||
} catch {
|
||||
// A bag that cannot be read is not worth an error banner on every
|
||||
// page — the badge stays empty and the next action retries.
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
const value = useMemo<CartState>(
|
||||
() => ({
|
||||
cart,
|
||||
loading,
|
||||
pending,
|
||||
error,
|
||||
addLine: (variantId, quantity = 1) =>
|
||||
run(() => browserApi.commerce.addCartLine(locale, { variantId, quantity })),
|
||||
setQuantity: async (variantId, quantity) => {
|
||||
await run(() => browserApi.commerce.updateCartLine(locale, variantId, quantity));
|
||||
},
|
||||
removeLine: async (variantId) => {
|
||||
await run(() => browserApi.commerce.removeCartLine(locale, variantId));
|
||||
},
|
||||
refresh,
|
||||
}),
|
||||
[cart, loading, pending, error, locale, run, refresh],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart(): CartState {
|
||||
const context = useContext(CartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCart must be used inside a CartProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user