diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b359af8..487d6ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "wiggleverse-ecomm-frontend", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wiggleverse-ecomm-frontend", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" @@ -16,7 +16,8 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", - "vite": "^5.4.11" + "vite": "^5.4.11", + "vitest": "^2.1.8" } }, "node_modules/@babel/code-frame": { @@ -1239,6 +1240,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.35", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", @@ -1286,6 +1410,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001797", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", @@ -1307,6 +1441,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1339,6 +1500,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.371", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", @@ -1346,6 +1517,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1395,6 +1573,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1464,6 +1662,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1474,6 +1679,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1510,6 +1725,23 @@ "node": ">=18" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1645,6 +1877,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1655,6 +1894,64 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1760,6 +2057,112 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index aa3eb07..8fc2c37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,13 @@ { "name": "wiggleverse-ecomm-frontend", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "react": "^18.3.1", @@ -17,6 +18,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.6.3", - "vite": "^5.4.11" + "vite": "^5.4.11", + "vitest": "^2.1.8" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa006dd..5bc0631 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,64 @@ -// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens -// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work. +// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders +// the matching screen. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a +// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3. +import { useEffect, useState } from "react"; +import { getMe, type VerifyResult } from "./api"; +import { routeFor, type SessionState } from "./routing"; +import Landing from "./screens/Landing"; +import SignIn from "./screens/SignIn"; +import SignedIn from "./screens/SignedIn"; + +type Door = "signup" | "login"; + export default function App() { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [door, setDoor] = useState(null); + const [justCreated, setJustCreated] = useState(false); + + async function reload() { + setLoading(true); + setSession(await getMe()); + setLoading(false); + } + + useEffect(() => { + void reload(); + }, []); + + if (loading) return
Loading…
; + + const route = routeFor(session ?? { account: null, storefront: null }); + + if (route === "landing") { + if (door) { + return ( + { + setDoor(null); + }} + onAuthed={(result: VerifyResult) => { + setJustCreated(result.created); + setDoor(null); + void reload(); + }} + /> + ); + } + return setDoor("signup")} onLogIn={() => setDoor("login")} />; + } + + // route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the + // signed-in placeholder so sign-out (PUC-9) is reachable. return ( -
-

ecomm

-

Honest commerce. Your storefront is yours.

-
+ { + setJustCreated(false); + void reload(); + }} + /> ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..04d7ccf --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,61 @@ +// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite +// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them. +import type { SessionState } from "./routing"; + +export interface ApiError { + code: string; + message: string; + retry_after_s?: number; + attempts_remaining?: number; +} + +export interface VerifyResult { + account: { email: string }; + storefront: { id: number; name: string } | null; + created: boolean; +} + +async function errorOf(resp: Response): Promise { + try { + const body = await resp.json(); + if (body && body.error) return body.error as ApiError; + } catch { + /* fall through */ + } + return { code: "unexpected", message: "Something went wrong. Please try again." }; +} + +export async function getMe(): Promise { + const resp = await fetch("/api/auth/me", { credentials: "include" }); + if (resp.status === 401) return null; + if (!resp.ok) return null; + return (await resp.json()) as SessionState; +} + +export async function requestCode(email: string): Promise { + const resp = await fetch("/api/auth/request-code", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email }), + }); + return resp.ok ? null : await errorOf(resp); +} + +export async function verifyCode( + email: string, + code: string, +): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> { + const resp = await fetch("/api/auth/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, code }), + }); + if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult }; + return { ok: false, error: await errorOf(resp) }; +} + +export async function logout(): Promise { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); +} diff --git a/frontend/src/routing.test.ts b/frontend/src/routing.test.ts new file mode 100644 index 0000000..9e954fa --- /dev/null +++ b/frontend/src/routing.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { routeFor } from "./routing"; + +describe("entry routing (SD-0001 §6.5)", () => { + it("no session -> landing", () => { + expect(routeFor({ account: null, storefront: null })).toBe("landing"); + }); + + it("session without storefront -> create-storefront", () => { + expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe( + "create-storefront", + ); + }); + + it("session with storefront -> admin", () => { + expect( + routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }), + ).toBe("admin"); + }); +}); diff --git a/frontend/src/routing.ts b/frontend/src/routing.ts new file mode 100644 index 0000000..16096dd --- /dev/null +++ b/frontend/src/routing.ts @@ -0,0 +1,16 @@ +// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands +// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the +// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3 +// makes "admin" reachable. +export interface SessionState { + account: { email: string } | null; + storefront: { id: number; name: string } | null; +} + +export type Screen = "landing" | "create-storefront" | "admin"; + +export function routeFor(s: SessionState): Screen { + if (!s.account) return "landing"; + if (!s.storefront) return "create-storefront"; + return "admin"; +} diff --git a/frontend/src/screens/Landing.tsx b/frontend/src/screens/Landing.tsx new file mode 100644 index 0000000..f1b54e5 --- /dev/null +++ b/frontend/src/screens/Landing.tsx @@ -0,0 +1,27 @@ +// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice, +// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2); +// they differ only in framing. +interface Props { + onGetStarted: () => void; + onLogIn: () => void; +} + +export default function Landing({ onGetStarted, onLogIn }: Props) { + return ( +
+
+ ecomm + +
+
+

