diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..776ad08 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.env +.env.local +*.db +docker +.github +.w4c +tests +README.md +coverage diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..325de68 --- /dev/null +++ b/.env.example @@ -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 " diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3b8ecbf --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-img-element": "off", + "react/no-unescaped-entities": "off" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be6fc50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee7ee5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.next +out +dist +coverage +.env +.env.local +*.db +*.db-journal +.vercel +next-env.d.ts diff --git a/.w4c/preview.png b/.w4c/preview.png new file mode 100644 index 0000000..bfae3a7 Binary files /dev/null and b/.w4c/preview.png differ diff --git a/.w4c/qa.json b/.w4c/qa.json new file mode 100644 index 0000000..3f85790 --- /dev/null +++ b/.w4c/qa.json @@ -0,0 +1,31 @@ +{ + "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": "/pricing", "expect": [200] }, + { "path": "/login", "expect": [200] }, + { "path": "/register", "expect": [200] }, + { "path": "/legal/privacy", "expect": [200] }, + { "path": "/dashboard", "expect": [307, 302] }, + { "path": "/admin", "expect": [307, 302] }, + { "path": "/api/health", "expect": [200] }, + { "path": "/api/auth/me", "expect": [401] }, + { "path": "/api/admin/users", "expect": [401] }, + { "path": "/sitemap.xml", "expect": [200] }, + { "path": "/robots.txt", "expect": [200] } + ], + "timeout": { "install": 900, "build": 900, "boot": 120 } +} diff --git a/.w4c/template.json b/.w4c/template.json new file mode 100644 index 0000000..0afe757 --- /dev/null +++ b/.w4c/template.json @@ -0,0 +1,30 @@ +{ + "schema": 2, + "system": "v2", + "catalog": false, + "id": "base-next", + "case": "base", + "stack": "next", + "title": "Base — Next.js", + "description": "The shared core: auth, roles, Prisma, mock payments/email, admin shell, SEO, CI and Docker.", + "tags": ["base", "next", "starter"], + "preview": ".w4c/preview.png", + "agentId": "w4c-start2", + "defaultName": "my-app", + "featured": false, + "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": "resend-email", "skill": "integration-resend-email" }, + { "id": "s3-storage", "skill": "integration-s3-storage" }, + { "id": "analytics", "skill": "integration-analytics" } + ], + "seededAccounts": [ + { "role": "admin", "email": "admin@example.com", "password": "password" }, + { "role": "user", "email": "user@example.com", "password": "password" } + ] +} diff --git a/README.md b/README.md index 3095b07..ec2b128 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ -# templates2.Base.Next +# Base — Next.js (templates2) -The shared core: auth, roles, Prisma, mock payments/email, admin shell, SEO, CI and Docker. \ No newline at end of file +A **ready-to-run** Next.js (App Router) base application. It is the shared core every Next.js +v2 case forks: authentication, roles, Prisma, mock payments and email, an admin shell, SEO, +i18n, observability, CI and a Docker image — all working on the first run. + +> Not listed in the user catalog (`"catalog": false` in `.w4c/template.json`); it exists to be +> forked by cases such as `Store.Next` or `Saas.Next`. + +## Quickstart + +```bash +npm ci +cp .env.example .env # SQLite + mock providers by default — no keys needed +npm run db:setup # prisma db push + seed +npm run dev # http://localhost:3000 +``` + +Demo accounts (seeded): `admin@example.com` (admin) and `user@example.com` (user) — password +`password`. + +## Scripts + +| Script | What it does | +|---|---| +| `npm run dev` | Dev server with HMR | +| `npm run build` / `npm start` | Production build / serve | +| `npm run lint` / `npm run typecheck` | ESLint / `tsc --noEmit` | +| `npm test` | Unit tests (Vitest) | +| `npm run test:e2e` | HTTP end-to-end smoke (server must be running) | +| `npm run db:setup` | `prisma db push` + seed | +| `npm run db:seed` | Seed only | + +## What works out of the box + +- **Auth & roles** — email + password, bcrypt, httpOnly cookie sessions (`Session` table), `user` + and `admin` roles, server-side guards (`requireUser`, `requireAdmin`) and API authorization. +- **Payments** — a provider interface with a **mock provider that needs no keys** and activates the + subscription immediately, plus a Stripe provider that turns on when `STRIPE_SECRET_KEY` is set. + Webhooks are signature-checked and idempotent. +- **Email** — a log provider by default, Resend when `RESEND_API_KEY` is set. +- **Admin** — role-gated shell, overview dashboard, users table (role change / delete with + confirmation) and an audit log. +- **SEO** — per-route metadata, OpenGraph, JSON-LD, `sitemap.xml`, `robots.txt`. +- **i18n** — English dictionary in `messages/en.json`, looked up via `t()`. +- **Observability** — `/api/health`, error boundary and loading/empty states. +- **Delivery** — multi-stage Dockerfile with a healthcheck, and CI running lint + typecheck + unit + + build + e2e. + +## Data model + +`prisma/schema.prisma`: `User`, `Session`, `Subscription`, `AuditLog`. SQLite in development; +point `DATABASE_URL` at Postgres for production. + +## Deployment + +```bash +docker compose -f docker/docker-compose.yml up --build +``` + +## Quality + +```bash +node ../templates2/qa.mjs Base.Next # from the repo root: node templates2/qa.mjs Base.Next +``` + +The harness isolates a clean copy, then runs install → verify → test → build → boot → routes → +e2e → screenshot. See [`../CONTRACT.md`](../CONTRACT.md) for the Definition of Done. diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..d4c5d19 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -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 ( +
+
+
+

