create-yerd 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/bin/create-yerd.js +17 -0
- package/package.json +42 -4
- package/src/args.js +140 -0
- package/src/cli.js +180 -0
- package/src/pm.js +33 -0
- package/src/prompt.js +284 -0
- package/src/render.js +159 -0
- package/src/style.js +33 -0
- package/src/target.js +68 -0
- package/src/versions.js +14 -0
- package/templates/_shared/_env.example +9 -0
- package/templates/_shared/_gitignore +6 -0
- package/templates/_shared/vitest.config.ts +8 -0
- package/templates/api/README.md +39 -0
- package/templates/api/src/app.ts +9 -0
- package/templates/api/src/main.ts +5 -0
- package/templates/api/src/notes.ts +65 -0
- package/templates/api/src/routes/notes.ts +49 -0
- package/templates/api/test/notes.test.ts +68 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/api/vite.config.ts +7 -0
- package/templates/full/README.md +46 -0
- package/templates/full/e2e/shop.spec.ts +18 -0
- package/templates/full/playwright.config.ts +16 -0
- package/templates/full/public/favicon.svg +1 -0
- package/templates/full/src/app.ts +8 -0
- package/templates/full/src/cart.ts +50 -0
- package/templates/full/src/catalog.ts +59 -0
- package/templates/full/src/components.tsx +31 -0
- package/templates/full/src/layouts.tsx +67 -0
- package/templates/full/src/main.ts +5 -0
- package/templates/full/src/orders.ts +52 -0
- package/templates/full/src/routes/api.ts +34 -0
- package/templates/full/src/routes/auth.tsx +71 -0
- package/templates/full/src/routes/cart.tsx +66 -0
- package/templates/full/src/routes/checkout.tsx +101 -0
- package/templates/full/src/routes/products.tsx +79 -0
- package/templates/full/src/styles.css +72 -0
- package/templates/full/src/users.ts +41 -0
- package/templates/full/test/shop.test.ts +130 -0
- package/templates/full/tsconfig.json +18 -0
- package/templates/full/vite.config.ts +7 -0
- package/templates/minimal/README.md +26 -0
- package/templates/minimal/public/favicon.svg +1 -0
- package/templates/minimal/src/app.ts +7 -0
- package/templates/minimal/src/layouts.tsx +32 -0
- package/templates/minimal/src/main.ts +5 -0
- package/templates/minimal/src/routes/home.tsx +44 -0
- package/templates/minimal/src/styles.css +32 -0
- package/templates/minimal/test/app.test.ts +35 -0
- package/templates/minimal/tsconfig.json +18 -0
- package/templates/minimal/vite.config.ts +7 -0
- package/templates/tailwind/_shared/vite.config.ts +8 -0
- package/templates/tailwind/full/src/styles.css +49 -0
- package/templates/tailwind/minimal/src/layouts.tsx +33 -0
- package/templates/tailwind/minimal/src/routes/home.tsx +48 -0
- package/templates/tailwind/minimal/src/styles.css +2 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Conflict, NotFound, is, length, service, trim } from "@toyz/yerd";
|
|
2
|
+
|
|
3
|
+
export interface Note {
|
|
4
|
+
id: number;
|
|
5
|
+
title: string;
|
|
6
|
+
body: string;
|
|
7
|
+
done: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The body of POST and PUT. Validated before the handler runs; every issue is reported at once. */
|
|
11
|
+
export class NoteInput {
|
|
12
|
+
@is.string({ transform: trim, validate: length(1, 120) }) title!: string;
|
|
13
|
+
@is.string({ transform: trim, validate: length(0, 2000) }) body = "";
|
|
14
|
+
@is.boolean() done = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A 404 whose JSON carries the id: `{"error":"No note 7","code":"not_found","id":7}`. */
|
|
18
|
+
export class NoteNotFound extends NotFound {
|
|
19
|
+
constructor(readonly id: number) {
|
|
20
|
+
super(`No note ${id}`, { details: { id } });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Notes, in memory. A singleton: swap in a database when you have one. */
|
|
25
|
+
@service
|
|
26
|
+
export class Notes {
|
|
27
|
+
#notes: Note[] = [{ id: 1, title: "Read the yerd guides", body: "https://toyz.github.io/yerd/", done: false }];
|
|
28
|
+
#next = 2;
|
|
29
|
+
|
|
30
|
+
list(done?: boolean): Note[] {
|
|
31
|
+
return done === undefined ? this.#notes : this.#notes.filter((n) => n.done === done);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The note, or a 404 for the request. */
|
|
35
|
+
find(id: number): Note {
|
|
36
|
+
const note = this.#notes.find((n) => n.id === id);
|
|
37
|
+
if (!note) throw new NoteNotFound(id);
|
|
38
|
+
return note;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
create(input: NoteInput): Note {
|
|
42
|
+
this.#unique(input.title);
|
|
43
|
+
const note = { id: this.#next++, ...input };
|
|
44
|
+
this.#notes.push(note);
|
|
45
|
+
return note;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
update(id: number, input: NoteInput): Note {
|
|
49
|
+
const note = this.find(id);
|
|
50
|
+
this.#unique(input.title, id);
|
|
51
|
+
return Object.assign(note, input);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
remove(id: number): void {
|
|
55
|
+
this.find(id);
|
|
56
|
+
this.#notes = this.#notes.filter((n) => n.id !== id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Titles are unique: a 409 with a code clients can switch on. */
|
|
60
|
+
#unique(title: string, except?: number): void {
|
|
61
|
+
if (this.#notes.some((n) => n.id !== except && n.title.toLowerCase() === title.toLowerCase())) {
|
|
62
|
+
throw new Conflict(`A note called "${title}" already exists`, { code: "duplicate_title" });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { HttpStatus, body, del, get, inject, param, post, put, query, route, status, toBool, toInt } from "@toyz/yerd";
|
|
2
|
+
import { NoteInput, Notes } from "../notes.js";
|
|
3
|
+
|
|
4
|
+
/** curl localhost:5173/api/notes?done=false */
|
|
5
|
+
@route("/api/notes")
|
|
6
|
+
export class NotesApi {
|
|
7
|
+
@inject(Notes) accessor notes!: Notes;
|
|
8
|
+
@query("done", { transform: toBool }) accessor done: boolean | undefined;
|
|
9
|
+
// Read and validated only when awaited: a bad body is a 400 listing every issue.
|
|
10
|
+
@body(NoteInput) accessor input!: Promise<NoteInput>;
|
|
11
|
+
|
|
12
|
+
@get list() {
|
|
13
|
+
return this.notes.list(this.done);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** curl -X POST localhost:5173/api/notes -H 'content-type: application/json' -d '{"title":"Ship it"}' */
|
|
17
|
+
@post @status(HttpStatus.Created) async create() {
|
|
18
|
+
return this.notes.create(await this.input);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@route("/api/notes/:id")
|
|
23
|
+
export class NoteApi {
|
|
24
|
+
@inject(Notes) accessor notes!: Notes;
|
|
25
|
+
// Not a number: a 400 before the handler runs.
|
|
26
|
+
@param("id", { transform: toInt }) accessor id!: number;
|
|
27
|
+
@body(NoteInput) accessor input!: Promise<NoteInput>;
|
|
28
|
+
|
|
29
|
+
@get show() {
|
|
30
|
+
return this.notes.find(this.id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@put async replace() {
|
|
34
|
+
return this.notes.update(this.id, await this.input);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@del remove() {
|
|
38
|
+
this.notes.remove(this.id);
|
|
39
|
+
return HttpStatus.NoContent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** For load balancers and uptime checks: 204, no body. */
|
|
44
|
+
@route("/api/health")
|
|
45
|
+
export class Health {
|
|
46
|
+
@get check() {
|
|
47
|
+
return HttpStatus.NoContent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp } from "../src/app.js";
|
|
3
|
+
import "../src/routes/notes.js";
|
|
4
|
+
|
|
5
|
+
// The app main.ts serves, with the routes this file imports. No port is opened.
|
|
6
|
+
const app = createApp({ handleSignals: false });
|
|
7
|
+
beforeAll(() => app.boot());
|
|
8
|
+
afterAll(() => app.stop());
|
|
9
|
+
|
|
10
|
+
/** A JSON request. */
|
|
11
|
+
function send(method: string, path: string, body?: unknown) {
|
|
12
|
+
return app.request(path, {
|
|
13
|
+
method,
|
|
14
|
+
headers: { "content-type": "application/json" },
|
|
15
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("notes", () => {
|
|
20
|
+
it("lists notes as JSON", async () => {
|
|
21
|
+
const res = await app.request("/api/notes");
|
|
22
|
+
expect(res.status).toBe(200);
|
|
23
|
+
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
|
24
|
+
expect(await res.json()).toMatchObject([{ id: 1, title: "Read the yerd guides" }]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates a note with 201, filling in defaults", async () => {
|
|
28
|
+
const res = await send("POST", "/api/notes", { title: " Write tests " });
|
|
29
|
+
expect(res.status).toBe(201);
|
|
30
|
+
expect(await res.json()).toEqual({ id: 2, title: "Write tests", body: "", done: false });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reports every invalid field at once", async () => {
|
|
34
|
+
const res = await send("POST", "/api/notes", { title: "", done: "maybe" });
|
|
35
|
+
expect(res.status).toBe(400);
|
|
36
|
+
expect(await res.json()).toMatchObject({ issues: [{ path: "title", message: "Can't be empty" }, { path: "done" }] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses a duplicate title with a code", async () => {
|
|
40
|
+
const res = await send("POST", "/api/notes", { title: "read the yerd guides" });
|
|
41
|
+
expect(res.status).toBe(409);
|
|
42
|
+
expect(await res.json()).toMatchObject({ code: "duplicate_title" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("replaces, filters and deletes", async () => {
|
|
46
|
+
const created = (await (await send("POST", "/api/notes", { title: "Deploy" })).json()) as { id: number };
|
|
47
|
+
|
|
48
|
+
const replaced = await send("PUT", `/api/notes/${created.id}`, { title: "Deploy", done: true });
|
|
49
|
+
expect(await replaced.json()).toMatchObject({ id: created.id, done: true });
|
|
50
|
+
expect(await (await app.request("/api/notes?done=true")).json()).toMatchObject([{ id: created.id }]);
|
|
51
|
+
|
|
52
|
+
const removed = await send("DELETE", `/api/notes/${created.id}`);
|
|
53
|
+
expect(removed.status).toBe(204);
|
|
54
|
+
expect(await removed.text()).toBe("");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("is a 404 with the id for an unknown note, and a 400 for a bad one", async () => {
|
|
58
|
+
const missing = await app.request("/api/notes/99");
|
|
59
|
+
expect(missing.status).toBe(404);
|
|
60
|
+
expect(await missing.json()).toEqual({ error: "No note 99", code: "not_found", id: 99 });
|
|
61
|
+
|
|
62
|
+
expect((await app.request("/api/notes/abc")).status).toBe(400);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("answers health checks with 204", async () => {
|
|
66
|
+
expect((await app.request("/api/health")).status).toBe(204);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "nodenext",
|
|
5
|
+
"moduleResolution": "nodenext",
|
|
6
|
+
"lib": ["ES2023", "esnext.decorators", "DOM"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noEmit": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src", "test", "*.config.ts"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A small [yerd](https://toyz.github.io/yerd/) shop: a catalog, product pages,
|
|
4
|
+
a cart in the session, sign-in, a validated checkout form and a JSON API.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
{{run_dev}} # dev server with reloads, on http://localhost:5173
|
|
8
|
+
{{run_test}} # vitest, in-process: no port, no browser
|
|
9
|
+
{{run_check}} # type-check (Vite doesn't)
|
|
10
|
+
{{run_build}} # production build: dist/client and dist/server/server.js
|
|
11
|
+
{{run_start}} # node dist/server/server.js, on PORT or 3000
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Sign in with `ada` / `analytical-engine`. Copy `.env.example` to `.env` and
|
|
15
|
+
set `YERD_SECRET` before you deploy: it signs forms and seals the session
|
|
16
|
+
cookie that holds the cart.
|
|
17
|
+
|
|
18
|
+
The Playwright test needs a browser once: `{{exec}} playwright install chromium`,
|
|
19
|
+
then `{{run_e2e}}`. It builds the app and runs it on port 4173.
|
|
20
|
+
|
|
21
|
+
| Route | What |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `GET /` | the catalog; `?q=` searches |
|
|
24
|
+
| `GET /products/:id` | a product, a 404 for an unknown id; add to cart is an action |
|
|
25
|
+
| `GET /cart` | the cart, in the session; remove is an action |
|
|
26
|
+
| `GET /login`, `POST /logout` | sign in keeps a guest's cart |
|
|
27
|
+
| `GET /checkout` | signed in only; a validated form that places the order |
|
|
28
|
+
| `GET /orders/:id` | the receipt, only for its owner |
|
|
29
|
+
| `GET /api/products`, `/api/products/:id` | JSON, no session |
|
|
30
|
+
| `GET /api/health` | 204, for load balancers |
|
|
31
|
+
|
|
32
|
+
| File | What |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `src/app.ts` | `createApp()`: layout, error page, sessions, auth; shared with the tests |
|
|
35
|
+
| `src/catalog.ts` | `Catalog` service, `ProductNotFound` |
|
|
36
|
+
| `src/cart.ts` | the `Cart` session shape, `priceCart()` |
|
|
37
|
+
| `src/orders.ts` | `CheckoutForm` DTO, `Orders` service |
|
|
38
|
+
| `src/users.ts` | `Users`, the demo account, `createAuth(...)` |
|
|
39
|
+
| `src/layouts.tsx` | `RootLayout` with the cart badge, `Narrow`, `ErrorPage` |
|
|
40
|
+
| `src/components.tsx` | `Price`, `ProductCard`, `Field` |
|
|
41
|
+
| `src/routes/` | products, cart, checkout and orders, sign-in, the API |
|
|
42
|
+
| `test/shop.test.ts` | every flow, with `app.request()` and `app.client()` |
|
|
43
|
+
| `e2e/shop.spec.ts` | the cart in a real browser |
|
|
44
|
+
|
|
45
|
+
The [guides](https://toyz.github.io/yerd/#/guide/your-first-app) build a
|
|
46
|
+
bigger version of this shop step by step.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// What only a browser shows: the runtime morphs the answer in, no reload.
|
|
4
|
+
// Everything else is covered faster by test/shop.test.ts.
|
|
5
|
+
test("adds to the cart without reloading the page", async ({ page }) => {
|
|
6
|
+
await page.goto("/products/2");
|
|
7
|
+
// A marker a reload would wipe.
|
|
8
|
+
await page.evaluate(() => ((window as unknown as { marker: boolean }).marker = true));
|
|
9
|
+
|
|
10
|
+
await page.getByRole("button", { name: "Add to cart" }).click();
|
|
11
|
+
await expect(page.locator("#cart-badge .badge")).toHaveText("1");
|
|
12
|
+
await expect(page.locator("#stock")).toHaveText("25 in stock");
|
|
13
|
+
expect(await page.evaluate(() => (window as unknown as { marker?: boolean }).marker)).toBe(true);
|
|
14
|
+
|
|
15
|
+
await page.locator("#cart-badge").click();
|
|
16
|
+
await expect(page).toHaveTitle("Your cart · {{name}}");
|
|
17
|
+
await expect(page.getByRole("link", { name: "Pocket notebook" })).toBeVisible();
|
|
18
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const port = Number(process.env.E2E_PORT ?? 4173);
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
testDir: "e2e",
|
|
7
|
+
use: { baseURL: `http://localhost:${port}` },
|
|
8
|
+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
|
9
|
+
// Against the production build, as it will run.
|
|
10
|
+
webServer: {
|
|
11
|
+
command: "{{run_build}} && {{run_start}}",
|
|
12
|
+
env: { PORT: String(port) },
|
|
13
|
+
url: `http://localhost:${port}/`,
|
|
14
|
+
reuseExistingServer: !process.env.CI,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2f5d8a"/><path d="M9 9l7 9 7-9M16 18v6" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { yerd, type YerdOptions } from "@toyz/yerd";
|
|
2
|
+
import { ErrorPage, RootLayout } from "./layouts.js";
|
|
3
|
+
import { auth } from "./users.js";
|
|
4
|
+
|
|
5
|
+
/** The app without routes: main.ts discovers them, tests import the ones they need. */
|
|
6
|
+
export function createApp(options?: YerdOptions) {
|
|
7
|
+
return yerd(options).layout(RootLayout).errorPage(ErrorPage).sessions().use(auth);
|
|
8
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Catalog, Product } from "./catalog.js";
|
|
2
|
+
|
|
3
|
+
export interface CartLine {
|
|
4
|
+
id: number;
|
|
5
|
+
qty: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The cart, as a session shape: `@session(Cart) accessor cart!: Session<Cart>`.
|
|
10
|
+
* Only ids and quantities are stored; names and prices come from the catalog
|
|
11
|
+
* at render. A new visitor gets no cookie until they add something.
|
|
12
|
+
*/
|
|
13
|
+
export class Cart {
|
|
14
|
+
lines: CartLine[] = [];
|
|
15
|
+
|
|
16
|
+
get count(): number {
|
|
17
|
+
return this.lines.reduce((sum, line) => sum + line.qty, 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
add(id: number): void {
|
|
21
|
+
const line = this.lines.find((l) => l.id === id);
|
|
22
|
+
// In-place changes are noticed and saved, like assignments.
|
|
23
|
+
if (line) line.qty++;
|
|
24
|
+
else this.lines.push({ id, qty: 1 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
remove(id: number): void {
|
|
28
|
+
this.lines = this.lines.filter((l) => l.id !== id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Not `clear()`: that's the session's own, and would sign the user out too. */
|
|
32
|
+
empty(): void {
|
|
33
|
+
this.lines = [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PricedLine {
|
|
38
|
+
product: Product;
|
|
39
|
+
qty: number;
|
|
40
|
+
subtotal: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The cart's lines with their products and prices. Products gone from the catalog drop out. */
|
|
44
|
+
export function priceCart(lines: CartLine[], catalog: Catalog): { lines: PricedLine[]; total: number } {
|
|
45
|
+
const priced = lines.flatMap(({ id, qty }) => {
|
|
46
|
+
const product = catalog.get(id);
|
|
47
|
+
return product ? [{ product, qty, subtotal: product.price * qty }] : [];
|
|
48
|
+
});
|
|
49
|
+
return { lines: priced, total: priced.reduce((sum, line) => sum + line.subtotal, 0) };
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NotFound, service } from "@toyz/yerd";
|
|
2
|
+
|
|
3
|
+
export interface Product {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
/** In cents: money is never a float. */
|
|
7
|
+
price: number;
|
|
8
|
+
stock: number;
|
|
9
|
+
description: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A NotFound: pages get the error page, the API a JSON 404 carrying the id. */
|
|
13
|
+
export class ProductNotFound extends NotFound {
|
|
14
|
+
constructor(readonly id: number) {
|
|
15
|
+
super(`No product ${id}`, { details: { id } });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PRODUCTS: Product[] = [
|
|
20
|
+
{ id: 1, name: "Fountain pen", price: 4800, stock: 5, description: "Steel nib, fine point, refillable." },
|
|
21
|
+
{ id: 2, name: "Pocket notebook", price: 900, stock: 25, description: "48 lined pages that fit a back pocket." },
|
|
22
|
+
{ id: 3, name: "Desk lamp", price: 6500, stock: 3, description: "Warm light, one arm, no app." },
|
|
23
|
+
{ id: 4, name: "Brass ruler", price: 1800, stock: 9, description: "Thirty centimetres, gets better with age." },
|
|
24
|
+
{ id: 5, name: "Dot grid journal", price: 3200, stock: 0, description: "A5, 160 pages, numbered and dotted." },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** The shelves, in memory. A singleton: every request sees the same stock. */
|
|
28
|
+
@service
|
|
29
|
+
export class Catalog {
|
|
30
|
+
// Copies, so tests and restarts start from the same shelves.
|
|
31
|
+
#products = PRODUCTS.map((p) => ({ ...p }));
|
|
32
|
+
|
|
33
|
+
/** Products whose name or description contains `q`. */
|
|
34
|
+
search(q = ""): Product[] {
|
|
35
|
+
const needle = q.toLowerCase();
|
|
36
|
+
return this.#products.filter((p) => `${p.name} ${p.description}`.toLowerCase().includes(needle));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(id: number): Product | undefined {
|
|
40
|
+
return this.#products.find((p) => p.id === id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Like get(), but a missing product answers the request with a 404. */
|
|
44
|
+
find(id: number): Product {
|
|
45
|
+
const product = this.get(id);
|
|
46
|
+
if (!product) throw new ProductNotFound(id);
|
|
47
|
+
return product;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Take sold items off the shelf. */
|
|
51
|
+
take(id: number, qty: number): void {
|
|
52
|
+
this.find(id).stock -= qty;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 2400 -> "$24.00". */
|
|
57
|
+
export function formatPrice(cents: number): string {
|
|
58
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { formatPrice, type Product } from "./catalog.js";
|
|
2
|
+
|
|
3
|
+
export function Price({ cents }: { cents: number }) {
|
|
4
|
+
return <span class="price">{formatPrice(cents)}</span>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ProductCard({ product }: { product: Product }) {
|
|
8
|
+
return (
|
|
9
|
+
<li class="card">
|
|
10
|
+
<a href={`/products/${product.id}`}>{product.name}</a>
|
|
11
|
+
<Price cents={product.price} />
|
|
12
|
+
{product.stock === 0 ? <span class="sold-out">Sold out</span> : null}
|
|
13
|
+
</li>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A labelled input with its error box. The runtime writes a failed field's
|
|
19
|
+
* message into [data-yx-error="<name>"]; a plain form post gets the same
|
|
20
|
+
* message from the server, via `error`.
|
|
21
|
+
*/
|
|
22
|
+
export function Field(props: { name: string; label: string; type?: string; value?: string | undefined; error?: string | undefined; autocomplete?: string }) {
|
|
23
|
+
const { name, label, type = "text", value, error, autocomplete } = props;
|
|
24
|
+
return (
|
|
25
|
+
<p class="field">
|
|
26
|
+
<label for={name}>{label}</label>
|
|
27
|
+
<input id={name} name={name} type={type} value={value} autocomplete={autocomplete} aria-invalid={error ? true : undefined} />
|
|
28
|
+
<span class="field-error" data-yx-error={name}>{error}</span>
|
|
29
|
+
</p>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Region, Scripts, Stylesheet, currentContext, type ErrorPageProps, type LayoutProps } from "@toyz/yerd";
|
|
2
|
+
import { Cart } from "./cart.js";
|
|
3
|
+
import { auth } from "./users.js";
|
|
4
|
+
|
|
5
|
+
/** The cart link in the header. A Region, so cart actions can send a new count. */
|
|
6
|
+
export function CartBadge({ count }: { count: number }) {
|
|
7
|
+
return (
|
|
8
|
+
<Region id="cart-badge" as="a" href="/cart" class="cart-badge">
|
|
9
|
+
Cart <span class="badge">{count}</span>
|
|
10
|
+
</Region>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Items in the visitor's cart; 0 on routes without a session (the API). */
|
|
15
|
+
function cartCount(): number {
|
|
16
|
+
const ctx = currentContext();
|
|
17
|
+
return ctx.route.session === false ? 0 : ctx.session.as(Cart).count;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The document. Every HTML response goes through it, error pages included. */
|
|
21
|
+
export function RootLayout({ children, meta }: LayoutProps) {
|
|
22
|
+
const me = auth.current();
|
|
23
|
+
return (
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
29
|
+
<title>{meta.title ? `${meta.title} · {{name}}` : "{{name}}"}</title>
|
|
30
|
+
<Stylesheet href="src/styles.css" />
|
|
31
|
+
<Scripts />
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<header class="top">
|
|
35
|
+
<a class="brand" href="/">{"{{name}}"}</a>
|
|
36
|
+
<CartBadge count={cartCount()} />
|
|
37
|
+
{me ? (
|
|
38
|
+
// A POST, not a link: a prefetch or a crawler must not sign anyone out.
|
|
39
|
+
<form class="account" method="post" action="/logout">
|
|
40
|
+
{me.name} <button class="link">Sign out</button>
|
|
41
|
+
</form>
|
|
42
|
+
) : (
|
|
43
|
+
<a class="account" href="/login">Sign in</a>
|
|
44
|
+
)}
|
|
45
|
+
</header>
|
|
46
|
+
<main>{children}</main>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A narrow column for forms: sign-in, checkout, the receipt. */
|
|
53
|
+
export function Narrow({ children }: LayoutProps) {
|
|
54
|
+
return <div class="narrow">{children}</div>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** app.errorPage(): 404s and crashes render here, inside RootLayout. */
|
|
58
|
+
export function ErrorPage({ status, message }: ErrorPageProps) {
|
|
59
|
+
return (
|
|
60
|
+
<section class="error-page">
|
|
61
|
+
<p class="status">{status}</p>
|
|
62
|
+
<h1>{status === 404 ? "Not on the shelves" : "Something went wrong"}</h1>
|
|
63
|
+
<p>{message}</p>
|
|
64
|
+
<a href="/">Back to the shop</a>
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { createApp } from "./app.js";
|
|
3
|
+
|
|
4
|
+
// The Vite entry. The plugin serves it in dev and the build's server.js listens: no listen() here.
|
|
5
|
+
export default createApp().discover(import.meta.glob("./routes/**/*.{ts,tsx}", { eager: true }));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { BadRequest, Conflict, inject, is, length, service, trim } from "@toyz/yerd";
|
|
2
|
+
import { priceCart, type CartLine } from "./cart.js";
|
|
3
|
+
import { Catalog } from "./catalog.js";
|
|
4
|
+
import type { User } from "./users.js";
|
|
5
|
+
|
|
6
|
+
/** The checkout form. Every field is checked on the server; the browser's checks are a courtesy. */
|
|
7
|
+
export class CheckoutForm {
|
|
8
|
+
@is.string({ transform: trim, validate: length(2, 80) }) name!: string;
|
|
9
|
+
@is.email() email!: string;
|
|
10
|
+
@is.string({ transform: trim, validate: length(5, 200) }) address!: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Order {
|
|
14
|
+
id: number;
|
|
15
|
+
userId: string;
|
|
16
|
+
email: string;
|
|
17
|
+
/** Names and prices as they were when the order was placed. */
|
|
18
|
+
lines: Array<{ name: string; price: number; qty: number }>;
|
|
19
|
+
total: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@service
|
|
23
|
+
export class Orders {
|
|
24
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
25
|
+
#orders: Order[] = [];
|
|
26
|
+
#next = 1001;
|
|
27
|
+
|
|
28
|
+
/** Check the stock, take it, keep the order. Throws before taking anything if a line can't be filled. */
|
|
29
|
+
place(user: User, details: CheckoutForm, cart: CartLine[]): Order {
|
|
30
|
+
const { lines, total } = priceCart(cart, this.catalog);
|
|
31
|
+
if (lines.length === 0) throw new BadRequest("Your cart is empty.");
|
|
32
|
+
for (const { product, qty } of lines) {
|
|
33
|
+
if (product.stock < qty) throw new Conflict(`Only ${product.stock} of ${product.name} left.`);
|
|
34
|
+
}
|
|
35
|
+
for (const { product, qty } of lines) this.catalog.take(product.id, qty);
|
|
36
|
+
|
|
37
|
+
const order: Order = {
|
|
38
|
+
id: this.#next++,
|
|
39
|
+
userId: user.id,
|
|
40
|
+
email: details.email,
|
|
41
|
+
lines: lines.map(({ product, qty }) => ({ name: product.name, price: product.price, qty })),
|
|
42
|
+
total,
|
|
43
|
+
};
|
|
44
|
+
this.#orders.push(order);
|
|
45
|
+
return order;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Someone's own order; another user's is as missing as one that never existed. */
|
|
49
|
+
get(id: number, user: User): Order | undefined {
|
|
50
|
+
return this.#orders.find((o) => o.id === id && o.userId === user.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { HttpStatus, get, inject, param, query, route, toInt, trim } from "@toyz/yerd";
|
|
2
|
+
import { Catalog, ProductNotFound } from "../catalog.js";
|
|
3
|
+
|
|
4
|
+
// The JSON API. `session: false`: API clients get no cookie, and no session is read.
|
|
5
|
+
|
|
6
|
+
@route("/api/products", { session: false })
|
|
7
|
+
export class ProductsApi {
|
|
8
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
9
|
+
@query("q", { transform: trim }) accessor q = "";
|
|
10
|
+
|
|
11
|
+
@get list() {
|
|
12
|
+
// An array becomes JSON.
|
|
13
|
+
return this.catalog.search(this.q);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@route("/api/products/:id", { session: false })
|
|
18
|
+
export class ProductApi {
|
|
19
|
+
@inject(Catalog) accessor catalog!: Catalog;
|
|
20
|
+
@param("id", { transform: toInt }) accessor id!: number;
|
|
21
|
+
|
|
22
|
+
@get show() {
|
|
23
|
+
// A returned error counts as thrown: a JSON 404 with the id.
|
|
24
|
+
return this.catalog.get(this.id) ?? new ProductNotFound(this.id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** For load balancers and uptime checks: 204, no body. */
|
|
29
|
+
@route("/api/health", { session: false })
|
|
30
|
+
export class Health {
|
|
31
|
+
@get check() {
|
|
32
|
+
return HttpStatus.NoContent;
|
|
33
|
+
}
|
|
34
|
+
}
|