Basic Architecture of Sport Web
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user