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..384d9d8 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..054b8b9 --- /dev/null +++ b/.w4c/qa.json @@ -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 } +} diff --git a/.w4c/template.json b/.w4c/template.json new file mode 100644 index 0000000..e442ec4 --- /dev/null +++ b/.w4c/template.json @@ -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" } + ] +} diff --git a/README.md b/README.md index 0ecac15..7bc2964 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +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 ``, + 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. diff --git a/app/(app)/cart/page.tsx b/app/(app)/cart/page.tsx new file mode 100644 index 0000000..f687d33 --- /dev/null +++ b/app/(app)/cart/page.tsx @@ -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 ( +
+

Your cart is empty

+ + Browse the shop + +
+ ); + } + + return ( +
+

Your cart

+
+ {items.map((item) => ( +
+
+

{item.name}

+ {item.variant &&

Option: {item.variant}

} +

{formatCurrency(item.price / 100)} each

+
+
+ + setQuantity(item.productId, item.variant, Number(e.target.value))} + className="input w-20" + /> + +
+
+ ))} +
+
+ Total: {formatCurrency(total / 100)} + + Checkout + +
+
+ ); +} diff --git a/app/(app)/checkout/page.tsx b/app/(app)/checkout/page.tsx new file mode 100644 index 0000000..a9cc031 --- /dev/null +++ b/app/(app)/checkout/page.tsx @@ -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(null); + const [order, setOrder] = useState<{ id: string; status: string; total: number } | null>(null); + + async function onSubmit(e: React.FormEvent) { + 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 ( +
+

Thank you!

+

+ Order {order.id.slice(0, 8)} is {order.status} — total{' '} + {formatCurrency(order.total / 100)}. +

+
+ + View my orders + + + Continue shopping + +
+
+ ); + } + + if (items.length === 0) { + return ( +
+

Nothing to check out

+ + Browse the shop + +
+ ); + } + + return ( +
+

Checkout

+
    + {items.map((i) => ( +
  • + {i.quantity}× {i.name} + {i.variant ? ` (${i.variant})` : ''} — {formatCurrency((i.price * i.quantity) / 100)} +
  • + ))} +
+

Total: {formatCurrency(total / 100)}

+ +
+ + + + +