chore(template): publish Store.Next
Some checks are pending
CI / quality (push) Waiting to run

This commit is contained in:
w4c-templates 2026-10-11 23:24:13 +03:00
parent 132a3f1160
commit bb75e7c742
123 changed files with 12995 additions and 2 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
node_modules
.next
.git
.env
.env.local
*.db
docker
.github
.w4c
tests
README.md
coverage

15
.env.example Normal file
View file

@ -0,0 +1,15 @@
# --- Core -------------------------------------------------------------------
# SQLite in development; point at Postgres in production (see README).
DATABASE_URL="file:./dev.db"
# Public origin used for absolute URLs (sitemap, OG, checkout redirects).
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# --- Payments (optional) ----------------------------------------------------
# Leave empty to use the built-in mock provider (no keys needed).
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
# --- Email (optional) -------------------------------------------------------
# Leave empty to use the built-in log provider (no keys needed).
RESEND_API_KEY=""
EMAIL_FROM="Acme <noreply@example.com>"

7
.eslintrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off",
"react/no-unescaped-entities": "off"
}
}

33
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
env:
DATABASE_URL: "file:./ci.db"
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run db:setup
- run: npm test
- run: npm run build
- name: e2e smoke
run: |
npm start &
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:3000/api/health >/dev/null; then break; fi
sleep 1
done
npm run test:e2e

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
node_modules
.next
out
dist
coverage
.env
.env.local
*.db
*.db-journal
.vercel
next-env.d.ts

BIN
.w4c/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

35
.w4c/qa.json Normal file
View file

@ -0,0 +1,35 @@
{
"install": "npm ci",
"setup": "npm run db:setup",
"lint": "npm run lint",
"typecheck": "npm run typecheck",
"unit": "npm run test",
"build": "npm run build",
"start": "npm run start",
"e2e": "node tests/e2e/smoke.mjs",
"port": 3000,
"health": "/api/health",
"env": {
"DATABASE_URL": "file:./dev.db",
"NEXT_PUBLIC_SITE_URL": "http://localhost:3000"
},
"routes": [
{ "path": "/", "expect": [200] },
{ "path": "/shop", "expect": [200] },
{ "path": "/shop/classic-tee", "expect": [200] },
{ "path": "/shop?category=home", "expect": [200] },
{ "path": "/cart", "expect": [200] },
{ "path": "/checkout", "expect": [200] },
{ "path": "/login", "expect": [200] },
{ "path": "/dashboard", "expect": [307, 302] },
{ "path": "/orders", "expect": [307, 302] },
{ "path": "/admin", "expect": [307, 302] },
{ "path": "/api/health", "expect": [200] },
{ "path": "/api/products", "expect": [200] },
{ "path": "/api/orders", "expect": [401] },
{ "path": "/api/admin/products", "expect": [401] },
{ "path": "/sitemap.xml", "expect": [200] },
{ "path": "/robots.txt", "expect": [200] }
],
"timeout": { "install": 900, "build": 900, "boot": 120 }
}

30
.w4c/template.json Normal file
View file

@ -0,0 +1,30 @@
{
"schema": 2,
"system": "v2",
"id": "store-next",
"case": "store",
"stack": "next",
"title": "Online Store — Next.js",
"description": "Ready-to-run storefront: catalog, cart, checkout, payments, orders and an admin panel.",
"tags": ["web", "e-commerce", "stripe"],
"preview": ".w4c/preview.png",
"demoUrl": "https://store-next.demo.wiz4chat.com",
"agentId": "w4c-start2",
"defaultName": "my-store",
"featured": true,
"env": [
{ "key": "DATABASE_URL", "required": false, "note": "SQLite by default; Postgres in production" },
{ "key": "NEXT_PUBLIC_SITE_URL", "required": false, "note": "Public origin for sitemap/OG/checkout" },
{ "key": "STRIPE_SECRET_KEY", "required": false, "note": "mock provider is used when absent" },
{ "key": "RESEND_API_KEY", "required": false, "note": "log provider is used when absent" }
],
"modules": [
{ "id": "stripe-payments", "skill": "integration-stripe-payments" },
{ "id": "resend-email", "skill": "integration-resend-email", "default": true },
{ "id": "s3-storage", "skill": "integration-s3-storage" }
],
"seededAccounts": [
{ "role": "admin", "email": "admin@example.com", "password": "password" },
{ "role": "user", "email": "customer@example.com", "password": "password" }
]
}

View file

@ -1,3 +1,55 @@
# templates2.Store.Next
# Online Store — Next.js (templates2)
Ready-to-run storefront: catalog, cart, checkout, payments, orders and an admin panel.
A **ready-to-run storefront** forked from `templates2.Base.Next`: catalog, cart, checkout with the
built-in mock payment provider, customer orders and a product/order admin — plus the whole base core
(auth, roles, mock email, Docker, CI).
## Quickstart
```bash
npm ci
cp .env.example .env # SQLite + mock providers by default — no keys needed
npm run db:setup # prisma db push + seed (3 categories, 12 products, 2 orders)
npm run dev # http://localhost:3000
```
Demo accounts (seeded): `admin@example.com` (admin) and `customer@example.com` (customer) — password
`password`.
## Flow
`/shop` (search, category filters, sort, pagination) → `/shop/[slug]` (variants, stock, JSON-LD
`Product`) → `/cart` (persists in `localStorage`) → `/checkout` (**mock payment, no keys**) → order
confirmation → `/orders`. Admin: `/admin/products` (CRUD, stock, hide/show) and `/admin/orders`
(status transitions).
## What this case adds over the base
- **Data model** — `Category`, `Product` (price in minor units, stock, JSON variants), `Order`,
`OrderItem`.
- **Server-side validation** — `lib/cart.ts` recomputes every line's price from the database (never
trusts the client total), matches variants, and rejects over-stock quantities; `POST /api/checkout`
creates the order, decrements inventory and (on the mock provider) marks it paid.
- **Cart** — a client `CartProvider` backed by `localStorage`, so it survives reloads.
- **Admin** — product CRUD and order status transitions, all role-gated.
- **SEO** — `Product`/`Offer` JSON-LD per product and a sitemap that includes every active product.
- **Artwork** — a cohesive set of generated product/editorial photos in `public/art/` (hero,
collection tiles, lookbook, CTA/OG and one shot per seeded product), rendered by `<ProductImage>`,
which falls back to the emoji placeholder when a product has no image. Prompts live in
`../tools/art/store.json`; regenerate the set with
`node ../tools/generate-images.mjs ../tools/art/store.json --out /tmp/store-art` (needs
`OPENROUTER_API_KEY`), then convert to WebP into `public/art/`.
## Tests
- Unit: `tests/unit/cart.test.ts` — totals, variant deltas, empty cart, unknown product/variant,
over-stock rejection.
- E2E smoke: `tests/e2e/smoke.mjs` — catalog, filters, checkout validation and a full mock checkout.
## Quality
```bash
node templates2/qa.mjs Store.Next
```
See [`../CONTRACT.md`](../CONTRACT.md) for the Definition of Done.

60
app/(app)/cart/page.tsx Normal file
View file

@ -0,0 +1,60 @@
'use client';
import Link from 'next/link';
import { useCart } from '@/components/cart/CartProvider';
import { formatCurrency } from '@/lib/utils';
export default function CartPage() {
const { items, total, setQuantity, remove } = useCart();
if (items.length === 0) {
return (
<div className="py-16 text-center">
<h1 className="text-2xl font-bold">Your cart is empty</h1>
<Link href="/shop" className="btn-primary mt-6 inline-flex">
Browse the shop
</Link>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold">Your cart</h1>
<div className="mt-6 space-y-4">
{items.map((item) => (
<div key={`${item.productId}-${item.variant ?? ''}`} className="card flex flex-wrap items-center justify-between gap-4">
<div>
<p className="font-semibold">{item.name}</p>
{item.variant && <p className="muted text-sm">Option: {item.variant}</p>}
<p className="muted text-sm">{formatCurrency(item.price / 100)} each</p>
</div>
<div className="flex items-center gap-3">
<label className="sr-only" htmlFor={`qty-${item.productId}`}>
Quantity
</label>
<input
id={`qty-${item.productId}`}
type="number"
min={1}
max={99}
value={item.quantity}
onChange={(e) => setQuantity(item.productId, item.variant, Number(e.target.value))}
className="input w-20"
/>
<button type="button" className="btn-ghost" onClick={() => remove(item.productId, item.variant)}>
Remove
</button>
</div>
</div>
))}
</div>
<div className="mt-8 flex items-center justify-between border-t pt-6" style={{ borderColor: 'var(--border)' }}>
<span className="text-lg font-bold">Total: {formatCurrency(total / 100)}</span>
<Link href="/checkout" className="btn-primary">
Checkout
</Link>
</div>
</div>
);
}

102
app/(app)/checkout/page.tsx Normal file
View file

@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useCart } from '@/components/cart/CartProvider';
import { Field } from '@/components/ui';
import { formatCurrency } from '@/lib/utils';
export default function CheckoutPage() {
const { items, total, clear } = useCart();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [order, setOrder] = useState<{ id: string; status: string; total: number } | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
setError(null);
const form = new FormData(e.currentTarget);
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.get('email'),
address: form.get('address'),
lines: items.map((i) => ({ productId: i.productId, variant: i.variant, quantity: i.quantity })),
}),
});
const data = (await res.json().catch(() => ({}))) as { order?: { id: string; status: string; total: number }; error?: string };
if (!res.ok || !data.order) throw new Error(data.error || 'Checkout failed.');
setOrder(data.order);
clear();
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed.');
} finally {
setBusy(false);
}
}
if (order) {
return (
<div className="py-16 text-center">
<h1 className="text-2xl font-bold">Thank you!</h1>
<p className="muted mt-3">
Order <span className="font-mono">{order.id.slice(0, 8)}</span> is {order.status} — total{' '}
{formatCurrency(order.total / 100)}.
</p>
<div className="mt-6 flex justify-center gap-3">
<Link href="/orders" className="btn-primary">
View my orders
</Link>
<Link href="/shop" className="btn-secondary">
Continue shopping
</Link>
</div>
</div>
);
}
if (items.length === 0) {
return (
<div className="py-16 text-center">
<h1 className="text-2xl font-bold">Nothing to check out</h1>
<Link href="/shop" className="btn-primary mt-6 inline-flex">
Browse the shop
</Link>
</div>
);
}
return (
<div className="mx-auto max-w-lg">
<h1 className="text-2xl font-bold">Checkout</h1>
<ul className="muted mt-4 space-y-1 text-sm">
{items.map((i) => (
<li key={`${i.productId}-${i.variant ?? ''}`}>
{i.quantity}× {i.name}
{i.variant ? ` (${i.variant})` : ''} — {formatCurrency((i.price * i.quantity) / 100)}
</li>
))}
</ul>
<p className="mt-4 text-lg font-bold">Total: {formatCurrency(total / 100)}</p>
<form className="mt-6 space-y-4" onSubmit={onSubmit} noValidate>
<Field label="Email" htmlFor="email">
<input id="email" name="email" type="email" required className="input" autoComplete="email" />
</Field>
<Field label="Shipping address" htmlFor="address">
<textarea id="address" name="address" rows={3} required className="input" autoComplete="street-address" />
</Field>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? 'Placing order…' : 'Place order (mock payment)'}
</button>
<p className="muted text-center text-xs">
The built-in mock provider completes payment with no keys. Configure Stripe via env for real charges.
</p>
</form>
</div>
);
}

View file

@ -0,0 +1,59 @@
import Link from 'next/link';
import { Badge, Card } from '@/components/ui';
import { requireUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { getPlan } from '@/lib/plans';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await requireUser();
const sub = await db.subscription.findUnique({ where: { userId: user.id } });
const plan = getPlan(sub?.plan);
const usedCalls = await db.auditLog.count({ where: { actorId: user.id } });
const usage = [
{ label: 'Seats', used: 1, limit: plan.limits.seats },
{ label: 'API calls', used: usedCalls, limit: plan.limits.apiCalls },
];
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t('dashboard.title')}</h1>
<p className="muted mt-1">{t('dashboard.welcome', { name: user.name || user.email })}</p>
</div>
<Badge tone={plan.id === 'pro' ? 'brand' : 'neutral'}>{plan.name}</Badge>
</header>
<Card title={t('dashboard.plan')} actions={<Link href="/pricing" className="btn-secondary">{t('pricing.choose')}</Link>}>
<p className="muted text-sm">
{plan.name} — ${plan.price} / month
</p>
</Card>
<Card title={t('dashboard.usage')}>
<ul className="space-y-4">
{usage.map((u) => {
const pct = Math.min(100, Math.round((u.used / u.limit) * 100));
return (
<li key={u.label}>
<div className="mb-1 flex justify-between text-sm">
<span>{u.label}</span>
<span className="muted">
{u.used} / {u.limit}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full" style={{ background: 'var(--border)' }}>
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: 'var(--color-brand-600)' }} />
</div>
</li>
);
})}
</ul>
</Card>
</div>
);
}

