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,559 @@
'use client';
import { Heart, Package, Trash2 } from 'lucide-react';
import { useFormatter, useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { isApiClientError, type AddressPayload } from '@sport/api-client';
import type {
CustomerAddress,
CustomerProfile,
Locale,
OrderListItem,
ProductListItem,
} from '@sport/types';
import { Badge, Button, Input, Skeleton } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* Shared loading/error scaffolding, so each panel is just its own content.
*
* `load` must be a stable reference (wrap it in `useCallback`) — it is a real
* dependency of the effect, and papering over that with a ref would be lying to
* the compiler about what this hook re-runs on.
*
* `refresh()` bumps a nonce rather than fetching directly, so every state
* update happens inside the effect behind the `cancelled` guard. A response
* that lands after the panel unmounted, or after a newer request was fired, is
* discarded instead of writing to dead state.
*/
function usePanel<T>(load: () => Promise<T>) {
const t = useTranslations('account');
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let cancelled = false;
load()
.then((result) => {
if (!cancelled) {
setData(result);
setError(null);
}
})
.catch((caught: unknown) => {
if (!cancelled) {
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
});
return () => {
cancelled = true;
};
}, [load, nonce, t]);
const refresh = useCallback(async () => {
setNonce((current) => current + 1);
}, []);
return { data, error, refresh, setData };
}
// ---- Overview ---------------------------------------------------------------
export function AccountOverview() {
const t = useTranslations('account');
const format = useFormatter();
const { data: orders } = usePanel(
useCallback(() => browserApi.account.listOrders({ perPage: 3 }), []),
);
return (
<div className="space-y-8">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.overview')}</h1>
<section>
<div className="flex items-baseline justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('recentOrders')}</h2>
<Link href={routes.accountOrders()} className="text-ink-500 hover:text-ink-950 text-xs">
{t('viewAll')}
</Link>
</div>
{!orders ? (
<Skeleton className="mt-4 h-24 w-full" />
) : orders.items.length === 0 ? (
<p className="text-ink-500 mt-4 text-sm">{t('noOrders')}</p>
) : (
<ul className="divide-ink-100 mt-4 divide-y">
{orders.items.map((order) => (
<OrderRow key={order.id} order={order} format={format} />
))}
</ul>
)}
</section>
</div>
);
}
// ---- Orders -----------------------------------------------------------------
export function AccountOrders() {
const t = useTranslations('account');
const format = useFormatter();
const { data, error } = usePanel(
useCallback(() => browserApi.account.listOrders({ perPage: 50 }), []),
);
return (
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.orders')}</h1>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!data ? (
<Skeleton className="h-40 w-full" />
) : data.items.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noOrders')}</p>
) : (
<ul className="divide-ink-100 divide-y border-y">
{data.items.map((order) => (
<OrderRow key={order.id} order={order} format={format} />
))}
</ul>
)}
</div>
);
}
function OrderRow({
order,
format,
}: {
order: OrderListItem;
format: ReturnType<typeof useFormatter>;
}) {
const t = useTranslations('account');
return (
<li className="flex flex-wrap items-center gap-4 py-4">
<Package className="text-ink-300 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-mono text-sm font-medium">{order.orderNumber}</p>
<p className="text-ink-500 text-xs">
{format.dateTime(new Date(order.placedAt), { dateStyle: 'medium' })} ·{' '}
{t('itemCount', { count: order.itemCount })}
</p>
</div>
<Badge variant="neutral">{order.status}</Badge>
<p className="text-sm font-semibold">{formatMoney(order.total, format)}</p>
</li>
);
}
// ---- Addresses --------------------------------------------------------------
const EMPTY_ADDRESS: AddressPayload = {
fullName: '',
phone: '',
line1: '',
ward: '',
district: '',
province: '',
isDefaultShipping: false,
};
export function AccountAddresses() {
const t = useTranslations('account');
const { data, error, refresh } = usePanel(
useCallback(() => browserApi.account.listAddresses(), []),
);
const [draft, setDraft] = useState<AddressPayload | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
async function save(event: FormEvent) {
event.preventDefault();
if (!draft) return;
setBusy(true);
setFormError(null);
try {
if (editingId) {
await browserApi.account.updateAddress(editingId, draft);
} else {
await browserApi.account.createAddress(draft);
}
setDraft(null);
setEditingId(null);
await refresh();
} catch (caught) {
setFormError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setBusy(false);
}
}
async function remove(id: string) {
await browserApi.account.deleteAddress(id);
await refresh();
}
function edit(address: CustomerAddress) {
setEditingId(address.id);
setDraft({
fullName: address.fullName,
phone: address.phone,
line1: address.line1,
line2: address.line2 ?? '',
ward: address.ward ?? '',
district: address.district ?? '',
province: address.province,
isDefaultShipping: address.isDefaultShipping,
isDefaultBilling: address.isDefaultBilling,
});
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.addresses')}</h1>
{!draft ? (
<Button
size="sm"
onClick={() => {
setEditingId(null);
setDraft({ ...EMPTY_ADDRESS });
}}
>
{t('addAddress')}
</Button>
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{draft ? (
<form onSubmit={save} className="border-ink-200 space-y-4 border p-5">
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t('fullName')}
value={draft.fullName}
onChange={(v) => setDraft({ ...draft, fullName: v })}
required
/>
<Field
label={t('phone')}
value={draft.phone}
onChange={(v) => setDraft({ ...draft, phone: v })}
required
/>
</div>
<Field
label={t('line1')}
value={draft.line1}
onChange={(v) => setDraft({ ...draft, line1: v })}
required
/>
<div className="grid gap-4 sm:grid-cols-3">
<Field
label={t('ward')}
value={draft.ward ?? ''}
onChange={(v) => setDraft({ ...draft, ward: v })}
/>
<Field
label={t('district')}
value={draft.district ?? ''}
onChange={(v) => setDraft({ ...draft, district: v })}
/>
<Field
label={t('province')}
value={draft.province}
onChange={(v) => setDraft({ ...draft, province: v })}
required
/>
</div>
<label className="flex items-center gap-2.5">
<input
type="checkbox"
checked={draft.isDefaultShipping ?? false}
onChange={(event) => setDraft({ ...draft, isDefaultShipping: event.target.checked })}
className="border-ink-300 accent-ink-950 size-4"
/>
<span className="text-sm">{t('makeDefault')}</span>
</label>
{formError ? (
<p role="alert" className="text-danger text-sm">
{formError}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={busy}>
{busy ? t('saving') : t('save')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setDraft(null);
setEditingId(null);
}}
>
{t('cancel')}
</Button>
</div>
</form>
) : null}
{!data ? (
<Skeleton className="h-32 w-full" />
) : data.length === 0 && !draft ? (
<p className="text-ink-500 text-sm">{t('noAddresses')}</p>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{data.map((address) => (
<li key={address.id} className="border-ink-200 space-y-2 border p-4">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold">{address.fullName}</p>
{address.isDefaultShipping ? <Badge variant="success">{t('default')}</Badge> : null}
</div>
<p className="text-ink-600 text-sm leading-relaxed">
{address.phone}
<br />
{[address.line1, address.ward, address.district, address.province]
.filter(Boolean)
.join(', ')}
</p>
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => edit(address)}>
{t('edit')}
</Button>
<Button variant="ghost" size="sm" onClick={() => void remove(address.id)}>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
function Field({
label,
value,
onChange,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
}) {
return (
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{label}</span>
<Input value={value} onChange={(event) => onChange(event.target.value)} required={required} />
</label>
);
}
// ---- Profile ----------------------------------------------------------------
export function AccountProfile() {
const t = useTranslations('account');
const { data, error, setData } = usePanel(useCallback(() => browserApi.account.getProfile(), []));
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
async function save(event: FormEvent) {
event.preventDefault();
if (!data) return;
setBusy(true);
setSaved(false);
setFormError(null);
try {
const updated = await browserApi.account.updateProfile({
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone || null,
acceptsMarketing: data.acceptsMarketing,
});
setData(updated);
setSaved(true);
} catch (caught) {
setFormError(isApiClientError(caught) ? caught.message : t('saveFailed'));
} finally {
setBusy(false);
}
}
if (error) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!data) return <Skeleton className="h-64 w-full" />;
const patch = (fields: Partial<CustomerProfile>) => setData({ ...data, ...fields });
return (
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.profile')}</h1>
<form onSubmit={save} className="max-w-md space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t('firstName')}
value={data.firstName}
onChange={(v) => patch({ firstName: v })}
required
/>
<Field
label={t('lastName')}
value={data.lastName}
onChange={(v) => patch({ lastName: v })}
required
/>
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('email')}</span>
{/*
Read-only: changing the address an account signs in with is an
identity change, not a profile edit, and needs its own verified
flow. The API refuses it too — this input simply says so.
*/}
<Input value={data.email} readOnly disabled />
<span className="text-ink-400 text-xs">{t('emailLocked')}</span>
</label>
<Field label={t('phone')} value={data.phone ?? ''} onChange={(v) => patch({ phone: v })} />
<label className="flex items-start gap-2.5">
<input
type="checkbox"
checked={data.acceptsMarketing}
onChange={(event) => patch({ acceptsMarketing: event.target.checked })}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
/>
<span className="text-ink-600 text-sm">{t('marketing')}</span>
</label>
{formError ? (
<p role="alert" className="text-danger text-sm">
{formError}
</p>
) : null}
{saved ? <p className="text-success text-sm">{t('saved')}</p> : null}
<Button type="submit" disabled={busy}>
{busy ? t('saving') : t('save')}
</Button>
</form>
</div>
);
}
// ---- Wishlist ---------------------------------------------------------------
export function AccountWishlist() {
const t = useTranslations('account');
const locale = useLocale() as Locale;
/**
* Two calls: the account owns the ids, the catalog owns what a product looks
* like. Resolving them here rather than having the account endpoint return
* product cards keeps CustomersModule from depending on ProductsModule for a
* presentation concern.
*/
const { data, error, refresh } = usePanel(
useCallback(async (): Promise<ProductListItem[]> => {
const ids = await browserApi.account.listWishlist();
if (ids.length === 0) return [];
const result = await browserApi.catalog.listProducts(locale, { ids, limit: ids.length });
// Restore the wishlist's own order — the catalog returns its default
// sort, and "most recently saved first" is what matters here.
const byId = new Map(result.items.map((item) => [item.id, item]));
return ids.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
}, [locale]),
);
return (
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.wishlist')}</h1>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!data ? (
<Skeleton className="h-32 w-full" />
) : data.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noWishlist')}</p>
) : (
<ul className="divide-ink-100 divide-y border-y">
{data.map((product) => (
<li key={product.id} className="flex items-center gap-4 py-4">
<Heart className="fill-ink-950 text-ink-950 size-4 shrink-0" />
<Link href={routes.product(product.slug)} className="min-w-0 flex-1 hover:underline">
<span className="block truncate text-sm font-medium">{product.name}</span>
{product.brandName ? (
<span className="text-ink-500 block text-xs">{product.brandName}</span>
) : null}
</Link>
<Button
variant="ghost"
size="sm"
onClick={async () => {
await browserApi.account.removeFromWishlist(product.id);
await refresh();
}}
>
{t('remove')}
</Button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,98 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, type ReactNode } from 'react';
import { Button, Skeleton, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
import { useCustomer } from './customer-provider';
const NAV = [
{ href: routes.account(), key: 'overview' },
{ href: routes.accountOrders(), key: 'orders' },
{ href: routes.accountAddresses(), key: 'addresses' },
{ href: routes.accountWishlist(), key: 'wishlist' },
{ href: routes.accountProfile(), key: 'profile' },
] as const;
/**
* The signed-in shell around every /account page.
*
* Guards on the client rather than in middleware, because the session lives in
* an httpOnly cookie that only the API can validate — middleware would have to
* call the API on every navigation to learn anything the provider already
* knows. The API re-checks every request regardless, so this is presentation:
* it decides what to *show*, never what is *allowed*.
*/
export function AccountShell({ children }: { children: ReactNode }) {
const t = useTranslations('account');
const { customer, status, signOut } = useCustomer();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (status !== 'anonymous') return;
// `next` so signing in returns them to the page they asked for, rather
// than dumping everyone on the account overview.
router.replace(`${routes.signIn()}?next=${encodeURIComponent(pathname)}`);
}, [status, router, pathname]);
if (status === 'loading') {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-64 w-full" />
</div>
);
}
// The redirect above is already in flight; rendering nothing avoids a flash
// of the empty account page on the way out.
if (!customer) return null;
return (
<div className="grid gap-10 lg:grid-cols-[13rem_1fr]">
<aside>
<p className="text-xs font-semibold uppercase tracking-widest">{t('greeting')}</p>
<p className="mt-1 text-lg font-bold">{customer.displayName}</p>
<nav className="mt-6">
<ul className="space-y-0.5">
{NAV.map((item) => {
const active =
item.href === routes.account()
? pathname.endsWith('/account')
: pathname.includes(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'block px-3 py-2 text-sm transition-colors',
active ? 'bg-ink-950 text-white' : 'text-ink-600 hover:bg-ink-100',
)}
>
{t(`nav.${item.key}`)}
</Link>
</li>
);
})}
</ul>
</nav>
<Button variant="ghost" size="sm" className="mt-6" onClick={() => void signOut()}>
{t('signOut')}
</Button>
</aside>
<div className="min-w-0">{children}</div>
</div>
);
}
@@ -0,0 +1,188 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react';
import { isApiClientError } from '@sport/api-client';
import { Button, Input } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
import { useCustomer } from './customer-provider';
/**
* Sign in, or create an account.
*
* One component for both because the difference is three extra fields and which
* call it makes — two near-identical forms would drift in validation, error
* handling and layout, and this is the screen where that shows most.
*/
export function AuthForm({ mode }: { mode: 'signIn' | 'register' }) {
const t = useTranslations('auth');
const router = useRouter();
const params = useSearchParams();
const { signIn, register } = useCustomer();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isRegister = mode === 'register';
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setBusy(true);
try {
if (isRegister) {
await register({
email,
password,
firstName,
lastName,
phone: phone.trim() || undefined,
acceptsMarketing,
});
} else {
await signIn(email, password);
}
/**
* Return to where they were headed, if anywhere.
*
* Only same-origin paths are honoured: `?next=https://evil.example` would
* otherwise turn the sign-in form into an open redirect that phishing can
* point at.
*/
const next = params.get('next');
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : null;
router.replace(safeNext ?? routes.account());
router.refresh();
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('failed'));
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto w-full max-w-sm">
<h1 className="text-3xl font-black uppercase tracking-tight">
{isRegister ? t('registerTitle') : t('signInTitle')}
</h1>
<p className="text-ink-500 mt-2 text-sm">
{isRegister ? t('registerIntro') : t('signInIntro')}
</p>
<form onSubmit={handleSubmit} className="mt-8 space-y-4">
{isRegister ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('firstName')}
</span>
<Input
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
autoComplete="given-name"
required
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('lastName')}
</span>
<Input
value={lastName}
onChange={(event) => setLastName(event.target.value)}
autoComplete="family-name"
required
/>
</label>
</div>
) : null}
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('email')}</span>
<Input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
required
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('password')}</span>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
// `new-password` on the register form tells a password manager to
// offer a generated one instead of autofilling the existing login.
autoComplete={isRegister ? 'new-password' : 'current-password'}
required
/>
{isRegister ? <span className="text-ink-400 text-xs">{t('passwordHint')}</span> : null}
</label>
{isRegister ? (
<>
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">
{t('phone')} <span className="text-ink-400 normal-case">{t('optional')}</span>
</span>
<Input
type="tel"
value={phone}
onChange={(event) => setPhone(event.target.value)}
autoComplete="tel"
placeholder="0912345678"
/>
</label>
<label className="flex items-start gap-2.5">
<input
type="checkbox"
checked={acceptsMarketing}
onChange={(event) => setAcceptsMarketing(event.target.checked)}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
/>
<span className="text-ink-600 text-sm">{t('marketing')}</span>
</label>
</>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<Button type="submit" disabled={busy} className="w-full">
{busy ? t('working') : isRegister ? t('createAccount') : t('signIn')}
</Button>
</form>
<p className="text-ink-500 mt-6 text-sm">
{isRegister ? t('haveAccount') : t('noAccount')}{' '}
<Link
href={isRegister ? routes.signIn() : routes.register()}
className="text-ink-950 underline underline-offset-2"
>
{isRegister ? t('signIn') : t('createAccount')}
</Link>
</p>
</div>
);
}
@@ -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;
}
@@ -0,0 +1,100 @@
'use client';
import { Heart } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { Button, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
import { routes } from '@/lib/routes';
import { useCustomer } from './customer-provider';
/**
* Save a product for later.
*
* Anonymous shoppers are sent to sign-in with `next` pointing back at the
* product, rather than the button silently doing nothing or the wishlist being
* kept in localStorage. A local wishlist reads as working right up until the
* shopper switches device, which is exactly when they go looking for it.
*/
export function WishlistButton({
productId,
productSlug,
}: {
productId: string;
productSlug: string;
}) {
const t = useTranslations('account');
const router = useRouter();
const { status } = useCustomer();
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
// Derived rather than stored: signing out must un-fill the heart, and doing
// that with a setState inside an effect is a cascading render for a value
// that was already knowable during render.
const isSaved = status === 'authenticated' && saved;
useEffect(() => {
if (status !== 'authenticated') return;
let cancelled = false;
browserApi.account
.listWishlist()
.then((ids) => {
if (!cancelled) setSaved(ids.includes(productId));
})
.catch(() => {
// A wishlist that cannot be read is a button that shows "not saved" —
// never an error on a product page.
});
return () => {
cancelled = true;
};
}, [status, productId]);
async function toggle() {
if (status !== 'authenticated') {
router.push(`${routes.signIn()}?next=${encodeURIComponent(routes.product(productSlug))}`);
return;
}
setBusy(true);
// Optimistic: the heart is the feedback, and waiting a round-trip to fill
// it makes the tap feel broken. Reverted below if the call fails.
const next = !isSaved;
setSaved(next);
try {
if (next) {
await browserApi.account.addToWishlist(productId);
} else {
await browserApi.account.removeFromWishlist(productId);
}
} catch {
setSaved(!next);
} finally {
setBusy(false);
}
}
return (
<Button
type="button"
variant="outline"
className="w-full"
disabled={busy}
aria-pressed={isSaved}
onClick={() => void toggle()}
>
<Heart className={cn('size-4', isSaved ? 'fill-ink-950 text-ink-950' : '')} />
{isSaved ? t('savedLabel') : t('saveForLater')}
</Button>
);
}