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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +148 -21
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- 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
|
+
}
|