Honest commerce. Your storefront is yours.

+

Sell online on a platform that treats you straight — no dark patterns, no lock-in.

+ +
+
+ ); +} diff --git a/frontend/src/screens/SignIn.tsx b/frontend/src/screens/SignIn.tsx new file mode 100644 index 0000000..88afd82 --- /dev/null +++ b/frontend/src/screens/SignIn.tsx @@ -0,0 +1,111 @@ +// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1 +// requests a code; step 2 verifies it. Honest copy states which door framing applies and, +// after verify, whether the account was created or resumed (PUC-3). storefront routing is +// handled by App on success. +import { useState } from "react"; +import { requestCode, verifyCode, type VerifyResult } from "../api"; + +type Door = "signup" | "login"; + +interface Props { + door: Door; + onAuthed: (result: VerifyResult) => void; + onBack: () => void; +} + +export default function SignIn({ door, onAuthed, onBack }: Props) { + const [step, setStep] = useState<"email" | "code">("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const heading = door === "signup" ? "Create your storefront" : "Log in"; + + async function submitEmail(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const err = await requestCode(email); + setBusy(false); + if (err) { + setError(err.message); + return; + } + setStep("code"); + } + + async function submitCode(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const res = await verifyCode(email, code); + setBusy(false); + if (!res.ok) { + setError(res.error.message); + return; + } + onAuthed(res.result); + } + + if (step === "email") { + return ( +
+
+ ecomm + +
+
+

{heading}

+ +

We'll email you a one-time code. That's all we need.

+ {error &&

{error}

} + +
+
+ ); + } + + return ( +
+
+ ecomm + +
+
+

Enter your code

+

We sent a code to {email}.

+ + {error &&

{error}

} + +
+
+ ); +} diff --git a/frontend/src/screens/SignedIn.tsx b/frontend/src/screens/SignedIn.tsx new file mode 100644 index 0000000..af3b8bd --- /dev/null +++ b/frontend/src/screens/SignedIn.tsx @@ -0,0 +1,34 @@ +// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in +// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No +// fabricated capability (INV-9). +import { logout } from "../api"; + +interface Props { + email: string; + created: boolean; + onSignedOut: () => void; +} + +export default function SignedIn({ email, created, onSignedOut }: Props) { + async function signOut() { + await logout(); + onSignedOut(); + } + + return ( +
+
+ ecomm + {email} + +
+
+

{created ? "Welcome to ecomm" : "Welcome back"}

+

You're signed in as {email}.

+

Creating your storefront arrives in the next slice. Nothing else is needed yet.

+
+
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e74f09e..270cc61 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,6 @@ -import { defineConfig } from "vite"; +/// import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; // The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the // browser makes no cross-origin calls in dev. Override the target with @@ -15,4 +16,9 @@ export default defineConfig({ "/api": apiTarget, }, }, + // Tests live alongside source; scoping the include to src/ keeps the runner out of + // node_modules (and any stray local backup dirs) without naming them. + test: { + include: ["src/**/*.test.{ts,tsx}"], + }, }); diff --git a/scripts/check.sh b/scripts/check.sh index cf6d605..4c76d9a 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -34,4 +34,7 @@ echo "==> backend tests (pytest)" echo "==> frontend typecheck + build" ( cd "$repo_root/frontend" && npm run build ) +echo "==> frontend unit tests (vitest)" +( cd "$repo_root/frontend" && npm test ) + echo "==> all gates green"