99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
'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>
|
|
);
|
|
}
|