create-ailk 0.11.2 → 0.11.3

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 +297 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  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 +143 -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 +88 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  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 +147 -21
  73. package/templates/database/CHANGELOG.md +7 -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
@@ -0,0 +1,118 @@
1
+ "use client"; // edits the session's name through Better Auth's client, then refreshes the route
2
+
3
+ /**
4
+ * NameForm — User settings › General's name field (#7430, operator fold-in
5
+ * 2026-10-01). An accepted applicant arrives with no name (#7418), so the
6
+ * member SETS it here, and edits it later. Saved through Better Auth's own
7
+ * `updateUser` (its `/update-user` endpoint — no new API), then the route
8
+ * refreshes so the frame's avatar and the "Add your name" For-you item
9
+ * re-read the session.
10
+ *
11
+ * DESIGN.md §5.12: the form ends with a hairline and a save row — "No
12
+ * unsaved changes" / "Unsaved changes" and a Save that is disabled until
13
+ * dirty.
14
+ */
15
+
16
+ import { Button, Input, InlineMessage, Label, Text } from "@working-theory/ui/primitives";
17
+ import { createAuthClient } from "better-auth/react";
18
+ import { useRouter } from "next/navigation";
19
+ import { useId, useState, type FormEvent, type ReactElement } from "react";
20
+
21
+ const authClient = createAuthClient();
22
+
23
+ export interface NameFormLabels {
24
+ name: string;
25
+ placeholder: string;
26
+ save: string;
27
+ saving: string;
28
+ saved: string;
29
+ unsaved: string;
30
+ clean: string;
31
+ required: string;
32
+ error: string;
33
+ }
34
+
35
+ export function NameForm({
36
+ initialName,
37
+ labels,
38
+ }: {
39
+ initialName: string;
40
+ labels: NameFormLabels;
41
+ }): ReactElement {
42
+ const router = useRouter();
43
+ const inputId = useId();
44
+ const [saved, setSaved] = useState(initialName);
45
+ const [name, setName] = useState(initialName);
46
+ const [state, setState] = useState<"idle" | "saving" | "saved" | "required" | "error">("idle");
47
+ const dirty = name !== saved;
48
+
49
+ const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
50
+ event.preventDefault();
51
+ const next = name.trim();
52
+ if (!next) {
53
+ setState("required");
54
+ return;
55
+ }
56
+ setState("saving");
57
+ try {
58
+ const { error } = await authClient.updateUser({ name: next });
59
+ if (error) {
60
+ setState("error");
61
+ return;
62
+ }
63
+ } catch {
64
+ // A network failure throws rather than answering `error`.
65
+ setState("error");
66
+ return;
67
+ }
68
+ setSaved(next);
69
+ setName(next);
70
+ setState("saved");
71
+ router.refresh();
72
+ };
73
+
74
+ const status =
75
+ state === "saved" && !dirty ? labels.saved : dirty ? labels.unsaved : labels.clean;
76
+
77
+ return (
78
+ <form
79
+ onSubmit={(event) => void onSubmit(event)}
80
+ className="flex flex-col gap-2"
81
+ data-testid="account-name-form"
82
+ >
83
+ <Label htmlFor={inputId}>{labels.name}</Label>
84
+ <Input
85
+ id={inputId}
86
+ name="name"
87
+ autoComplete="name"
88
+ value={name}
89
+ placeholder={labels.placeholder}
90
+ onChange={(event) => {
91
+ setName(event.target.value);
92
+ if (state !== "saving") setState("idle");
93
+ }}
94
+ className="max-w-md"
95
+ data-testid="account-name-input"
96
+ />
97
+ {state === "required" || state === "error" ? (
98
+ <InlineMessage variant="error">
99
+ {state === "required" ? labels.required : labels.error}
100
+ </InlineMessage>
101
+ ) : null}
102
+ <div className="mt-2 flex items-center justify-between gap-4 border-t border-(--color-border) pt-4">
103
+ <div aria-live="polite" data-testid="account-name-status">
104
+ <Text as="p" variant="caption" tone="muted">
105
+ {status}
106
+ </Text>
107
+ </div>
108
+ <Button
109
+ type="submit"
110
+ disabled={!dirty || state === "saving"}
111
+ data-testid="account-name-save"
112
+ >
113
+ {state === "saving" ? labels.saving : labels.save}
114
+ </Button>
115
+ </div>
116
+ </form>
117
+ );
118
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * /[locale]/account/settings — User settings (#7430, spec 7423 U4 · D10,
3
+ * D10a; DESIGN.md §5.12a's section content).
4
+ *
5
+ * RSC, inside the account layout's SignedInShell with the rail visible
6
+ * (D10a — a member's rail has one row, so a separate no-rail frame would add
7
+ * a step for nothing). One page of anchored sections — the For-you items
8
+ * link to `#general` and `#billing`:
9
+ *
10
+ * - General — color mode (only when the site's theme defines more than
11
+ * one mode), the email read-only, and the name the member sets and edits
12
+ * (an accepted applicant arrives with none, #7418).
13
+ * - Security — the sign-in methods this site has configured; a Password
14
+ * row only when the password method is on.
15
+ * - Notifications — a note until a preference store exists.
16
+ * - Privacy — the cookie settings (`/privacy#cookie-settings`, since the
17
+ * account area carries no consent Banner, D4) and "Request account
18
+ * deletion", which files a Support request of kind `deletion`.
19
+ * - Billing — only when `subscription.me()` answers a status or an
20
+ * available portal; absent on `status: null` + no portal, and on any
21
+ * 401/403/404 (the commerce-disabled build answers 404).
22
+ * - Then any section a module contributes (`accountSettingsSections`).
23
+ *
24
+ * No Credentials, Scope, Organizations or Credits section: those are an
25
+ * agent's or an operator's (§5.12a), never a member's.
26
+ */
27
+
28
+ import { subscription, type SubscriptionMeResponse } from "@working-theory/api-client";
29
+ import { localeHref } from "@working-theory/ui";
30
+ import { DescriptionList } from "@working-theory/ui/blocks";
31
+ import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
32
+ import type { Metadata } from "next";
33
+ import { getTranslations } from "next-intl/server";
34
+ import type { ReactElement, ReactNode } from "react";
35
+
36
+ import { accountSettingsSections } from "../../../../../lib/account-config";
37
+ import { getActiveProviderIds } from "../../../../../lib/auth-env";
38
+ import { accountSession, logReadFailure } from "../account-data";
39
+ import { openBillingPortal } from "../actions";
40
+
41
+ import { ColorModeSetting } from "./color-mode";
42
+ import { DeletionRequest } from "./deletion-request";
43
+ import { NameForm } from "./name-form";
44
+ import { siteThemeModeCount } from "./theme-modes";
45
+
46
+ interface Props {
47
+ params: Promise<{ locale: string }>;
48
+ }
49
+
50
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
51
+ const { locale } = await params;
52
+ const t = await getTranslations({ locale, namespace: "account" });
53
+ return { title: t("settings.title"), description: t("settings.lead") };
54
+ }
55
+
56
+ /** The section role DESIGN.md §5.12 names: a title, a muted subtitle, then the fields. */
57
+ function Section({
58
+ id,
59
+ title,
60
+ lead,
61
+ children,
62
+ }: {
63
+ id: string;
64
+ title: string;
65
+ lead?: string;
66
+ children: ReactNode;
67
+ }): ReactElement {
68
+ return (
69
+ <section
70
+ id={id}
71
+ aria-labelledby={`${id}-title`}
72
+ className="flex scroll-mt-8 flex-col gap-4 border-t border-(--color-border) pt-8"
73
+ data-testid={`account-settings-${id}`}
74
+ >
75
+ <div>
76
+ <Heading level={2} visual={4} id={`${id}-title`}>
77
+ {title}
78
+ </Heading>
79
+ {lead ? (
80
+ <Text as="p" variant="body-sm" tone="muted" className="mt-1">
81
+ {lead}
82
+ </Text>
83
+ ) : null}
84
+ </div>
85
+ {children}
86
+ </section>
87
+ );
88
+ }
89
+
90
+ /** The sign-in methods this deployment has configured, as message keys. */
91
+ const METHOD_KEYS: Record<string, string> = {
92
+ google: "settings.security.google",
93
+ github: "settings.security.github",
94
+ "magic-link": "settings.security.magicLink",
95
+ email: "settings.security.password",
96
+ };
97
+
98
+ /** The statuses with a message key; any other renders as the API sent it. */
99
+ const KNOWN_STATUSES: ReadonlySet<string> = new Set(["trialing", "active", "past_due", "canceled"]);
100
+
101
+ /**
102
+ * Billing's read: `null` on any non-2xx — 401/403/404 by D10 — and a failed
103
+ * read leaves the section out rather than failing the whole page. A failure
104
+ * other than those three is logged, so it is not silent to the operator.
105
+ */
106
+ async function readSubscription(
107
+ init: { headers: Record<string, string> },
108
+ ): Promise<SubscriptionMeResponse | null> {
109
+ try {
110
+ const res = await subscription.me(init);
111
+ if (res.ok) return res.body;
112
+ if (![401, 403, 404].includes(res.status)) {
113
+ console.error(`[account] read failed source=billing status=${res.status}`);
114
+ }
115
+ return null;
116
+ } catch (err) {
117
+ logReadFailure("billing", err);
118
+ return null;
119
+ }
120
+ }
121
+
122
+ export default async function AccountSettingsPage({ params }: Props): Promise<ReactElement> {
123
+ const { locale } = await params;
124
+ const t = await getTranslations({ locale, namespace: "account" });
125
+ const session = await accountSession(locale);
126
+
127
+ // Unreachable behind the (authed) gate; a session that expired between
128
+ // the layout and the page renders the heading alone.
129
+ if (!session) {
130
+ return (
131
+ <div className="max-w-3xl py-8" data-testid="account-settings">
132
+ <Heading level={1} visual={2}>
133
+ {t("settings.title")}
134
+ </Heading>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ const { ctx } = session;
140
+ const sub = await readSubscription(ctx.init);
141
+ const showBilling = sub !== null && (sub.status !== null || sub.portalAvailable);
142
+ const methods = getActiveProviderIds().filter((id) => id in METHOD_KEYS);
143
+
144
+ return (
145
+ <div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="account-settings">
146
+ <div>
147
+ <Heading level={1} visual={2}>
148
+ {t("settings.title")}
149
+ </Heading>
150
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
151
+ {t("settings.lead")}
152
+ </Text>
153
+ </div>
154
+
155
+ <Section id="general" title={t("settings.general.title")} lead={t("settings.general.lead")}>
156
+ {siteThemeModeCount() > 1 ? (
157
+ <div className="flex flex-col items-start gap-2">
158
+ <Text as="p" variant="body-sm" className="font-medium">
159
+ {t("settings.general.colorMode")}
160
+ </Text>
161
+ <ColorModeSetting
162
+ labels={{
163
+ title: t("settings.general.colorMode"),
164
+ light: t("settings.general.light"),
165
+ auto: t("settings.general.auto"),
166
+ dark: t("settings.general.dark"),
167
+ }}
168
+ />
169
+ </div>
170
+ ) : null}
171
+ <DescriptionList
172
+ items={[
173
+ {
174
+ label: t("settings.general.email"),
175
+ value: ctx.user.email,
176
+ "data-testid": "account-settings-email",
177
+ },
178
+ ]}
179
+ />
180
+ <NameForm
181
+ initialName={ctx.user.name ?? ""}
182
+ labels={{
183
+ name: t("settings.general.name"),
184
+ placeholder: t("settings.general.namePlaceholder"),
185
+ save: t("settings.general.save"),
186
+ saving: t("settings.general.saving"),
187
+ saved: t("settings.general.saved"),
188
+ unsaved: t("settings.general.unsaved"),
189
+ clean: t("settings.general.clean"),
190
+ required: t("settings.general.nameRequired"),
191
+ error: t("settings.general.saveError"),
192
+ }}
193
+ />
194
+ </Section>
195
+
196
+ <Section id="security" title={t("settings.security.title")} lead={t("settings.security.lead")}>
197
+ {methods.length > 0 ? (
198
+ <div className="flex flex-col gap-2">
199
+ <Text as="p" variant="body-sm" className="font-medium">
200
+ {t("settings.security.methods")}
201
+ </Text>
202
+ <DescriptionList
203
+ items={methods.map((id) => ({
204
+ label: ctx.t(METHOD_KEYS[id]!),
205
+ value: t("settings.security.available"),
206
+ "data-testid": `account-signin-${id}`,
207
+ }))}
208
+ />
209
+ </div>
210
+ ) : (
211
+ <Text as="p" variant="body-sm" tone="muted">
212
+ {t("settings.security.none")}
213
+ </Text>
214
+ )}
215
+ </Section>
216
+
217
+ <Section
218
+ id="notifications"
219
+ title={t("settings.notifications.title")}
220
+ lead={t("settings.notifications.lead")}
221
+ >
222
+ <Text as="p" variant="body-sm" tone="muted">
223
+ {t("settings.notifications.placeholder")}
224
+ </Text>
225
+ </Section>
226
+
227
+ <Section id="privacy" title={t("settings.privacy.title")} lead={t("settings.privacy.lead")}>
228
+ <div>
229
+ <Link
230
+ href={localeHref(locale, "/privacy#cookie-settings")}
231
+ className="inline-flex min-h-6 items-center"
232
+ data-testid="account-cookie-settings"
233
+ >
234
+ {t("settings.privacy.cookies")}
235
+ </Link>
236
+ </div>
237
+ <div className="flex flex-col gap-2">
238
+ <Text as="p" variant="body-sm" tone="muted">
239
+ {t("settings.privacy.deletionLead")}
240
+ </Text>
241
+ <DeletionRequest
242
+ labels={{
243
+ action: t("settings.privacy.deletion"),
244
+ subject: t("settings.privacy.deletionSubject"),
245
+ message: t("settings.privacy.deletionMessage"),
246
+ sent: t("settings.privacy.deletionSent"),
247
+ error: t("settings.privacy.deletionError"),
248
+ }}
249
+ />
250
+ </div>
251
+ </Section>
252
+
253
+ {showBilling ? (
254
+ <Section id="billing" title={t("settings.billing.title")} lead={t("settings.billing.lead")}>
255
+ {sub.status !== null || sub.accessRevokesAt ? (
256
+ <DescriptionList
257
+ items={[
258
+ ...(sub.status !== null
259
+ ? [
260
+ {
261
+ label: t("settings.billing.status"),
262
+ value: KNOWN_STATUSES.has(sub.status)
263
+ ? ctx.t(`settings.billing.statuses.${sub.status}`)
264
+ : sub.status,
265
+ },
266
+ ]
267
+ : []),
268
+ ...(sub.accessRevokesAt
269
+ ? [
270
+ {
271
+ label: t("settings.billing.accessRevokesAt"),
272
+ value: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
273
+ new Date(sub.accessRevokesAt),
274
+ ),
275
+ },
276
+ ]
277
+ : []),
278
+ ]}
279
+ />
280
+ ) : null}
281
+ {sub.portalAvailable ? (
282
+ <form action={openBillingPortal}>
283
+ <Button type="submit" variant="secondary" data-testid="account-billing-manage">
284
+ {t("settings.billing.manage")}
285
+ </Button>
286
+ </form>
287
+ ) : null}
288
+ </Section>
289
+ ) : null}
290
+
291
+ {accountSettingsSections.map((section) => (
292
+ <Section key={section.id} id={section.id} title={ctx.t(section.title)}>
293
+ {section.render(ctx)}
294
+ </Section>
295
+ ))}
296
+ </div>
297
+ );
298
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * theme-modes.ts — how many color modes the site's theme defines (#7430,
3
+ * operator fold-in 2026-10-01: the color-mode control renders only on a
4
+ * site whose theme has more than one).
5
+ *
6
+ * Read from the same `content/_site.mdx` overlay the locale layout applies
7
+ * (`loadSiteBrandTokens`):
8
+ *
9
+ * - no `theme:` colours (the default palette, light AND dark) → 2;
10
+ * - colours declared for both `light` and `dark` → 2;
11
+ * - colours declared for ONE mode only → 1. A site that designs a single
12
+ * palette (samhenry's light-only DESIGN.md) declares just that mode, and
13
+ * the other mode's defaults are not its design.
14
+ *
15
+ * A theme the loader rejects counts as the default (2): the layout surfaces
16
+ * that error itself, and settings must not be where it first appears.
17
+ */
18
+
19
+ import { loadSiteBrandTokens } from "../../../../../lib/site-theme";
20
+
21
+ export function siteThemeModeCount(): 1 | 2 {
22
+ try {
23
+ const colors = loadSiteBrandTokens()?.["colors"];
24
+ if (typeof colors !== "object" || colors === null) return 2;
25
+ const declared = (["light", "dark"] as const).filter(
26
+ (mode) => (colors as Record<string, unknown>)[mode] !== undefined,
27
+ );
28
+ return declared.length === 1 ? 1 : 2;
29
+ } catch {
30
+ return 2;
31
+ }
32
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * /[locale]/account/support — Contact support (#7430, spec 7423 U4 · D11).
3
+ *
4
+ * RSC, inside the account layout's SignedInShell. Reached from the help
5
+ * menu (the rail's ? and the phone tab bar's Help), never a rail row (D13).
6
+ * The form files a contact lead of kind `support` with the session's email,
7
+ * so it lands in the admin Inbox as a message; the reply goes to that email.
8
+ */
9
+
10
+ import { Heading, Text } from "@working-theory/ui/primitives";
11
+ import type { Metadata } from "next";
12
+ import { getTranslations } from "next-intl/server";
13
+ import type { ReactElement } from "react";
14
+
15
+ import { accountSession } from "../account-data";
16
+
17
+ import { SupportForm } from "./support-form";
18
+
19
+ interface Props {
20
+ params: Promise<{ locale: string }>;
21
+ }
22
+
23
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
24
+ const { locale } = await params;
25
+ const t = await getTranslations({ locale, namespace: "account" });
26
+ return { title: t("support.title") };
27
+ }
28
+
29
+ export default async function AccountSupportPage({ params }: Props): Promise<ReactElement> {
30
+ const { locale } = await params;
31
+ const t = await getTranslations({ locale, namespace: "account" });
32
+ const session = await accountSession(locale);
33
+
34
+ return (
35
+ <div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="account-support">
36
+ <div>
37
+ <Heading level={1} visual={2}>
38
+ {t("support.title")}
39
+ </Heading>
40
+ {session ? (
41
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
42
+ {t("support.lead", { email: session.ctx.user.email })}
43
+ </Text>
44
+ ) : null}
45
+ </div>
46
+ <SupportForm
47
+ labels={{
48
+ subject: t("support.subject"),
49
+ message: t("support.message"),
50
+ submit: t("support.submit"),
51
+ submitting: t("support.submitting"),
52
+ required: t("support.required"),
53
+ success: t("support.success"),
54
+ error: t("support.error"),
55
+ unavailable: t("support.unavailable"),
56
+ }}
57
+ />
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,98 @@
1
+ "use client"; // submits through the account's Server Action and shows its outcome
2
+
3
+ /**
4
+ * SupportForm — Contact support's form (#7430, spec 7423 D11): a subject and
5
+ * a message, filed by `submitSupportRequest` as a contact lead of kind
6
+ * `support`. The email is the session's, so the form never asks for it.
7
+ * The fields are controlled so a failed submit keeps what was typed.
8
+ */
9
+
10
+ import {
11
+ Button,
12
+ InlineMessage,
13
+ Input,
14
+ Label,
15
+ Textarea,
16
+ } from "@working-theory/ui/primitives";
17
+ import { useActionState, useId, useState, type ReactElement } from "react";
18
+
19
+ import { submitSupportRequest, type SupportRequestState } from "../support-actions";
20
+
21
+ export interface SupportFormLabels {
22
+ subject: string;
23
+ message: string;
24
+ submit: string;
25
+ submitting: string;
26
+ required: string;
27
+ success: string;
28
+ error: string;
29
+ unavailable: string;
30
+ }
31
+
32
+ export function SupportForm({ labels }: { labels: SupportFormLabels }): ReactElement {
33
+ const subjectId = useId();
34
+ const messageId = useId();
35
+ const [subject, setSubject] = useState("");
36
+ const [message, setMessage] = useState("");
37
+ const [state, formAction, pending] = useActionState<SupportRequestState, FormData>(
38
+ submitSupportRequest,
39
+ { kind: "idle" },
40
+ );
41
+
42
+ if (state.kind === "success") {
43
+ return (
44
+ <InlineMessage variant="success" role="status" className="text-body-sm">
45
+ {labels.success}
46
+ </InlineMessage>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <form action={formAction} className="flex flex-col gap-4" data-testid="account-support-form">
52
+ <input type="hidden" name="kind" value="support" />
53
+ <div className="flex flex-col gap-2">
54
+ <Label htmlFor={subjectId}>{labels.subject}</Label>
55
+ <Input
56
+ id={subjectId}
57
+ name="subject"
58
+ value={subject}
59
+ maxLength={200}
60
+ required
61
+ onChange={(event) => setSubject(event.target.value)}
62
+ />
63
+ </div>
64
+ <div className="flex flex-col gap-2">
65
+ <Label htmlFor={messageId}>{labels.message}</Label>
66
+ <Textarea
67
+ id={messageId}
68
+ name="message"
69
+ value={message}
70
+ rows={6}
71
+ maxLength={5000}
72
+ required
73
+ onChange={(event) => setMessage(event.target.value)}
74
+ />
75
+ </div>
76
+ {state.kind === "error" ? (
77
+ <InlineMessage variant="error">
78
+ {state.reason === "required"
79
+ ? labels.required
80
+ : state.reason === "unavailable"
81
+ ? labels.unavailable
82
+ : labels.error}
83
+ </InlineMessage>
84
+ ) : null}
85
+ <div>
86
+ <Button
87
+ type="submit"
88
+ disabled={pending}
89
+ // 44px on a phone (a touch-first context); the 36px primary from md up (DESIGN.md §4).
90
+ className="max-md:min-h-11"
91
+ data-testid="account-support-submit"
92
+ >
93
+ {pending ? labels.submitting : labels.submit}
94
+ </Button>
95
+ </div>
96
+ </form>
97
+ );
98
+ }