12
app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { SiteHeader } from '@/components/SiteHeader';
import { SiteFooter } from '@/components/SiteFooter';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="page flex-1 py-10">{children}</main>
<SiteFooter />
</div>
);
}

52
app/(app)/orders/page.tsx Normal file
View file

@ -0,0 +1,52 @@
import { Badge, EmptyState } from '@/components/ui';
import { requireUser } from '@/lib/server/auth';
import { listOrdersForUser } from '@/lib/server/store';
import { formatCurrency, formatDate } from '@/lib/utils';
export const dynamic = 'force-dynamic';
const tones: Record<string, 'brand' | 'success' | 'warning' | 'neutral' | 'danger'> = {
paid: 'success',
shipped: 'brand',
pending: 'warning',
canceled: 'danger',
};
export default async function OrdersPage() {
const user = await requireUser();
const orders = await listOrdersForUser(user.id);
return (
<div>
<h1 className="text-2xl font-bold">My orders</h1>
{orders.length === 0 ? (
<EmptyState>You have no orders yet.</EmptyState>
) : (
<div className="mt-6 space-y-5">
{orders.map((o) => (
<div key={o.id} className="card">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-mono text-sm">{o.id.slice(0, 8)}</p>
<p className="muted text-xs">{formatDate(o.createdAt)}</p>
</div>
<div className="flex items-center gap-3">
<Badge tone={tones[o.status] ?? 'neutral'}>{o.status}</Badge>
<span className="font-bold">{formatCurrency(o.total / 100)}</span>
</div>
</div>
<ul className="muted mt-3 space-y-1 text-sm">
{o.items.map((i) => (
<li key={i.id}>
{i.quantity}× {i.name}
{i.variant ? ` (${i.variant})` : ''}
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
);
}

13
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,13 @@
import Link from 'next/link';
import { t } from '@/lib/i18n';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-5 py-12">
<Link href="/" className="mb-8 text-xl font-bold">
{t('brand.name')}
</Link>
<div className="w-full max-w-sm">{children}</div>
</div>
);
}

15
app/(auth)/login/page.tsx Normal file
View file

@ -0,0 +1,15 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { AuthForm } from '@/components/AuthForm';
import { getCurrentUser } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export default async function LoginPage() {
if (await getCurrentUser()) redirect('/dashboard');
return (
<Suspense fallback={<p className="muted text-center">Loading…</p>}>
<AuthForm mode="login" />
</Suspense>
);
}

View file

@ -0,0 +1,15 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { AuthForm } from '@/components/AuthForm';
import { getCurrentUser } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export default async function RegisterPage() {
if (await getCurrentUser()) redirect('/dashboard');
return (
<Suspense fallback={<p className="muted text-center">Loading…</p>}>
<AuthForm mode="register" />
</Suspense>
);
}

View file

@ -0,0 +1,14 @@
import { SiteHeader } from '@/components/SiteHeader';
import { SiteFooter } from '@/components/SiteFooter';
import { RevealOnScroll } from '@/components/RevealOnScroll';
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
<RevealOnScroll />
</div>
);
}

View file

@ -0,0 +1,10 @@
export default function PrivacyPage() {
return (
<article className="page prose max-w-3xl py-16">
<h1 className="text-3xl font-bold">Privacy Policy</h1>
<p className="muted mt-4">
This is a placeholder legal page. Replace it with your own policy before going live.
</p>
</article>
);
}

View file

@ -0,0 +1,10 @@
export default function TermsPage() {
return (
<article className="page prose max-w-3xl py-16">
<h1 className="text-3xl font-bold">Terms of Service</h1>
<p className="muted mt-4">
This is a placeholder legal page. Replace it with your own terms before going live.
</p>
</article>
);
}

328
app/(marketing)/page.tsx Normal file
View file

@ -0,0 +1,328 @@
import Link from 'next/link';
import type { CSSProperties } from 'react';
import { db } from '@/lib/server/db';
import { formatCurrency } from '@/lib/utils';
import { t } from '@/lib/i18n';
import { ProductImage } from '@/components/ProductImage';
export const dynamic = 'force-dynamic';
const collections = [
{ slug: 'apparel', name: 'Apparel', blurb: 'Organic cotton, built to last', image: '/art/cat-apparel.webp' },
{ slug: 'accessories', name: 'Accessories', blurb: 'Everyday carry, refined', image: '/art/cat-accessories.webp' },
{ slug: 'home', name: 'Home', blurb: 'Objects you keep for years', image: '/art/cat-home.webp' },
] as const;
const materials = [
{ title: 'Organic cotton', note: 'GOTS-certified, combed for softness and dyed with low-impact pigments.' },
{ title: 'Recycled steel', note: 'Bottles and mugs pressed from 90% recycled stainless steel.' },
{ title: 'FSC paper', note: 'Notebooks and posters printed on responsibly sourced, acid-free stock.' },
{ title: 'Natural canvas', note: 'Heavy 16oz cotton canvas, woven for a decade of daily use.' },
] as const;
const materialShots = [
{ image: '/art/product-tote.webp', alt: 'Natural canvas tote' },
{ image: '/art/product-bottle.webp', alt: 'Recycled-steel insulated bottle' },
{ image: '/art/product-notebook.webp', alt: 'FSC dotted notebook' },
{ image: '/art/product-mug.webp', alt: 'Enamel mug' },
] as const;
const trustKeys = ['shipping', 'returns', 'secure', 'sustainable'] as const;
/** Minimal stroke icons (24×24, currentColor) — no icon dependency. */
function TrustIcon({ name }: { name: (typeof trustKeys)[number] }) {
const paths: Record<(typeof trustKeys)[number], React.ReactNode> = {
shipping: (
<>
<path d="M3 7h10v8H3zM13 10h4l3 3v2h-7z" />
<circle cx="6.5" cy="17" r="1.6" />
<circle cx="17" cy="17" r="1.6" />
</>
),
returns: (
<>
<path d="M4 9a8 8 0 0 1 13.5-3.5M20 15a8 8 0 0 1-13.5 3.5" />
<path d="M4 4v5h5M20 20v-5h-5" />
</>
),
secure: (
<>
<path d="M12 3l7 3v6c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z" />
<path d="M9.5 12l1.8 1.8 3.4-3.6" />
</>
),
sustainable: (
<>
<path d="M5 19c0-7 5-12 14-13 0 9-5 13-11 13H5z" />
<path d="M9 15c1.5-3 4-5.5 7-7" />
</>
),
};
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden
>
{paths[name]}
</svg>
);
}
const trustCopy: Record<(typeof trustKeys)[number], string> = {
shipping: 'Free shipping over $50',
returns: '30-day easy returns',
secure: 'Secure checkout',
sustainable: 'Responsible materials',
};
export default async function HomePage() {
const featured = await db.product.findMany({
where: { active: true },
orderBy: { createdAt: 'desc' },
take: 4,
include: { category: true },
});
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Store',
name: t('brand.name'),
url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
};
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* Hero — editorial copy beside a curated collection still-life. */}
<section className="relative overflow-hidden">
<div className="page grid items-center gap-12 py-16 lg:grid-cols-[1.02fr_1fr] lg:py-24">
<div>
<span className="kicker" style={{ color: 'var(--accent-ink)' }}>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
New season · {t('brand.name')}
</span>
<h1 className="display mt-6 text-[clamp(2.6rem,6vw,4.4rem)]">
Everyday goods,
<br />
made to last
</h1>
<p className="muted mt-7 max-w-xl text-lg leading-relaxed">
A ready-to-run storefront — browse a curated catalog, add to cart and check out with the
built-in mock payment provider. Swap in Stripe with one env var.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/shop" className="btn-primary px-5 py-2.5 text-base">
Shop the catalog
</Link>
<Link href="/pricing" className="btn-secondary px-5 py-2.5 text-base">
{t('nav.pricing')}
</Link>
</div>
<ul className="muted mt-9 flex flex-wrap gap-x-6 gap-y-3 text-sm">
{trustKeys.map((key) => (
<li key={key} className="flex items-center gap-2">
<span style={{ color: 'var(--color-brand-600)' }}>
<TrustIcon name={key} />
</span>
{trustCopy[key]}
</li>
))}
</ul>
</div>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element -- static /public art */}
<img
src="/art/hero.webp"
alt="A curated arrangement of Acme everyday goods"
className="shadow-soft w-full rounded-3xl object-cover ring-1 ring-black/5"
/>
<span
className="glass panel absolute -bottom-5 left-5 px-4 py-2.5 text-xs font-semibold"
style={{ color: 'var(--text)' }}
>
Free shipping over $50
</span>
<span
className="glass panel absolute -top-4 right-5 px-4 py-2.5 text-xs font-semibold"
style={{ color: 'var(--text)' }}
>
New in · SS26
</span>
</div>
</div>
</section>
{/* Value-prop ticker */}
<section className="border-y" style={{ borderColor: 'var(--border)', background: 'var(--canvas)' }}>
<div className="page overflow-hidden py-4 [mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]">
<div className="animate-marquee flex w-max items-center gap-10 pr-10">
{[...trustKeys, ...trustKeys].map((key, i) => (
<span key={`${key}-${i}`} className="kicker flex items-center gap-2.5" style={{ color: 'var(--text-muted)' }}>
<span style={{ color: 'var(--color-brand-600)' }}>
<TrustIcon name={key} />
</span>
{trustCopy[key]}
</span>
))}
</div>
</div>
</section>
{/* Collections */}
<section className="page py-20">
<div className="mb-8 flex items-end justify-between gap-4" data-reveal>
<div>
<h2 className="display text-3xl sm:text-4xl">Shop by collection</h2>
<p className="muted mt-2 text-sm">Three tight edits, no endless aisle.</p>
</div>
<Link href="/shop" className="btn-ghost text-sm">
All products →
</Link>
</div>
<div className="grid gap-5 sm:grid-cols-3">
{collections.map((c, i) => (
<Link
key={c.slug}
href={`/shop?category=${c.slug}`}
className="group relative overflow-hidden rounded-2xl border"
style={{ borderColor: 'var(--border)' }}
data-reveal
>
{/* eslint-disable-next-line @next/next/no-img-element -- static /public art */}
<img
src={c.image}
alt={c.name}
loading={i === 0 ? 'eager' : 'lazy'}
className="h-72 w-full object-cover transition duration-700 group-hover:scale-[1.04]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-5">
<h3 className="font-display text-2xl text-white">{c.name}</h3>
<p className="text-sm text-white/80">{c.blurb}</p>
</div>
</Link>
))}
</div>
</section>
{/* Featured products */}
<section className="page pb-24">
<div className="mb-8 flex items-end justify-between gap-4" data-reveal>
<div>
<h2 className="display text-3xl sm:text-4xl">New arrivals</h2>
<p className="muted mt-2 text-sm">Fresh from the studio, restocked weekly.</p>
</div>
<Link href="/shop" className="btn-ghost text-sm">
View all →
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{featured.map((p) => (
<Link key={p.id} href={`/shop/${p.slug}`} className="group" data-reveal>
<ProductImage
image={p.image}
alt={p.name}
className="aspect-square w-full transition duration-500 group-hover:-translate-y-1 group-hover:shadow-lg"
/>
<div className="mt-3 flex items-start justify-between gap-3">
<div>
<p className="kicker">{p.category?.name ?? 'Shop'}</p>
<h3 className="mt-1 font-medium">{p.name}</h3>
</div>
<span className="tabular font-medium">{formatCurrency(p.price / 100)}</span>
</div>
</Link>
))}
</div>
</section>
{/* Materials story */}
<section className="border-y" style={{ borderColor: 'var(--border)', background: 'var(--canvas)' }}>
<div className="page grid items-center gap-14 py-20 lg:grid-cols-2">
<div data-reveal>
<span className="kicker" style={{ color: 'var(--accent-ink)' }}>
Materials
</span>
<h2 className="display mt-4 text-3xl sm:text-4xl">
Made from materials we&apos;d want to live with
</h2>
<p className="muted mt-5 max-w-xl leading-relaxed">
Every product starts with the material. We choose fibres and finishes that age well and
can be repaired, then keep the palette quiet so the goods fit any home.
</p>
<dl className="mt-8 grid gap-6 sm:grid-cols-2">
{materials.map((m) => (
<div key={m.title}>
<dt className="flex items-center gap-2 font-medium">
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: 'var(--color-brand-600)' }} />
{m.title}
</dt>
<dd className="muted mt-1.5 text-sm leading-relaxed">{m.note}</dd>
</div>
))}
</dl>
</div>
<div className="grid grid-cols-2 gap-4" data-reveal style={{ '--reveal-delay': '100ms' } as CSSProperties}>
{materialShots.map((s, i) => (
<ProductImage key={s.image} image={s.image} alt={s.alt} className="aspect-square w-full" eager={i < 2} />
))}
</div>
</div>
</section>
{/* Lookbook band */}
<section className="relative isolate overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element -- static /public art */}
<img src="/art/lookbook.webp" alt="" aria-hidden className="absolute inset-0 -z-10 h-full w-full object-cover" />
<div className="absolute inset-0 -z-10 bg-gradient-to-l from-black/80 via-black/45 to-transparent" />
<div className="page flex justify-end py-24 lg:py-32">
<div className="max-w-md text-white">
<span className="kicker" style={{ color: 'rgba(255,255,255,0.7)' }}>
Lookbook
</span>
<h2 className="display mt-4 text-4xl sm:text-5xl">Designed for the everyday</h2>
<p className="mt-5 leading-relaxed text-slate-200">
A small wardrobe of essentials that works together — understated pieces you reach for
without thinking.
</p>
<Link href="/shop" className="btn mt-8 bg-white px-5 py-2.5 text-base font-semibold text-slate-900 transition hover:bg-slate-100">
Shop the look
</Link>
</div>
</div>
</section>
{/* CTA band — the artwork is light, so the copy stays in a fixed dark ink. */}
<section className="relative isolate overflow-hidden border-t" style={{ borderColor: 'var(--border)' }}>
{/* eslint-disable-next-line @next/next/no-img-element -- static /public art */}
<img src="/art/cta.webp" alt="" aria-hidden className="absolute inset-0 -z-10 h-full w-full object-cover" />
<div className="page py-20 text-center">
<h2 className="display mx-auto max-w-2xl text-4xl text-slate-900 sm:text-5xl">
Start selling in minutes
</h2>
<p className="mx-auto mt-5 max-w-xl text-lg text-slate-700">
Clone the storefront, connect a payment provider and go live — checkout already works with
the built-in mock provider.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link href="/shop" className="btn-primary px-5 py-2.5 text-base">
Browse products
</Link>
<Link
href="/register"
className="btn bg-white/60 px-5 py-2.5 text-base font-medium text-slate-900 ring-1 ring-inset ring-slate-900/20 backdrop-blur transition hover:bg-white/80"
>
{t('nav.register')}
</Link>
</div>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,74 @@
import Link from 'next/link';
import { Badge } from '@/components/ui';
import { UpgradeButton } from '@/components/UpgradeButton';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { PLANS, PLAN_IDS, getPlan } from '@/lib/plans';
import { formatCurrency } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function PricingPage({
searchParams,
}: {
searchParams: Promise<{ billing?: string }>;
}) {
const { billing } = await searchParams;
const user = await getCurrentUser();
const sub = user ? await db.subscription.findUnique({ where: { userId: user.id } }) : null;
const currentPlan = getPlan(sub?.plan).id;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: t('brand.name'),
applicationCategory: 'BusinessApplication',
offers: PLAN_IDS.map((id) => ({ '@type': 'Offer', name: PLANS[id].name, price: PLANS[id].price, priceCurrency: 'USD' })),
};
return (
<div className="page py-16">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<h1 className="text-center text-3xl font-bold">{t('pricing.title')}</h1>
<p className="muted mt-3 text-center">{t('pricing.subtitle')}</p>
{billing === 'success' && (
<p className="mx-auto mt-6 max-w-md rounded-lg border border-emerald-500 bg-emerald-50 px-4 py-3 text-center text-sm text-emerald-800">
{t('pricing.upgraded')}
</p>
)}
<div className="mx-auto mt-10 grid max-w-4xl gap-6 sm:grid-cols-2">
{PLAN_IDS.map((id) => {
const plan = PLANS[id];
const isCurrent = user && currentPlan === id;
return (
<div key={id} className="card flex flex-col">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{plan.name}</h2>
{isCurrent && <Badge tone="brand">Current</Badge>}
</div>
<p className="mt-4 text-3xl font-bold">
{formatCurrency(plan.price)}
<span className="muted text-base font-normal"> {t('pricing.perMonth')}</span>
</p>
<ul className="muted mt-5 flex-1 space-y-2 text-sm">
{plan.features.map((f) => (
<li key={f}>• {f}</li>
))}
</ul>
{isCurrent ? (
<p className="mt-6 text-center text-sm font-medium text-emerald-600">{t('pricing.current')}</p>
) : user ? (
<UpgradeButton plan={id} label={t('pricing.choose')} />
) : (
<Link href={`/login?next=/pricing`} className="btn-primary mt-6 w-full">
{t('pricing.choose')}
</Link>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { AddToCartButton } from '@/components/cart/AddToCartButton';
import { ProductImage } from '@/components/ProductImage';
import { getProductBySlug } from '@/lib/server/store';
import { parseVariants } from '@/lib/cart';
import { formatCurrency } from '@/lib/utils';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const product = await getProductBySlug(slug);
if (!product) return { title: 'Product not found' };
return {
title: product.name,
description: product.description,
openGraph: { title: product.name, description: product.description, type: 'website' },
};
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProductBySlug(slug);
if (!product || !product.active) notFound();
const variants = parseVariants(product.variants);
const available = product.stock > 0;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
sku: product.slug,
category: product.category?.name,
offers: {
'@type': 'Offer',
price: (product.price / 100).toFixed(2),
priceCurrency: product.currency,
availability: available ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
},
};
return (
<div className="page py-10">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<Link href="/shop" className="muted text-sm hover:opacity-80">
← Back to shop
</Link>
<div className="mt-6 grid gap-10 md:grid-cols-2">
<ProductImage image={product.image} alt={product.name} className="aspect-square w-full" eager />
<div>
<p className="muted text-xs uppercase">{product.category?.name ?? 'Shop'}</p>
<h1 className="mt-2 text-3xl font-bold">{product.name}</h1>
<p className="mt-4 text-2xl font-bold">{formatCurrency(product.price / 100)}</p>
<p className="muted mt-4">{product.description}</p>
<p className="muted mt-4 text-sm">{available ? `${product.stock} in stock` : 'Out of stock'}</p>
<div className="mt-6 max-w-xs">
{available ? (
<AddToCartButton productId={product.id} slug={product.slug} name={product.name} price={product.price} variants={variants} />
) : (
<button type="button" className="btn-secondary w-full" disabled>
Out of stock
</button>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import Link from 'next/link';
import { listCategories, listProducts } from '@/lib/server/store';
import { formatCurrency } from '@/lib/utils';
import { ProductImage } from '@/components/ProductImage';
export const dynamic = 'force-dynamic';
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; category?: string; sort?: string; page?: string }>;
}) {
const sp = await searchParams;
const q = sp.q?.trim() || undefined;
const category = sp.category || undefined;
const sort = sp.sort || undefined;
const page = Math.max(1, Number(sp.page ?? '1') || 1);
const [result, categories] = await Promise.all([listProducts({ q, category, sort, page }), listCategories()]);
const qs = (over: Record<string, string | number | undefined>) => {
const params = new URLSearchParams();
const merged = { q, category, sort, page, ...over };
for (const [k, v] of Object.entries(merged)) if (v !== undefined && v !== '' && v !== 1) params.set(k, String(v));
const s = params.toString();
return `/shop${s ? `?${s}` : ''}`;
};
return (
<div className="page py-10">
<h1 className="text-3xl font-bold">Shop</h1>
<div className="mt-6 flex flex-wrap items-center gap-3">
<form className="flex gap-2" action="/shop" method="get">
<input name="q" defaultValue={q} placeholder="Search products…" className="input max-w-xs" aria-label="Search" />
{category && <input type="hidden" name="category" value={category} />}
<button type="submit" className="btn-secondary">
Search
</button>
</form>
<div className="flex gap-2">
{['price-asc', 'price-desc'].map((s) => (
<Link key={s} href={qs({ sort: s, page: undefined })} className={`btn-secondary ${sort === s ? 'font-bold' : ''}`}>
{s === 'price-asc' ? 'Price ↑' : 'Price ↓'}
</Link>
))}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Link href={qs({ category: undefined, page: undefined })} className={`badge ${!category ? 'bg-brand-600 text-white' : 'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100'}`}>
All
</Link>
{categories.map((c) => (
<Link
key={c.id}
href={qs({ category: c.slug, page: undefined })}
className={`badge ${category === c.slug ? 'bg-brand-600 text-white' : 'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100'}`}
>
{c.name}
</Link>
))}
</div>
{result.items.length === 0 ? (
<p className="muted py-16 text-center">No products match your search.</p>
) : (
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{result.items.map((p) => (
<Link key={p.id} href={`/shop/${p.slug}`} className="group card transition hover:shadow-md">
<ProductImage image={p.image} alt={p.name} className="mb-4 aspect-[4/3] w-full" />
<p className="muted text-xs uppercase">{p.category?.name ?? 'Shop'}</p>
<h2 className="mt-1 font-semibold">{p.name}</h2>
<div className="mt-2 flex items-center justify-between">
<span className="font-bold">{formatCurrency(p.price / 100)}</span>
{p.stock <= 3 ? <span className="text-xs text-amber-600">Only {p.stock} left</span> : <span className="muted text-xs">In stock</span>}
</div>
</Link>
))}
</div>
)}
{result.pages > 1 && (
<nav className="mt-10 flex justify-center gap-2" aria-label="Pagination">
{Array.from({ length: result.pages }, (_, i) => i + 1).map((n) => (
<Link key={n} href={qs({ page: n })} className={`btn-secondary ${n === result.page ? 'font-bold' : ''}`}>
{n}
</Link>
))}
</nav>
)}
</div>
);
}

39
app/admin/audit/page.tsx Normal file
View file

@ -0,0 +1,39 @@
import { EmptyState } from '@/components/ui';
import { requireAdmin } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { formatDate } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminAuditPage() {
await requireAdmin();
const entries = await db.auditLog.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { actor: true },
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('admin.audit')}</h1>
{entries.length === 0 ? (
<EmptyState />
) : (
<ul className="divide-y rounded-xl border" style={{ borderColor: 'var(--border)' }}>
{entries.map((e) => (
<li key={e.id} className="flex items-center justify-between gap-4 px-4 py-3 text-sm">
<span>
<span className="font-medium">{e.action}</span>
{e.target ? <span className="muted"> → {e.target}</span> : null}
</span>
<span className="muted shrink-0 text-xs">
{e.actor?.email ?? 'system'} · {formatDate(e.createdAt)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

28
app/admin/layout.tsx Normal file
View file

@ -0,0 +1,28 @@
import Link from 'next/link';
import { AdminNav } from '@/components/AdminNav';
import { requireAdmin } from '@/lib/server/auth';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const admin = await requireAdmin();
return (
<div className="flex min-h-screen flex-col">
<header className="border-b" style={{ borderColor: 'var(--border)' }}>
<div className="page flex h-16 items-center justify-between">
<Link href="/" className="text-lg font-bold">
{t('brand.name')} <span className="muted font-normal">/ {t('admin.title')}</span>
</Link>
<span className="muted text-sm">{admin.email}</span>
</div>
</header>
<div className="page flex flex-1 flex-col gap-6 py-8 md:flex-row">
<aside className="md:w-52 md:shrink-0">
<AdminNav />
</aside>
<main className="min-w-0 flex-1">{children}</main>
</div>
</div>
);
}

74
app/admin/orders/page.tsx Normal file
View file

@ -0,0 +1,74 @@
'use client';
import { useEffect, useState } from 'react';
type Order = {
id: string;
email: string;
status: string;
total: number;
createdAt: string;
items: { id: string; name: string; quantity: number }[];
};
const STATUSES = ['pending', 'paid', 'shipped', 'canceled'];
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
async function load() {
const res = await fetch('/api/admin/orders');
const data = (await res.json().catch(() => ({}))) as { orders?: Order[] };
setOrders(data.orders ?? []);
}
useEffect(() => {
void load();
}, []);
async function setStatus(id: string, status: string) {
await fetch(`/api/admin/orders/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
await load();
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Orders</h1>
{orders.length === 0 ? (
<p className="muted py-6 text-center text-sm">No orders yet.</p>
) : (
<div className="space-y-4">
{orders.map((o) => (
<div key={o.id} className="card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-mono text-sm">{o.id.slice(0, 8)}</p>
<p className="muted text-xs">
{o.email} · {new Date(o.createdAt).toLocaleDateString('en-US')}
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-bold">${(o.total / 100).toFixed(2)}</span>
<select className="input w-36" value={o.status} onChange={(e) => setStatus(o.id, e.target.value)} aria-label={`Status for order ${o.id}`}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
<ul className="muted mt-3 space-y-1 text-sm">
{o.items.map((i) => (
<li key={i.id}>
{i.quantity}× {i.name}
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
);
}

45
app/admin/page.tsx Normal file
View file

@ -0,0 +1,45 @@
import { Card } from '@/components/ui';
import { db } from '@/lib/server/db';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminOverviewPage() {
const [totalUsers, activeSubs, recent] = await Promise.all([
db.user.count(),
db.subscription.count({ where: { status: 'active' } }),
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 5, include: { actor: true } }),
]);
const stats = [
{ label: t('admin.totalUsers'), value: totalUsers },
{ label: t('admin.activeSubs'), value: activeSubs },
];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('admin.overview')}</h1>
<div className="grid gap-5 sm:grid-cols-2">
{stats.map((s) => (
<div key={s.label} className="card">
<p className="muted text-sm">{s.label}</p>
<p className="mt-2 text-3xl font-bold">{s.value}</p>
</div>
))}
</div>
<Card title={t('admin.audit')}>
<ul className="divide-y" style={{ borderColor: 'var(--border)' }}>
{recent.map((entry) => (
<li key={entry.id} className="flex items-center justify-between py-2 text-sm">
<span>
<span className="font-medium">{entry.action}</span>
{entry.target ? <span className="muted"> → {entry.target}</span> : null}
</span>
<span className="muted text-xs">{entry.actor?.email ?? 'system'}</span>
</li>
))}
</ul>
</Card>
</div>
);
}

138
app/admin/products/page.tsx Normal file
View file

@ -0,0 +1,138 @@
'use client';
import { useEffect, useState } from 'react';
type Product = { id: string; name: string; slug: string; price: number; stock: number; active: boolean; category?: { name: string } | null };
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
const res = await fetch('/api/admin/products');
const data = (await res.json().catch(() => ({}))) as { products?: Product[] };
setProducts(data.products ?? []);
}
useEffect(() => {
void load();
}, []);
async function create(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
setError(null);
const form = new FormData(e.currentTarget);
const body = {
name: String(form.get('name') ?? ''),
slug: String(form.get('slug') ?? ''),
price: Math.round(Number(form.get('price') ?? 0) * 100),
stock: Number(form.get('stock') ?? 0),
description: String(form.get('description') ?? ''),
active: true,
};
const res = await fetch('/api/admin/products', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) setError('Could not create product (check the slug is unique).');
(e.target as HTMLFormElement).reset();
setBusy(false);
await load();
}
async function updateStock(id: string, stock: number) {
await fetch(`/api/admin/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stock }) });
await load();
}
async function toggleActive(p: Product) {
await fetch(`/api/admin/products/${p.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active: !p.active }) });
await load();
}
async function remove(id: string) {
if (!window.confirm('Delete this product? This cannot be undone.')) return;
await fetch(`/api/admin/products/${id}`, { method: 'DELETE' });
await load();
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Products</h1>
<form className="card grid gap-3 sm:grid-cols-2" onSubmit={create}>
<h2 className="text-lg font-semibold sm:col-span-2">Add product</h2>
<label className="text-sm">
Name
<input name="name" required className="input mt-1" />
</label>
<label className="text-sm">
Slug
<input name="slug" required pattern="[a-z0-9-]+" className="input mt-1" placeholder="my-product" />
</label>
<label className="text-sm">
Price (USD)
<input name="price" type="number" min={0} step="0.01" required className="input mt-1" />
</label>
<label className="text-sm">
Stock
<input name="stock" type="number" min={0} required className="input mt-1" />
</label>
<label className="text-sm sm:col-span-2">
Description
<textarea name="description" rows={2} className="input mt-1" />
</label>
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
<div className="sm:col-span-2">
<button type="submit" className="btn-primary" disabled={busy}>
Create product
</button>
</div>
</form>
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
<table className="w-full text-sm">
<thead className="muted text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Price</th>
<th className="px-4 py-3">Stock</th>
<th className="px-4 py-3">Active</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">{p.name}</td>
<td className="px-4 py-3">{p.category?.name ?? '—'}</td>
<td className="px-4 py-3">${(p.price / 100).toFixed(2)}</td>
<td className="px-4 py-3">
<input
type="number"
min={0}
defaultValue={p.stock}
className="input w-20"
onBlur={(e) => updateStock(p.id, Number(e.target.value))}
aria-label={`Stock for ${p.name}`}
/>
</td>
<td className="px-4 py-3">{p.active ? 'yes' : 'no'}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button type="button" className="btn-secondary" onClick={() => toggleActive(p)}>
{p.active ? 'Hide' : 'Show'}
</button>
<button type="button" className="btn-danger" onClick={() => remove(p.id)}>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

51
app/admin/users/page.tsx Normal file
View file

@ -0,0 +1,51 @@
import { Badge, EmptyState } from '@/components/ui';
import { UserActions } from '@/components/admin/UserActions';
import { requireAdmin } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { formatDate } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminUsersPage() {
await requireAdmin();
const users = await db.user.findMany({ orderBy: { createdAt: 'asc' }, include: { subscription: true } });
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('admin.users')}</h1>
{users.length === 0 ? (
<EmptyState />
) : (
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
<table className="w-full text-sm">
<thead className="muted text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Role</th>
<th className="px-4 py-3">Plan</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">{u.email}</td>
<td className="px-4 py-3">
<Badge tone={u.role === 'admin' ? 'brand' : 'neutral'}>{u.role}</Badge>
</td>
<td className="px-4 py-3">{u.subscription?.plan ?? 'free'}</td>
<td className="px-4 py-3">{formatDate(u.createdAt)}</td>
<td className="px-4 py-3">
<UserActions id={u.id} role={u.role} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/server/auth';
import { updateOrderStatus } from '@/lib/server/store';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
const schema = z.object({ status: z.enum(['pending', 'paid', 'shipped', 'canceled']) });
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (user.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { id } = await params;
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid status' }, { status: 400 });
const order = await updateOrderStatus(id, parsed.data.status);
await recordAudit({ actorId: user.id, action: 'order.status', target: id, meta: { status: parsed.data.status } });
return NextResponse.json({ order: { id: order.id, status: order.status } });
}

View file

@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { listAllOrders } from '@/lib/server/store';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (user.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
return NextResponse.json({ orders: await listAllOrders() });
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { deleteProduct, productSchema, upsertProduct, adminListProducts } from '@/lib/server/store';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
async function requireAdminApi() {
const user = await getCurrentUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (user.role !== 'admin') return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
return { user };
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const { id } = await params;
const parsed = productSchema.partial().safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid product.' }, { status: 400 });
const existing = await adminListProducts();
const current = existing.find((p) => p.id === id);
if (!current) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const product = await upsertProduct(
{ name: parsed.data.name ?? current.name, slug: parsed.data.slug ?? current.slug, price: parsed.data.price ?? current.price, stock: parsed.data.stock ?? current.stock, description: parsed.data.description ?? current.description, active: parsed.data.active ?? current.active },
id,
);
await recordAudit({ actorId: auth.user!.id, action: 'product.update', target: product.slug });
return NextResponse.json({ product });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const { id } = await params;
const product = await deleteProduct(id);
await recordAudit({ actorId: auth.user!.id, action: 'product.delete', target: product.slug });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { adminListProducts, productSchema, upsertProduct } from '@/lib/server/store';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
async function requireAdminApi() {
const user = await getCurrentUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (user.role !== 'admin') return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
return { user };
}
export async function GET() {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
return NextResponse.json({ products: await adminListProducts() });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const parsed = productSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid product.' }, { status: 400 });
const product = await upsertProduct(parsed.data);
await recordAudit({ actorId: auth.user!.id, action: 'product.create', target: product.slug });
return NextResponse.json({ product }, { status: 201 });
}

View file

@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
const patchSchema = z.object({ role: z.enum(['user', 'admin']) });
async function requireAdminApi() {
const user = await getCurrentUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (user.role !== 'admin') return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
return { user };
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const { id } = await params;
const parsed = patchSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid role' }, { status: 400 });
if (id === auth.user!.id && parsed.data.role !== 'admin') {
return NextResponse.json({ error: 'You cannot remove your own admin role.' }, { status: 400 });
}
const updated = await db.user.update({ where: { id }, data: { role: parsed.data.role } });
await recordAudit({ actorId: auth.user!.id, action: 'user.role', target: updated.email, meta: { role: parsed.data.role } });
return NextResponse.json({ user: { id: updated.id, email: updated.email, role: updated.role } });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const { id } = await params;
if (id === auth.user!.id) return NextResponse.json({ error: 'You cannot delete your own account.' }, { status: 400 });
const deleted = await db.user.delete({ where: { id } });
await recordAudit({ actorId: auth.user!.id, action: 'user.delete', target: deleted.email });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (user.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const users = await db.user.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, email: true, name: true, role: true, createdAt: true, subscription: { select: { plan: true, status: true } } },
});
return NextResponse.json({ users });
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/server/db';
import { createSession, verifyPassword } from '@/lib/server/auth';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
export async function POST(request: Request) {
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
const email = parsed.data.email.toLowerCase();
const user = await db.user.findUnique({ where: { email } });
// Constant-ish response: never reveal whether the email exists.
if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 });
}
await db.subscription.upsert({
where: { userId: user.id },
update: {},
create: { userId: user.id, plan: 'free', status: 'active', provider: 'mock' },
});
await createSession(user.id);
await recordAudit({ actorId: user.id, action: 'user.login', target: user.email });
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } });
}

View file

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export async function POST() {
await destroySession();
// 303 so a plain HTML form POST turns into a GET of the home page.
return NextResponse.redirect(new URL('/', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), 303);
}

10
app/api/auth/me/route.ts Normal file
View file

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ user: null }, { status: 401 });
return NextResponse.json({ user });
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/server/db';
import { createSession, hashPassword } from '@/lib/server/auth';
import { recordAudit } from '@/lib/server/audit';
import { sendEmail } from '@/lib/server/email';
export const dynamic = 'force-dynamic';
const schema = z.object({
email: z.string().email().max(200),
password: z.string().min(8).max(200),
name: z.string().max(120).optional(),
});
export async function POST(request: Request) {
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email or password (min 8 characters).' }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const existing = await db.user.findUnique({ where: { email } });
if (existing) return NextResponse.json({ error: 'An account with this email already exists.' }, { status: 409 });
const user = await db.user.create({
data: {
email,
name: parsed.data.name || null,
role: 'user',
passwordHash: await hashPassword(parsed.data.password),
subscription: { create: { plan: 'free', status: 'active', provider: 'mock' } },
},
});
await recordAudit({ actorId: user.id, action: 'user.register', target: user.email });
await sendEmail({
to: user.email,
subject: 'Welcome',
html: '<p>Your account is ready. Sign in to get started.</p>',
}).catch((e) => console.error('[email] register', e));
await createSession(user.id);
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } }, { status: 201 });
}

View file

@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { getPayments } from '@/lib/server/payments';
import { recordAudit } from '@/lib/server/audit';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic';
const schema = z.object({ plan: z.enum(['free', 'pro']) });
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
const provider = getPayments();
const siteUrl = env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, '');
const result = await provider.createCheckout({
userId: user.id,
email: user.email,
plan: parsed.data.plan,
successUrl: `${siteUrl}/dashboard`,
cancelUrl: `${siteUrl}/pricing`,
});
// The mock provider has no external redirect: activate the subscription right away so the
// full "plan → checkout → return → active" flow works with zero configuration.
if (provider.name === 'mock') {
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.subscription.upsert({
where: { userId: user.id },
update: { plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
create: { userId: user.id, plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
});
}
await recordAudit({ actorId: user.id, action: 'billing.checkout', target: parsed.data.plan, meta: { provider: provider.name } });
return NextResponse.json({ id: result.id, url: result.url, provider: provider.name });
}

View file

@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/server/db';
import { getPayments } from '@/lib/server/payments';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get('stripe-signature');
const provider = getPayments();
let event;
try {
event = await provider.parseWebhook(raw, signature);
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Bad webhook' }, { status: 400 });
}
if (event.type === 'checkout.completed' && event.userId) {
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.subscription.upsert({
where: { userId: event.userId },
update: { plan: event.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
create: { userId: event.userId, plan: event.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
});
await recordAudit({ actorId: event.userId, action: 'billing.activated', target: event.plan ?? 'pro' });
} else if (event.type === 'subscription.canceled' && event.userId) {
await db.subscription.update({ where: { userId: event.userId }, data: { status: 'canceled' } }).catch(() => undefined);
await recordAudit({ actorId: event.userId, action: 'billing.canceled', target: event.userId });
}
// Idempotent: re-delivered events simply re-apply the same upsert.
return NextResponse.json({ received: true });
}

29
app/api/checkout/route.ts Normal file
View file

@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { checkoutSchema, createOrder } from '@/lib/server/store';
import { recordAudit } from '@/lib/server/audit';
import { sendEmail } from '@/lib/server/email';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
const parsed = checkoutSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: 'Please provide a valid email, address and cart.' }, { status: 400 });
}
const user = await getCurrentUser();
const result = await createOrder(parsed.data, user?.id);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 409 });
await recordAudit({ actorId: user?.id ?? null, action: 'order.created', target: result.order.providerRef ?? result.order.id, meta: { total: result.order.total } });
await sendEmail({
to: result.order.email,
subject: `Order ${result.order.id.slice(0, 8)} confirmed`,
html: `<p>Thanks! Your order of $${(result.order.total / 100).toFixed(2)} is confirmed.</p>`,
}).catch((e) => console.error('[email] order', e));
return NextResponse.json(
{ order: { id: result.order.id, status: result.order.status, total: result.order.total } },
{ status: 201 },
);
}

7
app/api/health/route.ts Normal file
View file

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export function GET() {
return NextResponse.json({ status: 'ok', service: 'templates2-base-next', time: new Date().toISOString() });
}

11
app/api/orders/route.ts Normal file
View file

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { listOrdersForUser } from '@/lib/server/store';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ orders: await listOrdersForUser(user.id) });
}

16
app/api/products/route.ts Normal file
View file

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { listProducts } from '@/lib/server/store';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const url = new URL(request.url);
const result = await listProducts({
q: url.searchParams.get('q') ?? undefined,
category: url.searchParams.get('category') ?? undefined,
sort: url.searchParams.get('sort') ?? undefined,
page: Math.max(1, Number(url.searchParams.get('page') ?? '1') || 1),
pageSize: 9,
});
return NextResponse.json(result);
}

13
app/error.tsx Normal file
View file

@ -0,0 +1,13 @@
'use client';
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-5 text-center">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="muted mt-2 text-sm">{error.message || 'An unexpected error occurred.'}</p>
<button type="button" className="btn-primary mt-8" onClick={reset}>
Try again
</button>
</div>
);
}

354
app/globals.css Normal file
View file

@ -0,0 +1,354 @@
@import "tailwindcss";
/* ── Self-hosted variable fonts ────────────────────────────────────────────
Geist for UI, Instrument Serif for editorial display headings, Geist Mono
for micro-type (eyebrows, prices, order numbers). */
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/geist-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/geist-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Geist Mono";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/geist-mono-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Instrument Serif";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/instrument-serif-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Instrument Serif";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/instrument-serif-italic-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Theme is toggled by a `.dark` class on <html> (see ThemeProvider). */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Editorial retail palette: warm ink on paper, one ochre accent. */
--color-brand-50: #f4f3f1;
--color-brand-100: #e7e5e1;
--color-brand-200: #d6d3cc;
--color-brand-400: #78716c;
--color-brand-500: #33302c;
--color-brand-600: #1c1917;
--color-brand-700: #0c0a09;
--color-accent-300: #fcd34d;
--color-accent-400: #fbbf24;
--color-accent-500: #d97706;
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-display: "Instrument Serif", ui-serif, Georgia, "Times New Roman", serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-serif: "Instrument Serif", ui-serif, Georgia, "Times New Roman", serif;
--radius-xl: 14px;
--radius-2xl: 18px;
--radius-3xl: 26px;
}
@layer base {
:root {
--surface: #fffefc;
--surface-raised: #ffffff;
--canvas: #f5f3ee;
--surface-muted: #f0ede6;
--border: #e6e1d7;
--border-strong: #d6cfc1;
--text: #1a1814;
--text-muted: #6a6357;
--text-faint: #928b7d;
--accent-ink: #a35a12;
--warn-ink: #b45309;
--edge: rgba(255, 255, 255, 0.8);
--shadow-color: 26 22 16;
color-scheme: light;
}
.dark {
--surface: #0d0c0a;
--surface-raised: #14120f;
--canvas: #080706;
--surface-muted: #1a1815;
--border: #29261f;
--border-strong: #3a362c;
--text: #f2efe9;
--text-muted: #a8a196;
--text-faint: #7c766a;
--accent-ink: #f2c14e;
--warn-ink: #fcd34d;
--edge: rgba(255, 255, 255, 0.06);
--shadow-color: 0 0 0;
color-scheme: dark;
}
* {
border-color: var(--border);
}
html,
body {
background: var(--surface);
color: var(--text);
}
body {
font-family: var(--font-sans);
font-feature-settings: "cv11" 1, "ss01" 1;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
a {
color: inherit;
}
}
@layer components {
.page {
@apply mx-auto w-full max-w-6xl px-5;
}
.surface {
background: var(--surface);
}
.muted {
color: var(--text-muted);
}
.card {
@apply rounded-2xl border p-6;
background: var(--surface);
border-color: var(--border);
box-shadow: 0 1px 2px rgb(var(--shadow-color) / 0.04), 0 12px 28px -20px rgb(var(--shadow-color) / 0.14);
}
.panel {
@apply rounded-2xl border;
background: var(--surface-raised);
border-color: var(--border);
box-shadow:
inset 0 1px 0 0 var(--edge),
0 1px 2px rgb(var(--shadow-color) / 0.04),
0 22px 44px -28px rgb(var(--shadow-color) / 0.2);
}
.glass {
@apply rounded-2xl border backdrop-blur-md;
background: color-mix(in srgb, var(--surface) 70%, transparent);
border-color: color-mix(in srgb, var(--border) 80%, transparent);
}
.kicker {
@apply inline-flex items-center gap-2 font-mono text-[11px] font-medium uppercase tracking-[0.22em];
color: var(--text-faint);
}
.eyebrow {
@apply inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em];
}
.chip {
@apply inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium;
background: var(--surface);
border-color: var(--border);
}
.rule {
height: 1px;
background: linear-gradient(to right, transparent, var(--border-strong) 12%, var(--border-strong) 88%, transparent);
}
.btn,
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-danger {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition duration-200;
}
.btn:disabled,
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled {
@apply cursor-not-allowed opacity-60;
}
.btn-primary {
@apply text-white;
background: linear-gradient(180deg, color-mix(in srgb, var(--color-brand-500) 88%, white), var(--color-brand-600));
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.18),
0 1px 2px rgb(var(--shadow-color) / 0.2),
0 14px 26px -14px rgb(var(--shadow-color) / 0.5);
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(180deg, var(--color-brand-500), var(--color-brand-700));
transform: translateY(-1px);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-secondary {
@apply border;
background: var(--surface-raised);
color: var(--text);
border-color: var(--border-strong);
box-shadow: inset 0 1px 0 0 var(--edge);
}
.btn-secondary:hover:not(:disabled) {
background: var(--surface-muted);
}
.btn-ghost {
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface-muted);
color: var(--text);
}
.btn-danger {
@apply bg-red-600 text-white;
}
.btn-danger:hover:not(:disabled) {
@apply bg-red-700;
}
.label {
@apply mb-1 block text-sm font-medium;
}
.input {
@apply w-full rounded-xl border px-3 py-2 text-sm outline-none;
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.input:focus {
border-color: var(--color-brand-500);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-500) 20%, transparent);
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
}
}
@layer utilities {
/* Editorial display: serif, tight but not cramped, no heavy weight. */
.font-display,
.display {
font-family: var(--font-display);
letter-spacing: -0.01em;
line-height: 1.02;
font-weight: 400;
}
.tabular {
font-variant-numeric: tabular-nums;
}
.shadow-soft {
box-shadow:
inset 0 1px 0 0 var(--edge),
0 1px 2px rgb(var(--shadow-color) / 0.04),
0 30px 60px -36px rgb(var(--shadow-color) / 0.26);
}
.animate-marquee {
animation: marquee 40s linear infinite;
}
.grain::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.5;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.js [data-reveal] {
opacity: 0;
transform: translateY(16px);
transition:
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: var(--reveal-delay, 0ms);
}
.js [data-reveal].is-in {
opacity: 1;
transform: none;
}
:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
border-radius: 4px;
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
[data-reveal] {
opacity: 1;
transform: none;
}
html {
scroll-behavior: auto;
}
}

35
app/layout.tsx Normal file
View file

@ -0,0 +1,35 @@
import type { Metadata } from 'next';
import { CartProvider } from '@/components/cart/CartProvider';
import './globals.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { default: 'Acme Store', template: '%s · Acme Store' },
description: 'A ready-to-run storefront: catalog, cart, checkout, payments and admin.',
openGraph: {
type: 'website',
siteName: 'Acme Store',
url: '/',
title: 'Acme Store',
description: 'A ready-to-run storefront: catalog, cart, checkout, payments and admin.',
images: [{ url: '/art/og.webp', width: 1536, height: 1024, alt: 'Acme Store — everyday goods, made to last' }],
},
robots: { index: true, follow: true },
};
const themeScript = `document.documentElement.classList.add('js');try{var t=localStorage.getItem('theme');var d=t? t==='dark' : matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen antialiased">
<CartProvider>{children}</CartProvider>
</body>
</html>
);
}

10
app/loading.tsx Normal file
View file

@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex items-center gap-3">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden />
<span className="muted text-sm">Loading…</span>
</div>
</div>
);
}

14
app/not-found.tsx Normal file
View file

@ -0,0 +1,14 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-5 text-center">
<p className="text-6xl font-bold">404</p>
<h1 className="mt-4 text-xl font-semibold">Page not found</h1>
<p className="muted mt-2">The page you are looking for does not exist.</p>
<Link href="/" className="btn-primary mt-8">
Back home
</Link>
</div>
);
}

9
app/robots.ts Normal file
View file

@ -0,0 +1,9 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin', '/dashboard', '/api'] },
sitemap: `${base}/sitemap.xml`,
};
}

24
app/sitemap.ts Normal file
View file

@ -0,0 +1,24 @@
import type { MetadataRoute } from 'next';
import { db } from '@/lib/server/db';
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
const now = new Date();
const staticPages: MetadataRoute.Sitemap = [
{ url: `${base}/`, lastModified: now, changeFrequency: 'weekly', priority: 1 },
{ url: `${base}/shop`, lastModified: now, changeFrequency: 'daily', priority: 0.9 },
{ url: `${base}/pricing`, lastModified: now, changeFrequency: 'monthly', priority: 0.6 },
{ url: `${base}/legal/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/legal/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
];
let products: MetadataRoute.Sitemap = [];
try {
const rows = await db.product.findMany({ where: { active: true }, select: { slug: true } });
products = rows.map((p) => ({ url: `${base}/shop/${p.slug}`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 }));
} catch {
products = [];
}
return [...staticPages, ...products];
}

26
components/AdminNav.tsx Normal file
View file

@ -0,0 +1,26 @@
import Link from 'next/link';
import { t } from '@/lib/i18n';
const items = [
{ href: '/admin', label: t('admin.overview') },
{ href: '/admin/products', label: 'Products' },
{ href: '/admin/orders', label: 'Orders' },
{ href: '/admin/users', label: t('admin.users') },
{ href: '/admin/audit', label: t('admin.audit') },
];
export function AdminNav() {
return (
<nav className="flex gap-1 md:flex-col" aria-label="Admin">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-lg px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10"
>
{item.label}
</Link>
))}
</nav>
);
}

79
components/AuthForm.tsx Normal file
View file

@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Field } from '@/components/ui';
import { t } from '@/lib/i18n';
export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
const router = useRouter();
const params = useSearchParams();
const next = params.get('next') || '/dashboard';
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
setError(null);
const form = new FormData(e.currentTarget);
const body = Object.fromEntries(form.entries());
try {
const res = await fetch(`/api/auth/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `Request failed (${res.status})`);
}
router.push(next);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Request failed');
setBusy(false);
}
}
const isRegister = mode === 'register';
return (
<div className="card">
<h1 className="text-xl font-semibold">{isRegister ? t('auth.registerTitle') : t('auth.loginTitle')}</h1>
<form className="mt-6 space-y-4" onSubmit={onSubmit} noValidate>
{isRegister && (
<Field label={t('auth.name')} htmlFor="name">
<input id="name" name="name" className="input" autoComplete="name" />
</Field>
)}
<Field label={t('auth.email')} htmlFor="email">
<input id="email" name="email" type="email" required className="input" autoComplete="email" />
</Field>
<Field label={t('auth.password')} htmlFor="password">
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="input"
autoComplete={isRegister ? 'new-password' : 'current-password'}
/>
</Field>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? '…' : isRegister ? t('auth.submitRegister') : t('auth.submitLogin')}
</button>
</form>
<p className="muted mt-5 text-center text-sm">
{isRegister ? t('auth.hasAccount') : t('auth.noAccount')}{' '}
<Link href={isRegister ? '/login' : '/register'} className="font-medium text-brand-600 hover:underline">
{isRegister ? t('auth.submitLogin') : t('auth.submitRegister')}
</Link>
</p>
{!isRegister && <p className="muted mt-3 text-center text-xs">{t('auth.demoHint')}</p>}
</div>
);
}