{t('dashboard.title')}

+

{t('dashboard.welcome', { name: user.name || user.email })}

+
+ {plan.name} +
+ + {t('pricing.choose')}}> +

+ {plan.name} — ${plan.price} / month +

+
+ + +
    + {usage.map((u) => { + const pct = Math.min(100, Math.round((u.used / u.limit) * 100)); + return ( +
  • +
    + {u.label} + + {u.used} / {u.limit} + +
    +
    +
    +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..f5818a2 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,12 @@ +import { SiteHeader } from '@/components/SiteHeader'; +import { SiteFooter } from '@/components/SiteFooter'; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..c36e6dc --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; +import { t } from '@/lib/i18n'; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {t('brand.name')} + +
{children}
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..3b76b82 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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 ( + Loading…

}> + +
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..2ffbaee --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -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 ( + Loading…

}> + +
+ ); +} diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..b77354a --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import { SiteHeader } from '@/components/SiteHeader'; +import { SiteFooter } from '@/components/SiteFooter'; + +export default function MarketingLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/app/(marketing)/legal/privacy/page.tsx b/app/(marketing)/legal/privacy/page.tsx new file mode 100644 index 0000000..0bfe977 --- /dev/null +++ b/app/(marketing)/legal/privacy/page.tsx @@ -0,0 +1,10 @@ +export default function PrivacyPage() { + return ( +
+

Privacy Policy

+

+ This is a placeholder legal page. Replace it with your own policy before going live. +

+
+ ); +} diff --git a/app/(marketing)/legal/terms/page.tsx b/app/(marketing)/legal/terms/page.tsx new file mode 100644 index 0000000..65d9a97 --- /dev/null +++ b/app/(marketing)/legal/terms/page.tsx @@ -0,0 +1,10 @@ +export default function TermsPage() { + return ( +
+

Terms of Service

+

+ This is a placeholder legal page. Replace it with your own terms before going live. +

+
+ ); +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..95c8fea --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import { t } from '@/lib/i18n'; + +const features = ['auth', 'billing', 'admin', 'seo'] as const; + +export default function HomePage() { + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: t('brand.name'), + url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000', + }; + return ( + <> +