Stage M8
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user