View file

@ -0,0 +1,44 @@
/**
* Renders a product's artwork. Seeded products carry an image path
* (`/art/product-*.webp`); admin-created products may have no image, in which
* case the previous emoji/placeholder fallback is kept so nothing regresses.
*/
export function ProductImage({
image,
alt,
className = '',
eager = false,
}: {
image?: string | null;
alt: string;
className?: string;
eager?: boolean;
}) {
if (image && image.startsWith('/')) {
return (
<div
className={`overflow-hidden rounded-xl border ${className}`}
style={{ borderColor: 'var(--border)', background: 'var(--surface-muted)' }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- static /public art; keeps both stacks dependency-free */}
<img
src={image}
alt={alt}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
className="h-full w-full object-cover"
/>
</div>
);
}
return (
<div
className={`flex items-center justify-center rounded-xl border ${className}`}
style={{ borderColor: 'var(--border)', background: 'var(--surface-muted)' }}
>
<span aria-hidden className="text-5xl">
{image ?? '📦'}
</span>
</div>
);
}

View file

@ -0,0 +1,39 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
/**
* Adds `.is-in` to every `[data-reveal]` element as it scrolls into view, which
* drives the fade/rise defined in `globals.css`. Re-runs on client-side
* navigation so freshly mounted pages animate too. The hidden state is gated on
* `html.js`, so content stays visible without JavaScript, and
* `prefers-reduced-motion` shows everything immediately.
*/
export function RevealOnScroll() {
const pathname = usePathname();
useEffect(() => {
const els = Array.from(document.querySelectorAll<HTMLElement>('[data-reveal]:not(.is-in)'));
if (!els.length) return;
if (typeof IntersectionObserver === 'undefined') {
els.forEach((el) => el.classList.add('is-in'));
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('is-in');
io.unobserve(entry.target);
}
}
},
{ rootMargin: '0px 0px -6% 0px', threshold: 0.06 },
);
els.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [pathname]);
return null;
}

