Stage M1
This commit is contained in:
@@ -1,23 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sign in' };
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('common.login');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sits outside the dashboard route group so it renders without the sidebar and
|
||||
* without the auth requirement.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
export default async function LoginPage() {
|
||||
const t = await getTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="bg-ink-50 flex min-h-screen items-center justify-center px-6">
|
||||
<div className="border-ink-200 w-full max-w-sm border bg-white p-8">
|
||||
<h1 className="text-lg font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> Admin
|
||||
</h1>
|
||||
<p className="text-ink-500 mt-4 text-sm">
|
||||
Sign-in lands with the auth milestone (M2). Credentials will be exchanged for a
|
||||
short-lived access token plus a rotating, httpOnly refresh cookie scoped to the admin
|
||||
audience.
|
||||
</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<h1 className="text-lg font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> {t('appName')}
|
||||
</h1>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<p className="text-ink-500 mt-4 text-sm">{t('login.body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Brands' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.brands');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function BrandsPage() {
|
||||
const t = await getTranslations('pages.brands');
|
||||
|
||||
export default function BrandsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Brands"
|
||||
description="Brand records, logos and SEO fields."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="brand.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Categories' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.categories');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const t = await getTranslations('pages.categories');
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Categories"
|
||||
description="Drag-to-reorder category tree with materialised-path maintenance."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="category.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Content' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.cms');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CmsPage() {
|
||||
const t = await getTranslations('pages.cms');
|
||||
|
||||
export default function CmsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Content"
|
||||
description="Homepage blocks, banners, blog posts and static pages."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="cms.read"
|
||||
milestone="M7 — content"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Collections' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.collections');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CollectionsPage() {
|
||||
const t = await getTranslations('pages.collections');
|
||||
|
||||
export default function CollectionsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Collections"
|
||||
description="Manual and rule-based collections, with campaign scheduling."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="collection.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Coupons' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.coupons');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CouponsPage() {
|
||||
const t = await getTranslations('pages.coupons');
|
||||
|
||||
export default function CouponsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Coupons"
|
||||
description="Coupon codes, usage limits and redemption reporting."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="coupon.manage"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Customers' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.customers');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function CustomersPage() {
|
||||
const t = await getTranslations('pages.customers');
|
||||
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Customers"
|
||||
description="Customer records, order history and addresses."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="customer.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Inventory' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.inventory');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function InventoryPage() {
|
||||
const t = await getTranslations('pages.inventory');
|
||||
|
||||
export default function InventoryPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Inventory"
|
||||
description="Stock by variant and location, adjustments and the movement ledger."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="inventory.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { AdminSidebar } from '@/components/layout/admin-sidebar';
|
||||
|
||||
/**
|
||||
* Every route in this group requires an authenticated back-office actor.
|
||||
* Enforcement is layered: middleware checks for a session cookie, this layout
|
||||
* Enforcement is layered: the proxy checks for a session cookie, this layout
|
||||
* verifies the token server-side, and the API re-checks permissions on every
|
||||
* request. Only the last one is real security; the first two are UX.
|
||||
*/
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<AdminSidebar />
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="border-ink-200 flex h-14 items-center justify-end border-b bg-white px-6">
|
||||
<span className="text-ink-500 text-xs font-medium">Signed out</span>
|
||||
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6">
|
||||
<LanguageSwitcher />
|
||||
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Media' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.media');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function MediaPage() {
|
||||
const t = await getTranslations('pages.media');
|
||||
|
||||
export default function MediaPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Media"
|
||||
description="Asset library. Uploads go browser → presigned URL → R2; the API only records metadata."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="media.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Orders' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.orders');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function OrdersPage() {
|
||||
const t = await getTranslations('pages.orders');
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Orders"
|
||||
description="Order list and detail: fulfilment, status transitions, refunds and the audit trail."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="order.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Dashboard' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.dashboard');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const t = await getTranslations('pages.dashboard');
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Dashboard"
|
||||
description="Revenue, orders, conversion and low-stock alerts."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="order.read"
|
||||
milestone="M9 — admin analytics"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Products' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.products');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const t = await getTranslations('pages.products');
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Products"
|
||||
description="Product list with status, brand and variant count. The editor manages options, generates the variant matrix and edits per-variant SKU, price and stock."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="product.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Promotions' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.promotions');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function PromotionsPage() {
|
||||
const t = await getTranslations('pages.promotions');
|
||||
|
||||
export default function PromotionsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Promotions"
|
||||
description="Automatic cart-level discount rules."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="promotion.manage"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Reviews' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.reviews');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function ReviewsPage() {
|
||||
const t = await getTranslations('pages.reviews');
|
||||
|
||||
export default function ReviewsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Reviews"
|
||||
description="Review moderation queue."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="review.moderate"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Roles' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.roles');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function RolesPage() {
|
||||
const t = await getTranslations('pages.roles');
|
||||
|
||||
export default function RolesPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Roles"
|
||||
description="Role editor: a role is a named set of permissions, editable at runtime with no deploy."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="role.read"
|
||||
milestone="M2 — auth & RBAC"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Users' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('pages.users');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default async function UsersPage() {
|
||||
const t = await getTranslations('pages.users');
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Users"
|
||||
description="Back-office accounts and role assignment."
|
||||
title={t('title')}
|
||||
description={t('body')}
|
||||
permission="user.read"
|
||||
milestone="M2 — auth & RBAC"
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
|
||||
import { LOCALE_TAGS } from '@sport/types';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: 'Sport Store Admin', template: '%s | Sport Admin' },
|
||||
description: 'Back-office dashboard.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('common');
|
||||
|
||||
return {
|
||||
title: { default: t('appName'), template: `%s | ${t('appName')}` },
|
||||
// The admin must never be indexed: it is an internal tool, and its URLs
|
||||
// leak the shape of the back office even when they 401.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
// Locale comes from a cookie rather than the URL — see src/i18n/request.ts.
|
||||
const locale = await getLocale();
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
<html
|
||||
lang={LOCALE_TAGS[locale as keyof typeof LOCALE_TAGS] ?? 'vi-VN'}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-screen antialiased">
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { LOCALE_LABELS, LOCALES, type Locale } from '@sport/types';
|
||||
import { cn } from '@sport/ui';
|
||||
|
||||
import { setLocale } from '@/i18n/actions';
|
||||
|
||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
const t = useTranslations('common');
|
||||
const activeLocale = useLocale() as Locale;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-1', isPending && 'opacity-60', className)}
|
||||
role="group"
|
||||
aria-label={t('changeLanguage')}
|
||||
>
|
||||
{LOCALES.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={locale === activeLocale}
|
||||
onClick={() => startTransition(() => void setLocale(locale))}
|
||||
aria-current={locale === activeLocale ? 'true' : undefined}
|
||||
lang={locale}
|
||||
title={LOCALE_LABELS[locale]}
|
||||
className={cn(
|
||||
'rounded-card px-1.5 py-1 text-xs font-semibold uppercase tracking-widest transition-colors',
|
||||
locale === activeLocale ? 'bg-ink-100 text-ink-950' : 'text-ink-400 hover:text-ink-950',
|
||||
)}
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { NAVIGATION } from '@/lib/navigation';
|
||||
|
||||
@@ -7,20 +8,23 @@ import { NAVIGATION } from '@/lib/navigation';
|
||||
* item is filtered with `hasPermission(actor.permissions, item.permission)` —
|
||||
* the same catalog the API guards read, so menu and enforcement cannot drift.
|
||||
*/
|
||||
export function AdminSidebar() {
|
||||
export async function AdminSidebar() {
|
||||
const t = await getTranslations('common');
|
||||
const tPages = await getTranslations('pages');
|
||||
|
||||
return (
|
||||
<aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block">
|
||||
<div className="border-ink-200 flex h-14 items-center border-b px-5">
|
||||
<Link href="/" className="text-sm font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> Admin
|
||||
Sport<span className="text-volt-600">.</span> {t('appName')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-6 p-5">
|
||||
{NAVIGATION.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div key={section.titleKey}>
|
||||
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{section.title}
|
||||
{t(`sections.${section.titleKey}`)}
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
@@ -29,7 +33,7 @@ export function AdminSidebar() {
|
||||
href={item.href}
|
||||
className="rounded-card text-ink-600 hover:bg-ink-100 hover:text-ink-950 block px-2 py-1.5 text-sm"
|
||||
>
|
||||
{item.label}
|
||||
{tPages(`${item.labelKey}.title`)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@sport/ui';
|
||||
|
||||
/** Placeholder for admin screens that are not built yet. */
|
||||
@@ -12,13 +14,15 @@ export function PageScaffold({
|
||||
permission: string;
|
||||
milestone: string;
|
||||
}) {
|
||||
const t = useTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{description}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Badge variant="outline">requires: {permission}</Badge>
|
||||
<Badge variant="neutral">Planned: {milestone}</Badge>
|
||||
<Badge variant="outline">{t('requires', { permission })}</Badge>
|
||||
<Badge variant="neutral">{t('planned', { milestone })}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { isLocale, type Locale } from '@sport/types';
|
||||
|
||||
import { LOCALE_COOKIE } from './request';
|
||||
|
||||
/**
|
||||
* Persists the operator's language choice.
|
||||
*
|
||||
* A Server Action rather than a client-side cookie write, so the very next
|
||||
* render already has the new locale — no flash of the previous language, and no
|
||||
* need to duplicate the cookie name in client code.
|
||||
*/
|
||||
export async function setLocale(locale: Locale): Promise<void> {
|
||||
if (!isLocale(locale)) return;
|
||||
|
||||
const store = await cookies();
|
||||
store.set(LOCALE_COOKIE, locale, {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
// Not httpOnly: this is a display preference, not a credential, and reading
|
||||
// it client-side is occasionally useful.
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
// Every admin screen renders translated text, so the whole tree is stale.
|
||||
revalidatePath('/', 'layout');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from '@sport/types';
|
||||
|
||||
/**
|
||||
* The admin resolves its locale from a cookie, not from the URL.
|
||||
*
|
||||
* Deliberately different from the storefront. The storefront needs locale in
|
||||
* the path for SEO, shareable links and hreflang; the admin is an internal tool
|
||||
* behind a login with `noindex` on every page — none of that applies. Putting
|
||||
* `/vi/` in front of every admin route would add a proxy hop and double the
|
||||
* route tree to buy nothing.
|
||||
*/
|
||||
export const LOCALE_COOKIE = 'sport_admin_locale';
|
||||
|
||||
export async function getPreferredLocale(): Promise<Locale> {
|
||||
const store = await cookies();
|
||||
const value = store.get(LOCALE_COOKIE)?.value;
|
||||
|
||||
return value && isLocale(value) ? value : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locale = await getPreferredLocale();
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -3,55 +3,61 @@ import { PERMISSIONS, type Permission } from '@sport/types';
|
||||
/**
|
||||
* The sidebar is derived from the same permission catalog the API guards use.
|
||||
*
|
||||
* A user who cannot read orders never sees an Orders link, so the UI has no
|
||||
* dead ends. This is presentation only — hiding a link is not authorization.
|
||||
* An operator who cannot read orders never sees an Orders link, so the UI has
|
||||
* no dead ends. This is presentation only — hiding a link is not authorization.
|
||||
* The API re-checks every request, because a hidden link is one devtools
|
||||
* inspection away from being visible.
|
||||
*
|
||||
* Labels are message keys rather than strings: the sidebar is the most visible
|
||||
* surface in the admin, and hard-coding English here would have made the
|
||||
* language switcher look broken.
|
||||
*/
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
/** Key into the `pages` message namespace. */
|
||||
labelKey: string;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
title: string;
|
||||
/** Key into the `common.sections` message namespace. */
|
||||
titleKey: 'catalog' | 'sales' | 'marketing' | 'settings';
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const NAVIGATION: NavSection[] = [
|
||||
{
|
||||
title: 'Catalog',
|
||||
titleKey: 'catalog',
|
||||
items: [
|
||||
{ href: '/products', label: 'Products', permission: PERMISSIONS.PRODUCT_READ },
|
||||
{ href: '/categories', label: 'Categories', permission: PERMISSIONS.CATEGORY_READ },
|
||||
{ href: '/collections', label: 'Collections', permission: PERMISSIONS.COLLECTION_READ },
|
||||
{ href: '/brands', label: 'Brands', permission: PERMISSIONS.BRAND_READ },
|
||||
{ href: '/inventory', label: 'Inventory', permission: PERMISSIONS.INVENTORY_READ },
|
||||
{ href: '/media', label: 'Media', permission: PERMISSIONS.MEDIA_READ },
|
||||
{ href: '/products', labelKey: 'products', permission: PERMISSIONS.PRODUCT_READ },
|
||||
{ href: '/categories', labelKey: 'categories', permission: PERMISSIONS.CATEGORY_READ },
|
||||
{ href: '/collections', labelKey: 'collections', permission: PERMISSIONS.COLLECTION_READ },
|
||||
{ href: '/brands', labelKey: 'brands', permission: PERMISSIONS.BRAND_READ },
|
||||
{ href: '/inventory', labelKey: 'inventory', permission: PERMISSIONS.INVENTORY_READ },
|
||||
{ href: '/media', labelKey: 'media', permission: PERMISSIONS.MEDIA_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sales',
|
||||
titleKey: 'sales',
|
||||
items: [
|
||||
{ href: '/orders', label: 'Orders', permission: PERMISSIONS.ORDER_READ },
|
||||
{ href: '/customers', label: 'Customers', permission: PERMISSIONS.CUSTOMER_READ },
|
||||
{ href: '/orders', labelKey: 'orders', permission: PERMISSIONS.ORDER_READ },
|
||||
{ href: '/customers', labelKey: 'customers', permission: PERMISSIONS.CUSTOMER_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Marketing',
|
||||
titleKey: 'marketing',
|
||||
items: [
|
||||
{ href: '/promotions', label: 'Promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
|
||||
{ href: '/coupons', label: 'Coupons', permission: PERMISSIONS.COUPON_MANAGE },
|
||||
{ href: '/reviews', label: 'Reviews', permission: PERMISSIONS.REVIEW_MODERATE },
|
||||
{ href: '/cms', label: 'Content', permission: PERMISSIONS.CMS_READ },
|
||||
{ href: '/promotions', labelKey: 'promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
|
||||
{ href: '/coupons', labelKey: 'coupons', permission: PERMISSIONS.COUPON_MANAGE },
|
||||
{ href: '/reviews', labelKey: 'reviews', permission: PERMISSIONS.REVIEW_MODERATE },
|
||||
{ href: '/cms', labelKey: 'cms', permission: PERMISSIONS.CMS_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
titleKey: 'settings',
|
||||
items: [
|
||||
{ href: '/settings/users', label: 'Users', permission: PERMISSIONS.USER_READ },
|
||||
{ href: '/settings/roles', label: 'Roles', permission: PERMISSIONS.ROLE_READ },
|
||||
{ href: '/settings/users', labelKey: 'users', permission: PERMISSIONS.USER_READ },
|
||||
{ href: '/settings/roles', labelKey: 'roles', permission: PERMISSIONS.ROLE_READ },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Sport Admin",
|
||||
"changeLanguage": "Change language",
|
||||
"signedOut": "Signed out",
|
||||
"requires": "requires: {permission}",
|
||||
"planned": "Planned: {milestone}",
|
||||
"sections": {
|
||||
"catalog": "Catalog",
|
||||
"sales": "Sales",
|
||||
"marketing": "Marketing",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"body": "Sign-in lands with the auth milestone (M2). Credentials will be exchanged for a short-lived access token plus a rotating, httpOnly refresh cookie scoped to the admin audience."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"body": "Revenue, orders, conversion and low-stock alerts."
|
||||
},
|
||||
"products": {
|
||||
"title": "Products",
|
||||
"body": "Product list with status, brand and variant count. The editor manages options, generates the variant matrix and edits per-variant SKU, price and stock."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"body": "Drag-to-reorder category tree with materialised-path maintenance."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"body": "Manual and rule-based collections, with campaign scheduling."
|
||||
},
|
||||
"brands": {
|
||||
"title": "Brands",
|
||||
"body": "Brand records, logos and SEO fields."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Inventory",
|
||||
"body": "Stock by variant and location, adjustments and the movement ledger."
|
||||
},
|
||||
"media": {
|
||||
"title": "Media",
|
||||
"body": "Asset library. Uploads go browser → presigned URL → R2; the API only records metadata."
|
||||
},
|
||||
"orders": {
|
||||
"title": "Orders",
|
||||
"body": "Order list and detail: fulfilment, status transitions, refunds and the audit trail."
|
||||
},
|
||||
"customers": {
|
||||
"title": "Customers",
|
||||
"body": "Customer records, order history and addresses."
|
||||
},
|
||||
"promotions": {
|
||||
"title": "Promotions",
|
||||
"body": "Automatic cart-level discount rules."
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Coupons",
|
||||
"body": "Coupon codes, usage limits and redemption reporting."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Reviews",
|
||||
"body": "Review moderation queue."
|
||||
},
|
||||
"cms": {
|
||||
"title": "Content",
|
||||
"body": "Homepage blocks, banners, blog posts and static pages."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"body": "Back-office accounts and role assignment."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Roles",
|
||||
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Sport Admin",
|
||||
"changeLanguage": "Đổi ngôn ngữ",
|
||||
"signedOut": "Chưa đăng nhập",
|
||||
"requires": "cần quyền: {permission}",
|
||||
"planned": "Dự kiến: {milestone}",
|
||||
"sections": {
|
||||
"catalog": "Danh mục sản phẩm",
|
||||
"sales": "Bán hàng",
|
||||
"marketing": "Tiếp thị",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"login": {
|
||||
"title": "Đăng nhập",
|
||||
"body": "Chức năng đăng nhập sẽ có ở giai đoạn xác thực (M2). Thông tin đăng nhập sẽ được đổi lấy access token ngắn hạn cùng refresh cookie httpOnly xoay vòng, giới hạn cho phạm vi quản trị."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Tổng quan",
|
||||
"body": "Doanh thu, đơn hàng, tỷ lệ chuyển đổi và cảnh báo sắp hết hàng."
|
||||
},
|
||||
"products": {
|
||||
"title": "Sản phẩm",
|
||||
"body": "Danh sách sản phẩm kèm trạng thái, thương hiệu và số phiên bản. Trình soạn thảo quản lý tuỳ chọn, sinh ma trận phiên bản và chỉnh SKU, giá, tồn kho cho từng phiên bản."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Danh mục",
|
||||
"body": "Cây danh mục kéo-thả kèm cập nhật đường dẫn phân cấp."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Bộ sưu tập",
|
||||
"body": "Bộ sưu tập thủ công và theo quy tắc, kèm lịch chạy chiến dịch."
|
||||
},
|
||||
"brands": {
|
||||
"title": "Thương hiệu",
|
||||
"body": "Thông tin thương hiệu, logo và trường SEO."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Kho hàng",
|
||||
"body": "Tồn kho theo phiên bản và kho, điều chỉnh và sổ nhật ký xuất nhập."
|
||||
},
|
||||
"media": {
|
||||
"title": "Thư viện media",
|
||||
"body": "Kho tài nguyên. Tệp tải lên đi thẳng từ trình duyệt qua URL ký sẵn tới R2; API chỉ lưu thông tin mô tả."
|
||||
},
|
||||
"orders": {
|
||||
"title": "Đơn hàng",
|
||||
"body": "Danh sách và chi tiết đơn: giao hàng, chuyển trạng thái, hoàn tiền và nhật ký thay đổi."
|
||||
},
|
||||
"customers": {
|
||||
"title": "Khách hàng",
|
||||
"body": "Hồ sơ khách hàng, lịch sử đơn và địa chỉ."
|
||||
},
|
||||
"promotions": {
|
||||
"title": "Khuyến mãi",
|
||||
"body": "Quy tắc giảm giá tự động ở cấp giỏ hàng."
|
||||
},
|
||||
"coupons": {
|
||||
"title": "Mã giảm giá",
|
||||
"body": "Mã giảm giá, giới hạn sử dụng và báo cáo quy đổi."
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Đánh giá",
|
||||
"body": "Hàng đợi kiểm duyệt đánh giá."
|
||||
},
|
||||
"cms": {
|
||||
"title": "Nội dung",
|
||||
"body": "Khối trang chủ, banner, bài viết và trang tĩnh."
|
||||
},
|
||||
"users": {
|
||||
"title": "Người dùng",
|
||||
"body": "Tài khoản nội bộ và phân vai trò."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Vai trò",
|
||||
"body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user