Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from 'next';
export const metadata: Metadata = { title: 'Sign in' };
/**
* Sits outside the dashboard route group so it renders without the sidebar and
* without the auth requirement.
*/
export default function LoginPage() {
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>
</div>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Brands' };
export default function BrandsPage() {
return (
<PageScaffold
title="Brands"
description="Brand records, logos and SEO fields."
permission="brand.read"
milestone="M3 — admin catalog"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Categories' };
export default function CategoriesPage() {
return (
<PageScaffold
title="Categories"
description="Drag-to-reorder category tree with materialised-path maintenance."
permission="category.read"
milestone="M3 — admin catalog"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Content' };
export default function CmsPage() {
return (
<PageScaffold
title="Content"
description="Homepage blocks, banners, blog posts and static pages."
permission="cms.read"
milestone="M7 — content"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Collections' };
export default function CollectionsPage() {
return (
<PageScaffold
title="Collections"
description="Manual and rule-based collections, with campaign scheduling."
permission="collection.read"
milestone="M3 — admin catalog"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Coupons' };
export default function CouponsPage() {
return (
<PageScaffold
title="Coupons"
description="Coupon codes, usage limits and redemption reporting."
permission="coupon.manage"
milestone="M7 — marketing"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Customers' };
export default function CustomersPage() {
return (
<PageScaffold
title="Customers"
description="Customer records, order history and addresses."
permission="customer.read"
milestone="M5 — orders"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Inventory' };
export default function InventoryPage() {
return (
<PageScaffold
title="Inventory"
description="Stock by variant and location, adjustments and the movement ledger."
permission="inventory.read"
milestone="M3 — admin catalog"
/>
);
}
+21
View File
@@ -0,0 +1,21 @@
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
* 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 }) {
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>
<main>{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Media' };
export default function MediaPage() {
return (
<PageScaffold
title="Media"
description="Asset library. Uploads go browser → presigned URL → R2; the API only records metadata."
permission="media.read"
milestone="M3 — admin catalog"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Orders' };
export default function OrdersPage() {
return (
<PageScaffold
title="Orders"
description="Order list and detail: fulfilment, status transitions, refunds and the audit trail."
permission="order.read"
milestone="M5 — orders"
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Dashboard' };
export default function DashboardPage() {
return (
<PageScaffold
title="Dashboard"
description="Revenue, orders, conversion and low-stock alerts."
permission="order.read"
milestone="M9 — admin analytics"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: '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."
permission="product.read"
milestone="M3 — admin catalog"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Promotions' };
export default function PromotionsPage() {
return (
<PageScaffold
title="Promotions"
description="Automatic cart-level discount rules."
permission="promotion.manage"
milestone="M7 — marketing"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Reviews' };
export default function ReviewsPage() {
return (
<PageScaffold
title="Reviews"
description="Review moderation queue."
permission="review.moderate"
milestone="M7 — marketing"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: '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."
permission="role.read"
milestone="M2 — auth & RBAC"
/>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Users' };
export default function UsersPage() {
return (
<PageScaffold
title="Users"
description="Back-office accounts and role assignment."
permission="user.read"
milestone="M2 — auth & RBAC"
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
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 default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}
@@ -0,0 +1,42 @@
import Link from 'next/link';
import { NAVIGATION } from '@/lib/navigation';
/**
* Renders every section for now. Once the session carries permissions, each
* 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() {
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
</Link>
</div>
<nav className="space-y-6 p-5">
{NAVIGATION.map((section) => (
<div key={section.title}>
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{section.title}
</h2>
<ul className="mt-2 space-y-0.5">
{section.items.map((item) => (
<li key={item.href}>
<Link
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}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,25 @@
import { Badge } from '@sport/ui';
/** Placeholder for admin screens that are not built yet. */
export function PageScaffold({
title,
description,
permission,
milestone,
}: {
title: string;
description: string;
permission: string;
milestone: string;
}) {
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>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
# feature: auth
Admin sign-in, session handling and the permission-aware `<Can>` component.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
@@ -0,0 +1,7 @@
# feature: customers
Customer list and detail.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
@@ -0,0 +1,7 @@
# feature: inventory
Stock table, adjustments and the movement ledger.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
+7
View File
@@ -0,0 +1,7 @@
# feature: media
Asset library, uploader and picker dialog.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
+7
View File
@@ -0,0 +1,7 @@
# feature: orders
Order list, detail, fulfilment and refund flows.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
@@ -0,0 +1,7 @@
# feature: products
Product list, editor, option builder and the variant matrix grid.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
@@ -0,0 +1,7 @@
# feature: settings
Users, roles and store settings.
Same rules as the storefront's feature folders: no cross-feature imports, and
all data access goes through `@sport/api-client`. The admin has no database
client of its own.
+20
View File
@@ -0,0 +1,20 @@
import { createApiClient } from '@sport/api-client';
import { clientEnv, getServerEnv } from './env';
/**
* The admin's only channel to data.
*
* There is no Prisma client in this application and there never will be. Every
* read and write crosses the REST boundary, which is what guarantees that RBAC,
* validation and audit logging apply uniformly — a second write path is a
* second place for authorization to be forgotten.
*/
export function getServerApi() {
return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL });
}
export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
getAccessToken: () => null, // wired to the auth store in the auth milestone
});
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
const clientEnvSchema = z.object({
NEXT_PUBLIC_API_URL: z.url(),
NEXT_PUBLIC_APP_URL: z.url(),
});
export const clientEnv = clientEnvSchema.parse({
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
export function getServerEnv() {
return z
.object({ API_INTERNAL_URL: z.url() })
.parse({ API_INTERNAL_URL: process.env.API_INTERNAL_URL ?? clientEnv.NEXT_PUBLIC_API_URL });
}
+57
View File
@@ -0,0 +1,57 @@
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.
* The API re-checks every request, because a hidden link is one devtools
* inspection away from being visible.
*/
export interface NavItem {
href: string;
label: string;
permission: Permission;
}
export interface NavSection {
title: string;
items: NavItem[];
}
export const NAVIGATION: NavSection[] = [
{
title: '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 },
],
},
{
title: 'Sales',
items: [
{ href: '/orders', label: 'Orders', permission: PERMISSIONS.ORDER_READ },
{ href: '/customers', label: 'Customers', permission: PERMISSIONS.CUSTOMER_READ },
],
},
{
title: '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 },
],
},
{
title: 'Settings',
items: [
{ href: '/settings/users', label: 'Users', permission: PERMISSIONS.USER_READ },
{ href: '/settings/roles', label: 'Roles', permission: PERMISSIONS.ROLE_READ },
],
},
];
+20
View File
@@ -0,0 +1,20 @@
@import 'tailwindcss';
@import '@sport/config/tailwind/theme.css';
@source "../../../../packages/ui/src";
:root {
--font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-display: var(--font-inter);
}
html,
body {
height: 100%;
}
body {
background-color: var(--color-ink-50);
color: var(--color-ink-950);
font-family: var(--font-sans);
}