79
components/SiteFooter.tsx Normal file
View file

@ -0,0 +1,79 @@
import Link from 'next/link';
import { t } from '@/lib/i18n';
const columns: { title: string; links: { href: string; label: string }[] }[] = [
{
title: 'Shop',
links: [
{ href: '/shop', label: 'All products' },
{ href: '/shop?category=apparel', label: 'Apparel' },
{ href: '/shop?category=accessories', label: 'Accessories' },
{ href: '/shop?category=home', label: 'Home' },
],
},
{
title: 'Account',
links: [
{ href: '/orders', label: 'Orders' },
{ href: '/cart', label: 'Cart' },
{ href: '/login', label: t('nav.login') },
],
},
{
title: 'Company',
links: [
{ href: '/pricing', label: t('nav.pricing') },
{ href: '/legal/privacy', label: 'Privacy' },
{ href: '/legal/terms', label: 'Terms' },
],
},
];
export function SiteFooter() {
return (
<footer className="border-t" style={{ borderColor: 'var(--border)', background: 'var(--canvas)' }}>
<div className="page grid gap-12 py-16 lg:grid-cols-[1.6fr_1fr_1fr_1fr]">
<div>
<span className="font-display text-3xl leading-none tracking-tight">{t('brand.name')}</span>
<p className="muted mt-4 max-w-xs text-sm leading-relaxed">
Everyday goods, made to last. Free shipping over $50, 30-day returns and a checkout that works
out of the box.
</p>
</div>
{columns.map((col) => (
<nav key={col.title} aria-label={col.title}>
<h2 className="kicker">{col.title}</h2>
<ul className="mt-5 space-y-3 text-sm">
{col.links.map((link) => (
<li key={link.href}>
<Link href={link.href} className="transition-colors hover:text-[var(--text)]" style={{ color: 'var(--text-muted)' }}>
{link.label}
</Link>
</li>
))}
</ul>
</nav>
))}
</div>
<div className="page">
<div className="rule" />
</div>
<div className="page flex flex-col items-center justify-between gap-2 py-6 text-sm sm:flex-row">
<p className="muted">
© {new Date().getFullYear()} {t('brand.name')}
</p>
<p className="muted">Built with Next.js · mock payments ready</p>
</div>
<div aria-hidden className="page select-none overflow-hidden">
<div
className="font-display translate-y-[16%] text-center text-[24vw] leading-none tracking-tight lg:text-[16rem]"
style={{ color: 'color-mix(in srgb, var(--text) 6%, transparent)' }}
>
{t('brand.name')}
</div>
</div>
</footer>
);
}

