create-ailk 0.19.0 → 0.20.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/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* account/agreements-actions.ts — the account home's carried
|
|
5
|
+
* sign-up-agreement CONSUME step (#8438 D13, spec
|
|
6
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
|
|
7
|
+
*
|
|
8
|
+
* `apps/web` never touches the database (the ledger writer is `apps/api`),
|
|
9
|
+
* and Better Auth creates the user from the sign-up card before any Person
|
|
10
|
+
* exists to record a grant against. So the card remembers which agreements
|
|
11
|
+
* the person ticked, in a short-lived, signed, email-bound cookie — this
|
|
12
|
+
* file's `acceptCarriedAgreementsAction`, called once on the account home's
|
|
13
|
+
* first signed-in render (`agreements-prompt.tsx`), decodes it and writes
|
|
14
|
+
* each missing agreement with `via: "sign_up"`, then deletes the cookie
|
|
15
|
+
* either way (single-use).
|
|
16
|
+
*
|
|
17
|
+
* The MINT side, `rememberSignUpAgreementsAction`, lives in
|
|
18
|
+
* `(auth)/sign-up/sign-up.actions.ts` instead of here, deliberately:
|
|
19
|
+
* `(auth)/**` is one of the sign-in SEEDS a sign-in-less spoke drops
|
|
20
|
+
* (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), and
|
|
21
|
+
* `agreements-prompt.tsx` — this directory's own file, not a sign-in path —
|
|
22
|
+
* must never reach into a dropped seed, or the scaffolder's own
|
|
23
|
+
* dangling-import check (`spokeExclusions`) refuses the spoke build. So the
|
|
24
|
+
* two sides duplicate the small cookie codec (encode there, decode here)
|
|
25
|
+
* rather than share a module, precisely so each side's dependencies stay
|
|
26
|
+
* native to where it lives.
|
|
27
|
+
*
|
|
28
|
+
* Kept as its OWN file, separate from this directory's `actions.ts`
|
|
29
|
+
* (Billing's `openBillingPortal`): that file imports `billing-source.ts`,
|
|
30
|
+
* which pulls in `account-data.ts`, `@working-theory/ui` and
|
|
31
|
+
* `lucide-react` — a far heavier footprint than this small, self-contained
|
|
32
|
+
* cookie codec needs. Keeping it apart means a test (or a bundler) that
|
|
33
|
+
* reaches this file touches nothing from that tree.
|
|
34
|
+
*
|
|
35
|
+
* Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
|
|
36
|
+
* <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
|
|
37
|
+
* dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
|
|
38
|
+
* is unset. That secret is read directly HERE rather than importing
|
|
39
|
+
* `resolveSecret` from `auth.ts`: that module statically imports the
|
|
40
|
+
* ESM-only `better-auth` package, which every broad page-level test suite
|
|
41
|
+
* that renders the account area would then have to mock. Verified with a
|
|
42
|
+
* constant-time comparison; never trusted without it.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
46
|
+
|
|
47
|
+
import { member } from "@working-theory/api-client";
|
|
48
|
+
import { cookies, headers } from "next/headers";
|
|
49
|
+
|
|
50
|
+
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
51
|
+
import { readSession } from "../../../../lib/topology.generated";
|
|
52
|
+
|
|
53
|
+
const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
|
|
54
|
+
const DEV_COOKIE_NAME = "ailk-signup-agreements";
|
|
55
|
+
const MAX_AGE_MS = 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/** Mirrors `sign-up.actions.ts`'s own copy — see that file's header. */
|
|
58
|
+
const DEV_FALLBACK_SECRET =
|
|
59
|
+
"ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
60
|
+
|
|
61
|
+
/** Mirrors `sign-up.actions.ts`'s own copy exactly — see that file's header. */
|
|
62
|
+
function signUpAgreementsSecret(): string {
|
|
63
|
+
const configured = process.env.BETTER_AUTH_SECRET;
|
|
64
|
+
if (configured) return configured;
|
|
65
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
66
|
+
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"BETTER_AUTH_SECRET is required in production: it verifies the carried sign-up-agreement cookie.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return DEV_FALLBACK_SECRET;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cookieName(): string {
|
|
75
|
+
return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function emailHash(email: string): string {
|
|
79
|
+
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sign(payload: string): string {
|
|
83
|
+
return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Constant-time string equality — never a `===` on attacker-reachable input. */
|
|
87
|
+
function safeEqual(a: string, b: string): boolean {
|
|
88
|
+
const bufA = Buffer.from(a);
|
|
89
|
+
const bufB = Buffer.from(b);
|
|
90
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface CarriedPurpose {
|
|
94
|
+
id: string;
|
|
95
|
+
version: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface DecodedCarriedAgreements {
|
|
99
|
+
purposes: CarriedPurpose[];
|
|
100
|
+
emailHash: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Verifies the HMAC and the one-hour window; `null` on any failure. */
|
|
104
|
+
function decodeCarriedAgreements(value: string): DecodedCarriedAgreements | null {
|
|
105
|
+
const parts = value.split("|");
|
|
106
|
+
if (parts.length !== 4) return null;
|
|
107
|
+
const [purposesPart, issuedAtPart, emailHashPart, signature] = parts as [
|
|
108
|
+
string,
|
|
109
|
+
string,
|
|
110
|
+
string,
|
|
111
|
+
string,
|
|
112
|
+
];
|
|
113
|
+
if (!safeEqual(sign(`${purposesPart}|${issuedAtPart}|${emailHashPart}`), signature)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const issuedAtMs = Number(issuedAtPart);
|
|
118
|
+
if (!Number.isFinite(issuedAtMs) || Date.now() - issuedAtMs > MAX_AGE_MS) return null;
|
|
119
|
+
|
|
120
|
+
const purposes: CarriedPurpose[] = [];
|
|
121
|
+
for (const entry of purposesPart.split(",")) {
|
|
122
|
+
const [id, versionStr] = entry.split("@");
|
|
123
|
+
const version = Number(versionStr);
|
|
124
|
+
if (!id || !Number.isFinite(version)) return null;
|
|
125
|
+
purposes.push({ id, version });
|
|
126
|
+
}
|
|
127
|
+
if (purposes.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
return { purposes, emailHash: emailHashPart };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface AcceptCarriedAgreementsResult {
|
|
133
|
+
accepted: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Consumes the sign-up card's carried-agreement cookie — see the file
|
|
138
|
+
* header for the mint/consume split. Writes every id in `missingPurposeIds`
|
|
139
|
+
* with `via: "sign_up"` only when the cookie is valid, fresh, bound to the
|
|
140
|
+
* signed-in (verified) session's own email, and covers every one of those
|
|
141
|
+
* ids; otherwise writes nothing. The cookie is deleted either way once
|
|
142
|
+
* found, so this never runs twice for the same cookie.
|
|
143
|
+
*/
|
|
144
|
+
export async function acceptCarriedAgreementsAction(
|
|
145
|
+
missingPurposeIds: string[],
|
|
146
|
+
): Promise<AcceptCarriedAgreementsResult> {
|
|
147
|
+
const store = await cookies();
|
|
148
|
+
const name = cookieName();
|
|
149
|
+
const raw = store.get(name)?.value;
|
|
150
|
+
if (!raw) return { accepted: false };
|
|
151
|
+
store.delete(name);
|
|
152
|
+
|
|
153
|
+
const decoded = decodeCarriedAgreements(raw);
|
|
154
|
+
if (!decoded) return { accepted: false };
|
|
155
|
+
|
|
156
|
+
const session = await readSession(await headers());
|
|
157
|
+
if (!session || !session.user.emailVerified) return { accepted: false };
|
|
158
|
+
if (decoded.emailHash !== emailHash(session.user.email)) return { accepted: false };
|
|
159
|
+
|
|
160
|
+
const carriedIds = new Set(decoded.purposes.map((p) => p.id));
|
|
161
|
+
if (!missingPurposeIds.every((id) => carriedIds.has(id))) return { accepted: false };
|
|
162
|
+
|
|
163
|
+
const init = await forwardedCookieInit();
|
|
164
|
+
const results = await Promise.all(
|
|
165
|
+
missingPurposeIds.map((purposeId) =>
|
|
166
|
+
member.setConsent({ purposeId, granted: true, via: "sign_up" }, init),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
return { accepted: results.every((r) => r.ok) };
|
|
170
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AgreementsPrompt — the account home's sign-up-agreement re-ask (#8438
|
|
5
|
+
* D13, spec docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
|
|
6
|
+
*
|
|
7
|
+
* Renders only when `missing` is non-empty — `A/page.tsx` computes that
|
|
8
|
+
* from `GET /v1/member/consents`: a `signUp` agreement with no OWN grant at
|
|
9
|
+
* its current `policyVersion` (D4 — never an any-source row an anonymous
|
|
10
|
+
* form attached, SEC-8438-1).
|
|
11
|
+
*
|
|
12
|
+
* On mount, tries the CARRIED case first: `acceptCarriedAgreementsAction`
|
|
13
|
+
* (this directory's own `agreements-actions.ts`) consumes the sign-up
|
|
14
|
+
* card's signed, email-bound cookie and writes each missing agreement with
|
|
15
|
+
* `via: "sign_up"`, so a member who just ticked the boxes on `/sign-up` is
|
|
16
|
+
* never asked twice. On success the page refreshes and this component is
|
|
17
|
+
* gone from the next render (`missing` becomes empty); on failure — no
|
|
18
|
+
* cookie, expired, wrong account, or it does not cover every missing id —
|
|
19
|
+
* the explicit card below shows instead.
|
|
20
|
+
*
|
|
21
|
+
* Dynamically imported, never a static import, mirroring the generated
|
|
22
|
+
* seam's own idiom (`topology.generated.ts`'s `readSession` imports
|
|
23
|
+
* `../auth` dynamically): `./agreements-actions` reads a session and posts
|
|
24
|
+
* through `@working-theory/api-client`, the same database/sign-in-adjacent
|
|
25
|
+
* surface the seam exists to keep lazy. The cookie it consumes is minted by
|
|
26
|
+
* `(auth)/sign-up/sign-up.actions.ts` — a sign-in SEED a sign-in-less spoke
|
|
27
|
+
* drops (`SPOKE_REMOVED_ROOTS`/`spokeExclusions`,
|
|
28
|
+
* `packages/create-ailk/src/topology.ts` D4) — but this file never reaches
|
|
29
|
+
* that module directly; the consume-side logic lives here instead,
|
|
30
|
+
* precisely so this directory carries no cross-boundary import into a seed
|
|
31
|
+
* a spoke prunes. A spoke member signs up on the hub (C6), so on a spoke
|
|
32
|
+
* there is simply no carried cookie to find — the "Otherwise" card below
|
|
33
|
+
* shows instead, exactly as it does for any other failed carried check.
|
|
34
|
+
*
|
|
35
|
+
* "Otherwise" card: the statement (plain text, as `AgreementList` reads it
|
|
36
|
+
* — no markdown-link parsing here either; see that component's own note)
|
|
37
|
+
* plus a link to its policy when the wire carries one, and one Accept
|
|
38
|
+
* button that posts every missing id through `setConsentAction` with no
|
|
39
|
+
* `via` (`account_settings`).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
43
|
+
import { useRouter } from "next/navigation";
|
|
44
|
+
import { useEffect, useRef, useState, type ReactElement } from "react";
|
|
45
|
+
|
|
46
|
+
import { setConsentAction } from "./privacy/actions";
|
|
47
|
+
|
|
48
|
+
export interface MissingAgreement {
|
|
49
|
+
id: string;
|
|
50
|
+
statement: string;
|
|
51
|
+
policyHref: string | null;
|
|
52
|
+
version: number;
|
|
53
|
+
/** True when this purpose has an OWN grant at an older version (D13's "updated" copy). */
|
|
54
|
+
hadPriorGrant: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AgreementsPromptLabels {
|
|
58
|
+
titleNew: string;
|
|
59
|
+
titleUpdated: string;
|
|
60
|
+
accept: string;
|
|
61
|
+
error: string;
|
|
62
|
+
/** The policy link's label (the same text Privacy's own policy link uses). */
|
|
63
|
+
policyLink: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AgreementsPromptProps {
|
|
67
|
+
missing: MissingAgreement[];
|
|
68
|
+
labels: AgreementsPromptLabels;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type CarriedCheck = "checking" | "hidden" | "show";
|
|
72
|
+
|
|
73
|
+
export function AgreementsPrompt({ missing, labels }: AgreementsPromptProps): ReactElement | null {
|
|
74
|
+
const router = useRouter();
|
|
75
|
+
const [state, setState] = useState<CarriedCheck>(missing.length === 0 ? "hidden" : "checking");
|
|
76
|
+
const [pending, setPending] = useState(false);
|
|
77
|
+
const [error, setError] = useState<string | null>(null);
|
|
78
|
+
const attempted = useRef(false);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (missing.length === 0) {
|
|
82
|
+
setState("hidden");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (attempted.current) return;
|
|
86
|
+
attempted.current = true;
|
|
87
|
+
let active = true;
|
|
88
|
+
void (async () => {
|
|
89
|
+
try {
|
|
90
|
+
const { acceptCarriedAgreementsAction } = await import("./agreements-actions");
|
|
91
|
+
const result = await acceptCarriedAgreementsAction(missing.map((m) => m.id));
|
|
92
|
+
if (!active) return;
|
|
93
|
+
if (result.accepted) {
|
|
94
|
+
setState("hidden");
|
|
95
|
+
router.refresh();
|
|
96
|
+
} else {
|
|
97
|
+
setState("show");
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
if (active) setState("show");
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
return () => {
|
|
104
|
+
active = false;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
}, [router]);
|
|
108
|
+
|
|
109
|
+
async function acceptAll(): Promise<void> {
|
|
110
|
+
setPending(true);
|
|
111
|
+
setError(null);
|
|
112
|
+
try {
|
|
113
|
+
const results = await Promise.all(missing.map((m) => setConsentAction(m.id, true)));
|
|
114
|
+
if (results.every((r) => r.ok)) {
|
|
115
|
+
// Mirrors the carried-cookie path: hide now, rather than leaving
|
|
116
|
+
// `state` at "show" across the refresh below — the mount-time
|
|
117
|
+
// effect that would otherwise notice `missing` is now empty never
|
|
118
|
+
// re-runs (its deps are `[router]`, not `missing`), since this is
|
|
119
|
+
// the SAME component instance, not a fresh mount.
|
|
120
|
+
setState("hidden");
|
|
121
|
+
router.refresh();
|
|
122
|
+
} else {
|
|
123
|
+
setError(labels.error);
|
|
124
|
+
setPending(false);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
setError(labels.error);
|
|
128
|
+
setPending(false);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (state !== "show") return null;
|
|
133
|
+
|
|
134
|
+
const title = missing.some((m) => m.hadPriorGrant) ? labels.titleUpdated : labels.titleNew;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div
|
|
138
|
+
className="mb-block-loose flex flex-col gap-3 rounded-control border-hairline border-(--color-border) bg-surface-secondary p-4"
|
|
139
|
+
data-testid="account-agreements-prompt"
|
|
140
|
+
>
|
|
141
|
+
<Heading level={2} visual={4}>
|
|
142
|
+
{title}
|
|
143
|
+
</Heading>
|
|
144
|
+
<div className="flex flex-col gap-2">
|
|
145
|
+
{missing.map((agreement) => (
|
|
146
|
+
<div key={agreement.id} data-testid={`account-agreements-prompt-${agreement.id}`}>
|
|
147
|
+
<Text as="p" variant="body-sm">
|
|
148
|
+
{agreement.statement}
|
|
149
|
+
</Text>
|
|
150
|
+
{agreement.policyHref ? (
|
|
151
|
+
<Link
|
|
152
|
+
href={agreement.policyHref}
|
|
153
|
+
className="text-body-sm"
|
|
154
|
+
data-testid={`account-agreements-prompt-${agreement.id}-policy`}
|
|
155
|
+
>
|
|
156
|
+
{labels.policyLink}
|
|
157
|
+
</Link>
|
|
158
|
+
) : null}
|
|
159
|
+
</div>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
{error ? (
|
|
163
|
+
<span role="alert" data-testid="account-agreements-prompt-error">
|
|
164
|
+
<Text as="span" variant="caption">
|
|
165
|
+
{error}
|
|
166
|
+
</Text>
|
|
167
|
+
</span>
|
|
168
|
+
) : null}
|
|
169
|
+
<div>
|
|
170
|
+
<Button
|
|
171
|
+
type="button"
|
|
172
|
+
disabled={pending}
|
|
173
|
+
onClick={() => void acceptAll()}
|
|
174
|
+
data-testid="account-agreements-prompt-accept"
|
|
175
|
+
>
|
|
176
|
+
{labels.accept}
|
|
177
|
+
</Button>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
* decoded. No session, no match or a 404 read is a not-found; any other
|
|
23
23
|
* failed read renders the error row with Retry (spec 7423 D12). Titles,
|
|
24
24
|
* notes and comments render as text only.
|
|
25
|
+
*
|
|
26
|
+
* A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
|
|
27
|
+
* D19) is the same page with its own words: the caption "Proposal", its
|
|
28
|
+
* price ("Price <amount>", formatted from minor units), and the review form
|
|
29
|
+
* as "Your decision on version <n>" with Approve proposal / Request changes.
|
|
30
|
+
* In the latest version's place, once approved: Pay now (`pay-form.tsx`)
|
|
31
|
+
* while the payment is due, "Paid <date>" once paid, and — back from
|
|
32
|
+
* Stripe's success page (`?paid=1`) before the webhook lands — the pending
|
|
33
|
+
* line instead of Pay now. A deliverable, or an older hub's artifact with
|
|
34
|
+
* no `kind`, renders exactly as before.
|
|
25
35
|
*/
|
|
26
36
|
|
|
27
37
|
import { InboxItemFrame } from "@working-theory/templates/account";
|
|
@@ -41,16 +51,20 @@ import { accountSession, logReadFailure } from "../../account-data";
|
|
|
41
51
|
import { ErrorRow } from "../../error-row";
|
|
42
52
|
import {
|
|
43
53
|
artifactState,
|
|
54
|
+
isProposal,
|
|
44
55
|
isReviewable,
|
|
45
56
|
loadArtifacts,
|
|
57
|
+
paymentDue,
|
|
46
58
|
STATE_KEYS,
|
|
47
59
|
versionFileHref,
|
|
48
60
|
} from "../artifact-data";
|
|
49
61
|
|
|
62
|
+
import { PayForm, type PayFormLabels } from "./pay-form";
|
|
50
63
|
import { ReviewForm, type ReviewFormLabels } from "./review-form";
|
|
51
64
|
|
|
52
65
|
interface Props {
|
|
53
66
|
params: Promise<{ locale: string; pieceId: string }>;
|
|
67
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
/**
|
|
@@ -65,6 +79,25 @@ function decodeSegment(segment: string): string {
|
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A proposal's price in the reader's locale. The hub's answer is not parsed
|
|
84
|
+
* here, so a currency Intl cannot format falls back to the amount and code
|
|
85
|
+
* rather than erroring the page.
|
|
86
|
+
*/
|
|
87
|
+
function formatPrice(
|
|
88
|
+
price: { amount: number; currency: string },
|
|
89
|
+
locale: string,
|
|
90
|
+
): string {
|
|
91
|
+
try {
|
|
92
|
+
return new Intl.NumberFormat(locale, {
|
|
93
|
+
style: "currency",
|
|
94
|
+
currency: price.currency.toUpperCase(),
|
|
95
|
+
}).format(price.amount / 100);
|
|
96
|
+
} catch {
|
|
97
|
+
return `${String(price.amount / 100)} ${price.currency.toUpperCase()}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
68
101
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
69
102
|
const { locale } = await params;
|
|
70
103
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
@@ -73,6 +106,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
73
106
|
|
|
74
107
|
export default async function AccountArtifactPage({
|
|
75
108
|
params,
|
|
109
|
+
searchParams,
|
|
76
110
|
}: Props): Promise<ReactElement> {
|
|
77
111
|
const { locale, pieceId: segment } = await params;
|
|
78
112
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
@@ -108,11 +142,31 @@ export default async function AccountArtifactPage({
|
|
|
108
142
|
const state = artifactState(artifact);
|
|
109
143
|
const reviewable = isReviewable(artifact);
|
|
110
144
|
const latest = artifact.versions[0]!.version;
|
|
145
|
+
const proposal = isProposal(artifact);
|
|
146
|
+
const price =
|
|
147
|
+
proposal && artifact.price ? formatPrice(artifact.price, locale) : null;
|
|
148
|
+
const due = proposal && paymentDue(artifact);
|
|
149
|
+
// Stripe's success page returns here with `?paid=1`; until the webhook
|
|
150
|
+
// stamps the payment, the page says so instead of offering Pay now again.
|
|
151
|
+
const returnedPaid = due && (await searchParams)?.paid === "1";
|
|
152
|
+
const paidAt =
|
|
153
|
+
proposal && artifact.payment?.status === "paid"
|
|
154
|
+
? artifact.payment.paidAt
|
|
155
|
+
: null;
|
|
156
|
+
const payLabels: PayFormLabels = {
|
|
157
|
+
pay: t("artifacts.proposal.pay"),
|
|
158
|
+
paying: t("artifacts.proposal.paying"),
|
|
159
|
+
error: t("artifacts.proposal.payError"),
|
|
160
|
+
};
|
|
111
161
|
const labels: ReviewFormLabels = {
|
|
112
|
-
heading:
|
|
162
|
+
heading: proposal
|
|
163
|
+
? t("artifacts.proposal.heading", { version: latest })
|
|
164
|
+
: t("artifacts.review.heading", { version: latest }),
|
|
113
165
|
comment: t("artifacts.review.comment"),
|
|
114
166
|
commentHint: t("artifacts.review.commentHint"),
|
|
115
|
-
approve:
|
|
167
|
+
approve: proposal
|
|
168
|
+
? t("artifacts.proposal.approve")
|
|
169
|
+
: t("artifacts.review.approve"),
|
|
116
170
|
requestChanges: t("artifacts.review.requestChanges"),
|
|
117
171
|
submitting: t("artifacts.review.submitting"),
|
|
118
172
|
sent: t("artifacts.review.sent"),
|
|
@@ -122,6 +176,7 @@ export default async function AccountArtifactPage({
|
|
|
122
176
|
not_latest: t("artifacts.review.errors.notLatest"),
|
|
123
177
|
rate_limited: t("artifacts.review.errors.rateLimited"),
|
|
124
178
|
failed: t("artifacts.review.errors.failed"),
|
|
179
|
+
payment_failed: t("artifacts.proposal.payError"),
|
|
125
180
|
},
|
|
126
181
|
};
|
|
127
182
|
|
|
@@ -163,7 +218,9 @@ export default async function AccountArtifactPage({
|
|
|
163
218
|
href: localeHref(locale, "/account/artifacts"),
|
|
164
219
|
label: t("artifacts.back"),
|
|
165
220
|
}}
|
|
166
|
-
caption={
|
|
221
|
+
caption={
|
|
222
|
+
proposal ? t("artifacts.proposal.caption") : t("artifacts.caption")
|
|
223
|
+
}
|
|
167
224
|
title={artifact.title}
|
|
168
225
|
status={t(STATE_KEYS[state])}
|
|
169
226
|
lead={
|
|
@@ -173,6 +230,13 @@ export default async function AccountArtifactPage({
|
|
|
173
230
|
}
|
|
174
231
|
testIdPrefix="artifact"
|
|
175
232
|
>
|
|
233
|
+
{price !== null ? (
|
|
234
|
+
<div className="mt-block-tight" data-testid="artifact-price">
|
|
235
|
+
<Text as="p" variant="body" className="font-semibold">
|
|
236
|
+
{t("artifacts.proposal.price", { amount: price })}
|
|
237
|
+
</Text>
|
|
238
|
+
</div>
|
|
239
|
+
) : null}
|
|
176
240
|
<section className="mt-block-loose">
|
|
177
241
|
<Heading level={2} visual={4} className="!text-body font-semibold">
|
|
178
242
|
{t("artifacts.versions")}
|
|
@@ -231,8 +295,29 @@ export default async function AccountArtifactPage({
|
|
|
231
295
|
pieceId={artifact.id}
|
|
232
296
|
version={v.version}
|
|
233
297
|
labels={labels}
|
|
298
|
+
pay={proposal ? payLabels : undefined}
|
|
234
299
|
/>
|
|
235
300
|
) : null}
|
|
301
|
+
{v.version === latest && due && !returnedPaid ? (
|
|
302
|
+
<PayForm pieceId={artifact.id} labels={payLabels} />
|
|
303
|
+
) : null}
|
|
304
|
+
{v.version === latest && returnedPaid ? (
|
|
305
|
+
<div
|
|
306
|
+
className="mt-block-tight"
|
|
307
|
+
data-testid="artifact-paid-pending"
|
|
308
|
+
>
|
|
309
|
+
<Text as="p" variant="body-sm">
|
|
310
|
+
{t("artifacts.proposal.paidPending")}
|
|
311
|
+
</Text>
|
|
312
|
+
</div>
|
|
313
|
+
) : null}
|
|
314
|
+
{v.version === latest && paidAt !== null ? (
|
|
315
|
+
<div className="mt-block-tight" data-testid="artifact-paid">
|
|
316
|
+
<Text as="p" variant="body-sm">
|
|
317
|
+
{t("artifacts.proposal.paid", { date: date(paidAt) })}
|
|
318
|
+
</Text>
|
|
319
|
+
</div>
|
|
320
|
+
) : null}
|
|
236
321
|
{v.version === latest && state === "changes-requested" ? (
|
|
237
322
|
<div
|
|
238
323
|
className="mt-block-tight"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client"; // holds the Pay now Server Action's state: its pending label and error line
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PayForm — Pay now on an approved proposal (#8516, spec
|
|
5
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19). The artifact
|
|
6
|
+
* page renders it in the latest version's place while the payment is due.
|
|
7
|
+
*
|
|
8
|
+
* One primary button and nothing else: no input of any kind, so no card,
|
|
9
|
+
* number or expiry field can ever live here — the client pays on Stripe's
|
|
10
|
+
* hosted Checkout page, which `payForProposal` redirects to. The piece goes
|
|
11
|
+
* as the button's own name and value, and the amount is the API's, from the
|
|
12
|
+
* stored proposal. A payment that could not open shows the error line; a
|
|
13
|
+
* successful one never returns. Every string arrives through `labels`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Button, InlineMessage } from "@working-theory/ui/primitives";
|
|
17
|
+
import { useActionState, type ReactElement } from "react";
|
|
18
|
+
|
|
19
|
+
import { payForProposal, type ProposalPayState } from "../artifact-actions";
|
|
20
|
+
|
|
21
|
+
export interface PayFormLabels {
|
|
22
|
+
pay: string;
|
|
23
|
+
paying: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function PayForm({
|
|
28
|
+
pieceId,
|
|
29
|
+
labels,
|
|
30
|
+
}: {
|
|
31
|
+
pieceId: string;
|
|
32
|
+
labels: PayFormLabels;
|
|
33
|
+
}): ReactElement {
|
|
34
|
+
const [state, formAction, pending] = useActionState<
|
|
35
|
+
ProposalPayState,
|
|
36
|
+
FormData
|
|
37
|
+
>(payForProposal, { kind: "idle" });
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<form
|
|
41
|
+
action={formAction}
|
|
42
|
+
className="mt-block-tight flex flex-col gap-4"
|
|
43
|
+
data-testid="artifact-pay-form"
|
|
44
|
+
>
|
|
45
|
+
{state.kind === "error" ? (
|
|
46
|
+
<InlineMessage variant="error" data-testid="artifact-pay-error">
|
|
47
|
+
{labels.error}
|
|
48
|
+
</InlineMessage>
|
|
49
|
+
) : null}
|
|
50
|
+
<div>
|
|
51
|
+
<Button
|
|
52
|
+
type="submit"
|
|
53
|
+
name="pieceId"
|
|
54
|
+
value={pieceId}
|
|
55
|
+
// The page's one primary action: a 44px target at every width.
|
|
56
|
+
size="big"
|
|
57
|
+
disabled={pending}
|
|
58
|
+
data-testid="artifact-pay"
|
|
59
|
+
>
|
|
60
|
+
{pending ? labels.paying : labels.pay}
|
|
61
|
+
</Button>
|
|
62
|
+
</div>
|
|
63
|
+
</form>
|
|
64
|
+
);
|
|
65
|
+
}
|
package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* (AC-MAA-07). A review that went through shows the sent line and refreshes
|
|
11
11
|
* the page once, which re-reads the artifact and shows Approved or the
|
|
12
12
|
* waiting line in the form's place. Every string arrives through `labels`.
|
|
13
|
+
*
|
|
14
|
+
* A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
|
|
15
|
+
* D19) passes its own heading and Approve label, and `pay`: an approval
|
|
16
|
+
* whose payment could not open (`payment_failed` — the review itself is
|
|
17
|
+
* recorded) shows the error line and Pay now in the form's place, so the
|
|
18
|
+
* client can try again at once. A deliverable passes no `pay` and renders
|
|
19
|
+
* as before.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import {
|
|
@@ -36,6 +43,8 @@ import {
|
|
|
36
43
|
type ArtifactReviewState,
|
|
37
44
|
} from "../artifact-actions";
|
|
38
45
|
|
|
46
|
+
import { PayForm, type PayFormLabels } from "./pay-form";
|
|
47
|
+
|
|
39
48
|
export interface ReviewFormLabels {
|
|
40
49
|
heading: string;
|
|
41
50
|
comment: string;
|
|
@@ -51,10 +60,13 @@ export function ReviewForm({
|
|
|
51
60
|
pieceId,
|
|
52
61
|
version,
|
|
53
62
|
labels,
|
|
63
|
+
pay,
|
|
54
64
|
}: {
|
|
55
65
|
pieceId: string;
|
|
56
66
|
version: number;
|
|
57
67
|
labels: ReviewFormLabels;
|
|
68
|
+
/** A proposal's Pay now labels, for an approval whose payment did not open. */
|
|
69
|
+
pay?: PayFormLabels | undefined;
|
|
58
70
|
}): ReactElement {
|
|
59
71
|
const commentId = useId();
|
|
60
72
|
const hintId = useId();
|
|
@@ -71,6 +83,21 @@ export function ReviewForm({
|
|
|
71
83
|
if (reviewed) router.refresh();
|
|
72
84
|
}, [reviewed, router]);
|
|
73
85
|
|
|
86
|
+
if (
|
|
87
|
+
pay !== undefined &&
|
|
88
|
+
state.kind === "error" &&
|
|
89
|
+
state.reason === "payment_failed"
|
|
90
|
+
) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="mt-block-tight flex flex-col gap-4">
|
|
93
|
+
<InlineMessage variant="error" data-testid="artifact-review-error">
|
|
94
|
+
{labels.errors.payment_failed}
|
|
95
|
+
</InlineMessage>
|
|
96
|
+
<PayForm pieceId={pieceId} labels={pay} />
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
if (reviewed) {
|
|
75
102
|
return (
|
|
76
103
|
<InlineMessage
|