Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
@@ -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;
}