55
components/SiteHeader.tsx Normal file
View file

@ -0,0 +1,55 @@
import Link from 'next/link';
import { getCurrentUser } from '@/lib/server/auth';
import { t } from '@/lib/i18n';
import { ThemeToggle } from './ThemeToggle';
import { CartLink } from './cart/CartLink';
export async function SiteHeader() {
const user = await getCurrentUser();
return (
<header
className="sticky top-0 z-40 border-b backdrop-blur-xl"
style={{
borderColor: 'color-mix(in srgb, var(--border) 85%, transparent)',
background: 'color-mix(in srgb, var(--surface) 78%, transparent)',
}}
>
<div className="page flex h-16 items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-2">
<span className="font-display text-2xl leading-none tracking-tight">{t('brand.name')}</span>
<span className="kicker hidden sm:inline" aria-hidden>
Store
</span>
</Link>
<nav className="hidden items-center gap-7 text-sm md:flex" aria-label="Main">
<Link href="/shop" className="transition-colors hover:text-[var(--text)]" style={{ color: 'var(--text-muted)' }}>
Shop
</Link>
{user && (
<Link href="/orders" className="transition-colors hover:text-[var(--text)]" style={{ color: 'var(--text-muted)' }}>
Orders
</Link>
)}
{user?.role === 'admin' && (
<Link href="/admin" className="transition-colors hover:text-[var(--text)]" style={{ color: 'var(--text-muted)' }}>
{t('nav.admin')}
</Link>
)}
</nav>
<div className="flex items-center gap-2">
<ThemeToggle />
<CartLink />
{user ? (
<Link href="/dashboard" className="btn-secondary">
{t('nav.dashboard')}
</Link>
) : (
<Link href="/login" className="btn-primary">
{t('nav.login')}
</Link>
)}
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,41 @@
'use client';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
function apply(theme: Theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
try {
localStorage.setItem('theme', theme);
} catch {
/* ignore */
}
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
const current = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
setTheme(current);
}, []);
function toggle() {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
apply(next);
}
return (
<button
type="button"
className="btn-secondary"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
<span aria-hidden>{theme === 'dark' ? '☾' : '☀'}</span>
<span className="hidden sm:inline">{theme === 'dark' ? 'Dark' : 'Light'}</span>
</button>
);
}

