@madojs/mado 0.10.1 → 0.11.1
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/AGENTS.md +24 -26
- package/CHANGELOG.md +95 -0
- package/README.md +22 -47
- package/TODO.md +52 -48
- package/dist/src/component.d.ts +2 -1
- package/dist/src/component.js +5 -2
- package/dist/src/component.js.map +1 -1
- package/dist/src/each.d.ts +1 -1
- package/dist/src/each.js +1 -1
- package/dist/src/each.js.map +1 -1
- package/dist/src/html/bindings.js +3 -3
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/index.d.ts +11 -6
- package/dist/src/index.js +5 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/lazy.d.ts +1 -1
- package/dist/src/lazy.js +1 -1
- package/dist/src/lazy.js.map +1 -1
- package/dist/src/page.d.ts +17 -21
- package/dist/src/page.js +7 -12
- package/dist/src/page.js.map +1 -1
- package/dist/src/router/manifest.d.ts +1 -1
- package/dist/src/router/manifest.js +21 -13
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +2 -2
- package/dist/src/router/match.js +3 -3
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.js +1 -1
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/vite/index.d.ts +10 -0
- package/dist/src/vite/index.js +33 -0
- package/dist/src/vite/index.js.map +1 -0
- package/docs/en/00-the-mado-way.md +25 -12
- package/docs/en/01-routing.md +90 -142
- package/docs/en/02-project-layout.md +59 -53
- package/docs/en/03-static-bake.md +5 -6
- package/docs/en/05-why-mado.md +6 -6
- package/docs/en/06-for-backenders.md +18 -22
- package/docs/en/08-llm-zero-history-test.md +9 -14
- package/docs/en/09-shadow-vs-light-dom.md +28 -36
- package/docs/en/10-app-architecture.md +158 -96
- package/docs/en/11-layouts.md +22 -24
- package/docs/en/12-auth-and-api.md +89 -182
- package/docs/en/13-deployment.md +18 -22
- package/docs/en/14-testing.md +4 -4
- package/docs/en/16-bake-cookbook.md +11 -12
- package/docs/en/18-api-freeze-map.md +6 -4
- package/docs/en/20-v1-stability.md +1 -1
- package/docs/fr/00-the-mado-way.md +55 -90
- package/docs/fr/01-routing.md +70 -152
- package/docs/fr/02-project-layout.md +61 -42
- package/docs/fr/03-static-bake.md +1 -1
- package/docs/fr/05-why-mado.md +6 -6
- package/docs/fr/06-for-backenders.md +7 -7
- package/docs/fr/08-llm-zero-history-test.md +21 -48
- package/docs/fr/09-shadow-vs-light-dom.md +43 -162
- package/docs/fr/10-app-architecture.md +110 -33
- package/docs/fr/11-layouts.md +24 -12
- package/docs/fr/12-auth-and-api.md +63 -22
- package/docs/fr/13-deployment.md +7 -10
- package/docs/fr/14-testing.md +1 -1
- package/docs/fr/16-bake-cookbook.md +2 -2
- package/docs/fr/18-api-freeze-map.md +1 -1
- package/docs/fr/20-v1-stability.md +1 -1
- package/docs/recipes/nginx/README.md +13 -0
- package/docs/ru/00-the-mado-way.md +53 -75
- package/docs/ru/01-routing.md +68 -143
- package/docs/ru/02-project-layout.md +61 -41
- package/docs/ru/03-static-bake.md +2 -2
- package/docs/ru/05-why-mado.md +6 -6
- package/docs/ru/06-for-backenders.md +7 -7
- package/docs/ru/08-llm-zero-history-test.md +9 -14
- package/docs/ru/09-shadow-vs-light-dom.md +43 -178
- package/docs/ru/10-app-architecture.md +115 -63
- package/docs/ru/11-layouts.md +24 -24
- package/docs/ru/12-auth-and-api.md +57 -35
- package/docs/ru/13-deployment.md +7 -11
- package/docs/ru/14-testing.md +1 -1
- package/docs/ru/16-bake-cookbook.md +12 -6
- package/docs/ru/18-api-freeze-map.md +5 -3
- package/docs/ru/20-v1-stability.md +1 -1
- package/docs/uk/00-the-mado-way.md +70 -44
- package/docs/uk/01-routing.md +41 -47
- package/docs/uk/02-project-layout.md +68 -41
- package/docs/uk/03-static-bake.md +1 -2
- package/docs/uk/06-for-backenders.md +3 -3
- package/docs/uk/08-llm-zero-history-test.md +22 -24
- package/docs/uk/09-shadow-vs-light-dom.md +37 -86
- package/docs/uk/10-app-architecture.md +72 -31
- package/docs/uk/11-layouts.md +25 -12
- package/docs/uk/12-auth-and-api.md +58 -22
- package/docs/uk/13-deployment.md +4 -3
- package/docs/uk/14-testing.md +1 -1
- package/docs/uk/18-api-freeze-map.md +1 -1
- package/docs/uk/20-v1-stability.md +1 -1
- package/llms.txt +14 -15
- package/package.json +18 -11
- package/scripts/_config.mjs +15 -161
- package/scripts/bake.mjs +74 -63
- package/scripts/cli/generate.mjs +348 -0
- package/scripts/cli/help.mjs +27 -0
- package/scripts/cli/index.mjs +79 -0
- package/scripts/cli/init.mjs +153 -0
- package/scripts/cli/release.mjs +152 -0
- package/scripts/cli/run.mjs +96 -0
- package/scripts/cli.mjs +2 -621
- package/scripts/package-smoke.mjs +4 -1
- package/scripts/preview.mjs +13 -37
- package/scripts/size-budget.mjs +5 -2
- package/scripts/vite.default.mjs +11 -0
- package/starters/default/.editorconfig +12 -0
- package/starters/default/README.md +74 -0
- package/starters/default/eslint.config.mjs +256 -0
- package/starters/default/index.html +13 -0
- package/starters/default/package.json +30 -0
- package/starters/default/public/favicon.svg +4 -0
- package/starters/default/src/app.routes.ts +39 -0
- package/starters/default/src/layouts/app-shell.layout.ts +35 -0
- package/starters/default/src/layouts/auth-shell.layout.ts +17 -0
- package/starters/default/src/main.ts +16 -0
- package/starters/default/src/modules/auth/_contracts/auth-api.types.ts +17 -0
- package/starters/default/src/modules/auth/auth.connector.ts +45 -0
- package/starters/default/src/modules/auth/auth.guard.ts +22 -0
- package/starters/default/src/modules/auth/auth.public.ts +9 -0
- package/starters/default/src/modules/auth/auth.routes.ts +8 -0
- package/starters/default/src/modules/auth/auth.service.ts +71 -0
- package/starters/default/src/modules/auth/auth.types.ts +15 -0
- package/starters/default/src/modules/auth/login.page.ts +62 -0
- package/starters/default/src/modules/billing/_contracts/stripe.types.ts +17 -0
- package/starters/default/src/modules/billing/api/stripe.connector.ts +71 -0
- package/starters/default/src/modules/billing/billing.public.ts +5 -0
- package/starters/default/src/modules/billing/billing.routes.ts +9 -0
- package/starters/default/src/modules/billing/billing.types.ts +15 -0
- package/starters/default/src/modules/billing/components/invoice-status-badge.component.ts +43 -0
- package/starters/default/src/modules/billing/data/invoices.resource.ts +35 -0
- package/starters/default/src/modules/billing/pages/invoice-detail.page.ts +70 -0
- package/starters/default/src/modules/billing/pages/invoices-list.page.ts +73 -0
- package/starters/default/src/modules/home/home.page.ts +34 -0
- package/starters/default/src/modules/home/not-found.page.ts +11 -0
- package/starters/default/src/shared/http/http-client.ts +86 -0
- package/starters/default/src/shared/http/http-error.ts +37 -0
- package/starters/default/src/shared/http/interceptors.ts +59 -0
- package/starters/default/src/shared/lib/format-date.ts +19 -0
- package/starters/default/src/shared/styles/content.css +70 -0
- package/starters/default/src/shared/styles/reset.css +32 -0
- package/starters/default/src/shared/styles/shell.css +57 -0
- package/starters/default/src/shared/styles/tokens.css +44 -0
- package/starters/default/src/shared/ui/x-button.component.ts +49 -0
- package/starters/default/src/shared/ui/x-spinner.component.ts +22 -0
- package/starters/default/src/styles.d.ts +1 -0
- package/starters/default/src/vite-env.d.ts +1 -0
- package/starters/default/tsconfig.json +24 -0
- package/starters/default/vite.config.ts +9 -0
- package/MADO_V1_PLAN.md +0 -179
- package/ROADMAP.md +0 -178
- package/dist/src/html.d.ts +0 -18
- package/dist/src/html.js +0 -17
- package/dist/src/html.js.map +0 -1
- package/dist/src/router.d.ts +0 -13
- package/dist/src/router.js +0 -13
- package/dist/src/router.js.map +0 -1
- package/scripts/bundle.mjs +0 -212
- package/scripts/llm-zero-history-smoke.mjs +0 -93
- package/scripts/new.mjs +0 -80
- package/scripts/showcase-regression.mjs +0 -392
- package/server/serve.mjs +0 -455
- package/starters/admin/README.md +0 -63
- package/starters/admin/index.html +0 -28
- package/starters/admin/mado.config.json +0 -22
- package/starters/admin/package.json +0 -24
- package/starters/admin/public/favicon.svg +0 -4
- package/starters/admin/src/components/x-button.ts +0 -82
- package/starters/admin/src/components/x-input.ts +0 -105
- package/starters/admin/src/layouts/app.ts +0 -101
- package/starters/admin/src/layouts/auth.ts +0 -41
- package/starters/admin/src/lib/api.ts +0 -184
- package/starters/admin/src/lib/auth.ts +0 -83
- package/starters/admin/src/main.ts +0 -15
- package/starters/admin/src/pages/admin/dashboard.ts +0 -48
- package/starters/admin/src/pages/admin/order-detail.ts +0 -80
- package/starters/admin/src/pages/admin/orders.ts +0 -117
- package/starters/admin/src/pages/home.ts +0 -34
- package/starters/admin/src/pages/login.ts +0 -70
- package/starters/admin/src/pages/not-found.ts +0 -12
- package/starters/admin/src/routes.ts +0 -40
- package/starters/admin/src/styles/global.ts +0 -86
- package/starters/admin/tsconfig.json +0 -15
- package/starters/crud/README.md +0 -33
- package/starters/crud/index.html +0 -28
- package/starters/crud/mado.config.json +0 -20
- package/starters/crud/package.json +0 -24
- package/starters/crud/src/components/app-shell.ts +0 -56
- package/starters/crud/src/components/ticket-detail.ts +0 -33
- package/starters/crud/src/components/ticket-form.ts +0 -69
- package/starters/crud/src/components/ticket-list.ts +0 -66
- package/starters/crud/src/lib/api.ts +0 -76
- package/starters/crud/src/main.ts +0 -9
- package/starters/crud/src/pages/home.ts +0 -34
- package/starters/crud/src/pages/not-found.ts +0 -12
- package/starters/crud/src/pages/ticket-detail.ts +0 -7
- package/starters/crud/src/pages/ticket-new.ts +0 -7
- package/starters/crud/src/pages/tickets.ts +0 -7
- package/starters/crud/src/routes.ts +0 -11
- package/starters/crud/src/styles/global.ts +0 -155
- package/starters/crud/tsconfig.json +0 -15
- package/starters/minimal/README.md +0 -21
- package/starters/minimal/index.html +0 -28
- package/starters/minimal/mado.config.json +0 -20
- package/starters/minimal/package.json +0 -24
- package/starters/minimal/src/components/app-counter.ts +0 -31
- package/starters/minimal/src/main.ts +0 -9
- package/starters/minimal/src/pages/home.ts +0 -35
- package/starters/minimal/src/pages/not-found.ts +0 -14
- package/starters/minimal/src/routes.ts +0 -8
- package/starters/minimal/src/styles/global.ts +0 -60
- package/starters/minimal/tsconfig.json +0 -15
- package/templates/page-detail.ts +0 -63
- package/templates/page-form.ts +0 -94
- package/templates/page-list.ts +0 -79
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Canonical *.connector.ts shape:
|
|
2
|
+
// 1. CONFIG — base URL, version flags
|
|
3
|
+
// 2. MAPPERS — DTO → domain (pure functions, tested in isolation)
|
|
4
|
+
// 3. ENDPOINTS — thin, return domain types
|
|
5
|
+
// 4. ERRORS — provider-specific subclasses of HttpError (optional)
|
|
6
|
+
//
|
|
7
|
+
// Connectors NEVER import signals, resources, html, or pages.
|
|
8
|
+
|
|
9
|
+
import { httpClient } from "../../shared/http/http-client";
|
|
10
|
+
import { HttpError } from "../../shared/http/http-error";
|
|
11
|
+
|
|
12
|
+
import type { LoginRequestDTO, LoginResponseDTO } from "./_contracts/auth-api.types";
|
|
13
|
+
import type { Credentials, User } from "./auth.types";
|
|
14
|
+
|
|
15
|
+
// 1. CONFIG
|
|
16
|
+
const base = "/api/auth";
|
|
17
|
+
|
|
18
|
+
// 2. MAPPERS
|
|
19
|
+
const toUser = (dto: LoginResponseDTO["user"]): User => ({
|
|
20
|
+
id: dto.id,
|
|
21
|
+
email: dto.email,
|
|
22
|
+
roles: dto.roles,
|
|
23
|
+
permissions: dto.permissions,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// 3. ENDPOINTS
|
|
27
|
+
export const authApi = {
|
|
28
|
+
login: async (creds: Credentials): Promise<{ token: string; user: User }> => {
|
|
29
|
+
const req: LoginRequestDTO = { email: creds.email, password: creds.password };
|
|
30
|
+
const res = await httpClient.post<LoginResponseDTO>(`${base}/login`, req);
|
|
31
|
+
return { token: res.token, user: toUser(res.user) };
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
me: async (): Promise<User> => {
|
|
35
|
+
const res = await httpClient.get<LoginResponseDTO["user"]>(`${base}/me`);
|
|
36
|
+
return toUser(res);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
logout: (): Promise<void> => httpClient.post<void>(`${base}/logout`),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// 4. ERRORS
|
|
43
|
+
export class AuthError extends HttpError {
|
|
44
|
+
override readonly name = "AuthError";
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Route guards live in the auth module so they can be reused across
|
|
2
|
+
// app.routes.ts and any module that needs to gate UI by permission.
|
|
3
|
+
//
|
|
4
|
+
// A guard is a plain function: returns `true` to allow, returns a path
|
|
5
|
+
// (string) to redirect, or false to deny.
|
|
6
|
+
|
|
7
|
+
import { hasPermission, isAuthed } from "./auth.service";
|
|
8
|
+
|
|
9
|
+
export function requireAuth(): boolean | string {
|
|
10
|
+
if (isAuthed()) return true;
|
|
11
|
+
// Redirect to login. Real apps may want to preserve the original target
|
|
12
|
+
// via a query param; keep simple here.
|
|
13
|
+
return "/login";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function requirePermission(perm: string): () => boolean | string {
|
|
17
|
+
return () => {
|
|
18
|
+
if (!isAuthed()) return "/login";
|
|
19
|
+
if (!hasPermission(perm)) return "/";
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// THE single import surface other modules (and app.routes.ts) may use to
|
|
2
|
+
// reach into `auth`. Anything not re-exported here is private to the module.
|
|
3
|
+
// ESLint enforces it.
|
|
4
|
+
//
|
|
5
|
+
// Re-export only what callers should actually depend on.
|
|
6
|
+
|
|
7
|
+
export { requireAuth, requirePermission } from "./auth.guard";
|
|
8
|
+
export { hasPermission, hasRole, isAuthed, isBooting, login, logout, user } from "./auth.service";
|
|
9
|
+
export type { Credentials, User, UserId } from "./auth.types";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Canonical *.routes.ts shape: a plain map of path → lazy page imports.
|
|
2
|
+
//
|
|
3
|
+
// Modules NEVER wrap themselves in a layout. Wrapping (which shell + guard)
|
|
4
|
+
// is a composition decision that lives in src/app.routes.ts.
|
|
5
|
+
|
|
6
|
+
export const authRoutes = {
|
|
7
|
+
"/": () => import("./login.page"), // mounted as /login by app.routes.ts
|
|
8
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Canonical *.service.ts shape:
|
|
2
|
+
// 1. PRIVATE STATE — _signals, not exported
|
|
3
|
+
// 2. PUBLIC READS — getters / computed
|
|
4
|
+
// 3. ACTIONS — async functions that mutate state
|
|
5
|
+
// 4. (optional) init() — wires interceptors, restores from storage
|
|
6
|
+
//
|
|
7
|
+
// Services are plain ES modules. The module identity = the singleton.
|
|
8
|
+
|
|
9
|
+
import { computed, signal } from "@madojs/mado";
|
|
10
|
+
|
|
11
|
+
import { registerAuthTokenProvider } from "../../shared/http/interceptors";
|
|
12
|
+
|
|
13
|
+
import { authApi } from "./auth.connector";
|
|
14
|
+
import type { Credentials, User } from "./auth.types";
|
|
15
|
+
|
|
16
|
+
// 1. PRIVATE STATE
|
|
17
|
+
const _user = signal<User | null>(null);
|
|
18
|
+
const _token = signal<string | null>(null);
|
|
19
|
+
const _booting = signal(true);
|
|
20
|
+
|
|
21
|
+
const TOKEN_KEY = "mado.auth.token";
|
|
22
|
+
|
|
23
|
+
// 2. PUBLIC READS
|
|
24
|
+
// Note: Mado signals are getter functions — read with `_user()`, write with
|
|
25
|
+
// `_user.set(value)` or `_user.update(fn)`. There is no `_user.value`.
|
|
26
|
+
export const user = (): User | null => _user();
|
|
27
|
+
export const token = (): string | null => _token();
|
|
28
|
+
export const isAuthed = computed(() => _user() !== null);
|
|
29
|
+
export const isBooting = (): boolean => _booting();
|
|
30
|
+
|
|
31
|
+
export const hasRole = (role: string): boolean => _user()?.roles.includes(role) ?? false;
|
|
32
|
+
export const hasPermission = (perm: string): boolean =>
|
|
33
|
+
_user()?.permissions.includes(perm) ?? false;
|
|
34
|
+
|
|
35
|
+
// 3. ACTIONS
|
|
36
|
+
export async function login(creds: Credentials): Promise<void> {
|
|
37
|
+
const { token: t, user: u } = await authApi.login(creds);
|
|
38
|
+
localStorage.setItem(TOKEN_KEY, t);
|
|
39
|
+
_token.set(t);
|
|
40
|
+
_user.set(u);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function logout(): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
await authApi.logout();
|
|
46
|
+
} catch {
|
|
47
|
+
/* ignore network errors on logout */
|
|
48
|
+
}
|
|
49
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
50
|
+
_token.set(null);
|
|
51
|
+
_user.set(null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 4. INIT
|
|
55
|
+
// Called from main.ts at startup. Restores token, refetches user, wires the
|
|
56
|
+
// HTTP auth interceptor.
|
|
57
|
+
export async function init(): Promise<void> {
|
|
58
|
+
registerAuthTokenProvider(() => _token());
|
|
59
|
+
|
|
60
|
+
const saved = localStorage.getItem(TOKEN_KEY);
|
|
61
|
+
if (saved) {
|
|
62
|
+
_token.set(saved);
|
|
63
|
+
try {
|
|
64
|
+
_user.set(await authApi.me());
|
|
65
|
+
} catch {
|
|
66
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
67
|
+
_token.set(null);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
_booting.set(false);
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Domain types exported by the auth module. Never import provider DTOs here.
|
|
2
|
+
|
|
3
|
+
export type UserId = string;
|
|
4
|
+
|
|
5
|
+
export interface User {
|
|
6
|
+
id: UserId;
|
|
7
|
+
email: string;
|
|
8
|
+
roles: string[];
|
|
9
|
+
permissions: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Credentials {
|
|
13
|
+
email: string;
|
|
14
|
+
password: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { html, navigate, page, signal, useForm } from "@madojs/mado";
|
|
2
|
+
|
|
3
|
+
import "../../shared/ui/x-button.component";
|
|
4
|
+
|
|
5
|
+
import { login } from "./auth.service";
|
|
6
|
+
|
|
7
|
+
// 1. LOCAL STATE
|
|
8
|
+
// (per-view)
|
|
9
|
+
|
|
10
|
+
// 2. DATA
|
|
11
|
+
// (none)
|
|
12
|
+
|
|
13
|
+
// 3. ACTIONS — handled inside the view (useForm is per-render)
|
|
14
|
+
|
|
15
|
+
// 4. VIEW
|
|
16
|
+
export default page({
|
|
17
|
+
title: "Sign in",
|
|
18
|
+
view: () => {
|
|
19
|
+
const submitting = signal(false);
|
|
20
|
+
const error = signal<string | null>(null);
|
|
21
|
+
const form = useForm({
|
|
22
|
+
email: { required: true, type: "email" },
|
|
23
|
+
password: { required: true, minLength: 6 },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const onSubmit = form.onSubmit(async (values) => {
|
|
27
|
+
submitting.set(true);
|
|
28
|
+
error.set(null);
|
|
29
|
+
try {
|
|
30
|
+
await login({
|
|
31
|
+
email: String(values.email ?? ""),
|
|
32
|
+
password: String(values.password ?? ""),
|
|
33
|
+
});
|
|
34
|
+
navigate("/");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
error.set(err instanceof Error ? err.message : "Login failed");
|
|
37
|
+
} finally {
|
|
38
|
+
submitting.set(false);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return html`
|
|
43
|
+
<section>
|
|
44
|
+
<h1>Sign in</h1>
|
|
45
|
+
<form @submit=${onSubmit}>
|
|
46
|
+
<label>
|
|
47
|
+
Email
|
|
48
|
+
<input name="email" type="email" required @input=${form.onInput} />
|
|
49
|
+
</label>
|
|
50
|
+
<label>
|
|
51
|
+
Password
|
|
52
|
+
<input name="password" type="password" required @input=${form.onInput} />
|
|
53
|
+
</label>
|
|
54
|
+
${() => (error() ? html`<p class="error">${error()}</p>` : null)}
|
|
55
|
+
<x-button ?disabled=${() => submitting() || !form.isValid()}>
|
|
56
|
+
${() => (submitting() ? "Signing in…" : "Sign in")}
|
|
57
|
+
</x-button>
|
|
58
|
+
</form>
|
|
59
|
+
</section>
|
|
60
|
+
`;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Raw Stripe DTOs. PRIVATE to stripe.connector.ts.
|
|
2
|
+
// Other files (resources, pages) must use domain Invoice from billing.types.ts.
|
|
3
|
+
|
|
4
|
+
export interface StripeInvoiceDTO {
|
|
5
|
+
id: string;
|
|
6
|
+
number: string;
|
|
7
|
+
customer_email: string;
|
|
8
|
+
amount_due: number; // cents
|
|
9
|
+
currency: string; // lowercase
|
|
10
|
+
status: "draft" | "open" | "paid" | "void" | "uncollectible";
|
|
11
|
+
created: number; // unix
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StripeListResponseDTO<T> {
|
|
15
|
+
data: T[];
|
|
16
|
+
has_more: boolean;
|
|
17
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Connector for ONE external system (Stripe). Stays small and predictable:
|
|
2
|
+
// 1. CONFIG 2. MAPPERS 3. ENDPOINTS 4. ERRORS
|
|
3
|
+
//
|
|
4
|
+
// If we ever swap Stripe → another PSP, only this file (and its _contracts/)
|
|
5
|
+
// changes. Pages and resources keep using the domain `Invoice`.
|
|
6
|
+
|
|
7
|
+
import { httpClient } from "../../../shared/http/http-client";
|
|
8
|
+
import { HttpError } from "../../../shared/http/http-error";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
StripeInvoiceDTO,
|
|
12
|
+
StripeListResponseDTO,
|
|
13
|
+
} from "../_contracts/stripe.types";
|
|
14
|
+
import type { Invoice, InvoiceStatus } from "../billing.types";
|
|
15
|
+
|
|
16
|
+
// 1. CONFIG
|
|
17
|
+
const base = "/api/billing/stripe";
|
|
18
|
+
|
|
19
|
+
// 2. MAPPERS
|
|
20
|
+
const toStatus = (s: StripeInvoiceDTO["status"]): InvoiceStatus => {
|
|
21
|
+
switch (s) {
|
|
22
|
+
case "paid":
|
|
23
|
+
return "paid";
|
|
24
|
+
case "draft":
|
|
25
|
+
return "draft";
|
|
26
|
+
case "void":
|
|
27
|
+
case "uncollectible":
|
|
28
|
+
return "void";
|
|
29
|
+
default:
|
|
30
|
+
return "pending";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const toInvoice = (dto: StripeInvoiceDTO): Invoice => ({
|
|
35
|
+
id: dto.id,
|
|
36
|
+
number: dto.number,
|
|
37
|
+
customerEmail: dto.customer_email,
|
|
38
|
+
amount: dto.amount_due / 100,
|
|
39
|
+
currency: dto.currency.toUpperCase(),
|
|
40
|
+
status: toStatus(dto.status),
|
|
41
|
+
issuedAt: new Date(dto.created * 1000).toISOString(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 3. ENDPOINTS
|
|
45
|
+
export const stripeApi = {
|
|
46
|
+
listInvoices: async (params: { limit?: number; cursor?: string } = {}): Promise<{
|
|
47
|
+
items: Invoice[];
|
|
48
|
+
hasMore: boolean;
|
|
49
|
+
}> => {
|
|
50
|
+
const res = await httpClient.get<StripeListResponseDTO<StripeInvoiceDTO>>(
|
|
51
|
+
`${base}/invoices`,
|
|
52
|
+
{ query: { limit: params.limit ?? 25, starting_after: params.cursor } },
|
|
53
|
+
);
|
|
54
|
+
return { items: res.data.map(toInvoice), hasMore: res.has_more };
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
getInvoice: async (id: string): Promise<Invoice> => {
|
|
58
|
+
const dto = await httpClient.get<StripeInvoiceDTO>(`${base}/invoices/${id}`);
|
|
59
|
+
return toInvoice(dto);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
payInvoice: async (id: string): Promise<Invoice> => {
|
|
63
|
+
const dto = await httpClient.post<StripeInvoiceDTO>(`${base}/invoices/${id}/pay`);
|
|
64
|
+
return toInvoice(dto);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 4. ERRORS
|
|
69
|
+
export class StripeError extends HttpError {
|
|
70
|
+
override readonly name = "StripeError";
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Canonical *.routes.ts shape: a plain map of path → lazy page imports.
|
|
2
|
+
//
|
|
3
|
+
// Modules NEVER wrap themselves in a layout. Wrapping (which shell + guard)
|
|
4
|
+
// is a composition decision that lives in src/app.routes.ts.
|
|
5
|
+
|
|
6
|
+
export const billingRoutes = {
|
|
7
|
+
"/invoices": () => import("./pages/invoices-list.page"),
|
|
8
|
+
"/invoices/:id": () => import("./pages/invoice-detail.page"),
|
|
9
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Domain types for the billing module. Public via billing.public.ts.
|
|
2
|
+
|
|
3
|
+
export type InvoiceId = string;
|
|
4
|
+
|
|
5
|
+
export type InvoiceStatus = "draft" | "pending" | "paid" | "void";
|
|
6
|
+
|
|
7
|
+
export interface Invoice {
|
|
8
|
+
id: InvoiceId;
|
|
9
|
+
number: string;
|
|
10
|
+
customerEmail: string;
|
|
11
|
+
amount: number;
|
|
12
|
+
currency: string;
|
|
13
|
+
status: InvoiceStatus;
|
|
14
|
+
issuedAt: string; // ISO date
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Module-local UI brick. Reactive attribute via ctx.attr().
|
|
2
|
+
//
|
|
3
|
+
// If another module ever needs this badge, MOVE the file to shared/ui/.
|
|
4
|
+
// Don't import across modules.
|
|
5
|
+
|
|
6
|
+
import { component, css, html } from "@madojs/mado";
|
|
7
|
+
|
|
8
|
+
component(
|
|
9
|
+
"invoice-status-badge",
|
|
10
|
+
({ attr }) => {
|
|
11
|
+
const status = attr("status", "pending");
|
|
12
|
+
return () => html`
|
|
13
|
+
<span class=${() => `badge badge--${status()}`}>${status}</span>
|
|
14
|
+
`;
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
styles: css`
|
|
18
|
+
.badge {
|
|
19
|
+
display: inline-block;
|
|
20
|
+
padding: 0 var(--space-2);
|
|
21
|
+
border-radius: var(--radius-sm);
|
|
22
|
+
font-size: 0.85em;
|
|
23
|
+
line-height: 1.6;
|
|
24
|
+
}
|
|
25
|
+
.badge--paid {
|
|
26
|
+
background: color-mix(in srgb, var(--color-success) 18%, transparent);
|
|
27
|
+
color: var(--color-success);
|
|
28
|
+
}
|
|
29
|
+
.badge--pending {
|
|
30
|
+
background: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
|
|
31
|
+
color: var(--color-text-muted);
|
|
32
|
+
}
|
|
33
|
+
.badge--void {
|
|
34
|
+
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
|
35
|
+
color: var(--color-danger);
|
|
36
|
+
}
|
|
37
|
+
.badge--draft {
|
|
38
|
+
background: color-mix(in srgb, var(--color-border) 60%, transparent);
|
|
39
|
+
color: var(--color-text);
|
|
40
|
+
}
|
|
41
|
+
`,
|
|
42
|
+
},
|
|
43
|
+
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Canonical *.resource.ts shape:
|
|
2
|
+
// - Factories that return `resource(...)` / `mutation(...)`.
|
|
3
|
+
// - Resource KEYS use the API URL (or a clear URL-shaped string) so
|
|
4
|
+
// `invalidates: ["/api/billing/invoices*"]` matches naturally.
|
|
5
|
+
// - NO UI, NO services. Pure data layer over the connector.
|
|
6
|
+
|
|
7
|
+
import { mutation, resource } from "@madojs/mado";
|
|
8
|
+
|
|
9
|
+
import { stripeApi } from "../api/stripe.connector";
|
|
10
|
+
import type { InvoiceId } from "../billing.types";
|
|
11
|
+
|
|
12
|
+
const listKey = (cursor: string | undefined) =>
|
|
13
|
+
`/api/billing/invoices?cursor=${cursor ?? "first"}`;
|
|
14
|
+
const oneKey = (id: InvoiceId) => `/api/billing/invoices/${id}`;
|
|
15
|
+
|
|
16
|
+
export const useInvoices = (cursor: () => string | undefined) =>
|
|
17
|
+
resource(
|
|
18
|
+
() => listKey(cursor()),
|
|
19
|
+
() => {
|
|
20
|
+
const c = cursor();
|
|
21
|
+
return stripeApi.listInvoices(c ? { cursor: c } : {});
|
|
22
|
+
},
|
|
23
|
+
{ staleTime: 30_000 },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const useInvoice = (id: () => InvoiceId) =>
|
|
27
|
+
resource(
|
|
28
|
+
() => oneKey(id()),
|
|
29
|
+
() => stripeApi.getInvoice(id()),
|
|
30
|
+
{ staleTime: 60_000 },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const payInvoice = mutation((id: InvoiceId) => stripeApi.payInvoice(id), {
|
|
34
|
+
invalidates: ["/api/billing/invoices*"],
|
|
35
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { html, page, signal } from "@madojs/mado";
|
|
2
|
+
|
|
3
|
+
import { formatDate, formatMoney } from "../../../shared/lib/format-date";
|
|
4
|
+
import "../../../shared/ui/x-button.component";
|
|
5
|
+
import "../../../shared/ui/x-spinner.component";
|
|
6
|
+
|
|
7
|
+
import { hasPermission } from "../../auth/auth.public";
|
|
8
|
+
|
|
9
|
+
import "../components/invoice-status-badge.component";
|
|
10
|
+
import { payInvoice, useInvoice } from "../data/invoices.resource";
|
|
11
|
+
|
|
12
|
+
// 4. VIEW
|
|
13
|
+
export default page({
|
|
14
|
+
title: "Invoice",
|
|
15
|
+
// The router passes URL params and the matched query to the view.
|
|
16
|
+
view: ({ params }) => {
|
|
17
|
+
if (!params.id) return html`<p class="error">Missing invoice id.</p>`;
|
|
18
|
+
const invoiceId = params.id;
|
|
19
|
+
|
|
20
|
+
// 1. LOCAL STATE (per-render)
|
|
21
|
+
const paying = signal(false);
|
|
22
|
+
|
|
23
|
+
// 2. DATA
|
|
24
|
+
const invoice = useInvoice(() => invoiceId);
|
|
25
|
+
|
|
26
|
+
// 3. ACTIONS
|
|
27
|
+
const onPay = async () => {
|
|
28
|
+
const current = invoice.data();
|
|
29
|
+
if (!current) return;
|
|
30
|
+
paying.set(true);
|
|
31
|
+
try {
|
|
32
|
+
await payInvoice.run(current.id);
|
|
33
|
+
invoice.refresh();
|
|
34
|
+
} finally {
|
|
35
|
+
paying.set(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return html`
|
|
40
|
+
<section>
|
|
41
|
+
${() =>
|
|
42
|
+
invoice.loading()
|
|
43
|
+
? html`<x-spinner></x-spinner>`
|
|
44
|
+
: invoice.error()
|
|
45
|
+
? html`<p class="error">${() => invoice.error()?.message}</p>`
|
|
46
|
+
: (() => {
|
|
47
|
+
const i = invoice.data();
|
|
48
|
+
if (!i) return null;
|
|
49
|
+
return html`
|
|
50
|
+
<h1>Invoice ${i.number}</h1>
|
|
51
|
+
<dl>
|
|
52
|
+
<dt>Customer</dt><dd>${i.customerEmail}</dd>
|
|
53
|
+
<dt>Amount</dt><dd>${formatMoney(i.amount, i.currency)}</dd>
|
|
54
|
+
<dt>Status</dt>
|
|
55
|
+
<dd>
|
|
56
|
+
<invoice-status-badge status=${i.status}></invoice-status-badge>
|
|
57
|
+
</dd>
|
|
58
|
+
<dt>Issued</dt><dd>${formatDate(i.issuedAt)}</dd>
|
|
59
|
+
</dl>
|
|
60
|
+
${i.status === "pending" && hasPermission("billing.invoices.pay")
|
|
61
|
+
? html`<x-button ?disabled=${paying} @click=${onPay}>
|
|
62
|
+
${() => (paying() ? "Paying…" : "Pay now")}
|
|
63
|
+
</x-button>`
|
|
64
|
+
: null}
|
|
65
|
+
`;
|
|
66
|
+
})()}
|
|
67
|
+
</section>
|
|
68
|
+
`;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { each, html, page, signal, untracked } from "@madojs/mado";
|
|
2
|
+
|
|
3
|
+
import { formatDate, formatMoney } from "../../../shared/lib/format-date";
|
|
4
|
+
import "../../../shared/ui/x-spinner.component";
|
|
5
|
+
|
|
6
|
+
import "../components/invoice-status-badge.component";
|
|
7
|
+
import { useInvoices } from "../data/invoices.resource";
|
|
8
|
+
|
|
9
|
+
// 1. LOCAL STATE
|
|
10
|
+
// (per-view)
|
|
11
|
+
|
|
12
|
+
// 2. DATA
|
|
13
|
+
// (per-view)
|
|
14
|
+
|
|
15
|
+
// 3. ACTIONS
|
|
16
|
+
// (pagination handlers would go here)
|
|
17
|
+
|
|
18
|
+
// 4. VIEW
|
|
19
|
+
export default page({
|
|
20
|
+
title: "Invoices",
|
|
21
|
+
view: () => {
|
|
22
|
+
const cursor = signal<string | undefined>(undefined);
|
|
23
|
+
const invoices = untracked(() => useInvoices(cursor));
|
|
24
|
+
|
|
25
|
+
return html`
|
|
26
|
+
<section>
|
|
27
|
+
<h1>Invoices</h1>
|
|
28
|
+
${() =>
|
|
29
|
+
invoices.loading()
|
|
30
|
+
? html`<x-spinner></x-spinner>`
|
|
31
|
+
: invoices.error()
|
|
32
|
+
? html`<p class="error">${() => invoices.error()?.message}</p>`
|
|
33
|
+
: html`
|
|
34
|
+
<table class="data">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr>
|
|
37
|
+
<th>Number</th>
|
|
38
|
+
<th>Customer</th>
|
|
39
|
+
<th>Amount</th>
|
|
40
|
+
<th>Status</th>
|
|
41
|
+
<th>Issued</th>
|
|
42
|
+
<th></th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
${() =>
|
|
47
|
+
each(
|
|
48
|
+
invoices.data()?.items ?? [],
|
|
49
|
+
(i) => i.id,
|
|
50
|
+
(i) => html`
|
|
51
|
+
<tr>
|
|
52
|
+
<td>
|
|
53
|
+
<a href=${`/billing/invoices/${i.id}`}>${i.number}</a>
|
|
54
|
+
</td>
|
|
55
|
+
<td>${i.customerEmail}</td>
|
|
56
|
+
<td>${formatMoney(i.amount, i.currency)}</td>
|
|
57
|
+
<td>
|
|
58
|
+
<invoice-status-badge status=${i.status}></invoice-status-badge>
|
|
59
|
+
</td>
|
|
60
|
+
<td>${formatDate(i.issuedAt)}</td>
|
|
61
|
+
<td>
|
|
62
|
+
<a href=${`/billing/invoices/${i.id}`}>Open</a>
|
|
63
|
+
</td>
|
|
64
|
+
</tr>
|
|
65
|
+
`,
|
|
66
|
+
)}
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
`}
|
|
70
|
+
</section>
|
|
71
|
+
`;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Canonical *.page.ts shape:
|
|
2
|
+
// 1. LOCAL STATE — per-view signals owned by this page
|
|
3
|
+
// 2. DATA — resources from this module's *.resource.ts
|
|
4
|
+
// 3. ACTIONS — event handlers, mutations
|
|
5
|
+
// 4. VIEW — default export via page({...})
|
|
6
|
+
//
|
|
7
|
+
// A page should be read top-to-bottom and understood without jumping files.
|
|
8
|
+
|
|
9
|
+
import { html, page } from "@madojs/mado";
|
|
10
|
+
|
|
11
|
+
// 1. LOCAL STATE — none
|
|
12
|
+
// 2. DATA — none
|
|
13
|
+
// 3. ACTIONS — none
|
|
14
|
+
|
|
15
|
+
// 4. VIEW
|
|
16
|
+
export default page({
|
|
17
|
+
title: "Home",
|
|
18
|
+
head: () => ({
|
|
19
|
+
description: "A modular Mado application.",
|
|
20
|
+
}),
|
|
21
|
+
bake: {
|
|
22
|
+
paths: () => [{}],
|
|
23
|
+
data: () => ({}),
|
|
24
|
+
},
|
|
25
|
+
view: () => html`
|
|
26
|
+
<section>
|
|
27
|
+
<h1>Mado App</h1>
|
|
28
|
+
<p>
|
|
29
|
+
Welcome. Try <a href="/billing/invoices">billing</a> or
|
|
30
|
+
<a href="/login">sign in</a>.
|
|
31
|
+
</p>
|
|
32
|
+
</section>
|
|
33
|
+
`,
|
|
34
|
+
});
|