@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.
Files changed (219) hide show
  1. package/AGENTS.md +24 -26
  2. package/CHANGELOG.md +95 -0
  3. package/README.md +22 -47
  4. package/TODO.md +52 -48
  5. package/dist/src/component.d.ts +2 -1
  6. package/dist/src/component.js +5 -2
  7. package/dist/src/component.js.map +1 -1
  8. package/dist/src/each.d.ts +1 -1
  9. package/dist/src/each.js +1 -1
  10. package/dist/src/each.js.map +1 -1
  11. package/dist/src/html/bindings.js +3 -3
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/index.d.ts +11 -6
  14. package/dist/src/index.js +5 -3
  15. package/dist/src/index.js.map +1 -1
  16. package/dist/src/lazy.d.ts +1 -1
  17. package/dist/src/lazy.js +1 -1
  18. package/dist/src/lazy.js.map +1 -1
  19. package/dist/src/page.d.ts +17 -21
  20. package/dist/src/page.js +7 -12
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/router/manifest.d.ts +1 -1
  23. package/dist/src/router/manifest.js +21 -13
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/match.d.ts +2 -2
  26. package/dist/src/router/match.js +3 -3
  27. package/dist/src/router/match.js.map +1 -1
  28. package/dist/src/router/navigation.js +1 -1
  29. package/dist/src/router/navigation.js.map +1 -1
  30. package/dist/src/vite/index.d.ts +10 -0
  31. package/dist/src/vite/index.js +33 -0
  32. package/dist/src/vite/index.js.map +1 -0
  33. package/docs/en/00-the-mado-way.md +25 -12
  34. package/docs/en/01-routing.md +90 -142
  35. package/docs/en/02-project-layout.md +59 -53
  36. package/docs/en/03-static-bake.md +5 -6
  37. package/docs/en/05-why-mado.md +6 -6
  38. package/docs/en/06-for-backenders.md +18 -22
  39. package/docs/en/08-llm-zero-history-test.md +9 -14
  40. package/docs/en/09-shadow-vs-light-dom.md +28 -36
  41. package/docs/en/10-app-architecture.md +158 -96
  42. package/docs/en/11-layouts.md +22 -24
  43. package/docs/en/12-auth-and-api.md +89 -182
  44. package/docs/en/13-deployment.md +18 -22
  45. package/docs/en/14-testing.md +4 -4
  46. package/docs/en/16-bake-cookbook.md +11 -12
  47. package/docs/en/18-api-freeze-map.md +6 -4
  48. package/docs/en/20-v1-stability.md +1 -1
  49. package/docs/fr/00-the-mado-way.md +55 -90
  50. package/docs/fr/01-routing.md +70 -152
  51. package/docs/fr/02-project-layout.md +61 -42
  52. package/docs/fr/03-static-bake.md +1 -1
  53. package/docs/fr/05-why-mado.md +6 -6
  54. package/docs/fr/06-for-backenders.md +7 -7
  55. package/docs/fr/08-llm-zero-history-test.md +21 -48
  56. package/docs/fr/09-shadow-vs-light-dom.md +43 -162
  57. package/docs/fr/10-app-architecture.md +110 -33
  58. package/docs/fr/11-layouts.md +24 -12
  59. package/docs/fr/12-auth-and-api.md +63 -22
  60. package/docs/fr/13-deployment.md +7 -10
  61. package/docs/fr/14-testing.md +1 -1
  62. package/docs/fr/16-bake-cookbook.md +2 -2
  63. package/docs/fr/18-api-freeze-map.md +1 -1
  64. package/docs/fr/20-v1-stability.md +1 -1
  65. package/docs/recipes/nginx/README.md +13 -0
  66. package/docs/ru/00-the-mado-way.md +53 -75
  67. package/docs/ru/01-routing.md +68 -143
  68. package/docs/ru/02-project-layout.md +61 -41
  69. package/docs/ru/03-static-bake.md +2 -2
  70. package/docs/ru/05-why-mado.md +6 -6
  71. package/docs/ru/06-for-backenders.md +7 -7
  72. package/docs/ru/08-llm-zero-history-test.md +9 -14
  73. package/docs/ru/09-shadow-vs-light-dom.md +43 -178
  74. package/docs/ru/10-app-architecture.md +115 -63
  75. package/docs/ru/11-layouts.md +24 -24
  76. package/docs/ru/12-auth-and-api.md +57 -35
  77. package/docs/ru/13-deployment.md +7 -11
  78. package/docs/ru/14-testing.md +1 -1
  79. package/docs/ru/16-bake-cookbook.md +12 -6
  80. package/docs/ru/18-api-freeze-map.md +5 -3
  81. package/docs/ru/20-v1-stability.md +1 -1
  82. package/docs/uk/00-the-mado-way.md +70 -44
  83. package/docs/uk/01-routing.md +41 -47
  84. package/docs/uk/02-project-layout.md +68 -41
  85. package/docs/uk/03-static-bake.md +1 -2
  86. package/docs/uk/06-for-backenders.md +3 -3
  87. package/docs/uk/08-llm-zero-history-test.md +22 -24
  88. package/docs/uk/09-shadow-vs-light-dom.md +37 -86
  89. package/docs/uk/10-app-architecture.md +72 -31
  90. package/docs/uk/11-layouts.md +25 -12
  91. package/docs/uk/12-auth-and-api.md +58 -22
  92. package/docs/uk/13-deployment.md +4 -3
  93. package/docs/uk/14-testing.md +1 -1
  94. package/docs/uk/18-api-freeze-map.md +1 -1
  95. package/docs/uk/20-v1-stability.md +1 -1
  96. package/llms.txt +14 -15
  97. package/package.json +18 -11
  98. package/scripts/_config.mjs +15 -161
  99. package/scripts/bake.mjs +74 -63
  100. package/scripts/cli/generate.mjs +348 -0
  101. package/scripts/cli/help.mjs +27 -0
  102. package/scripts/cli/index.mjs +79 -0
  103. package/scripts/cli/init.mjs +153 -0
  104. package/scripts/cli/release.mjs +152 -0
  105. package/scripts/cli/run.mjs +96 -0
  106. package/scripts/cli.mjs +2 -621
  107. package/scripts/package-smoke.mjs +4 -1
  108. package/scripts/preview.mjs +13 -37
  109. package/scripts/size-budget.mjs +5 -2
  110. package/scripts/vite.default.mjs +11 -0
  111. package/starters/default/.editorconfig +12 -0
  112. package/starters/default/README.md +74 -0
  113. package/starters/default/eslint.config.mjs +256 -0
  114. package/starters/default/index.html +13 -0
  115. package/starters/default/package.json +30 -0
  116. package/starters/default/public/favicon.svg +4 -0
  117. package/starters/default/src/app.routes.ts +39 -0
  118. package/starters/default/src/layouts/app-shell.layout.ts +35 -0
  119. package/starters/default/src/layouts/auth-shell.layout.ts +17 -0
  120. package/starters/default/src/main.ts +16 -0
  121. package/starters/default/src/modules/auth/_contracts/auth-api.types.ts +17 -0
  122. package/starters/default/src/modules/auth/auth.connector.ts +45 -0
  123. package/starters/default/src/modules/auth/auth.guard.ts +22 -0
  124. package/starters/default/src/modules/auth/auth.public.ts +9 -0
  125. package/starters/default/src/modules/auth/auth.routes.ts +8 -0
  126. package/starters/default/src/modules/auth/auth.service.ts +71 -0
  127. package/starters/default/src/modules/auth/auth.types.ts +15 -0
  128. package/starters/default/src/modules/auth/login.page.ts +62 -0
  129. package/starters/default/src/modules/billing/_contracts/stripe.types.ts +17 -0
  130. package/starters/default/src/modules/billing/api/stripe.connector.ts +71 -0
  131. package/starters/default/src/modules/billing/billing.public.ts +5 -0
  132. package/starters/default/src/modules/billing/billing.routes.ts +9 -0
  133. package/starters/default/src/modules/billing/billing.types.ts +15 -0
  134. package/starters/default/src/modules/billing/components/invoice-status-badge.component.ts +43 -0
  135. package/starters/default/src/modules/billing/data/invoices.resource.ts +35 -0
  136. package/starters/default/src/modules/billing/pages/invoice-detail.page.ts +70 -0
  137. package/starters/default/src/modules/billing/pages/invoices-list.page.ts +73 -0
  138. package/starters/default/src/modules/home/home.page.ts +34 -0
  139. package/starters/default/src/modules/home/not-found.page.ts +11 -0
  140. package/starters/default/src/shared/http/http-client.ts +86 -0
  141. package/starters/default/src/shared/http/http-error.ts +37 -0
  142. package/starters/default/src/shared/http/interceptors.ts +59 -0
  143. package/starters/default/src/shared/lib/format-date.ts +19 -0
  144. package/starters/default/src/shared/styles/content.css +70 -0
  145. package/starters/default/src/shared/styles/reset.css +32 -0
  146. package/starters/default/src/shared/styles/shell.css +57 -0
  147. package/starters/default/src/shared/styles/tokens.css +44 -0
  148. package/starters/default/src/shared/ui/x-button.component.ts +49 -0
  149. package/starters/default/src/shared/ui/x-spinner.component.ts +22 -0
  150. package/starters/default/src/styles.d.ts +1 -0
  151. package/starters/default/src/vite-env.d.ts +1 -0
  152. package/starters/default/tsconfig.json +24 -0
  153. package/starters/default/vite.config.ts +9 -0
  154. package/MADO_V1_PLAN.md +0 -179
  155. package/ROADMAP.md +0 -178
  156. package/dist/src/html.d.ts +0 -18
  157. package/dist/src/html.js +0 -17
  158. package/dist/src/html.js.map +0 -1
  159. package/dist/src/router.d.ts +0 -13
  160. package/dist/src/router.js +0 -13
  161. package/dist/src/router.js.map +0 -1
  162. package/scripts/bundle.mjs +0 -212
  163. package/scripts/llm-zero-history-smoke.mjs +0 -93
  164. package/scripts/new.mjs +0 -80
  165. package/scripts/showcase-regression.mjs +0 -392
  166. package/server/serve.mjs +0 -455
  167. package/starters/admin/README.md +0 -63
  168. package/starters/admin/index.html +0 -28
  169. package/starters/admin/mado.config.json +0 -22
  170. package/starters/admin/package.json +0 -24
  171. package/starters/admin/public/favicon.svg +0 -4
  172. package/starters/admin/src/components/x-button.ts +0 -82
  173. package/starters/admin/src/components/x-input.ts +0 -105
  174. package/starters/admin/src/layouts/app.ts +0 -101
  175. package/starters/admin/src/layouts/auth.ts +0 -41
  176. package/starters/admin/src/lib/api.ts +0 -184
  177. package/starters/admin/src/lib/auth.ts +0 -83
  178. package/starters/admin/src/main.ts +0 -15
  179. package/starters/admin/src/pages/admin/dashboard.ts +0 -48
  180. package/starters/admin/src/pages/admin/order-detail.ts +0 -80
  181. package/starters/admin/src/pages/admin/orders.ts +0 -117
  182. package/starters/admin/src/pages/home.ts +0 -34
  183. package/starters/admin/src/pages/login.ts +0 -70
  184. package/starters/admin/src/pages/not-found.ts +0 -12
  185. package/starters/admin/src/routes.ts +0 -40
  186. package/starters/admin/src/styles/global.ts +0 -86
  187. package/starters/admin/tsconfig.json +0 -15
  188. package/starters/crud/README.md +0 -33
  189. package/starters/crud/index.html +0 -28
  190. package/starters/crud/mado.config.json +0 -20
  191. package/starters/crud/package.json +0 -24
  192. package/starters/crud/src/components/app-shell.ts +0 -56
  193. package/starters/crud/src/components/ticket-detail.ts +0 -33
  194. package/starters/crud/src/components/ticket-form.ts +0 -69
  195. package/starters/crud/src/components/ticket-list.ts +0 -66
  196. package/starters/crud/src/lib/api.ts +0 -76
  197. package/starters/crud/src/main.ts +0 -9
  198. package/starters/crud/src/pages/home.ts +0 -34
  199. package/starters/crud/src/pages/not-found.ts +0 -12
  200. package/starters/crud/src/pages/ticket-detail.ts +0 -7
  201. package/starters/crud/src/pages/ticket-new.ts +0 -7
  202. package/starters/crud/src/pages/tickets.ts +0 -7
  203. package/starters/crud/src/routes.ts +0 -11
  204. package/starters/crud/src/styles/global.ts +0 -155
  205. package/starters/crud/tsconfig.json +0 -15
  206. package/starters/minimal/README.md +0 -21
  207. package/starters/minimal/index.html +0 -28
  208. package/starters/minimal/mado.config.json +0 -20
  209. package/starters/minimal/package.json +0 -24
  210. package/starters/minimal/src/components/app-counter.ts +0 -31
  211. package/starters/minimal/src/main.ts +0 -9
  212. package/starters/minimal/src/pages/home.ts +0 -35
  213. package/starters/minimal/src/pages/not-found.ts +0 -14
  214. package/starters/minimal/src/routes.ts +0 -8
  215. package/starters/minimal/src/styles/global.ts +0 -60
  216. package/starters/minimal/tsconfig.json +0 -15
  217. package/templates/page-detail.ts +0 -63
  218. package/templates/page-form.ts +0 -94
  219. 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,5 @@
1
+ // Public surface of the billing module.
2
+ // Anything not re-exported here is internal and may change without notice.
3
+
4
+ export type { Invoice, InvoiceId, InvoiceStatus } from "./billing.types";
5
+ export { useInvoice, useInvoices } from "./data/invoices.resource";
@@ -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
+ });
@@ -0,0 +1,11 @@
1
+ import { html, page } from "@madojs/mado";
2
+
3
+ export default page({
4
+ title: "Not Found",
5
+ view: () => html`
6
+ <section>
7
+ <h1>404</h1>
8
+ <p>This route does not exist. <a href="/">Go home</a>.</p>
9
+ </section>
10
+ `,
11
+ });