View file

@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function UpgradeButton({ plan, label, disabled }: { plan: string; label: string; disabled?: boolean }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function upgrade() {
setBusy(true);
setError(null);
try {
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
});
if (res.status === 401) {
router.push('/login?next=/pricing');
return;
}
if (!res.ok) throw new Error(`Checkout failed (${res.status})`);
const data = (await res.json()) as { url: string };
router.push(data.url.replace(/^https?:\/\/[^/]+/, ''));
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : 'Checkout failed');
} finally {
setBusy(false);
}
}
return (
<div className="mt-6">
<button type="button" className="btn-primary w-full" onClick={upgrade} disabled={busy || disabled}>
{busy ? 'Working…' : label}
</button>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function UserActions({ id, role }: { id: string; role: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function update(nextRole: string) {
setBusy(true);
await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: nextRole }),
});
setBusy(false);
router.refresh();
}
async function remove() {
if (!window.confirm('Delete this user? This cannot be undone.')) return;
setBusy(true);
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
setBusy(false);
router.refresh();
}
return (
<div className="flex justify-end gap-2">
<button
type="button"
className="btn-secondary"
disabled={busy}
onClick={() => update(role === 'admin' ? 'user' : 'admin')}
>
{role === 'admin' ? 'Make user' : 'Make admin'}
</button>
<button type="button" className="btn-danger" disabled={busy} onClick={remove}>
Delete
</button>
</div>
);
}

View file

@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { useCart } from './CartProvider';
export function AddToCartButton({
productId,
slug,
name,
price,
variants,
}: {
productId: string;
slug: string;
name: string;
price: number;
variants?: { name: string; priceDelta: number }[];
}) {
const { add } = useCart();
const [variant, setVariant] = useState(variants?.[0]?.name);
const [added, setAdded] = useState(false);
const delta = variants?.find((v) => v.name === variant)?.priceDelta ?? 0;
return (
<div className="space-y-3">
{variants && variants.length > 0 && (
<div>
<label className="label" htmlFor="variant">
Option
</label>
<select id="variant" className="input" value={variant} onChange={(e) => setVariant(e.target.value)}>
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.priceDelta ? ` (+$${(v.priceDelta / 100).toFixed(2)})` : ''}
</option>
))}
</select>
</div>
)}
<button
type="button"
className="btn-primary w-full"
onClick={() => {
add({ productId, slug, name, variant, price: price + delta });
setAdded(true);
setTimeout(() => setAdded(false), 1500);
}}
>
{added ? 'Added ✓' : 'Add to cart'}
</button>
</div>
);
}

View file

@ -0,0 +1,14 @@
'use client';
import Link from 'next/link';
import { useCart } from './CartProvider';
export function CartLink() {
const { count } = useCart();
return (
<Link href="/cart" className="btn-secondary" aria-label={`Cart, ${count} items`}>
<span aria-hidden>🛒</span>
<span>Cart{count > 0 ? ` (${count})` : ''}</span>
</Link>
);
}

View file

@ -0,0 +1,83 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
export type CartItem = { productId: string; slug: string; name: string; variant?: string; price: number; quantity: number };
type CartContextValue = {
items: CartItem[];
count: number;
total: number;
add: (item: Omit<CartItem, 'quantity'>, quantity?: number) => void;
setQuantity: (productId: string, variant: string | undefined, quantity: number) => void;
remove: (productId: string, variant: string | undefined) => void;
clear: () => void;
};
const CartContext = createContext<CartContextValue | null>(null);
const STORAGE_KEY = 'w4c_cart';
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setItems(JSON.parse(raw) as CartItem[]);
} catch {
/* ignore */
}
setReady(true);
}, []);
useEffect(() => {
if (ready) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
/* ignore */
}
}
}, [items, ready]);
const add = useCallback((item: Omit<CartItem, 'quantity'>, quantity = 1) => {
setItems((prev) => {
const idx = prev.findIndex((i) => i.productId === item.productId && i.variant === item.variant);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], quantity: Math.min(99, next[idx].quantity + quantity) };
return next;
}
return [...prev, { ...item, quantity }];
});
}, []);
const setQuantity = useCallback((productId: string, variant: string | undefined, quantity: number) => {
setItems((prev) =>
quantity <= 0
? prev.filter((i) => !(i.productId === productId && i.variant === variant))
: prev.map((i) => (i.productId === productId && i.variant === variant ? { ...i, quantity: Math.min(99, quantity) } : i)),
);
}, []);
const remove = useCallback((productId: string, variant: string | undefined) => {
setItems((prev) => prev.filter((i) => !(i.productId === productId && i.variant === variant)));
}, []);
const clear = useCallback(() => setItems([]), []);
const value = useMemo<CartContextValue>(() => {
const count = items.reduce((n, i) => n + i.quantity, 0);
const total = items.reduce((n, i) => n + i.price * i.quantity, 0);
return { items, count, total, add, setQuantity, remove, clear };
}, [items, add, setQuantity, remove, clear]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart(): CartContextValue {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within a CartProvider');
return ctx;
}

68
components/ui/index.tsx Normal file
View file

@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
export function Card({ title, children, actions }: { title?: ReactNode; children: ReactNode; actions?: ReactNode }) {
return (
<section className="card">
{(title || actions) && (
<header className="mb-4 flex items-center justify-between gap-4">
{title ? <h2 className="text-lg font-semibold">{title}</h2> : <span />}
{actions}
</header>
)}
{children}
</section>
);
}
const badgeTones: Record<string, string> = {
neutral: 'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100',
success: 'bg-emerald-600 text-white',
warning: 'bg-amber-300 text-black',
danger: 'bg-red-600 text-white',
brand: 'bg-brand-600 text-white',
};
export function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: keyof typeof badgeTones }) {
return <span className={`badge ${badgeTones[tone] ?? badgeTones.neutral}`}>{children}</span>;
}
const alertTones: Record<string, string> = {
info: 'border-brand-400 bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100',
success: 'border-emerald-500 bg-emerald-50 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100',
warning: 'border-amber-400 bg-amber-50 text-black',
error: 'border-red-500 bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-100',
};
export function Alert({ children, tone = 'info' }: { children: ReactNode; tone?: keyof typeof alertTones }) {
return (
<div role="status" className={`rounded-lg border px-4 py-3 text-sm ${alertTones[tone] ?? alertTones.info}`}>
{children}
</div>
);
}
export function Field({
label,
htmlFor,
hint,
children,
}: {
label: string;
htmlFor: string;
hint?: string;
children: ReactNode;
}) {
return (
<div>
<label className="label" htmlFor={htmlFor}>
{label}
</label>
{children}
{hint && <p className="muted mt-1 text-xs">{hint}</p>}
</div>
);
}
export function EmptyState({ children }: { children?: ReactNode }) {
return <p className="muted py-6 text-center text-sm">{children ?? 'Nothing here yet.'}</p>;
}

23
docker/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Build stage
FROM node:22-alpine AS build
WORKDIR /app
RUN apk add --no-cache libc6-compat openssl
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npx prisma generate && npm run build
# Runtime stage
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl && addgroup -S app && adduser -S app -G app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY --from=build /app/prisma ./prisma
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]

15
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: "file:./prod.db"
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3

57
lib/cart.ts Normal file
View file

