Stage M8
This commit is contained in:
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { AccountAddresses } from '@/features/account/account-panels';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.addresses' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// Account pages are per-person and behind a session; never indexed.
|
||||
return { title: t('nav.addresses'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function AddressesPage({ params }: PageProps) {
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.addresses');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountAddresses />;
|
||||
}
|
||||
|
||||
@@ -1,62 +1,11 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { SiteHeader } from '@/components/commerce/site-header';
|
||||
import { SiteFooter } from '@/components/layout/site-footer';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { fetchNavigation } from '@/lib/catalog';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* Everything under /account requires a signed-in customer. Enforcement will be
|
||||
* layered: the proxy checks for a session cookie, this layout verifies the
|
||||
* token server-side, and the API re-checks on every request. Only the last one
|
||||
* is real security; the first two are UX.
|
||||
*/
|
||||
export default async function AccountLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('account');
|
||||
const navigation = await fetchNavigation(locale as Locale);
|
||||
|
||||
const items = [
|
||||
{ href: routes.accountProfile(), label: t('profile') },
|
||||
{ href: routes.accountOrders(), label: t('orders') },
|
||||
{ href: routes.accountAddresses(), label: t('addresses') },
|
||||
{ href: routes.accountWishlist(), label: t('wishlist') },
|
||||
];
|
||||
import { AccountShell } from '@/features/account/account-shell';
|
||||
|
||||
export default function AccountLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<div className="max-w-page px-gutter mx-auto flex w-full flex-1 gap-12 py-16">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<nav className="mt-4 space-y-1">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-ink-600 hover:text-ink-950 block py-1.5 text-sm"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
<SiteFooter navigation={navigation} />
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<AccountShell>{children}</AccountShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { AccountOrders } from '@/features/account/account-panels';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.orders' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// Account pages are per-person and behind a session; never indexed.
|
||||
return { title: t('nav.orders'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function OrdersPage({ params }: PageProps) {
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.orders');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountOrders />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { AccountOverview } from '@/features/account/account-panels';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.account' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// Account pages are per-person and behind a session; never indexed.
|
||||
return { title: t('nav.overview'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function AccountPage({ params }: PageProps) {
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.account');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountOverview />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { AccountProfile } from '@/features/account/account-panels';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.profile' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// Account pages are per-person and behind a session; never indexed.
|
||||
return { title: t('nav.profile'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function ProfilePage({ params }: PageProps) {
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.profile');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountProfile />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { AccountWishlist } from '@/features/account/account-panels';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.wishlist' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'account' });
|
||||
// Account pages are per-person and behind a session; never indexed.
|
||||
return { title: t('nav.wishlist'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function WishlistPage({ params }: PageProps) {
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.wishlist');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
|
||||
);
|
||||
return <AccountWishlist />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { SiteHeader } from '@/components/commerce/site-header';
|
||||
import { SiteFooter } from '@/components/layout/site-footer';
|
||||
import { fetchNavigation } from '@/lib/catalog';
|
||||
|
||||
/**
|
||||
* Full site chrome around sign-in and the account area.
|
||||
*
|
||||
* Deliberately the shop layout rather than checkout's stripped-back one: an
|
||||
* account page is browsing, not a funnel. Someone checking an old order is one
|
||||
* click from wanting the catalog, and a bare page with no way back to the store
|
||||
* is a dead end.
|
||||
*/
|
||||
export default async function AccountGroupLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const navigation = await fetchNavigation(locale as Locale);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter navigation={navigation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { AuthForm } from '@/features/account/auth-form';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth' });
|
||||
// Never indexed: a sign-in form is not a landing page.
|
||||
return { title: t('signInTitle'), robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="px-gutter mx-auto max-w-md py-16">
|
||||
{/* AuthForm reads ?next= via useSearchParams, which needs a Suspense
|
||||
boundary so the rest of the page can still be prerendered. */}
|
||||
<Suspense fallback={null}>
|
||||
<AuthForm mode="signIn" />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { AuthForm } from '@/features/account/auth-form';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth' });
|
||||
// Never indexed: a sign-in form is not a landing page.
|
||||
return { title: t('registerTitle'), robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="px-gutter mx-auto max-w-md py-16">
|
||||
{/* AuthForm reads ?next= via useSearchParams, which needs a Suspense
|
||||
boundary so the rest of the page can still be prerendered. */}
|
||||
<Suspense fallback={null}>
|
||||
<AuthForm mode="register" />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { LOCALE_TAGS, type Locale } from '@sport/types';
|
||||
|
||||
import { CustomerProvider } from '@/features/account/customer-provider';
|
||||
import { CartProvider } from '@/features/cart/cart-provider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
@@ -70,7 +71,9 @@ export default async function LocaleLayout({
|
||||
<NextIntlClientProvider>
|
||||
{/* Above both route groups: the bag survives moving from browsing
|
||||
into checkout, which is the one transition it must not lose. */}
|
||||
<CartProvider locale={locale as Locale}>{children}</CartProvider>
|
||||
<CustomerProvider>
|
||||
<CartProvider locale={locale as Locale}>{children}</CartProvider>
|
||||
</CustomerProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Locale } from '@sport/types';
|
||||
import { Button, Input } from '@sport/ui';
|
||||
|
||||
import { useCustomer } from '@/features/account/customer-provider';
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
@@ -54,6 +55,55 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
|
||||
const { cart, loading, refresh } = useCart();
|
||||
|
||||
const [fields, setFields] = useState<Fields>(EMPTY);
|
||||
const { customer, status } = useCustomer();
|
||||
|
||||
/**
|
||||
* Prefill from the account, once, and never over typing already done.
|
||||
*
|
||||
* The `previous.line1 ? previous : ...` guard is the important part: the
|
||||
* address book request is in flight while the shopper is already filling the
|
||||
* form, and a prefill that lands late and overwrites what they typed is worse
|
||||
* than no prefill at all.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticated' || !customer) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
browserApi.account
|
||||
.listAddresses()
|
||||
.then((addresses) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const preferred = addresses.find((address) => address.isDefaultShipping) ?? addresses[0];
|
||||
|
||||
setFields((previous) => {
|
||||
if (previous.line1 || previous.fullName) return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
email: previous.email || customer.email,
|
||||
...(preferred
|
||||
? {
|
||||
fullName: preferred.fullName,
|
||||
phone: preferred.phone,
|
||||
line1: preferred.line1,
|
||||
ward: preferred.ward ?? '',
|
||||
district: preferred.district ?? '',
|
||||
province: preferred.province,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// An address book that will not load still leaves a usable form.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [status, customer]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMemo, useState } from 'react';
|
||||
import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types';
|
||||
import { cn } from '@sport/ui';
|
||||
|
||||
import { WishlistButton } from '@/features/account/wishlist-button';
|
||||
import { discountPercent, formatMoney } from '@/lib/format';
|
||||
|
||||
import { AddToBag } from './add-to-bag';
|
||||
@@ -203,6 +204,8 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
|
||||
|
||||
<AddToBag variant={selectedVariant} />
|
||||
|
||||
<WishlistButton productId={product.id} productSlug={product.slug} />
|
||||
|
||||
{selectedVariant ? (
|
||||
<p className="text-ink-400 text-center text-xs">
|
||||
{t('sku')}: {selectedVariant.sku}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,9 +34,71 @@ export function getServerApi() {
|
||||
* and then break the moment customer sign-in lands in M8, which is precisely
|
||||
* the kind of latent inconsistency worth removing now.
|
||||
*/
|
||||
/**
|
||||
* In-memory access token.
|
||||
*
|
||||
* Module scope rather than React state, for the same reasons as the admin's:
|
||||
* the API client reads it from inside a `fetch` callback and it must survive
|
||||
* re-renders. It is never written to localStorage or a readable cookie —
|
||||
* those outlive the tab and are readable by any script, which is exactly what
|
||||
* an XSS payload goes looking for. The httpOnly refresh cookie is what
|
||||
* survives a reload.
|
||||
*/
|
||||
let accessToken: string | null = null;
|
||||
let onSessionLost: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* The in-flight refresh, if any.
|
||||
*
|
||||
* Without it, every request that 401s at the same moment starts its own
|
||||
* rotation — and because rotation invalidates the previous token, the second
|
||||
* one is treated as *token reuse* and revokes the whole family. A shopper with
|
||||
* two tabs open would be signed out for it. One shared promise, one rotation.
|
||||
*/
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
export const customerTokenStore = {
|
||||
get: (): string | null => accessToken,
|
||||
set: (token: string | null): void => {
|
||||
accessToken = token;
|
||||
},
|
||||
onLost: (handler: (() => void) | null): void => {
|
||||
onSessionLost = handler;
|
||||
},
|
||||
};
|
||||
|
||||
export const browserApi = createApiClient({
|
||||
baseUrl: '',
|
||||
getAccessToken: () => null, // wired to the customer auth store in M8
|
||||
getAccessToken: () => customerTokenStore.get(),
|
||||
|
||||
/**
|
||||
* Transparent re-auth: on a 401, rotate once and retry.
|
||||
*
|
||||
* Storefront-specific consequence: a shopper whose access token expires
|
||||
* mid-checkout must not be bounced to a login form. If the rotation itself
|
||||
* fails they were genuinely signed out, and the provider reacts.
|
||||
*/
|
||||
onUnauthorized: () => {
|
||||
refreshInFlight ??= (async () => {
|
||||
try {
|
||||
const refreshed = await browserApi.auth.refresh();
|
||||
customerTokenStore.set(refreshed.accessToken);
|
||||
return true;
|
||||
} catch {
|
||||
customerTokenStore.set(null);
|
||||
onSessionLost?.();
|
||||
return false;
|
||||
} finally {
|
||||
// Cleared in a microtask so every caller awaiting this rotation sees
|
||||
// the same result before a new one can start.
|
||||
queueMicrotask(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshInFlight;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export const routes = {
|
||||
checkout: () => '/checkout',
|
||||
orderConfirmation: () => '/order-confirmation',
|
||||
|
||||
signIn: () => '/login',
|
||||
register: () => '/register',
|
||||
account: () => '/account',
|
||||
accountProfile: () => '/account/profile',
|
||||
accountOrders: () => '/account/orders',
|
||||
|
||||
@@ -110,11 +110,45 @@
|
||||
"policies": "Information"
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"profile": "Profile",
|
||||
"orders": "Orders",
|
||||
"addresses": "Addresses",
|
||||
"wishlist": "Wishlist"
|
||||
"greeting": "Signed in as",
|
||||
"signOut": "Sign out",
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"orders": "Orders",
|
||||
"addresses": "Addresses",
|
||||
"wishlist": "Wishlist",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"loadFailed": "Could not load that.",
|
||||
"saveFailed": "Could not save that.",
|
||||
"recentOrders": "Recent orders",
|
||||
"viewAll": "View all",
|
||||
"noOrders": "No orders yet.",
|
||||
"itemCount": "{count, plural, one {# item} other {# items}}",
|
||||
"noAddresses": "No saved addresses yet.",
|
||||
"addAddress": "Add address",
|
||||
"fullName": "Full name",
|
||||
"phone": "Phone",
|
||||
"line1": "Street address",
|
||||
"ward": "Ward",
|
||||
"district": "District",
|
||||
"province": "Province / City",
|
||||
"makeDefault": "Use as my default delivery address",
|
||||
"default": "Default",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Saved.",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"email": "Email",
|
||||
"emailLocked": "Contact us to change the email on your account.",
|
||||
"marketing": "Email me about new drops and offers.",
|
||||
"noWishlist": "Nothing saved yet. Tap the heart on any product.",
|
||||
"saveForLater": "Save for later",
|
||||
"savedLabel": "Saved"
|
||||
},
|
||||
"notFound": {
|
||||
"code": "404",
|
||||
@@ -254,5 +288,25 @@
|
||||
"empty": "Nothing published yet.",
|
||||
"backToJournal": "Back to the journal",
|
||||
"by": "By {name}"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Sign in",
|
||||
"registerTitle": "Create an account",
|
||||
"signInIntro": "Your orders, addresses and saved products in one place.",
|
||||
"registerIntro": "Checkout faster, and keep your order history.",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"phone": "Phone",
|
||||
"optional": "(optional)",
|
||||
"passwordHint": "At least 10 characters, with an uppercase letter and a number.",
|
||||
"marketing": "Email me about new drops and offers.",
|
||||
"signIn": "Sign in",
|
||||
"createAccount": "Create account",
|
||||
"working": "Just a moment…",
|
||||
"failed": "That did not work. Check your details and try again.",
|
||||
"noAccount": "No account yet?",
|
||||
"haveAccount": "Already have an account?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +110,45 @@
|
||||
"policies": "Thông tin"
|
||||
},
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
"profile": "Hồ sơ",
|
||||
"orders": "Đơn hàng",
|
||||
"addresses": "Địa chỉ",
|
||||
"wishlist": "Yêu thích"
|
||||
"greeting": "Đang đăng nhập",
|
||||
"signOut": "Đăng xuất",
|
||||
"nav": {
|
||||
"overview": "Tổng quan",
|
||||
"orders": "Đơn hàng",
|
||||
"addresses": "Địa chỉ",
|
||||
"wishlist": "Yêu thích",
|
||||
"profile": "Hồ sơ"
|
||||
},
|
||||
"loadFailed": "Không tải được dữ liệu.",
|
||||
"saveFailed": "Không lưu được.",
|
||||
"recentOrders": "Đơn hàng gần đây",
|
||||
"viewAll": "Xem tất cả",
|
||||
"noOrders": "Chưa có đơn hàng nào.",
|
||||
"itemCount": "{count, plural, other {# sản phẩm}}",
|
||||
"noAddresses": "Chưa lưu địa chỉ nào.",
|
||||
"addAddress": "Thêm địa chỉ",
|
||||
"fullName": "Họ và tên",
|
||||
"phone": "Số điện thoại",
|
||||
"line1": "Địa chỉ",
|
||||
"ward": "Phường / Xã",
|
||||
"district": "Quận / Huyện",
|
||||
"province": "Tỉnh / Thành phố",
|
||||
"makeDefault": "Dùng làm địa chỉ giao hàng mặc định",
|
||||
"default": "Mặc định",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu…",
|
||||
"cancel": "Huỷ",
|
||||
"saved": "Đã lưu.",
|
||||
"firstName": "Tên",
|
||||
"lastName": "Họ",
|
||||
"email": "Email",
|
||||
"emailLocked": "Liên hệ với chúng tôi để đổi email tài khoản.",
|
||||
"marketing": "Nhận email về sản phẩm mới và ưu đãi.",
|
||||
"noWishlist": "Chưa lưu sản phẩm nào. Nhấn vào biểu tượng trái tim.",
|
||||
"saveForLater": "Lưu để mua sau",
|
||||
"savedLabel": "Đã lưu"
|
||||
},
|
||||
"notFound": {
|
||||
"code": "404",
|
||||
@@ -254,5 +288,25 @@
|
||||
"empty": "Chưa có bài viết nào.",
|
||||
"backToJournal": "Quay lại danh sách bài viết",
|
||||
"by": "Bởi {name}"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Đăng nhập",
|
||||
"registerTitle": "Tạo tài khoản",
|
||||
"signInIntro": "Đơn hàng, địa chỉ và sản phẩm đã lưu ở cùng một nơi.",
|
||||
"registerIntro": "Thanh toán nhanh hơn và lưu lại lịch sử đơn hàng.",
|
||||
"email": "Email",
|
||||
"password": "Mật khẩu",
|
||||
"firstName": "Tên",
|
||||
"lastName": "Họ",
|
||||
"phone": "Số điện thoại",
|
||||
"optional": "(không bắt buộc)",
|
||||
"passwordHint": "Tối thiểu 10 ký tự, có chữ hoa và chữ số.",
|
||||
"marketing": "Nhận email về sản phẩm mới và ưu đãi.",
|
||||
"signIn": "Đăng nhập",
|
||||
"createAccount": "Tạo tài khoản",
|
||||
"working": "Vui lòng đợi…",
|
||||
"failed": "Chưa được. Kiểm tra lại thông tin và thử lần nữa.",
|
||||
"noAccount": "Chưa có tài khoản?",
|
||||
"haveAccount": "Đã có tài khoản?"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user