create-ailk 0.18.0 → 0.19.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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Tests for the New menu's three popups (#8233, spec
3
+ * docs/specs/8233-member-new-menu-the-new-spec.md D5–D10; AC-3, AC-4, AC-5).
4
+ *
5
+ * The Inbox page renders with every action offered; each case opens a popup
6
+ * through the menu, so the popup runs its real server action
7
+ * (`new-actions.ts`) against the mocked member reads and writes.
8
+ *
9
+ * Covers:
10
+ * 1. Request quote renders the request-quote magnet's CURRENT steps, read
11
+ * on each open — a step the provider adds shows on the next open — and
12
+ * posts the typed answers, a blank optional one omitted (AC-3).
13
+ * 2. Send message posts subject, body and an optional link; Schedule
14
+ * service lists only the member's meeting types, reads a picked type's
15
+ * slots over 14 days in the browser's time zone, and books as the
16
+ * caller; with no type it says so and offers Send message. A success
17
+ * closes the popup and refreshes the Inbox (AC-4).
18
+ * 3. A refusal shows a plain message, keeps every typed value and leaves
19
+ * the popup open; a taken slot re-reads the slots (AC-5).
20
+ * 4. No popup asks for a name or an email.
21
+ */
22
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
23
+
24
+ jest.mock("next-intl/server", () => {
25
+ const msgs: unknown = require("../../../../../messages/en.json");
26
+ const dig = (obj: unknown, path: string): unknown =>
27
+ path.split(".").reduce<unknown>(
28
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
29
+ obj,
30
+ );
31
+ return {
32
+ __esModule: true,
33
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
34
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
35
+ const messages = dig(msgs, ns);
36
+ return (key: string, values?: Record<string, unknown>): string => {
37
+ const value = dig(messages, key);
38
+ if (typeof value !== "string") return `${ns}.${key}`;
39
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
40
+ values && name in values ? String(values[name]) : m,
41
+ );
42
+ };
43
+ },
44
+ };
45
+ });
46
+
47
+ jest.mock("next/headers", () => ({
48
+ __esModule: true,
49
+ headers: jest.fn().mockResolvedValue(new Headers()),
50
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
51
+ }));
52
+
53
+ const mockGetSession = jest.fn();
54
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
55
+ jest.mock("../../../../../auth", () => ({
56
+ __esModule: true,
57
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
58
+ }));
59
+
60
+ const mockNotFound = jest.fn();
61
+ const mockMemberMessages = jest.fn();
62
+ const mockMemberMeetingTypes = jest.fn();
63
+ const mockMemberAvailability = jest.fn();
64
+ const mockMemberRequestForm = jest.fn();
65
+ const mockMemberRequestQuote = jest.fn();
66
+ const mockMemberSendMessage = jest.fn();
67
+ const mockMemberBook = jest.fn();
68
+ jest.mock("@working-theory/api-client", () => ({
69
+ __esModule: true,
70
+ waitlistSignups: { me: jest.fn() },
71
+ subscription: { me: (...args: unknown[]) => mockNotFound(...args) },
72
+ member: {
73
+ pieces: (...args: unknown[]) => mockNotFound(...args),
74
+ purchases: (...args: unknown[]) => mockNotFound(...args),
75
+ appointments: (...args: unknown[]) => mockNotFound(...args),
76
+ messages: (...args: unknown[]) => mockMemberMessages(...args),
77
+ meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
78
+ availability: (...args: unknown[]) => mockMemberAvailability(...args),
79
+ requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
80
+ requestQuote: (...args: unknown[]) => mockMemberRequestQuote(...args),
81
+ sendMessage: (...args: unknown[]) => mockMemberSendMessage(...args),
82
+ book: (...args: unknown[]) => mockMemberBook(...args),
83
+ },
84
+ }));
85
+
86
+ const mockPush = jest.fn();
87
+ const mockRefresh = jest.fn();
88
+ jest.mock("next/navigation", () => ({
89
+ __esModule: true,
90
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
91
+ usePathname: () => "/account",
92
+ useSearchParams: () => new URLSearchParams(),
93
+ }));
94
+
95
+ import { PhoneTabProvider } from "@working-theory/templates/account";
96
+ import { requestQuoteFlowConfig } from "@working-theory/validation";
97
+
98
+ import Page from "../page";
99
+
100
+ const SESSION = {
101
+ user: {
102
+ id: "user_1",
103
+ name: "Ada",
104
+ email: "ada@example.com",
105
+ emailVerified: true,
106
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
107
+ },
108
+ };
109
+
110
+ /** The cookie a single site forwards (`forwardedCookieInit()`). */
111
+ const INIT = { headers: { cookie: "session=abc" } };
112
+
113
+ const notFound = {
114
+ ok: false,
115
+ status: 404,
116
+ body: { ok: false, error: { code: "not_found", message: "Not found" } },
117
+ };
118
+ const refusal = (status: number, code: string) => ({
119
+ ok: false,
120
+ status,
121
+ body: { ok: false, error: { code, message: "x" } },
122
+ });
123
+ const ok = <T,>(body: T) => ({ ok: true, body });
124
+
125
+ const TYPES = [
126
+ { slug: "consult", title: "Consultation", durationMinutes: 30 },
127
+ { slug: "review", title: "Design review", durationMinutes: 60 },
128
+ ];
129
+ const SLOTS = [
130
+ { start: "2026-10-20T15:00:00.000Z", end: "2026-10-20T15:30:00.000Z" },
131
+ { start: "2026-10-21T16:00:00.000Z", end: "2026-10-21T16:30:00.000Z" },
132
+ ];
133
+ const AVAILABILITY = {
134
+ event: { title: "Consultation", durationMinutes: 30, timeZone: "UTC" },
135
+ slots: SLOTS,
136
+ };
137
+
138
+ const FORM = {
139
+ form: { title: "Request a quote", defaultLocale: "en", steps: requestQuoteFlowConfig.steps },
140
+ };
141
+ const FORM_PLUS_STEP = {
142
+ form: {
143
+ ...FORM.form,
144
+ steps: [
145
+ ...requestQuoteFlowConfig.steps,
146
+ {
147
+ key: "extra",
148
+ type: "field",
149
+ copy: { title: { en: "A little more" } },
150
+ fields: [{ name: "deadline", inputType: "text", copy: { label: { en: "Deadline" } } }],
151
+ },
152
+ ],
153
+ },
154
+ };
155
+
156
+ const RECEIPT = ok({ id: "intx_1", createdAt: "2026-10-11T10:00:00.000Z" });
157
+ const BROWSER_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
158
+ const DAY_MS = 24 * 60 * 60 * 1000;
159
+
160
+ beforeAll(() => {
161
+ if (typeof globalThis.PointerEvent === "undefined") {
162
+ // eslint-disable-next-line no-restricted-syntax -- a MouseEvent stands in for jsdom's missing PointerEvent
163
+ globalThis.PointerEvent = MouseEvent as unknown as typeof PointerEvent;
164
+ }
165
+ });
166
+
167
+ beforeEach(() => {
168
+ window.localStorage.clear();
169
+ mockPush.mockReset();
170
+ mockRefresh.mockReset();
171
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
172
+ mockNotFound.mockReset().mockResolvedValue(notFound);
173
+ mockMemberMessages.mockReset().mockResolvedValue(ok({ items: [] }));
174
+ mockMemberMeetingTypes.mockReset().mockResolvedValue(ok({ items: TYPES }));
175
+ mockMemberAvailability.mockReset().mockResolvedValue(ok(AVAILABILITY));
176
+ mockMemberRequestForm.mockReset().mockResolvedValue(ok(FORM));
177
+ mockMemberRequestQuote.mockReset().mockResolvedValue(RECEIPT);
178
+ mockMemberSendMessage.mockReset().mockResolvedValue(RECEIPT);
179
+ mockMemberBook.mockReset().mockResolvedValue(ok({ bookingId: "bk_1" }));
180
+ });
181
+
182
+ async function renderInbox(): Promise<void> {
183
+ const element = await Page({ params: Promise.resolve({ locale: "en" }) });
184
+ render(<PhoneTabProvider tab="inbox">{element}</PhoneTabProvider>);
185
+ }
186
+
187
+ /** Opens one New action's popup and returns it. */
188
+ async function openPopup(label: string): Promise<HTMLElement> {
189
+ fireEvent.pointerDown(screen.getByTestId("account-new-menu-trigger"), {
190
+ button: 0,
191
+ ctrlKey: false,
192
+ pointerType: "mouse",
193
+ });
194
+ fireEvent.click(screen.getByRole("menuitem", { name: label }));
195
+ return screen.findByTestId("account-new-popup");
196
+ }
197
+
198
+ async function closePopup(popup: HTMLElement): Promise<void> {
199
+ fireEvent.click(within(popup).getByRole("button", { name: "Close" }));
200
+ await waitFor(() => expect(screen.queryByTestId("account-new-popup")).not.toBeInTheDocument());
201
+ }
202
+
203
+ function type(control: HTMLElement, value: string): void {
204
+ fireEvent.change(control, { target: { value } });
205
+ }
206
+
207
+ function expectNoIdentityFields(popup: HTMLElement): void {
208
+ expect(within(popup).queryAllByLabelText(/name|e-?mail/i)).toEqual([]);
209
+ expect(popup.querySelector('input[type="email"]')).toBeNull();
210
+ }
211
+
212
+ // ── Request quote ────────────────────────────────────────────────────────────
213
+
214
+ describe("Request quote — the magnet's current steps, read on each open (AC-3)", () => {
215
+ it("renders the four default fields, need required, and no identity field", async () => {
216
+ await renderInbox();
217
+ const popup = await openPopup("Request quote");
218
+ await within(popup).findByLabelText("What do you need?");
219
+ expect(within(popup).getAllByRole("textbox")).toHaveLength(4);
220
+ for (const label of ["When do you need it?", "Budget range", "Link"]) {
221
+ expect(within(popup).getByLabelText(label)).not.toBeRequired();
222
+ }
223
+ expect(within(popup).getByLabelText("What do you need?")).toBeRequired();
224
+ expectNoIdentityFields(popup);
225
+ });
226
+
227
+ it("shows a step the provider added on the next open, with no page re-render", async () => {
228
+ await renderInbox();
229
+ let popup = await openPopup("Request quote");
230
+ await within(popup).findByLabelText("What do you need?");
231
+ expect(within(popup).queryByLabelText("Deadline")).not.toBeInTheDocument();
232
+ await closePopup(popup);
233
+
234
+ mockMemberRequestForm.mockResolvedValue(ok(FORM_PLUS_STEP));
235
+ popup = await openPopup("Request quote");
236
+ expect(await within(popup).findByLabelText("Deadline")).toBeInTheDocument();
237
+ expect(within(popup).getAllByRole("textbox")).toHaveLength(5);
238
+ });
239
+
240
+ it("posts the typed answers through member.requestQuote, a blank optional answer omitted", async () => {
241
+ await renderInbox();
242
+ const popup = await openPopup("Request quote");
243
+ type(await within(popup).findByLabelText("What do you need?"), "A logo");
244
+ fireEvent.click(within(popup).getByTestId("new-quote-submit"));
245
+ await waitFor(() => expect(mockMemberRequestQuote).toHaveBeenCalledTimes(1));
246
+ expect(mockMemberRequestQuote).toHaveBeenCalledWith({ answers: { need: "A logo" } }, INIT);
247
+ await waitFor(() => expect(screen.queryByTestId("account-new-popup")).not.toBeInTheDocument());
248
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ it("with the form switched off since the menu loaded, says Requests are off", async () => {
252
+ await renderInbox();
253
+ mockMemberRequestForm.mockResolvedValue(ok({ form: null }));
254
+ const popup = await openPopup("Request quote");
255
+ expect(await within(popup).findByText("Requests are switched off right now.")).toBeInTheDocument();
256
+ expect(within(popup).queryAllByRole("textbox")).toEqual([]);
257
+ });
258
+
259
+ it("a failed read shows the load error, and Try again reads once more", async () => {
260
+ await renderInbox();
261
+ mockMemberRequestForm.mockResolvedValue(refusal(500, "failed"));
262
+ const popup = await openPopup("Request quote");
263
+ expect(await within(popup).findByText("This could not load.")).toBeInTheDocument();
264
+ mockMemberRequestForm.mockResolvedValue(ok(FORM));
265
+ fireEvent.click(within(popup).getByRole("button", { name: "Try again" }));
266
+ expect(await within(popup).findByLabelText("What do you need?")).toBeInTheDocument();
267
+ });
268
+ });
269
+
270
+ // ── Send message ─────────────────────────────────────────────────────────────
271
+
272
+ describe("Send message — subject, body and an optional https link (AC-4)", () => {
273
+ async function fill(popup: HTMLElement, link: string): Promise<void> {
274
+ type(within(popup).getByLabelText("Subject"), "Hello");
275
+ type(within(popup).getByLabelText("Message"), "Line 1\nLine 2");
276
+ type(within(popup).getByLabelText("Link"), link);
277
+ fireEvent.click(within(popup).getByTestId("new-message-submit"));
278
+ }
279
+
280
+ it("posts the three values as typed, closes and refreshes the Inbox", async () => {
281
+ await renderInbox();
282
+ const popup = await openPopup("Send message");
283
+ expectNoIdentityFields(popup);
284
+ await fill(popup, "https://example.com/brief");
285
+ await waitFor(() => expect(mockMemberSendMessage).toHaveBeenCalledTimes(1));
286
+ expect(mockMemberSendMessage).toHaveBeenCalledWith(
287
+ { subject: "Hello", body: "Line 1\nLine 2", link: "https://example.com/brief" },
288
+ INIT,
289
+ );
290
+ await waitFor(() => expect(screen.queryByTestId("account-new-popup")).not.toBeInTheDocument());
291
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
292
+ });
293
+
294
+ it("with the link left blank, posts no link key", async () => {
295
+ await renderInbox();
296
+ const popup = await openPopup("Send message");
297
+ await fill(popup, " ");
298
+ await waitFor(() => expect(mockMemberSendMessage).toHaveBeenCalledTimes(1));
299
+ expect(mockMemberSendMessage.mock.calls[0]![0]).toEqual({
300
+ subject: "Hello",
301
+ body: "Line 1\nLine 2",
302
+ });
303
+ });
304
+ });
305
+
306
+ // ── Schedule service ─────────────────────────────────────────────────────────
307
+
308
+ describe("Schedule service — the member's meeting types, booked as the caller (AC-4)", () => {
309
+ it("lists exactly the types member.meetingTypes returns", async () => {
310
+ await renderInbox();
311
+ const popup = await openPopup("Schedule service");
312
+ await within(popup).findByTestId("new-schedule-type-consult");
313
+ expect(within(popup).getAllByRole("radio").map((r) => r.getAttribute("value"))).toEqual([
314
+ "consult",
315
+ "review",
316
+ ]);
317
+ expect(within(popup).getByText("Consultation")).toBeInTheDocument();
318
+ expect(within(popup).getByText("Design review")).toBeInTheDocument();
319
+ expectNoIdentityFields(popup);
320
+ });
321
+
322
+ it("picking a type reads its slots once: that slug, 14 days, the browser's time zone", async () => {
323
+ await renderInbox();
324
+ const popup = await openPopup("Schedule service");
325
+ fireEvent.click(await within(popup).findByTestId("new-schedule-type-consult"));
326
+ await within(popup).findByTestId("new-schedule-slot-0");
327
+ expect(mockMemberAvailability).toHaveBeenCalledTimes(1);
328
+ const [query, init] = mockMemberAvailability.mock.calls[0]! as [
329
+ { slug: string; from: string; to: string; timeZone: string },
330
+ unknown,
331
+ ];
332
+ expect(init).toEqual(INIT);
333
+ expect(query.slug).toBe("consult");
334
+ expect(query.timeZone).toBe(BROWSER_TIME_ZONE);
335
+ expect(Date.parse(query.to) - Date.parse(query.from)).toBe(14 * DAY_MS);
336
+ });
337
+
338
+ it("books a listed slot with slug, start, time zone and notes — no name or email", async () => {
339
+ await renderInbox();
340
+ const popup = await openPopup("Schedule service");
341
+ fireEvent.click(await within(popup).findByTestId("new-schedule-type-consult"));
342
+ fireEvent.click(await within(popup).findByTestId("new-schedule-slot-1"));
343
+ type(within(popup).getByLabelText(/Notes/), "Bring the brief");
344
+ fireEvent.click(within(popup).getByTestId("new-schedule-submit"));
345
+ await waitFor(() => expect(mockMemberBook).toHaveBeenCalledTimes(1));
346
+ expect(mockMemberBook).toHaveBeenCalledWith(
347
+ {
348
+ slug: "consult",
349
+ start: SLOTS[1]!.start,
350
+ timeZone: BROWSER_TIME_ZONE,
351
+ notes: "Bring the brief",
352
+ },
353
+ INIT,
354
+ );
355
+ await waitFor(() => expect(screen.queryByTestId("account-new-popup")).not.toBeInTheDocument());
356
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
357
+ });
358
+
359
+ it("with no type offered says so, and its Send a message switches to Send message", async () => {
360
+ mockMemberMeetingTypes.mockResolvedValue(ok({ items: [] }));
361
+ await renderInbox();
362
+ const popup = await openPopup("Schedule service");
363
+ expect(
364
+ await within(popup).findByText("No times are offered right now. You can send a message instead."),
365
+ ).toBeInTheDocument();
366
+ fireEvent.click(within(popup).getByRole("button", { name: "Send a message" }));
367
+ const next = await screen.findByTestId("account-new-popup");
368
+ await waitFor(() =>
369
+ expect(within(next).getByRole("heading", { name: "Send message" })).toBeInTheDocument(),
370
+ );
371
+ expect(within(next).getByLabelText("Subject")).toBeInTheDocument();
372
+ });
373
+ });
374
+
375
+ // ── Refusals ─────────────────────────────────────────────────────────────────
376
+
377
+ describe("a refusal shows a plain message and keeps every typed value (AC-5)", () => {
378
+ it("Send message: link_not_https keeps subject, body and link, and stays open", async () => {
379
+ mockMemberSendMessage.mockResolvedValue(refusal(400, "link_not_https"));
380
+ await renderInbox();
381
+ const popup = await openPopup("Send message");
382
+ type(within(popup).getByLabelText("Subject"), "Hello");
383
+ type(within(popup).getByLabelText("Message"), "Line 1\nLine 2");
384
+ type(within(popup).getByLabelText("Link"), "http://example.com");
385
+ fireEvent.click(within(popup).getByTestId("new-message-submit"));
386
+ expect(await within(popup).findByText("A link must start with https://.")).toBeInTheDocument();
387
+ expect(mockMemberSendMessage.mock.calls[0]![0]).toMatchObject({ link: "http://example.com" });
388
+ expect(within(popup).getByLabelText("Subject")).toHaveValue("Hello");
389
+ expect(within(popup).getByLabelText("Message")).toHaveValue("Line 1\nLine 2");
390
+ expect(within(popup).getByLabelText("Link")).toHaveValue("http://example.com");
391
+ expect(screen.getByTestId("account-new-popup")).toBeInTheDocument();
392
+ expect(mockRefresh).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("Request quote: a bare-domain link is sent as typed, refused, and all four answers kept", async () => {
396
+ mockMemberRequestQuote.mockResolvedValue(refusal(400, "link_not_https"));
397
+ await renderInbox();
398
+ const popup = await openPopup("Request quote");
399
+ const values: Record<string, string> = {
400
+ "What do you need?": "A logo",
401
+ "When do you need it?": "Next month",
402
+ "Budget range": "$1k",
403
+ Link: "example.com",
404
+ };
405
+ for (const [label, value] of Object.entries(values)) {
406
+ type(await within(popup).findByLabelText(label), value);
407
+ }
408
+ fireEvent.click(within(popup).getByTestId("new-quote-submit"));
409
+ expect(await within(popup).findByText("A link must start with https://.")).toBeInTheDocument();
410
+ expect(mockMemberRequestQuote).toHaveBeenCalledWith(
411
+ { answers: { need: "A logo", timing: "Next month", budget: "$1k", link: "example.com" } },
412
+ INIT,
413
+ );
414
+ for (const [label, value] of Object.entries(values)) {
415
+ expect(within(popup).getByLabelText(label)).toHaveValue(value);
416
+ }
417
+ expect(screen.getByTestId("account-new-popup")).toBeInTheDocument();
418
+ expect(mockRefresh).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it("Schedule service: slot_unavailable re-reads the slots and keeps the type and the notes", async () => {
422
+ mockMemberBook.mockResolvedValue(refusal(409, "slot_unavailable"));
423
+ await renderInbox();
424
+ const popup = await openPopup("Schedule service");
425
+ fireEvent.click(await within(popup).findByTestId("new-schedule-type-consult"));
426
+ fireEvent.click(await within(popup).findByTestId("new-schedule-slot-0"));
427
+ type(within(popup).getByLabelText(/Notes/), "Bring the brief");
428
+ fireEvent.click(within(popup).getByTestId("new-schedule-submit"));
429
+ expect(
430
+ await within(popup).findByText("That time was just taken. Pick another."),
431
+ ).toBeInTheDocument();
432
+ await waitFor(() => expect(mockMemberAvailability).toHaveBeenCalledTimes(2));
433
+ expect(within(popup).getByTestId("new-schedule-type-consult")).toHaveAttribute(
434
+ "aria-checked",
435
+ "true",
436
+ );
437
+ expect(within(popup).getByLabelText(/Notes/)).toHaveValue("Bring the brief");
438
+ expect(mockRefresh).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it("an unlisted refusal code shows the generic message", async () => {
442
+ mockMemberSendMessage.mockResolvedValue(refusal(503, "tenant_unresolved"));
443
+ await renderInbox();
444
+ const popup = await openPopup("Send message");
445
+ type(within(popup).getByLabelText("Subject"), "Hello");
446
+ type(within(popup).getByLabelText("Message"), "Hi");
447
+ fireEvent.click(within(popup).getByTestId("new-message-submit"));
448
+ expect(await within(popup).findByText("This could not be sent. Try again.")).toBeInTheDocument();
449
+ expect(within(popup).getByLabelText("Subject")).toHaveValue("Hello");
450
+ });
451
+ });