@ -0,0 +1,57 @@
export type CartLine = { productId: string; variant?: string; quantity: number };
export type Variant = { name: string; priceDelta: number };
export type ProductLike = {
id: string;
name: string;
price: number;
stock: number;
active?: boolean;
variants?: unknown;
};
export type PricedItem = { productId: string; name: string; variant?: string; price: number; quantity: number };
export type ValidationResult = { ok: true; items: PricedItem[]; total: number } | { ok: false; error: string };
export function parseVariants(value: unknown): Variant[] {
if (!Array.isArray(value)) return [];
return value
.filter((v): v is Record<string, unknown> => !!v && typeof v === 'object')
.map((v) => ({ name: String(v.name ?? ''), priceDelta: Number(v.priceDelta ?? 0) }))
.filter((v) => v.name.length > 0);
}
export function unitPrice(product: ProductLike, variant?: string): number | null {
if (!variant) return product.price;
const match = parseVariants(product.variants).find((v) => v.name === variant);
return match ? product.price + match.priceDelta : null;
}
/**
* Validate cart lines against authoritative product data (never trust client prices) and compute
* the order total. Pure so it is unit-testable and usable on both server and client.
*/
export function validateLines(lines: CartLine[], products: ProductLike[]): ValidationResult {
if (!Array.isArray(lines) || lines.length === 0) return { ok: false, error: 'Your cart is empty.' };
const byId = new Map(products.map((p) => [p.id, p]));
const items: PricedItem[] = [];
let total = 0;
for (const line of lines) {
const product = byId.get(line.productId);
if (!product || product.active === false) {
return { ok: false, error: 'A product in your cart is no longer available.' };
}
const quantity = Math.floor(Number(line.quantity));
if (!Number.isFinite(quantity) || quantity < 1) return { ok: false, error: 'Invalid quantity.' };
const price = unitPrice(product, line.variant);
if (price === null) return { ok: false, error: `Unknown option "${line.variant}" for ${product.name}.` };
if (quantity > product.stock) return { ok: false, error: `Only ${product.stock} left of ${product.name}.` };
items.push({ productId: product.id, name: product.name, variant: line.variant, price, quantity });
total += price * quantity;
}
return { ok: true, items, total };
}

20
lib/env.ts Normal file
View file

@ -0,0 +1,20 @@
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().default('file:./dev.db'),
NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
STRIPE_SECRET_KEY: z.string().optional().default(''),
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
RESEND_API_KEY: z.string().optional().default(''),
EMAIL_FROM: z.string().default('Acme <noreply@example.com>'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
}
export const env = parsed.data;
export const isProd = process.env.NODE_ENV === 'production';

19
lib/i18n.ts Normal file
View file

@ -0,0 +1,19 @@
import en from '@/messages/en.json';
export const DEFAULT_LOCALE = 'en';
export const messages = { en } as const;
export type Locale = keyof typeof messages;
type Vars = Record<string, string | number>;
/** Tiny dictionary lookup: t('nav.pricing') with optional {name} interpolation. */
export function t(key: string, vars?: Vars, locale: Locale = DEFAULT_LOCALE): string {
const dict = messages[locale] as Record<string, unknown>;
const value = key.split('.').reduce<unknown>((acc, part) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part];
return undefined;
}, dict);
if (typeof value !== 'string') return key;
if (!vars) return value;
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`));
}

10
lib/password.ts Normal file
View file

@ -0,0 +1,10 @@
import bcrypt from 'bcryptjs';
/** Pure password helpers (no server-only import) so they are unit-testable. */
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 10);
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

41
lib/plans.ts Normal file
View file

@ -0,0 +1,41 @@
export type PlanId = 'free' | 'pro';
export type Plan = {
id: PlanId;
name: string;
price: number;
/** Monthly included usage per metric. */
limits: { seats: number; apiCalls: number };
features: string[];
};
export const PLANS: Record<PlanId, Plan> = {
free: {
id: 'free',
name: 'Free',
price: 0,
limits: { seats: 2, apiCalls: 1_000 },
features: ['Up to 2 members', '1,000 API calls / month', 'Community support'],
},
pro: {
id: 'pro',
name: 'Pro',
price: 29,
limits: { seats: 25, apiCalls: 100_000 },
features: ['Up to 25 members', '100,000 API calls / month', 'Priority support'],
},
};
export const PLAN_IDS: PlanId[] = ['free', 'pro'];
export function isPlanId(value: unknown): value is PlanId {
return value === 'free' || value === 'pro';
}
export function getPlan(value: unknown): Plan {
return isPlanId(value) ? PLANS[value] : PLANS.free;
}
export function withinLimit(plan: unknown, metric: keyof Plan['limits'], used: number): boolean {
return used < getPlan(plan).limits[metric];
}

15
lib/server/audit.ts Normal file
View file

@ -0,0 +1,15 @@
import 'server-only';
import { db } from './db';
export type AuditInput = { actorId?: string | null; action: string; target?: string; meta?: unknown };
export async function recordAudit({ actorId, action, target, meta }: AuditInput): Promise<void> {
await db.auditLog.create({
data: {
actorId: actorId ?? null,
action,
target: target ?? null,
meta: meta === undefined ? null : JSON.stringify(meta),
},
});
}

64
lib/server/auth.ts Normal file
View file

@ -0,0 +1,64 @@
import 'server-only';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { randomBytes } from 'node:crypto';
import { db } from './db';
export { hashPassword, verifyPassword } from '@/lib/password';
export const SESSION_COOKIE = 'w4c_session';
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
export type SessionUser = {
id: string;
email: string;
name: string | null;
role: string;
};
/** Create a session row and set the httpOnly cookie (works in Route Handlers and Server Actions). */
export async function createSession(userId: string): Promise<string> {
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + TTL_MS);
await db.session.create({ data: { token, userId, expiresAt } });
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
expires: expiresAt,
});
return token;
}
export async function destroySession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) await db.session.deleteMany({ where: { token } });
store.delete(SESSION_COOKIE);
}
export async function getCurrentUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await db.session.findUnique({ where: { token }, include: { user: true } });
if (!session || session.expiresAt < new Date()) return null;
const { id, email, name, role } = session.user;
return { id, email, name, role };
}
/** Page guard: redirect to /login when unauthenticated. */
export async function requireUser(): Promise<SessionUser> {
const user = await getCurrentUser();
if (!user) redirect('/login');
return user;
}
/** Page guard: require the admin role. */
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== 'admin') redirect('/dashboard');
return user;
}

8
lib/server/db.ts Normal file
View file

@ -0,0 +1,8 @@
import { PrismaClient } from '@prisma/client';
// A single PrismaClient per process (avoids exhausting connections in dev HMR).
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

44
lib/server/email.ts Normal file
View file

@ -0,0 +1,44 @@
import 'server-only';
import { env } from '@/lib/env';
export type EmailMessage = { to: string; subject: string; html: string };
export type EmailResult = { id: string; provider: string };
export interface EmailProvider {
name: string;
send(message: EmailMessage): Promise<EmailResult>;
}
/** Default provider: no keys needed — logs the message and returns a synthetic id. */
export class LogProvider implements EmailProvider {
name = 'log';
async send(message: EmailMessage): Promise<EmailResult> {
console.log(`[email:log] to=${message.to} subject="${message.subject}"`);
return { id: `log_${Date.now().toString(36)}`, provider: this.name };
}
}
/** Real provider, activated by RESEND_API_KEY. */
export class ResendProvider implements EmailProvider {
name = 'resend';
constructor(private apiKey: string, private from: string) {}
async send(message: EmailMessage): Promise<EmailResult> {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: this.from, to: message.to, subject: message.subject, html: message.html }),
});
if (!res.ok) throw new Error(`Resend error ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { id: string };
return { id: data.id, provider: this.name };
}
}
export function getEmail(): EmailProvider {
if (env.RESEND_API_KEY) return new ResendProvider(env.RESEND_API_KEY, env.EMAIL_FROM);
return new LogProvider();
}
export function sendEmail(message: EmailMessage): Promise<EmailResult> {
return getEmail().send(message);
}

86
lib/server/payments.ts Normal file
View file

@ -0,0 +1,86 @@
import 'server-only';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '@/lib/env';
export type CheckoutInput = { userId: string; email: string; plan: string; successUrl: string; cancelUrl: string };
export type CheckoutResult = { id: string; url: string };
export type WebhookEvent = { type: 'checkout.completed' | 'subscription.canceled' | 'ignored'; userId?: string; plan?: string };
export interface PaymentProvider {
name: string;
createCheckout(input: CheckoutInput): Promise<CheckoutResult>;
parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent>;
}
/**
* Zero-config provider. It never redirects off-site: the checkout route marks the
* subscription active directly, so "plan → checkout → return → active" works with no keys.
*/
export class MockProvider implements PaymentProvider {
name = 'mock';
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
const id = `mock_${input.userId.slice(0, 8)}_${input.plan}_${Date.now().toString(36)}`;
return { id, url: `${input.successUrl}?billing=success&provider=mock&plan=${encodeURIComponent(input.plan)}` };
}
async parseWebhook(rawBody: string): Promise<WebhookEvent> {
try {
const data = JSON.parse(rawBody) as { type?: string; userId?: string; plan?: string };
if (!data.type) return { type: 'ignored' };
return { type: data.type as WebhookEvent['type'], userId: data.userId, plan: data.plan };
} catch {
return { type: 'ignored' };
}
}
}
/** Stripe provider over the REST API (no SDK dependency), activated by STRIPE_SECRET_KEY. */
export class StripeProvider implements PaymentProvider {
name = 'stripe';
constructor(private secretKey: string, private webhookSecret: string) {}
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
const body = new URLSearchParams();
body.set('mode', 'subscription');
body.set('success_url', input.successUrl);
body.set('cancel_url', input.cancelUrl);
body.set('client_reference_id', input.userId);
body.set('customer_email', input.email);
body.set('line_items[0][price]', input.plan);
body.set('line_items[0][quantity]', '1');
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!res.ok) throw new Error(`Stripe error ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { id: string; url: string };
return { id: data.id, url: data.url };
}
async parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent> {
if (!this.webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
if (!verifyStripeSignature(rawBody, signature, this.webhookSecret)) return { type: 'ignored' };
const event = JSON.parse(rawBody) as { type?: string; data?: { object?: { client_reference_id?: string } } };
if (event.type === 'checkout.session.completed') {
return { type: 'checkout.completed', userId: event.data?.object?.client_reference_id };
}
if (event.type === 'customer.subscription.deleted') return { type: 'subscription.canceled' };
return { type: 'ignored' };
}
}
// Minimal Stripe signature check (t=timestamp,v1=signature) to make webhooks idempotent + trusted.
function verifyStripeSignature(payload: string, header: string | null, secret: string): boolean {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
if (!parts.t || !parts.v1) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${payload}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
export function getPayments(): PaymentProvider {
if (env.STRIPE_SECRET_KEY) return new StripeProvider(env.STRIPE_SECRET_KEY, env.STRIPE_WEBHOOK_SECRET);
return new MockProvider();
}

125
lib/server/store.ts Normal file
View file

@ -0,0 +1,125 @@
import 'server-only';
import { z } from 'zod';
import { db } from './db';
import { getPayments } from './payments';
import { validateLines, type CartLine } from '@/lib/cart';
export const cartLineSchema = z.object({
productId: z.string().min(1),
variant: z.string().max(60).optional(),
quantity: z.number().int().min(1).max(99),
});
export const checkoutSchema = z.object({
email: z.string().email(),
address: z.string().min(5).max(300),
lines: z.array(cartLineSchema).min(1).max(50),
});
export async function listCategories() {
return db.category.findMany({ orderBy: { name: 'asc' } });
}
export type ProductQuery = { q?: string; category?: string; sort?: string; page?: number; pageSize?: number };
export async function listProducts({ q, category, sort, page = 1, pageSize = 9 }: ProductQuery) {
const where = {
active: true,
...(category ? { category: { slug: category } } : {}),
...(q ? { OR: [{ name: { contains: q } }, { description: { contains: q } }] } : {}),
};
const orderBy = sort === 'price-asc' ? { price: 'asc' as const } : sort === 'price-desc' ? { price: 'desc' as const } : { createdAt: 'desc' as const };
const [items, total] = await Promise.all([
db.product.findMany({ where, orderBy, skip: (page - 1) * pageSize, take: pageSize, include: { category: true } }),
db.product.count({ where }),
]);
return { items, total, page, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) };
}
export function getProductBySlug(slug: string) {
return db.product.findUnique({ where: { slug }, include: { category: true } });
}
export async function createOrder(input: z.infer<typeof checkoutSchema>, userId?: string | null) {
const ids = [...new Set(input.lines.map((l) => l.productId))];
const products = await db.product.findMany({ where: { id: { in: ids } } });
const result = validateLines(input.lines as CartLine[], products);
if (!result.ok) return result;
const provider = getPayments();
const providerRef = `${provider.name}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const order = await db.order.create({
data: {
userId: userId ?? null,
email: input.email.toLowerCase(),
address: input.address,
total: result.total,
currency: 'USD',
status: provider.name === 'mock' ? 'paid' : 'pending',
provider: provider.name,
providerRef,
items: {
create: result.items.map((i) => ({
productId: i.productId,
name: i.name,
variant: i.variant ?? null,
price: i.price,
quantity: i.quantity,
})),
},
},
include: { items: true },
});
// Decrement inventory (server-side, after a validated order).
for (const item of result.items) {
await db.product.update({ where: { id: item.productId }, data: { stock: { decrement: item.quantity } } });
}
return { ok: true as const, order };
}
export function listOrdersForUser(userId: string) {
return db.order.findMany({ where: { userId }, include: { items: true }, orderBy: { createdAt: 'desc' } });
}
export function listAllOrders() {
return db.order.findMany({ include: { items: true }, orderBy: { createdAt: 'desc' }, take: 100 });
}
export function updateOrderStatus(id: string, status: string) {
return db.order.update({ where: { id }, data: { status } });
}
export function adminListProducts() {
return db.product.findMany({ include: { category: true }, orderBy: { createdAt: 'desc' } });
}
export const productSchema = z.object({
name: z.string().min(1).max(160),
slug: z.string().min(1).max(160).regex(/^[a-z0-9-]+$/),
description: z.string().max(2000).optional().or(z.literal('')),
price: z.number().int().min(0),
stock: z.number().int().min(0),
categoryId: z.string().optional().nullable(),
active: z.boolean().optional(),
});
export async function upsertProduct(data: z.infer<typeof productSchema>, id?: string) {
const payload = {
name: data.name,
slug: data.slug,
description: data.description || '',
price: data.price,
stock: data.stock,
categoryId: data.categoryId || null,
active: data.active ?? true,
};
if (id) return db.product.update({ where: { id }, data: payload });
return db.product.create({ data: payload });
}
export function deleteProduct(id: string) {
return db.product.delete({ where: { id } });
}

