This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
+17 -10
View File
@@ -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>
);
+10 -4
View File
@@ -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"
/>
+10 -4
View File
@@ -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"
/>
+10 -4
View File
@@ -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>
+10 -4
View File
@@ -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"
/>
+10 -4
View File
@@ -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"
/>
+10 -4
View File
@@ -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"
/>
+25 -8
View File
@@ -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>
);
+32
View File
@@ -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');
}
+31
View File
@@ -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,
};
});
+28 -22
View File
@@ -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 },
],
},
];
+81
View File
@@ -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."
}
}
}
+81
View File
@@ -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."
}
}
}