create-ailk 0.11.2 → 0.11.4

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 (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +148 -21
  73. package/templates/database/CHANGELOG.md +14 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -1,20 +1,30 @@
1
1
  /**
2
- * Tests for /[locale]/account — the respondent's own account page (#4984).
2
+ * Tests for /[locale]/account — the base account Inbox (#7430, spec 7423
3
+ * U4 · D6a, D9, D12; AC-7 as amended 2026-10-01).
3
4
  *
4
5
  * Covers:
5
- * 1. Results + Purchases both render from fixtures; a 404'd experiment key
6
- * is skipped (no throw, no row); a non-skippable error rethrows.
7
- * 2. Empty states: no configured experiment keys + `[]` checkouts.
8
- * 3. Profile: name, email, verified / unverified badge copy.
9
- * 4. SidebarNav carries the three in-page anchors (Profile / Results / Purchases).
10
- * 5. Sign-out: `signOut()` then `router.push("/")`.
6
+ * 1. The D6a tabs Recents · Suggested · Scheduled · All, each with its
7
+ * count (zeros included), Recents selected.
8
+ * 2. `result` and `purchase` rows sit under Recents and All, newest first,
9
+ * each linking to its item page.
10
+ * 3. `email-unverified` and `payment-due` are For-you items (the phone
11
+ * For you tab), never Inbox rows.
12
+ * 4. A member with no name gets an "Add your name" For-you item (needs-you,
13
+ * an Open action) linking to User settings › General.
14
+ * 5. A failed read renders an error row with Retry — never an empty Inbox.
15
+ * 6. #7469 (DESIGN.md §2b as amended 2026-10-01) — the route is the
16
+ * phone's two main tabs. The Inbox tab is the filter tabs and the list
17
+ * only: no For-you stack, no bottom sheet, no large title. The For you
18
+ * tab lists every For-you item in one inset card, and ✕ dismisses one.
19
+ * At `md`+ only the Inbox panel shows (the aside holds For you).
11
20
  */
12
- import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
21
+ import { fireEvent, render, screen, within } from "@testing-library/react";
13
22
 
14
23
  // The global setup mock resolves only top-level namespace keys against
15
- // en.json; this page reads nested keys (`nav.profile`, `profile.verified`, …)
16
- // via `t("nav.profile")` — a call shape real next-intl supports — so resolve
17
- // dotted namespaces AND dotted keys here against the same catalog.
24
+ // en.json; the area reads nested keys (`inbox.tabs.recents`, …) — a call
25
+ // shape real next-intl supports — so resolve dotted namespaces AND dotted
26
+ // keys here against the same catalog. `{name}` placeholders are filled from
27
+ // `values` the way next-intl does.
18
28
  jest.mock("next-intl/server", () => {
19
29
  const msgs: unknown = require("../../../../../messages/en.json");
20
30
  const dig = (obj: unknown, path: string): unknown =>
@@ -27,9 +37,12 @@ jest.mock("next-intl/server", () => {
27
37
  getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
28
38
  const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
29
39
  const messages = dig(msgs, ns);
30
- return (key: string): string => {
40
+ return (key: string, values?: Record<string, unknown>): string => {
31
41
  const value = dig(messages, key);
32
- return typeof value === "string" ? value : `${ns}.${key}`;
42
+ if (typeof value !== "string") return `${ns}.${key}`;
43
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
44
+ values && name in values ? String(values[name]) : m,
45
+ );
33
46
  };
34
47
  },
35
48
  };
@@ -42,7 +55,7 @@ jest.mock("next/headers", () => ({
42
55
  }));
43
56
 
44
57
  const mockGetSession = jest.fn();
45
- // 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 profile fields can mock without standing up Prisma.
58
+ // 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.
46
59
  jest.mock("../../../../../auth", () => ({
47
60
  __esModule: true,
48
61
  auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
@@ -51,44 +64,33 @@ jest.mock("../../../../../auth", () => ({
51
64
  const mockSignupMe = jest.fn();
52
65
  const mockCheckoutsMe = jest.fn();
53
66
  const mockSubscriptionMe = jest.fn();
54
- const mockSubscriptionPortalSession = jest.fn();
55
67
  jest.mock("@working-theory/api-client", () => ({
56
68
  __esModule: true,
57
69
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
58
70
  flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
59
- subscription: {
60
- me: (...args: unknown[]) => mockSubscriptionMe(...args),
61
- portalSession: (...args: unknown[]) => mockSubscriptionPortalSession(...args),
62
- },
63
- }));
64
-
65
- let mockExperimentKeys: string[] = [];
66
- // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; account-config.ts is the OSS template's (empty) experiment registry — a pure constant with no external boundary to mock instead, overridden here to exercise the populated path.
67
- jest.mock("../../../../../lib/account-config", () => ({
68
- __esModule: true,
69
- get accountExperimentKeys() {
70
- return mockExperimentKeys;
71
- },
71
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
72
72
  }));
73
73
 
74
- const mockSignOut = jest.fn();
75
- jest.mock("better-auth/react", () => ({
76
- __esModule: true,
77
- createAuthClient: () => ({ signOut: (...args: unknown[]) => mockSignOut(...args) }),
78
- }));
79
-
80
- const mockPush = jest.fn();
81
74
  const mockRefresh = jest.fn();
82
75
  jest.mock("next/navigation", () => ({
83
76
  __esModule: true,
84
- useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
85
- usePathname: () => "/en/account",
77
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
78
+ usePathname: () => "/account",
86
79
  }));
87
80
 
81
+ import { PhoneTabProvider } from "@working-theory/templates/account";
82
+
83
+ import { accountExperimentKeys } from "../../../../../lib/account-config";
88
84
  import Page from "../page";
89
85
 
90
86
  const SESSION = {
91
- user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
87
+ user: {
88
+ id: "user_1",
89
+ name: "Ada",
90
+ email: "ada@example.com",
91
+ emailVerified: true,
92
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
93
+ },
92
94
  };
93
95
 
94
96
  const SIGNUP = {
@@ -101,244 +103,275 @@ const SIGNUP = {
101
103
  answers: { q3: null },
102
104
  };
103
105
 
104
- const NOT_FOUND = {
105
- ok: false,
106
- status: 404,
107
- body: { error: { code: "signup_not_found", message: "Not found" } },
106
+ const CHECKOUT = {
107
+ id: "fc_done",
108
+ flowSlug: "founder-flow",
109
+ experimentKey: "ailk-founder-diagnostic",
110
+ offerKey: "pro",
111
+ status: "completed",
112
+ amountMinor: 4900,
113
+ currency: "usd",
114
+ period: "monthly",
115
+ completedAt: "2026-09-04T10:00:00.000Z",
116
+ createdAt: "2026-09-04T09:58:00.000Z",
108
117
  };
109
118
 
110
- const CHECKOUTS = [
111
- {
112
- id: "fc_done",
113
- flowSlug: "founder-flow",
114
- experimentKey: "ailk-founder-diagnostic",
115
- offerKey: "pro",
116
- status: "completed",
117
- amountMinor: 4900,
118
- currency: "usd",
119
- period: "monthly",
120
- completedAt: "2026-09-04T10:00:00.000Z",
121
- createdAt: "2026-09-04T09:58:00.000Z",
122
- },
123
- {
124
- id: "fc_open",
125
- flowSlug: "founder-flow",
126
- experimentKey: "ailk-founder-diagnostic",
127
- offerKey: "starter",
128
- status: "created",
129
- amountMinor: 1900,
130
- currency: "usd",
131
- period: "monthly",
132
- completedAt: null,
133
- createdAt: "2026-09-05T08:00:00.000Z",
134
- },
135
- ];
119
+ const NO_SUBSCRIPTION = {
120
+ ok: true,
121
+ body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
122
+ };
136
123
 
137
- async function renderPage(): Promise<void> {
138
- const element = await Page({ params: Promise.resolve({ locale: "en" }) });
139
- render(element);
124
+ /** Renders the page on a phone tab — the frame's PhoneTabProvider. */
125
+ async function renderPage(locale = "en", tab = "inbox"): Promise<void> {
126
+ const element = await Page({ params: Promise.resolve({ locale }) });
127
+ render(<PhoneTabProvider tab={tab}>{element}</PhoneTabProvider>);
140
128
  }
141
129
 
142
- beforeEach(() => {
143
- mockGetSession.mockReset().mockResolvedValue(SESSION);
144
- mockSignupMe.mockReset().mockImplementation(async (key: string) =>
145
- key === SIGNUP.experimentKey ? { ok: true, body: SIGNUP } : NOT_FOUND,
146
- );
147
- mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: CHECKOUTS });
148
- // Default: a healthy subscriber (no banner) — commerce-disabled builds
149
- // read identically (a 404), covered explicitly below (D8).
150
- mockSubscriptionMe.mockReset().mockResolvedValue({
130
+ const UNVERIFIED_AND_PAST_DUE = (): void => {
131
+ mockGetSession.mockResolvedValue({
132
+ user: { ...SESSION.user, emailVerified: false },
133
+ });
134
+ mockSubscriptionMe.mockResolvedValue({
151
135
  ok: true,
152
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
136
+ body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
153
137
  });
154
- mockSignOut.mockReset().mockResolvedValue(undefined);
155
- mockPush.mockReset();
138
+ };
139
+
140
+ beforeEach(() => {
141
+ window.localStorage.clear();
142
+ accountExperimentKeys.length = 0;
143
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
144
+ mockSignupMe.mockReset().mockResolvedValue({ ok: true, body: SIGNUP });
145
+ mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
146
+ mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
156
147
  mockRefresh.mockReset();
157
- mockExperimentKeys = ["ailk-founder-diagnostic", "beta-cohort"];
158
148
  });
159
149
 
160
- describe("AccountPage — results + purchases", () => {
161
- it("renders the signup for a configured key and skips a 404'd key", async () => {
162
- await renderPage();
150
+ afterAll(() => {
151
+ accountExperimentKeys.length = 0;
152
+ });
163
153
 
164
- const results = screen.getByTestId("account-results");
165
- const row = within(results).getByTestId("account-result-ailk-founder-diagnostic");
166
- expect(within(row).getByText("hot")).toBeInTheDocument();
167
- expect(within(row).getByText("early_bird")).toBeInTheDocument();
168
- expect(within(results).queryByTestId("account-result-beta-cohort")).not.toBeInTheDocument();
169
- expect(within(results).queryByText("beta-cohort")).not.toBeInTheDocument();
170
-
171
- // One read per configured key, each forwarding the caller's cookie.
172
- expect(mockSignupMe).toHaveBeenCalledTimes(2);
173
- expect(mockSignupMe).toHaveBeenCalledWith("ailk-founder-diagnostic", {
174
- headers: { cookie: "session=abc" },
175
- });
176
- expect(mockSignupMe).toHaveBeenCalledWith("beta-cohort", {
177
- headers: { cookie: "session=abc" },
178
- });
154
+ describe("the Inbox — D6a tabs (AC-7)", () => {
155
+ it("renders Recents · Suggested · Scheduled · All with counts, Recents selected", async () => {
156
+ accountExperimentKeys.push("ailk-founder-diagnostic");
157
+ await renderPage();
158
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Inbox");
159
+ const tabs = screen.getAllByRole("tab");
160
+ expect(tabs.map((tab) => tab.getAttribute("data-testid"))).toEqual([
161
+ "inbox-tab-recents",
162
+ "inbox-tab-suggested",
163
+ "inbox-tab-scheduled",
164
+ "inbox-tab-all",
165
+ ]);
166
+ expect(tabs.map((tab) => tab.textContent)).toEqual([
167
+ "Recents2",
168
+ "Suggested0",
169
+ "Scheduled0",
170
+ "All2",
171
+ ]);
172
+ expect(screen.getByTestId("inbox-tab-recents")).toHaveAttribute("aria-selected", "true");
179
173
  });
180
174
 
181
- it("renders every checkout, newest-first order preserved, with a formatted amount", async () => {
175
+ it("result and purchase rows sit under Recents, newest first, each linking to its item page", async () => {
176
+ accountExperimentKeys.push("ailk-founder-diagnostic");
182
177
  await renderPage();
183
-
184
- const purchases = screen.getByTestId("account-purchases");
185
- const done = within(purchases).getByTestId("account-purchase-fc_done");
186
- const open = within(purchases).getByTestId("account-purchase-fc_open");
187
- expect(done).toBeInTheDocument();
188
- expect(open).toBeInTheDocument();
189
- expect(within(done).getByText("$49")).toBeInTheDocument();
190
- expect(within(done).getByText("completed")).toBeInTheDocument();
191
- expect(within(open).getByText("created")).toBeInTheDocument();
192
- expect(mockCheckoutsMe).toHaveBeenCalledWith({ headers: { cookie: "session=abc" } });
178
+ const recents = screen.getByTestId("inbox-recents");
179
+ const rows = within(recents).getAllByTestId("inbox-recents-row");
180
+ expect(rows).toHaveLength(2);
181
+ // The purchase (Sep 4) is newer than the result (Sep 3).
182
+ expect(rows[0]).toHaveTextContent("pro");
183
+ expect(rows[1]).toHaveTextContent("ailk-founder-diagnostic");
184
+ expect(within(recents).getByText("pro").closest("a")).toHaveAttribute(
185
+ "href",
186
+ "/account/inbox/purchase/fc_done",
187
+ );
188
+ expect(
189
+ within(recents).getByText("ailk-founder-diagnostic").closest("a"),
190
+ ).toHaveAttribute("href", "/account/inbox/result/ailk-founder-diagnostic");
193
191
  });
194
192
 
195
- it("rethrows a non-skippable signup error to the route error boundary", async () => {
196
- mockSignupMe.mockResolvedValue({
197
- ok: false,
198
- status: 500,
199
- body: { error: { code: "internal_error", message: "Boom" } },
200
- });
201
- await expect(Page({ params: Promise.resolve({ locale: "en" }) })).rejects.toThrow("Boom");
193
+ it("the same rows are in All; a non-default locale prefixes the item href", async () => {
194
+ await renderPage("fr");
195
+ fireEvent.mouseDown(screen.getByTestId("inbox-tab-all"));
196
+ const all = screen.getByTestId("inbox-all");
197
+ expect(within(all).getByText("pro").closest("a")).toHaveAttribute(
198
+ "href",
199
+ "/fr/account/inbox/purchase/fc_done",
200
+ );
202
201
  });
203
202
 
204
- it("treats a 403 email_unverified checkouts read as no purchases, not an error", async () => {
205
- mockCheckoutsMe.mockResolvedValue({
206
- ok: false,
207
- status: 403,
208
- body: { error: { code: "email_unverified", message: "Verify first" } },
209
- });
203
+ it("with no rows, every tab still renders and Recents shows the empty line", async () => {
204
+ mockCheckoutsMe.mockResolvedValue({ ok: true, body: [] });
210
205
  await renderPage();
211
- expect(within(screen.getByTestId("account-purchases")).getByText("No purchases yet")).toBeInTheDocument();
206
+ expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
207
+ "Recents0",
208
+ "Suggested0",
209
+ "Scheduled0",
210
+ "All0",
211
+ ]);
212
+ expect(screen.getByTestId("inbox-empty")).toHaveTextContent("Nothing here yet.");
212
213
  });
213
214
  });
214
215
 
215
- describe("AccountPage — empty states", () => {
216
- it("renders both empty states with no configured keys and no checkouts", async () => {
217
- mockExperimentKeys = [];
218
- mockCheckoutsMe.mockResolvedValue({ ok: true, body: [] });
219
- await renderPage();
216
+ describe("For-you items, not Inbox rows (AC-7, D9 as amended)", () => {
217
+ it("email-unverified and payment-due are For you tab items, never Inbox rows", async () => {
218
+ UNVERIFIED_AND_PAST_DUE();
219
+ await renderPage("en", "for-you");
220
+ const forYou = screen.getByTestId("inbox-panel-for-you");
221
+ expect(within(forYou).getAllByTestId("for-you-item")).toHaveLength(2);
222
+ const list = screen.getByTestId("inbox-list");
223
+ expect(within(list).queryByText("Email not verified")).not.toBeInTheDocument();
224
+ expect(
225
+ within(list).queryByText("Your payment didn't go through"),
226
+ ).not.toBeInTheDocument();
227
+ });
220
228
 
221
- expect(mockSignupMe).not.toHaveBeenCalled();
222
- expect(within(screen.getByTestId("account-results")).getByText("No results yet")).toBeInTheDocument();
223
- expect(within(screen.getByTestId("account-purchases")).getByText("No purchases yet")).toBeInTheDocument();
229
+ it("a member with no name sees 'Add your name' (needs-you, Open → User settings › General)", async () => {
230
+ mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: "" } });
231
+ await renderPage("en", "for-you");
232
+ const forYou = screen.getByTestId("inbox-panel-for-you");
233
+ const item = within(forYou).getByTestId("for-you-item");
234
+ expect(item).toHaveAttribute("data-item-id", "add-name");
235
+ expect(item).toHaveTextContent("Add your name");
236
+ // DESIGN.md §5.4c — the tag is icon + label.
237
+ expect(within(item).getByTestId("for-you-tag").querySelector("svg")).not.toBeNull();
238
+ expect(within(item).getByTestId("for-you-open")).toHaveAttribute(
239
+ "href",
240
+ "/account/settings#general",
241
+ );
224
242
  });
225
243
  });
226
244
 
227
- describe("AccountPage — profile", () => {
228
- it("shows name, email, and the verified badge", async () => {
245
+ describe("the phone Inbox tab (#7469, DESIGN.md §2b · §5.15)", () => {
246
+ it("holds the filter tabs and the list only — no For-you stack and no bottom sheet", async () => {
247
+ UNVERIFIED_AND_PAST_DUE();
229
248
  await renderPage();
230
- const profile = screen.getByTestId("account-profile");
231
- expect(within(profile).getByTestId("account-profile-name")).toHaveTextContent("Ada");
232
- expect(within(profile).getByTestId("account-profile-email")).toHaveTextContent("ada@example.com");
233
- expect(within(profile).getByTestId("account-email-status")).toHaveTextContent("Email verified");
249
+ const inbox = screen.getByTestId("inbox-panel-inbox");
250
+ expect(within(inbox).getByTestId("inbox-list")).toBeInTheDocument();
251
+ expect(screen.queryByTestId("for-you-stack")).not.toBeInTheDocument();
252
+ expect(screen.queryByTestId("for-you-stack-count")).not.toBeInTheDocument();
253
+ expect(screen.queryByTestId("for-you-sheet")).not.toBeInTheDocument();
254
+ expect(screen.queryByTestId("inbox-for-you")).not.toBeInTheDocument();
234
255
  });
235
256
 
236
- it("shows the unverified badge for an unverified session", async () => {
237
- mockGetSession.mockResolvedValue({
238
- user: { ...SESSION.user, emailVerified: false },
239
- });
257
+ it("shows on the phone and at md+, while the For you panel stays hidden", async () => {
258
+ UNVERIFIED_AND_PAST_DUE();
240
259
  await renderPage();
241
- expect(within(screen.getByTestId("account-profile")).getByTestId("account-email-status")).toHaveTextContent(
242
- "Email not verified",
243
- );
260
+ expect(screen.getByTestId("inbox-panel-inbox")).not.toHaveClass("hidden");
261
+ expect(screen.getByTestId("inbox-panel-inbox")).not.toHaveClass("md:hidden");
262
+ expect(screen.getByTestId("inbox-panel-for-you")).toHaveClass("hidden");
244
263
  });
245
- });
246
264
 
247
- describe("AccountPage — sidebar nav", () => {
248
- it("renders the three in-page anchors with their labels", async () => {
265
+ it("the phone has no large title or lead — both stay at md+", async () => {
249
266
  await renderPage();
250
- // AppShell may mount the rail twice (desktop rail + drawer) — assert on
251
- // the first instance of each.
252
- expect(screen.getAllByTestId("sidebar-nav-item-profile")[0]).toHaveTextContent("Profile");
253
- expect(screen.getAllByTestId("sidebar-nav-item-results")[0]).toHaveTextContent("Results");
254
- expect(screen.getAllByTestId("sidebar-nav-item-purchases")[0]).toHaveTextContent("Purchases");
255
- expect(screen.getAllByTestId("sidebar-nav-item-results")[0]).toHaveAttribute(
256
- "href",
257
- "/account#results",
258
- );
267
+ expect(screen.getByRole("heading", { level: 1 })).toHaveClass("max-md:sr-only");
268
+ expect(screen.getByRole("heading", { level: 1 })).not.toHaveClass("hidden");
269
+ expect(screen.getByText("What changed, and what's coming.")).toHaveClass("hidden", "md:block");
259
270
  });
260
- });
261
271
 
262
- describe("AccountPage — sign out", () => {
263
- it("calls signOut, then pushes home and refreshes", async () => {
272
+ it("the list sits in a bordered card, inset by the canvas on every side", async () => {
273
+ accountExperimentKeys.push("ailk-founder-diagnostic");
264
274
  await renderPage();
265
- fireEvent.click(screen.getByTestId("account-sign-out"));
266
-
267
- await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
268
- expect(mockSignOut).toHaveBeenCalledTimes(1);
269
- expect(mockRefresh).toHaveBeenCalledTimes(1);
270
- expect(mockSignOut.mock.invocationCallOrder[0]).toBeLessThan(mockPush.mock.invocationCallOrder[0]!);
275
+ // The shell's main slot insets the canvas (p-4 below md); the page adds
276
+ // no full-bleed escape, and the list is RowGroup's bordered box.
277
+ const page = screen.getByTestId("account-inbox");
278
+ expect(page.className).not.toMatch(/-mx-|w-screen/);
279
+ expect(screen.getByTestId("inbox-panel-inbox").className ?? "").not.toMatch(/-mx-|w-screen/);
271
280
  });
272
281
  });
273
282
 
274
- describe("AccountPage — subscription status banner (#6038)", () => {
275
- it("renders the banner for a subscriber with actionRequired: true", async () => {
276
- mockSubscriptionMe.mockResolvedValue({
277
- ok: true,
278
- body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
283
+ describe("the phone For you tab (#7469, DESIGN.md §2b · §5.15)", () => {
284
+ it("lists every item with its type tag, title, Open or View, and ✕", async () => {
285
+ UNVERIFIED_AND_PAST_DUE();
286
+ mockGetSession.mockResolvedValue({
287
+ user: { ...SESSION.user, name: "", emailVerified: false },
279
288
  });
280
- await renderPage();
281
- expect(screen.getByTestId("subscription-status-banner")).toBeInTheDocument();
289
+ await renderPage("en", "for-you");
290
+ const panel = screen.getByTestId("inbox-panel-for-you");
291
+ // Phone-only: shown below md, hidden at md+ where the aside holds it.
292
+ expect(panel).toHaveClass("md:hidden");
293
+ expect(panel).not.toHaveClass("hidden");
294
+ // The Inbox panel is hidden on the phone but still shows at md+.
295
+ expect(screen.getByTestId("inbox-panel-inbox")).toHaveClass("hidden", "md:block");
296
+ const card = within(panel).getByTestId("for-you-list-card");
297
+ const items = within(card).getAllByTestId("for-you-item");
298
+ expect(items).toHaveLength(3);
299
+ for (const item of items) {
300
+ expect(within(item).getByTestId("for-you-tag")).toBeInTheDocument();
301
+ expect(
302
+ within(item).queryByTestId("for-you-open") ?? within(item).queryByTestId("for-you-view"),
303
+ ).not.toBeNull();
304
+ expect(within(item).getByTestId("for-you-dismiss")).toHaveAccessibleName(/^Dismiss/);
305
+ }
306
+ expect(within(panel).getByText("Add your name")).toBeInTheDocument();
307
+ expect(within(panel).getByText("Email not verified")).toBeInTheDocument();
308
+ expect(within(panel).getByText("Your payment didn't go through")).toBeInTheDocument();
282
309
  });
283
310
 
284
- it("renders no banner for a healthy subscriber — AC-1", async () => {
285
- mockSubscriptionMe.mockResolvedValue({
286
- ok: true,
287
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: true },
288
- });
289
- await renderPage();
290
- expect(screen.queryByTestId("subscription-status-banner")).not.toBeInTheDocument();
311
+ it("✕ dismisses an item — it leaves the list", async () => {
312
+ UNVERIFIED_AND_PAST_DUE();
313
+ await renderPage("en", "for-you");
314
+ const panel = screen.getByTestId("inbox-panel-for-you");
315
+ const [first] = within(panel).getAllByTestId("for-you-item");
316
+ const id = first!.getAttribute("data-item-id");
317
+ fireEvent.click(within(first!).getByTestId("for-you-dismiss"));
318
+ const left = within(panel).getAllByTestId("for-you-item");
319
+ expect(left).toHaveLength(1);
320
+ expect(left.map((item) => item.getAttribute("data-item-id"))).not.toContain(id);
291
321
  });
292
322
 
293
- it("clears once the recovery lands — AC-3, no dismissal consulted", async () => {
294
- mockSubscriptionMe.mockResolvedValue({
295
- ok: true,
296
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: true },
297
- });
298
- await renderPage();
299
- expect(screen.queryByTestId("subscription-status-banner")).not.toBeInTheDocument();
323
+ it("the For you tab keeps the page's h1 (visually hidden on the phone)", async () => {
324
+ await renderPage("en", "for-you");
325
+ const h1 = screen.getByRole("heading", { level: 1 });
326
+ expect(h1).toHaveTextContent("Inbox");
327
+ expect(screen.getByTestId("inbox-panel-inbox")).not.toContainElement(h1);
300
328
  });
301
329
 
302
- it.each([401, 403, 404])(
303
- "renders no banner and no throw on a %d read (AC-4, the existing SKIPPED_STATUSES constant)",
304
- async (status) => {
305
- mockSubscriptionMe.mockResolvedValue({
306
- ok: false,
307
- status,
308
- body: { error: { code: "n/a", message: "n/a" } },
309
- });
310
- await renderPage();
311
- expect(screen.getByTestId("account-page")).toBeInTheDocument();
312
- expect(screen.queryByTestId("subscription-status-banner")).not.toBeInTheDocument();
313
- },
314
- );
315
-
316
- it("rejects to the route error boundary on a 500 subscription read", async () => {
317
- mockSubscriptionMe.mockResolvedValue({
318
- ok: false,
319
- status: 500,
320
- body: { error: { code: "internal_error", message: "Boom" } },
321
- });
322
- await expect(Page({ params: Promise.resolve({ locale: "en" }) })).rejects.toThrow("Boom");
330
+ it("with nothing waiting the tab shows its empty line, not a blank page", async () => {
331
+ await renderPage("en", "for-you");
332
+ const panel = screen.getByTestId("inbox-panel-for-you");
333
+ expect(within(panel).queryByTestId("for-you-item")).not.toBeInTheDocument();
334
+ expect(within(panel).getByTestId("for-you-empty")).toHaveTextContent(
335
+ "Nothing here right now.",
336
+ );
323
337
  });
338
+ });
324
339
 
325
- it("renders the action slot when portalAvailable is true", async () => {
326
- mockSubscriptionMe.mockResolvedValue({
327
- ok: true,
328
- body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
329
- });
340
+ describe("Hub unavailable (D12)", () => {
341
+ it("a failed read renders an error row with Retry, never an empty Inbox", async () => {
342
+ mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
330
343
  await renderPage();
331
- const banner = screen.getByTestId("subscription-status-banner");
332
- expect(within(banner).getByRole("button")).toBeInTheDocument();
344
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent(
345
+ "We couldn't load everything in your Inbox.",
346
+ );
347
+ expect(screen.queryByTestId("inbox-empty")).not.toBeInTheDocument();
348
+ fireEvent.click(screen.getByTestId("inbox-retry"));
349
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
333
350
  });
334
351
 
335
- it("renders no action slot when portalAvailable is false — D3.0a", async () => {
336
- mockSubscriptionMe.mockResolvedValue({
337
- ok: true,
338
- body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: false },
339
- });
352
+ it("a failed For-you read also shows the error row", async () => {
353
+ mockSubscriptionMe.mockResolvedValue({ ok: false, status: 502, body: {} });
340
354
  await renderPage();
341
- const banner = screen.getByTestId("subscription-status-banner");
342
- expect(within(banner).queryByRole("button")).not.toBeInTheDocument();
355
+ expect(
356
+ within(screen.getByTestId("inbox-panel-inbox")).getByTestId("inbox-error"),
357
+ ).toBeInTheDocument();
358
+ });
359
+
360
+ it("on the phone For you tab, a failed For-you read shows the error row above the list (#7469)", async () => {
361
+ mockSubscriptionMe.mockResolvedValue({ ok: false, status: 502, body: {} });
362
+ await renderPage("en", "for-you");
363
+ const panel = screen.getByTestId("inbox-panel-for-you");
364
+ expect(within(panel).getByTestId("inbox-error")).toHaveTextContent(
365
+ "We couldn't load everything in your Inbox.",
366
+ );
367
+ // Never a silently empty list (D12): the empty line gives way to the error row.
368
+ expect(within(panel).queryByTestId("for-you-empty")).not.toBeInTheDocument();
369
+ });
370
+
371
+ it("a failed Inbox-kind read leaves the For you tab without an error row", async () => {
372
+ mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
373
+ await renderPage("en", "for-you");
374
+ const panel = screen.getByTestId("inbox-panel-for-you");
375
+ expect(within(panel).queryByTestId("inbox-error")).not.toBeInTheDocument();
343
376
  });
344
377
  });