21
lib/utils.ts Normal file
View file

@ -0,0 +1,21 @@
/** Join conditional class names. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function formatDate(value: Date | string): string {
const d = typeof value === 'string' ? new Date(value) : value;
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
}
export function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

71
messages/en.json Normal file
View file

@ -0,0 +1,71 @@
{
"brand": { "name": "Acme" },
"nav": {
"home": "Home",
"pricing": "Pricing",
"dashboard": "Dashboard",
"admin": "Admin",
"login": "Sign in",
"register": "Create account",
"logout": "Sign out"
},
"home": {
"title": "Ship your product, not your boilerplate",
"subtitle": "A ready-to-run base app: authentication, roles, payments, admin and SEO — working on day one.",
"cta": "Get started",
"secondaryCta": "View pricing",
"featuresTitle": "Everything included",
"features": {
"auth": { "title": "Auth & roles", "body": "Email + password, httpOnly sessions, role guards on every route." },
"billing": { "title": "Payments", "body": "Checkout works with a mock provider and no keys; Stripe activates from env." },
"admin": { "title": "Admin panel", "body": "A role-gated shell with a dashboard and CRUD primitives." },
"seo": { "title": "SEO & a11y", "body": "Metadata, sitemap, robots and AA contrast out of the box." }
}
},
"pricing": {
"title": "Simple pricing",
"subtitle": "Start free. Upgrade when you grow.",
"perMonth": "/ month",
"choose": "Choose plan",
"current": "Current plan",
"upgraded": "Your plan is now active."
},
"auth": {
"loginTitle": "Sign in",
"registerTitle": "Create your account",
"email": "Email",
"password": "Password",
"name": "Name",
"submitLogin": "Sign in",
"submitRegister": "Create account",
"noAccount": "No account yet?",
"hasAccount": "Already have an account?",
"demoHint": "Demo: admin@example.com / user@example.com — password \"password\"."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome, {name}",
"plan": "Your plan",
"usage": "Usage"
},
"admin": {
"title": "Admin",
"overview": "Overview",
"users": "Users",
"audit": "Audit log",
"totalUsers": "Total users",
"activeSubs": "Active subscriptions",
"makeAdmin": "Make admin",
"makeUser": "Make user",
"delete": "Delete"
},
"common": {
"loading": "Loading…",
"empty": "Nothing here yet.",
"error": "Something went wrong.",
"retry": "Try again",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
}
}

28
middleware.ts Normal file
View file

@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from 'next/server';
// Keep in sync with lib/server/auth.ts (middleware runs on the edge runtime and must not
// import the server-only auth module).
const SESSION_COOKIE = 'w4c_session';
const PROTECTED = ['/dashboard', '/admin', '/orders'];
/**
* Coarse gate: unauthenticated visitors are redirected to /login before the page renders,
* so the response is a real 307 (not a streamed client redirect). Role checks stay in the
* page/layout guards, which remain authoritative.
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = PROTECTED.some((p) => pathname === p || pathname.startsWith(`${p}/`));
if (!isProtected) return NextResponse.next();
if (request.cookies.get(SESSION_COOKIE)) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = '/login';
url.search = '';
url.searchParams.set('next', pathname);
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/orders/:path*'],
};

26
next.config.ts Normal file
View file

@ -0,0 +1,26 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
output: 'standalone',
outputFileTracingRoot: root,
poweredByHeader: false,
reactStrictMode: true,
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
],
},
];
},
};
export default nextConfig;

8771
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

46
package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "templates2-store-next",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "node tests/e2e/smoke.mjs",
"db:push": "prisma db push --skip-generate",
"db:seed": "prisma db seed",
"db:setup": "prisma db push --skip-generate && prisma db seed",
"postinstall": "prisma generate"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.3.0",
"bcryptjs": "^2.4.3",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "^15.2.0",
"postcss": "^8.4.49",
"prisma": "^6.3.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^2.1.8"
}
}

5
postcss.config.mjs Normal file
View file

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

115
prisma/schema.prisma Normal file
View file

@ -0,0 +1,115 @@
// Templates2 Base — core data model shared by every case.
// SQLite in development (file:./dev.db); switch the datasource url to Postgres in production.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
role String @default("user") // user | admin
createdAt DateTime @default(now())
sessions Session[]
subscription Subscription?
auditLogs AuditLog[]
orders Order[]
}
model Session {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
plan String @default("free") // free | pro
status String @default("active") // active | canceled | past_due
provider String @default("mock") // mock | stripe
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AuditLog {
id String @id @default(cuid())
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
action String
target String?
meta String?
createdAt DateTime @default(now())
@@index([actorId])
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
products Product[]
}
model Product {
id String @id @default(cuid())
slug String @unique
name String
description String
price Int // minor units (e.g. cents)
currency String @default("USD")
image String?
stock Int @default(0)
active Boolean @default(true)
variants Json?
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
items OrderItem[]
@@index([categoryId])
}
model Order {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String
status String @default("pending") // pending | paid | shipped | canceled
total Int
currency String @default("USD")
address String
provider String @default("mock")
providerRef String? @unique
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
name String
variant String?
price Int
quantity Int
}

114
prisma/seed.ts Normal file
View file

@ -0,0 +1,114 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const db = new PrismaClient();
const CATEGORIES = [
{ slug: 'apparel', name: 'Apparel' },
{ slug: 'accessories', name: 'Accessories' },
{ slug: 'home', name: 'Home' },
];
const PRODUCTS = [
{ slug: 'classic-tee', name: 'Classic Tee', price: 2900, stock: 40, image: '/art/product-classic-tee.webp', category: 'apparel', description: 'A soft, durable everyday t-shirt.', variants: [{ name: 'S', priceDelta: 0 }, { name: 'M', priceDelta: 0 }, { name: 'L', priceDelta: 200 }] },
{ slug: 'hoodie', name: 'Heavyweight Hoodie', price: 5900, stock: 25, image: '/art/product-hoodie.webp', category: 'apparel', description: 'Brushed-back fleece with a relaxed fit.', variants: [{ name: 'M', priceDelta: 0 }, { name: 'L', priceDelta: 0 }, { name: 'XL', priceDelta: 300 }] },
{ slug: 'cap', name: 'Everyday Cap', price: 1900, stock: 60, image: '/art/product-cap.webp', category: 'apparel', description: 'Six-panel cotton cap with a curved brim.' },
{ slug: 'socks', name: 'Crew Socks (3-pack)', price: 1500, stock: 80, image: '/art/product-socks.webp', category: 'apparel', description: 'Cushioned cotton socks.' },
{ slug: 'tote', name: 'Canvas Tote', price: 2400, stock: 35, image: '/art/product-tote.webp', category: 'accessories', description: 'Heavy canvas tote with an inner pocket.' },
{ slug: 'mug', name: 'Enamel Mug', price: 1800, stock: 50, image: '/art/product-mug.webp', category: 'home', description: 'A sturdy 12oz enamel mug.' },
{ slug: 'bottle', name: 'Insulated Bottle', price: 3200, stock: 30, image: '/art/product-bottle.webp', category: 'accessories', description: 'Keeps drinks cold for 24 hours.' },
{ slug: 'notebook', name: 'Dotted Notebook', price: 1600, stock: 70, image: '/art/product-notebook.webp', category: 'home', description: 'A5 dotted journal, 160 pages.' },
{ slug: 'pen', name: 'Precision Pen', price: 900, stock: 120, image: '/art/product-pen.webp', category: 'accessories', description: 'Aluminium body, 0.5mm refill.' },
{ slug: 'stickers', name: 'Sticker Pack', price: 700, stock: 200, image: '/art/product-stickers.webp', category: 'accessories', description: 'Ten weatherproof vinyl stickers.' },
{ slug: 'lamp', name: 'Desk Lamp', price: 4200, stock: 18, image: '/art/product-lamp.webp', category: 'home', description: 'Warm dimmable LED desk lamp.' },
{ slug: 'poster', name: 'Framed Poster', price: 3800, stock: 22, image: '/art/product-poster.webp', category: 'home', description: 'Museum-grade print, 18×24″.' },
];
async function main() {
const password = await bcrypt.hash('password', 10);
const admin = await db.user.upsert({
where: { email: 'admin@example.com' },
update: { role: 'admin' },
create: { email: 'admin@example.com', name: 'Store Admin', role: 'admin', passwordHash: password },
});
const customer = await db.user.upsert({
where: { email: 'customer@example.com' },
update: {},
create: { email: 'customer@example.com', name: 'Demo Customer', role: 'user', passwordHash: password },
});
await db.subscription.upsert({ where: { userId: admin.id }, update: {}, create: { userId: admin.id, plan: 'pro', status: 'active', provider: 'mock' } });
await db.subscription.upsert({ where: { userId: customer.id }, update: {}, create: { userId: customer.id, plan: 'free', status: 'active', provider: 'mock' } });
const categories = new Map<string, string>();
for (const c of CATEGORIES) {
const row = await db.category.upsert({ where: { slug: c.slug }, update: { name: c.name }, create: c });
categories.set(c.slug, row.id);
}
const products: Record<string, string> = {};
for (const p of PRODUCTS) {
const row = await db.product.upsert({
where: { slug: p.slug },
update: { price: p.price, stock: p.stock, name: p.name, description: p.description, image: p.image, variants: p.variants ?? undefined, categoryId: categories.get(p.category) },
create: { slug: p.slug, name: p.name, description: p.description, price: p.price, stock: p.stock, image: p.image, variants: p.variants ?? undefined, categoryId: categories.get(p.category) },
});
products[p.slug] = row.id;
}
if ((await db.order.count()) === 0) {
await db.order.create({
data: {
userId: customer.id,
email: customer.email,
address: '1 Demo Street, Springfield',
status: 'paid',
total: 2900 + 5900 * 2,
currency: 'USD',
provider: 'mock',
providerRef: 'seed_order_1',
items: {
create: [
{ productId: products['classic-tee'], name: 'Classic Tee', variant: 'M', price: 2900, quantity: 1 },
{ productId: products['hoodie'], name: 'Heavyweight Hoodie', variant: 'L', price: 5900, quantity: 2 },
],
},
},
});
await db.order.create({
data: {
userId: customer.id,
email: customer.email,
address: '1 Demo Street, Springfield',
status: 'shipped',
total: 2400,
currency: 'USD',
provider: 'mock',
providerRef: 'seed_order_2',
items: { create: [{ productId: products['tote'], name: 'Canvas Tote', price: 2400, quantity: 1 }] },
},
});
}
if ((await db.auditLog.count()) === 0) {
await db.auditLog.createMany({
data: [
{ actorId: admin.id, action: 'seed', target: 'system', meta: 'Initial demo data' },
{ actorId: admin.id, action: 'catalog.loaded', target: `${PRODUCTS.length} products` },
],
});
}
console.log(`Seeded store: ${admin.email} (admin), ${customer.email} (customer) — password: "password"`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await db.$disconnect();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/art/cat-apparel.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/art/cat-home.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/art/cta.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
public/art/hero.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
public/art/lookbook.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
public/art/og.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/art/product-cap.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Some files were not shown because too many files have changed in this diff Show more