create-ailk 0.17.0 → 0.19.0
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/billing — User settings › Billing (#8339 D8; builds on
|
|
3
|
+
* #8232 D9, AC-MAA-19).
|
|
4
|
+
*
|
|
5
|
+
* RSC, inside the account layout's SignedInShell with the rail visible, in
|
|
6
|
+
* the member's four-tab settings frame (D7). Lays out like the Admin's
|
|
7
|
+
* organization Billing page (`OrgBillingPanel`'s `current-plan` layout):
|
|
8
|
+
* the shared `CurrentPlanCard` (@working-theory/templates/account, spec
|
|
9
|
+
* 8339 D5), by `loadBilling()`'s answer (`billing-source.ts`, the billing
|
|
10
|
+
* topology seam):
|
|
11
|
+
*
|
|
12
|
+
* - a subscription — the plan (or `unnamedPlan`), the status plus when
|
|
13
|
+
* access ends (when set, C4 — `subscription.me` carries no price), the
|
|
14
|
+
* card on file, then Manage in Stripe when a portal is available;
|
|
15
|
+
* - no subscription — the same card shape with the plain line and no
|
|
16
|
+
* button (C5);
|
|
17
|
+
* - a failed read — the area's error row with Retry, never a false "you
|
|
18
|
+
* have no subscription".
|
|
19
|
+
*
|
|
20
|
+
* After any of the three, one plain line when some of the member's billing
|
|
21
|
+
* is on a Stripe account this spoke's key cannot read (#8217 D12), then
|
|
22
|
+
* each section a module contributes (`accountBillingSections`, #8231 D4).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { AccountSettingsFrame, AccountSettingsPane, CurrentPlanCard } from "@working-theory/templates/account";
|
|
26
|
+
import { Button, Text } from "@working-theory/ui/primitives";
|
|
27
|
+
import type { Metadata } from "next";
|
|
28
|
+
import { getTranslations } from "next-intl/server";
|
|
29
|
+
import type { ReactElement } from "react";
|
|
30
|
+
|
|
31
|
+
import { accountBillingSections } from "../../../../../lib/account-config";
|
|
32
|
+
import { accountSession } from "../account-data";
|
|
33
|
+
import { openBillingPortal } from "../actions";
|
|
34
|
+
import { type BillingState, loadBilling } from "../billing-source";
|
|
35
|
+
import { ErrorRow } from "../error-row";
|
|
36
|
+
import { memberSettingsFrame, SettingsSection, type Translate as MemberTranslate } from "../settings-frame";
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
params: Promise<{ locale: string }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
43
|
+
const { locale } = await params;
|
|
44
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
45
|
+
return { title: t("settings.billing.title"), description: t("settings.billing.lead") };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The statuses with a message key; any other renders as the API sent it. */
|
|
49
|
+
const KNOWN_STATUSES: ReadonlySet<string> = new Set(["trialing", "active", "past_due", "canceled"]);
|
|
50
|
+
|
|
51
|
+
type Translate = Awaited<ReturnType<typeof getTranslations<"account">>>;
|
|
52
|
+
|
|
53
|
+
function Subscription({
|
|
54
|
+
state,
|
|
55
|
+
locale,
|
|
56
|
+
t,
|
|
57
|
+
}: {
|
|
58
|
+
state: Extract<BillingState, { kind: "subscription" }>;
|
|
59
|
+
locale: string;
|
|
60
|
+
t: Translate;
|
|
61
|
+
}): ReactElement {
|
|
62
|
+
const card = state.paymentMethod;
|
|
63
|
+
const statusLabel = KNOWN_STATUSES.has(state.status)
|
|
64
|
+
? t(`settings.billing.statuses.${state.status}`)
|
|
65
|
+
: state.status;
|
|
66
|
+
const line = (
|
|
67
|
+
<span data-testid="account-billing-status">
|
|
68
|
+
{state.accessRevokesAt
|
|
69
|
+
? `${statusLabel} · ${t("settings.billing.accessEnds", {
|
|
70
|
+
date: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
|
|
71
|
+
new Date(state.accessRevokesAt),
|
|
72
|
+
),
|
|
73
|
+
})}`
|
|
74
|
+
: statusLabel}
|
|
75
|
+
</span>
|
|
76
|
+
);
|
|
77
|
+
const caption = (
|
|
78
|
+
<span data-testid="account-billing-payment">
|
|
79
|
+
{card !== null
|
|
80
|
+
? t("settings.billing.card", {
|
|
81
|
+
brand: card.brand,
|
|
82
|
+
last4: card.last4,
|
|
83
|
+
month: String(card.expMonth).padStart(2, "0"),
|
|
84
|
+
year: String(card.expYear),
|
|
85
|
+
})
|
|
86
|
+
: t("settings.billing.noCard")}
|
|
87
|
+
</span>
|
|
88
|
+
);
|
|
89
|
+
return (
|
|
90
|
+
<CurrentPlanCard
|
|
91
|
+
title={t("settings.billing.currentPlan")}
|
|
92
|
+
heading={
|
|
93
|
+
<span data-testid="account-billing-plan">
|
|
94
|
+
{state.planName ?? t("settings.billing.unnamedPlan")}
|
|
95
|
+
</span>
|
|
96
|
+
}
|
|
97
|
+
line={line}
|
|
98
|
+
caption={caption}
|
|
99
|
+
action={
|
|
100
|
+
state.portalAvailable ? (
|
|
101
|
+
<form action={openBillingPortal}>
|
|
102
|
+
<Button type="submit" variant="outline" data-testid="account-billing-manage">
|
|
103
|
+
{t("settings.billing.manage")}
|
|
104
|
+
</Button>
|
|
105
|
+
</form>
|
|
106
|
+
) : undefined
|
|
107
|
+
}
|
|
108
|
+
testId="account-billing"
|
|
109
|
+
cardTestId="account-billing-card"
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default async function AccountBillingPage({ params }: Props): Promise<ReactElement> {
|
|
115
|
+
const { locale } = await params;
|
|
116
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
117
|
+
const session = await accountSession(locale);
|
|
118
|
+
const frame = memberSettingsFrame(locale, t as unknown as MemberTranslate, "billing");
|
|
119
|
+
|
|
120
|
+
// Unreachable behind the (authed) gate; a session that expired between
|
|
121
|
+
// the layout and the page renders the pane's title alone.
|
|
122
|
+
if (!session) {
|
|
123
|
+
return (
|
|
124
|
+
<AccountSettingsFrame {...frame} name="">
|
|
125
|
+
<AccountSettingsPane id="account-settings-billing" title={t("settings.billing.title")} lead="">
|
|
126
|
+
{null}
|
|
127
|
+
</AccountSettingsPane>
|
|
128
|
+
</AccountSettingsFrame>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { ctx } = session;
|
|
133
|
+
const state = await loadBilling(ctx.init);
|
|
134
|
+
const name = ctx.user.name || ctx.user.email;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<AccountSettingsFrame {...frame} name={name}>
|
|
138
|
+
<AccountSettingsPane
|
|
139
|
+
id="account-settings-billing"
|
|
140
|
+
title={t("settings.billing.title")}
|
|
141
|
+
lead={t("settings.billing.lead")}
|
|
142
|
+
>
|
|
143
|
+
{state.kind === "subscription" ? (
|
|
144
|
+
<Subscription state={state} locale={locale} t={t} />
|
|
145
|
+
) : state.kind === "none" ? (
|
|
146
|
+
<CurrentPlanCard
|
|
147
|
+
title={t("settings.billing.currentPlan")}
|
|
148
|
+
line={<span data-testid="account-billing-none">{t("settings.billing.none")}</span>}
|
|
149
|
+
testId="account-billing"
|
|
150
|
+
cardTestId="account-billing-card"
|
|
151
|
+
/>
|
|
152
|
+
) : (
|
|
153
|
+
<ErrorRow message={t("settings.billing.unavailable")} retryLabel={t("inbox.retry")} />
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{state.kind !== "failed" && state.unreadable ? (
|
|
157
|
+
<div data-testid="account-billing-unreadable">
|
|
158
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
159
|
+
{t("settings.billing.unreadable")}
|
|
160
|
+
</Text>
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
|
|
164
|
+
{accountBillingSections.map((section) => (
|
|
165
|
+
<SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
|
|
166
|
+
{section.render(ctx)}
|
|
167
|
+
</SettingsSection>
|
|
168
|
+
))}
|
|
169
|
+
</AccountSettingsPane>
|
|
170
|
+
</AccountSettingsFrame>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account/billing-source.ts (server code) — what User settings › Billing
|
|
3
|
+
* shows (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
|
|
4
|
+
* D9).
|
|
5
|
+
*
|
|
6
|
+
* One of the account tree's TWO topology seams (roadmap D1; the other is
|
|
7
|
+
* `sign-in-method.ts`): no other file under the account tree tests
|
|
8
|
+
* `SIGN_IN_PROVIDER`, `TOPOLOGY` or `SIGN_IN_ENABLED`.
|
|
9
|
+
*
|
|
10
|
+
* - On a spoke that signs in through its hub, the spoke's own billing
|
|
11
|
+
* (#8217 D10): `loadSpokeBilling()` from the generated seam, which
|
|
12
|
+
* reaches the rendered `billing/spoke-billing.action.ts` on a spoke that
|
|
13
|
+
* carries commerce and resolves `null` on one that does not — then
|
|
14
|
+
* `none` with no call. The hub admits no subscription read for a hub credential
|
|
15
|
+
* (roadmap D11), and this file imports nothing from the checkout package:
|
|
16
|
+
* the action reads the vouched customer ids itself.
|
|
17
|
+
* - Otherwise `subscription.me(init, { include: "billing" })` — the plan
|
|
18
|
+
* name and the card summary are #8171's additive fields:
|
|
19
|
+
* - 401, 403 or 404 (the commerce-disabled build answers 404), or a
|
|
20
|
+
* 2xx whose `status` is `null` ⇒ `none` — the caller has no
|
|
21
|
+
* subscription (C9);
|
|
22
|
+
* - any other non-2xx, or a throw ⇒ `failed`, logged with the status
|
|
23
|
+
* or `err.name` — the page shows the error row with Retry, never a
|
|
24
|
+
* false "you have no subscription";
|
|
25
|
+
* - otherwise `subscription`, absent optional fields read as `null`.
|
|
26
|
+
*
|
|
27
|
+
* `billingPortalUrl()` is the one place a portal URL is minted, for
|
|
28
|
+
* `openBillingPortal` (`actions.ts`): the spoke's own key through the same
|
|
29
|
+
* loader on a hub-mode spoke, else the hub's portal route. `null` means no
|
|
30
|
+
* portal — the action then redirects nowhere.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
subscription,
|
|
35
|
+
type SubscriptionLifecycleStatus,
|
|
36
|
+
type SubscriptionPaymentMethod,
|
|
37
|
+
} from "@working-theory/api-client";
|
|
38
|
+
|
|
39
|
+
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
40
|
+
import {
|
|
41
|
+
SIGN_IN_PROVIDER,
|
|
42
|
+
type SpokeBillingRead,
|
|
43
|
+
loadSpokeBilling,
|
|
44
|
+
} from "../../../../lib/topology.generated";
|
|
45
|
+
|
|
46
|
+
import { logReadFailure } from "./account-data";
|
|
47
|
+
|
|
48
|
+
export type BillingState =
|
|
49
|
+
| {
|
|
50
|
+
kind: "subscription";
|
|
51
|
+
status: SubscriptionLifecycleStatus;
|
|
52
|
+
planName: string | null;
|
|
53
|
+
paymentMethod: SubscriptionPaymentMethod | null;
|
|
54
|
+
accessRevokesAt: string | null;
|
|
55
|
+
portalAvailable: boolean;
|
|
56
|
+
/** Some vouched billing is on a Stripe account this spoke's key cannot read (#8217 D12). */
|
|
57
|
+
unreadable: boolean;
|
|
58
|
+
}
|
|
59
|
+
| { kind: "none"; unreadable: boolean }
|
|
60
|
+
| { kind: "failed" };
|
|
61
|
+
|
|
62
|
+
/** The statuses that mean "this caller has no subscription to show" (C9). */
|
|
63
|
+
const NO_SUBSCRIPTION_STATUSES: ReadonlySet<number> = new Set([401, 403, 404]);
|
|
64
|
+
|
|
65
|
+
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
66
|
+
function errorName(err: unknown): string {
|
|
67
|
+
return err instanceof Error ? err.name : "unknown";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A hub-mode spoke's Billing: the rendered action through the seam, else none (#8217 D10). */
|
|
71
|
+
async function loadSpokeBillingState(): Promise<BillingState> {
|
|
72
|
+
let read: SpokeBillingRead | null;
|
|
73
|
+
try {
|
|
74
|
+
const billing = await loadSpokeBilling();
|
|
75
|
+
read = billing === null ? null : await billing.read();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logReadFailure("spoke-billing", err);
|
|
78
|
+
return { kind: "failed" };
|
|
79
|
+
}
|
|
80
|
+
if (read === null) return { kind: "none", unreadable: false };
|
|
81
|
+
if (read.kind !== "subscription") return read;
|
|
82
|
+
return {
|
|
83
|
+
kind: "subscription",
|
|
84
|
+
status: read.status,
|
|
85
|
+
planName: read.planName,
|
|
86
|
+
paymentMethod: read.paymentMethod,
|
|
87
|
+
accessRevokesAt: null,
|
|
88
|
+
portalAvailable: true,
|
|
89
|
+
unreadable: read.unreadable,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function loadBilling(init: { headers: Record<string, string> }): Promise<BillingState> {
|
|
94
|
+
if (SIGN_IN_PROVIDER === "hub") return loadSpokeBillingState();
|
|
95
|
+
try {
|
|
96
|
+
const res = await subscription.me(init, { include: "billing" });
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
if (NO_SUBSCRIPTION_STATUSES.has(res.status)) return { kind: "none", unreadable: false };
|
|
99
|
+
console.error(`[account] read failed source=billing status=${res.status}`);
|
|
100
|
+
return { kind: "failed" };
|
|
101
|
+
}
|
|
102
|
+
const sub = res.body;
|
|
103
|
+
if (sub.status === null) return { kind: "none", unreadable: false };
|
|
104
|
+
return {
|
|
105
|
+
kind: "subscription",
|
|
106
|
+
status: sub.status,
|
|
107
|
+
planName: sub.planName ?? null,
|
|
108
|
+
paymentMethod: sub.paymentMethod ?? null,
|
|
109
|
+
accessRevokesAt: sub.accessRevokesAt,
|
|
110
|
+
portalAvailable: sub.portalAvailable,
|
|
111
|
+
unreadable: false,
|
|
112
|
+
};
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logReadFailure("billing", err);
|
|
115
|
+
return { kind: "failed" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The caller's Stripe billing-portal URL, or `null` for no portal (#8217
|
|
121
|
+
* D10). Minted on a Manage click, never on render: on a hub-mode spoke from
|
|
122
|
+
* the spoke's own key (a loader that fails to load is `null`), otherwise from
|
|
123
|
+
* the hub's portal route — `null` on a non-2xx or a throw, logged with the
|
|
124
|
+
* status or `err.name` only.
|
|
125
|
+
*/
|
|
126
|
+
export async function billingPortalUrl(): Promise<string | null> {
|
|
127
|
+
if (SIGN_IN_PROVIDER === "hub") {
|
|
128
|
+
try {
|
|
129
|
+
return (await (await loadSpokeBilling())?.portalUrl()) ?? null;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error(`[openBillingPortal] source=spoke-billing errName=${errorName(err)}`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const init = await forwardedCookieInit();
|
|
136
|
+
try {
|
|
137
|
+
const res = await subscription.portalSession(init);
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
console.error(`[openBillingPortal] status=${res.status}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return res.body.url;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`[openBillingPortal] errName=${errorName(err)}`);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client"; // reads the request form on open and posts a controlled form
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RequestQuote — the New menu's Request quote popup (#8233, spec D5, D7,
|
|
5
|
+
* D10; roadmap D5). It renders the request-quote magnet's CURRENT steps:
|
|
6
|
+
* the form is read each time the popup opens (`readRequestForm`), so a step
|
|
7
|
+
* the provider adds in the Admin shows on the next open. `form: null` —
|
|
8
|
+
* Request quote switched off since the menu loaded — says so, with no form.
|
|
9
|
+
*
|
|
10
|
+
* Every field of every served step renders in order (the server checks
|
|
11
|
+
* every one, and evaluates no `showIf`), its copy resolved from the
|
|
12
|
+
* provider's `LocalizedString`s and rendered as text, never markup. Only
|
|
13
|
+
* the six input types the server answers render; a step title is a heading
|
|
14
|
+
* only when there is more than one step. Each control posts as
|
|
15
|
+
* `answer.<name>`; a blank optional answer is omitted by the action. No
|
|
16
|
+
* identity field is added — the server takes the name and email from the
|
|
17
|
+
* session; a field shows only because the provider's form asks it.
|
|
18
|
+
*
|
|
19
|
+
* Controlled, posting what the state holds, so a refusal keeps every answer.
|
|
20
|
+
* The link is sent as typed (`inputMode="url"`, no browser URL check): the
|
|
21
|
+
* server's https rule is the only one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
Button,
|
|
26
|
+
Heading,
|
|
27
|
+
Input,
|
|
28
|
+
Label,
|
|
29
|
+
Select,
|
|
30
|
+
SelectContent,
|
|
31
|
+
SelectItem,
|
|
32
|
+
SelectTrigger,
|
|
33
|
+
SelectValue,
|
|
34
|
+
Text,
|
|
35
|
+
Textarea,
|
|
36
|
+
} from "@working-theory/ui/primitives";
|
|
37
|
+
import {
|
|
38
|
+
resolveLocalizedString,
|
|
39
|
+
type MemberRequestFormResponse,
|
|
40
|
+
} from "@working-theory/validation";
|
|
41
|
+
import {
|
|
42
|
+
startTransition,
|
|
43
|
+
useActionState,
|
|
44
|
+
useId,
|
|
45
|
+
useState,
|
|
46
|
+
type FormEvent,
|
|
47
|
+
type ReactElement,
|
|
48
|
+
} from "react";
|
|
49
|
+
|
|
50
|
+
import { readRequestForm, requestQuote, type NewActionState } from "../new-actions";
|
|
51
|
+
import {
|
|
52
|
+
NewPopupRefusal,
|
|
53
|
+
NewPopupStatus,
|
|
54
|
+
useNewMenuPopup,
|
|
55
|
+
useOpenRead,
|
|
56
|
+
type NewPopupLabels,
|
|
57
|
+
} from "../new-menu";
|
|
58
|
+
|
|
59
|
+
type RequestForm = NonNullable<MemberRequestFormResponse["form"]>;
|
|
60
|
+
type FormField = RequestForm["steps"][number]["fields"][number];
|
|
61
|
+
|
|
62
|
+
/** The input types the requests write answers (`ANSWERABLE`, 8170). */
|
|
63
|
+
const RENDERED = new Set(["text", "email", "tel", "textarea", "select", "url"]);
|
|
64
|
+
|
|
65
|
+
export interface RequestQuoteLabels extends NewPopupLabels {
|
|
66
|
+
submit: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RequestQuoteProps {
|
|
70
|
+
locale: string;
|
|
71
|
+
labels: RequestQuoteLabels;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function RequestQuote({ locale, labels }: RequestQuoteProps): ReactElement {
|
|
75
|
+
const [read, retry] = useOpenRead(readRequestForm);
|
|
76
|
+
if (read === "loading" || read.kind === "error") {
|
|
77
|
+
return <NewPopupStatus failed={read !== "loading"} labels={labels} onRetry={retry} />;
|
|
78
|
+
}
|
|
79
|
+
if (read.form === null) {
|
|
80
|
+
return (
|
|
81
|
+
<Text as="p" variant="body-sm" data-testid="new-quote-off">
|
|
82
|
+
{labels.errors.request_form_off}
|
|
83
|
+
</Text>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return <RequestQuoteForm form={read.form} locale={locale} labels={labels} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function RequestQuoteForm({
|
|
90
|
+
form,
|
|
91
|
+
locale,
|
|
92
|
+
labels,
|
|
93
|
+
}: {
|
|
94
|
+
form: RequestForm;
|
|
95
|
+
locale: string;
|
|
96
|
+
labels: RequestQuoteLabels;
|
|
97
|
+
}): ReactElement {
|
|
98
|
+
const popup = useNewMenuPopup();
|
|
99
|
+
const id = useId();
|
|
100
|
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
101
|
+
const [state, dispatch, pending] = useActionState<NewActionState, FormData>(
|
|
102
|
+
async (prev, formData) => {
|
|
103
|
+
const next = await requestQuote(prev, formData);
|
|
104
|
+
if (next.kind === "success") popup.close(true);
|
|
105
|
+
return next;
|
|
106
|
+
},
|
|
107
|
+
{ kind: "idle" },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const text = (value: Record<string, string>): string =>
|
|
111
|
+
resolveLocalizedString(value, locale, form.defaultLocale);
|
|
112
|
+
const setAnswer = (name: string, value: string): void =>
|
|
113
|
+
setAnswers((current) => ({ ...current, [name]: value }));
|
|
114
|
+
|
|
115
|
+
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
const formData = new FormData();
|
|
118
|
+
for (const [name, value] of Object.entries(answers)) formData.set(`answer.${name}`, value);
|
|
119
|
+
startTransition(() => dispatch(formData));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const renderField = (field: FormField): ReactElement | null => {
|
|
123
|
+
if (!RENDERED.has(field.inputType)) return null;
|
|
124
|
+
const controlId = `${id}-${field.name}`;
|
|
125
|
+
const helpId = `${controlId}-help`;
|
|
126
|
+
const help = field.copy.help ? text(field.copy.help) : null;
|
|
127
|
+
const value = answers[field.name] ?? "";
|
|
128
|
+
const common = {
|
|
129
|
+
id: controlId,
|
|
130
|
+
name: `answer.${field.name}`,
|
|
131
|
+
required: field.required === true,
|
|
132
|
+
...(help ? { "aria-describedby": helpId } : {}),
|
|
133
|
+
"data-testid": `new-quote-field-${field.name}`,
|
|
134
|
+
};
|
|
135
|
+
const placeholder = field.copy.placeholder ? text(field.copy.placeholder) : undefined;
|
|
136
|
+
const onChange = (event: { target: { value: string } }): void =>
|
|
137
|
+
setAnswer(field.name, event.target.value);
|
|
138
|
+
|
|
139
|
+
let control: ReactElement;
|
|
140
|
+
if (field.inputType === "textarea") {
|
|
141
|
+
control = (
|
|
142
|
+
<Textarea {...common} rows={4} value={value} placeholder={placeholder} onChange={onChange} />
|
|
143
|
+
);
|
|
144
|
+
} else if (field.inputType === "select") {
|
|
145
|
+
control = (
|
|
146
|
+
<Select
|
|
147
|
+
name={common.name}
|
|
148
|
+
required={common.required}
|
|
149
|
+
value={value}
|
|
150
|
+
onValueChange={(next) => setAnswer(field.name, next)}
|
|
151
|
+
>
|
|
152
|
+
<SelectTrigger
|
|
153
|
+
id={controlId}
|
|
154
|
+
{...(help ? { "aria-describedby": helpId } : {})}
|
|
155
|
+
data-testid={common["data-testid"]}
|
|
156
|
+
>
|
|
157
|
+
<SelectValue {...(placeholder ? { placeholder } : {})} />
|
|
158
|
+
</SelectTrigger>
|
|
159
|
+
<SelectContent>
|
|
160
|
+
{(field.options ?? []).map((option) => (
|
|
161
|
+
<SelectItem key={option.value} value={option.value}>
|
|
162
|
+
{text(option.label)}
|
|
163
|
+
</SelectItem>
|
|
164
|
+
))}
|
|
165
|
+
</SelectContent>
|
|
166
|
+
</Select>
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
control = (
|
|
170
|
+
<Input
|
|
171
|
+
{...common}
|
|
172
|
+
// A url field takes text: no browser URL check, so the server's plain refusal is the rule.
|
|
173
|
+
type={field.inputType === "url" ? "text" : field.inputType}
|
|
174
|
+
{...(field.inputType === "url" ? { inputMode: "url" as const } : {})}
|
|
175
|
+
value={value}
|
|
176
|
+
placeholder={placeholder}
|
|
177
|
+
onChange={onChange}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div key={field.name} className="flex flex-col gap-2">
|
|
184
|
+
<div className="flex items-baseline gap-2">
|
|
185
|
+
<Label htmlFor={controlId}>{text(field.copy.label)}</Label>
|
|
186
|
+
{field.required ? null : (
|
|
187
|
+
<Text as="span" variant="caption">
|
|
188
|
+
{labels.optional}
|
|
189
|
+
</Text>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
{control}
|
|
193
|
+
{help ? (
|
|
194
|
+
<Text as="p" id={helpId} variant="caption">
|
|
195
|
+
{help}
|
|
196
|
+
</Text>
|
|
197
|
+
) : null}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const titled = form.steps.length > 1;
|
|
203
|
+
return (
|
|
204
|
+
<form onSubmit={onSubmit} className="flex flex-col gap-4" data-testid="new-quote-form">
|
|
205
|
+
{form.steps.map((step) => (
|
|
206
|
+
<div key={step.key} className="flex flex-col gap-4">
|
|
207
|
+
{titled ? (
|
|
208
|
+
<Heading level={3} visual={4} className="!text-body font-semibold">
|
|
209
|
+
{text(step.copy.title)}
|
|
210
|
+
</Heading>
|
|
211
|
+
) : null}
|
|
212
|
+
{step.fields.map(renderField)}
|
|
213
|
+
</div>
|
|
214
|
+
))}
|
|
215
|
+
{state.kind === "error" ? <NewPopupRefusal code={state.code} labels={labels} /> : null}
|
|
216
|
+
<div>
|
|
217
|
+
<Button
|
|
218
|
+
type="submit"
|
|
219
|
+
disabled={pending}
|
|
220
|
+
// 44px on a phone (a touch-first context); the 36px primary from md up.
|
|
221
|
+
className="max-md:min-h-11"
|
|
222
|
+
data-testid="new-quote-submit"
|
|
223
|
+
>
|
|
224
|
+
{pending ? labels.submitting : labels.submit}
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
</form>
|
|
228
|
+
);
|
|
229
|
+
}
|