Finish admin page

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent cda5d21d0d
commit 8fd23d162f
107 changed files with 6321 additions and 563 deletions
@@ -1,6 +1,7 @@
import { setRequestLocale } from 'next-intl/server';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SkipLink } from '@sport/ui';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer';
@@ -24,12 +25,17 @@ export default async function AccountGroupLayout({
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('common');
const navigation = await fetchNavigation(locale as Locale);
return (
<div className="flex min-h-screen flex-col">
<SkipLink>{t('skipToContent')}</SkipLink>
<SiteHeader navigation={navigation} locale={locale as Locale} />
<main className="flex-1">{children}</main>
<main id="main" className="flex-1">
{children}
</main>
<SiteFooter navigation={navigation} />
</div>
);
@@ -27,7 +27,7 @@ export default async function CheckoutLayout({
<Link href={routes.home()} className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</Link>
<span className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
<span className="text-ink-500 text-xs font-semibold uppercase tracking-widest">
{t('title')}
</span>
</div>
@@ -76,7 +76,7 @@ export default async function PostPage({ params }: PageProps) {
{post.publishedAt ? (
<time
dateTime={post.publishedAt}
className="text-ink-400 text-xs uppercase tracking-widest"
className="text-ink-500 text-xs uppercase tracking-widest"
>
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'long' })}
</time>
@@ -64,7 +64,7 @@ export default async function BlogPage({ params }: PageProps) {
{post.publishedAt ? (
<time
dateTime={post.publishedAt}
className="text-ink-400 mt-4 block text-xs uppercase tracking-widest"
className="text-ink-500 mt-4 block text-xs uppercase tracking-widest"
>
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'medium' })}
</time>
@@ -1,6 +1,7 @@
import { setRequestLocale } from 'next-intl/server';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SkipLink } from '@sport/ui';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer';
@@ -27,12 +28,17 @@ export default async function ShopLayout({
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('common');
const navigation = await fetchNavigation(locale as Locale);
return (
<div className="flex min-h-screen flex-col">
<SkipLink>{t('skipToContent')}</SkipLink>
<SiteHeader navigation={navigation} locale={locale as Locale} />
<main className="flex-1">{children}</main>
<main id="main" className="flex-1">
{children}
</main>
<SiteFooter navigation={navigation} />
</div>
);
@@ -37,7 +37,7 @@ export default async function HomePage({ params }: PageProps) {
/>
<section className="max-w-page px-gutter mx-auto py-16">
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
<h2 className="text-ink-500 text-xs font-semibold uppercase tracking-widest">
{t('shopBySport')}
</h2>
<ul className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
@@ -10,7 +10,7 @@ export default function NotFound() {
return (
<div className="max-w-page px-gutter mx-auto flex min-h-screen flex-col items-center justify-center text-center">
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{t('code')}</p>
<p className="text-ink-500 text-xs font-semibold uppercase tracking-widest">{t('code')}</p>
<h1 className="mt-4 text-5xl font-black uppercase">{t('title')}</h1>
<p className="text-ink-500 mt-4 max-w-md">{t('body')}</p>
<Button className="mt-8" asChild>
@@ -28,7 +28,7 @@ export function CartBadge() {
>
<ShoppingBag className="size-5" />
{!loading && count > 0 ? (
<span className="bg-volt-500 text-ink-950 absolute right-0.5 top-0.5 grid min-w-4 place-items-center rounded-full px-1 text-[0.625rem] font-bold leading-4">
<span className="bg-volt-500 text-ink-950 text-eyebrow absolute right-0.5 top-0.5 grid min-w-4 place-items-center rounded-full px-1 font-bold leading-4">
{count}
</span>
) : null}
@@ -105,7 +105,7 @@ export function CartView() {
</Link>
</h2>
<p className="text-ink-500 mt-1 text-xs">{line.variantTitle}</p>
<p className="text-ink-400 mt-0.5 font-mono text-[0.625rem]">{line.sku}</p>
<p className="text-ink-500 text-eyebrow mt-0.5 font-mono">{line.sku}</p>
<div className="mt-3 flex items-center gap-4">
<QuantityStepper
@@ -131,7 +131,7 @@ export function CartView() {
<div className="text-right">
<p className="text-sm font-semibold">{formatMoney(line.lineTotal, format)}</p>
{line.compareAtPrice ? (
<p className="text-ink-400 text-xs line-through">
<p className="text-ink-500 text-xs line-through">
{formatMoney(
{
amount: line.compareAtPrice.amount * line.quantity,
@@ -141,7 +141,7 @@ export function CartView() {
)}
</p>
) : null}
<p className="text-ink-400 mt-1 text-xs">
<p className="text-ink-500 mt-1 text-xs">
{formatMoney(line.unitPrice, format)} × {line.quantity}
</p>
</div>
@@ -189,7 +189,7 @@ function Row({ label, value, muted }: { label: string; value: string; muted?: bo
return (
<div className="flex items-baseline justify-between">
<dt className="text-ink-500">{label}</dt>
<dd className={muted ? 'text-ink-400 text-xs' : ''}>{value}</dd>
<dd className={muted ? 'text-ink-500 text-xs' : ''}>{value}</dd>
</div>
);
}
@@ -295,7 +295,7 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
) : null}
<div className="flex justify-between">
<dt className="text-ink-500">{t('shipping')}</dt>
<dd className="text-ink-400 text-xs">{t('shippingLater')}</dd>
<dd className="text-ink-500 text-xs">{t('shippingLater')}</dd>
</div>
<div className="border-ink-200 flex items-baseline justify-between border-t pt-2">
<dt className="font-semibold">{t('total')}</dt>
@@ -315,7 +315,7 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
</Button>
{/* Honest about scope: payment providers arrive with M9. */}
<p className="text-ink-400 mt-3 text-center text-xs">{t('paymentLater')}</p>
<p className="text-ink-500 mt-3 text-center text-xs">{t('paymentLater')}</p>
</aside>
</form>
);
@@ -339,7 +339,7 @@ function Field({
{required ? <span className="text-danger ml-1">*</span> : null}
</span>
{children}
{hint ? <span className="text-ink-400 block text-xs">{hint}</span> : null}
{hint ? <span className="text-ink-500 block text-xs">{hint}</span> : null}
</label>
);
}
@@ -70,7 +70,7 @@ export function DiscountForm({
disabled={pending}
onClick={() => void removeCode(discount.code as string)}
aria-label={t('removeCode', { code: discount.code })}
className="text-ink-400 hover:text-danger focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px] disabled:opacity-50"
className="text-ink-500 hover:text-danger focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px] disabled:opacity-50"
>
<X className="size-3.5" />
</button>
@@ -34,7 +34,7 @@ export function FilterSheet({
<SlidersHorizontal />
{t('filters')}
{activeCount > 0 ? (
<span className="bg-ink-950 grid size-5 place-items-center rounded-full text-[0.625rem] text-white">
<span className="bg-ink-950 text-eyebrow grid size-5 place-items-center rounded-full text-white">
{activeCount}
</span>
) : null}
@@ -84,7 +84,7 @@ export function LoadMore({
) : null}
<div className="mt-12 flex flex-col items-center gap-3">
<p className="text-ink-400 text-xs uppercase tracking-widest">
<p className="text-ink-500 text-xs uppercase tracking-widest">
{t('showing', { shown, total: totalCount })}
</p>
@@ -103,9 +103,7 @@ export function MegaMenu({
<div className="max-w-page px-gutter mx-auto grid grid-cols-2 gap-10 py-10 md:grid-cols-4">
{entry.columns.map((column) => (
<div key={column.heading}>
<p className="text-volt-500 text-[0.625rem] font-semibold uppercase tracking-widest">
{column.heading}
</p>
<p className="text-volt-500 text-eyebrow uppercase">{column.heading}</p>
<ul className="mt-4 space-y-2.5">
{column.links.map((link) => (
<li key={link.href}>
@@ -84,9 +84,7 @@ export function ProductCard({
<div className="mt-3 space-y-1">
{product.brandName ? (
<p className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{product.brandName}
</p>
<p className="text-ink-500 text-eyebrow uppercase">{product.brandName}</p>
) : null}
<h3 className="text-ink-950 text-sm font-medium">
@@ -107,7 +105,7 @@ export function ProductCard({
{formatMoney(priceRange.min, format)}
</span>
{priceRange.compareAtMax && product.isOnSale ? (
<span className="text-ink-400 text-xs line-through">
<span className="text-ink-500 text-xs line-through">
{formatMoney(priceRange.compareAtMax, format)}
</span>
) : null}
@@ -125,7 +123,7 @@ export function ProductCard({
/>
))}
{product.colorSwatches.length > 5 ? (
<li className="text-ink-400 text-[0.625rem]">+{product.colorSwatches.length - 5}</li>
<li className="text-ink-500 text-eyebrow">+{product.colorSwatches.length - 5}</li>
) : null}
</ul>
) : null}
@@ -118,7 +118,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
<div className="space-y-8 lg:pt-4">
<div>
{product.brand ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
<p className="text-ink-500 text-xs font-semibold uppercase tracking-widest">
{product.brand.name}
</p>
) : null}
@@ -134,7 +134,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
</span>
{compareAt && discount > 0 ? (
<>
<span className="text-ink-400 text-base line-through">
<span className="text-ink-500 text-base line-through">
{formatMoney(compareAt, format)}
</span>
<span className="text-sale text-xs font-semibold uppercase tracking-widest">
@@ -182,7 +182,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
// Reachable but sold out reads differently from
// impossible: struck through, still selectable, so
// stock is discoverable rather than hidden.
reachableValue && !stocked && 'text-ink-400 line-through',
reachableValue && !stocked && 'text-ink-500 line-through',
)}
style={
isColour && value.swatchHex
@@ -207,7 +207,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
<WishlistButton productId={product.id} productSlug={product.slug} />
{selectedVariant ? (
<p className="text-ink-400 text-center text-xs">
<p className="text-ink-500 text-center text-xs">
{t('sku')}: {selectedVariant.sku}
</p>
) : null}
@@ -144,7 +144,7 @@ export async function ProductFilters({
)}
>
<span>{brand.label}</span>
<span className="text-ink-400 text-xs">{brand.count}</span>
<span className="text-ink-500 text-xs">{brand.count}</span>
</Link>
</li>
);
@@ -59,7 +59,7 @@ export async function ProductListing({
<div className="max-w-page px-gutter mx-auto py-12">
<header className="mb-10">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
<p className="text-ink-500 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-2 text-4xl font-black uppercase sm:text-5xl">{title}</h1>
{description ? <p className="text-ink-500 mt-4 max-w-2xl text-sm">{description}</p> : null}
@@ -71,7 +71,7 @@ export async function ProductListing({
instead of pushing the products another screen down.
*/}
<div className="border-ink-200 mb-6 flex items-center justify-between gap-4 border-b pb-4">
<p className="text-ink-400 text-xs uppercase tracking-widest">
<p className="text-ink-500 text-xs uppercase tracking-widest">
{t('resultsCount', { count: result.totalCount })}
</p>
<SortMenu basePath={basePath} query={query} />
@@ -1,8 +1,7 @@
import { Star } from 'lucide-react';
import { getFormatter, getTranslations } from 'next-intl/server';
import type { ProductReviews as ProductReviewsPayload } from '@sport/api-client';
import { cn } from '@sport/ui';
import { Rating } from '@sport/ui';
import { getServerApi } from '@/lib/api';
@@ -50,9 +49,9 @@ export async function ProductReviews({ productId }: { productId: string }) {
<span className="text-5xl font-black tabular-nums">
{format.number(summary.average, { minimumFractionDigits: 1 })}
</span>
<span className="text-ink-400 text-sm">/ 5</span>
<span className="text-ink-500 text-sm">/ 5</span>
</div>
<Stars rating={Math.round(summary.average)} className="mt-2" />
<Rating value={Math.round(summary.average)} className="mt-2" />
<p className="text-ink-500 mt-2 text-xs">{t('count', { count: summary.count })}</p>
</div>
@@ -91,7 +90,7 @@ export async function ProductReviews({ productId }: { productId: string }) {
{items.map((review) => (
<li key={review.id} className="py-6">
<div className="flex flex-wrap items-center gap-3">
<Stars rating={review.rating} />
<Rating value={review.rating} />
{review.title ? <h3 className="text-sm font-semibold">{review.title}</h3> : null}
</div>
@@ -126,17 +125,3 @@ export async function ProductReviews({ productId }: { productId: string }) {
</section>
);
}
export function Stars({ rating, className }: { rating: number; className?: string }) {
return (
<span className={cn('flex items-center gap-0.5', className)} aria-label={`${rating}/5`}>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
aria-hidden
className={cn('size-4', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
))}
</span>
);
}
@@ -117,7 +117,7 @@ export function SearchDialog({ locale }: { locale: Locale }) {
</DialogHeader>
<form onSubmit={submit} className="border-ink-200 flex items-center gap-3 border-b px-5">
<Search className="text-ink-400 size-5 shrink-0" />
<Search className="text-ink-500 size-5 shrink-0" />
{/* No `autoFocus`: Radix moves focus to the first focusable element
when the dialog opens, which is this input. Setting it as well
fights the dialog's own focus management. */}
@@ -126,9 +126,9 @@ export function SearchDialog({ locale }: { locale: Locale }) {
onChange={(event) => setQuery(event.target.value)}
placeholder={t('placeholder')}
aria-label={t('title')}
className="placeholder:text-ink-400 h-14 w-full bg-transparent text-base outline-none"
className="placeholder:text-ink-500 h-14 w-full bg-transparent text-base outline-none"
/>
{loading ? <Loader2 className="text-ink-400 size-4 animate-spin" /> : null}
{loading ? <Loader2 className="text-ink-500 size-4 animate-spin" /> : null}
</form>
{active && suggestions.length > 0 ? (
@@ -151,7 +151,7 @@ export function SearchDialog({ locale }: { locale: Locale }) {
<p className="text-ink-500 px-5 py-6 text-center text-sm">{t('noSuggestions')}</p>
) : null}
<p className="border-ink-200 text-ink-400 border-t px-5 py-3 text-xs">
<p className="border-ink-200 text-ink-500 border-t px-5 py-3 text-xs">
{t('enterToSearch')}
</p>
</DialogContent>
@@ -35,7 +35,7 @@ export function SortMenu({ basePath, query }: { basePath: string; query: Product
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="text-ink-400 hidden font-normal normal-case sm:inline">
<span className="text-ink-500 hidden font-normal normal-case sm:inline">
{t('sortBy')}
</span>
{t(`sort.${active}`)}
@@ -58,7 +58,7 @@ export function LanguageSwitcher({ className }: { className?: string }) {
'px-1.5 py-1 text-xs font-semibold uppercase tracking-widest transition-colors',
locale === activeLocale
? 'text-ink-950 underline underline-offset-4'
: 'text-ink-400 hover:text-ink-950',
: 'text-ink-500 hover:text-ink-950',
)}
>
{locale}
@@ -25,7 +25,7 @@ export function PageScaffold({
return (
<div className="max-w-page px-gutter mx-auto py-20">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
<p className="text-ink-500 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-3 text-4xl font-black uppercase sm:text-6xl">{title}</h1>
@@ -12,7 +12,7 @@ import type {
OrderListItem,
ProductListItem,
} from '@sport/types';
import { Badge, Button, Input, Skeleton } from '@sport/ui';
import { Badge, Button, EmptyState, Input, Skeleton } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
@@ -124,7 +124,7 @@ export function AccountOrders() {
{!data ? (
<Skeleton className="h-40 w-full" />
) : data.items.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noOrders')}</p>
<EmptyState title={t('noOrders')} />
) : (
<ul className="divide-ink-100 divide-y border-y">
{data.items.map((order) => (
@@ -329,7 +329,7 @@ export function AccountAddresses() {
{!data ? (
<Skeleton className="h-32 w-full" />
) : data.length === 0 && !draft ? (
<p className="text-ink-500 text-sm">{t('noAddresses')}</p>
<EmptyState title={t('noAddresses')} />
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{data.map((address) => (
@@ -454,7 +454,7 @@ export function AccountProfile() {
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>
<span className="text-ink-500 text-xs">{t('emailLocked')}</span>
</label>
<Field label={t('phone')} value={data.phone ?? ''} onChange={(v) => patch({ phone: v })} />
@@ -526,7 +526,7 @@ export function AccountWishlist() {
{!data ? (
<Skeleton className="h-32 w-full" />
) : data.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noWishlist')}</p>
<EmptyState title={t('noWishlist')} />
) : (
<ul className="divide-ink-100 divide-y border-y">
{data.map((product) => (
@@ -133,14 +133,14 @@ export function AuthForm({ mode }: { mode: 'signIn' | 'register' }) {
autoComplete={isRegister ? 'new-password' : 'current-password'}
required
/>
{isRegister ? <span className="text-ink-400 text-xs">{t('passwordHint')}</span> : null}
{isRegister ? <span className="text-ink-500 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>
{t('phone')} <span className="text-ink-500 normal-case">{t('optional')}</span>
</span>
<Input
type="tel"
+3
View File
@@ -308,5 +308,8 @@
"failed": "That did not work. Check your details and try again.",
"noAccount": "No account yet?",
"haveAccount": "Already have an account?"
},
"common": {
"skipToContent": "Skip to content"
}
}
+3
View File
@@ -308,5 +308,8 @@
"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?"
},
"common": {
"skipToContent": "Bỏ qua, tới nội dung chính"
}
}