@cosmicdrift/kumiko-samples 0.397.0 → 0.398.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/package.json +1 -1
- package/packages/bundled-features/package.json +4 -4
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +19 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +44 -5
- package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +21 -18
- package/packages/bundled-features/src/billing-foundation/i18n.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +3 -1
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +14 -51
- package/packages/bundled-features/src/billing-foundation/web/consent-fields.tsx +111 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +14 -58
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.398.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.398.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -179,10 +179,10 @@
|
|
|
179
179
|
"@types/qrcode": "^1.5.5",
|
|
180
180
|
"clsx": "^2.1.1",
|
|
181
181
|
"happy-dom": "^21.0.4",
|
|
182
|
-
"hono": "^4.13.
|
|
182
|
+
"hono": "^4.13.14",
|
|
183
183
|
"imapflow": "^1.7.8",
|
|
184
184
|
"ioredis": "^5.10.1",
|
|
185
|
-
"jose": "^6.2.
|
|
185
|
+
"jose": "^6.2.13",
|
|
186
186
|
"lucide-react": "^1.14.0",
|
|
187
187
|
"mailparser": "^3.12.4",
|
|
188
188
|
"marked": "^18.0.3",
|
|
@@ -752,7 +752,7 @@
|
|
|
752
752
|
"LICENSE"
|
|
753
753
|
],
|
|
754
754
|
"devDependencies": {
|
|
755
|
-
"@testing-library/user-event": "^14.6.
|
|
755
|
+
"@testing-library/user-event": "^14.6.8",
|
|
756
756
|
"@cosmicdrift/kumiko-locale-de": "workspace:*",
|
|
757
757
|
"@cosmicdrift/kumiko-locale-es": "workspace:*",
|
|
758
758
|
"@cosmicdrift/kumiko-locale-fr": "workspace:*",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.398.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Consent box takes a refund policy link and an optional documents checkbox",
|
|
6
|
+
"detail": "`legalLinks.refund` is rendered next to the other links in the checkout and plan-switch dialogs. `consumerProtection.documentsAcknowledgement: true` adds a required first checkbox whose text depends on whether a refund link is set for the locale. The text joins `consentTextVersion` only when the option is on, `start-plan-checkout`, `create-checkout-session` and `switch-plan` then reject a consent without `documentsAcknowledged: true` with 422 `consent_required`, and the recorded consent stores the flag. Without the option nothing changes, including the version. New i18n key `billing-foundation.consent.link.refund`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.394.0",
|
|
4
10
|
"type": "improvement",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts
CHANGED
|
@@ -20,7 +20,12 @@ import {
|
|
|
20
20
|
SUBSCRIPTION_AGGREGATE_TYPE,
|
|
21
21
|
} from "../events.ts";
|
|
22
22
|
import type { ConsumerProtectionOptions, ProviderPrice } from "../types.ts";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
consentTextVersion,
|
|
25
|
+
documentsAcknowledgementTextFor,
|
|
26
|
+
resolveConsentLocale,
|
|
27
|
+
submitMessageFor,
|
|
28
|
+
} from "./consent-text.ts";
|
|
24
29
|
|
|
25
30
|
export type CheckoutMode = "subscription" | "payment";
|
|
26
31
|
|
|
@@ -30,6 +35,7 @@ export const consentPayloadSchema = z
|
|
|
30
35
|
.object({
|
|
31
36
|
earlyPerformanceRequested: z.boolean(),
|
|
32
37
|
withdrawalLossAcknowledged: z.boolean(),
|
|
38
|
+
documentsAcknowledged: z.boolean().optional(),
|
|
33
39
|
consentTextVersion: z.string().min(1).max(64),
|
|
34
40
|
locale: z.string().min(2).max(35),
|
|
35
41
|
})
|
|
@@ -42,6 +48,7 @@ export type PreparedConsent = {
|
|
|
42
48
|
readonly locale: string;
|
|
43
49
|
readonly submitMessage: string;
|
|
44
50
|
readonly consentTextVersion: string;
|
|
51
|
+
readonly documentsAcknowledged: boolean;
|
|
45
52
|
readonly termsHash: string;
|
|
46
53
|
readonly termsTemplateVersion: number;
|
|
47
54
|
};
|
|
@@ -88,14 +95,20 @@ export async function prepareConsent(
|
|
|
88
95
|
consent: ConsentPayload | undefined,
|
|
89
96
|
mode: CheckoutMode,
|
|
90
97
|
): Promise<PreparedConsent> {
|
|
91
|
-
|
|
98
|
+
const documentsRequired = options.documentsAcknowledgement === true;
|
|
99
|
+
if (
|
|
100
|
+
!consent?.earlyPerformanceRequested ||
|
|
101
|
+
!consent.withdrawalLossAcknowledged ||
|
|
102
|
+
(documentsRequired && consent.documentsAcknowledged !== true)
|
|
103
|
+
) {
|
|
92
104
|
throw new UnprocessableError("consent_required", {
|
|
93
105
|
i18nKey: "billing-foundation.errors.consentRequired",
|
|
94
|
-
message: "billing-foundation:
|
|
106
|
+
message: "billing-foundation: all consent flags must be true to start a checkout",
|
|
95
107
|
});
|
|
96
108
|
}
|
|
97
109
|
const locale = resolveConsentLocale(consent.locale);
|
|
98
|
-
|
|
110
|
+
const documentsText = documentsAcknowledgementTextFor(options, locale);
|
|
111
|
+
if (consent.consentTextVersion !== consentTextVersion(locale, documentsText)) {
|
|
99
112
|
throw new UnprocessableError("consent_text_outdated", {
|
|
100
113
|
i18nKey: "billing-foundation.errors.consentTextOutdated",
|
|
101
114
|
message: `billing-foundation: consent text version "${consent.consentTextVersion}" is not current for locale "${locale}"`,
|
|
@@ -128,6 +141,7 @@ export async function prepareConsent(
|
|
|
128
141
|
locale,
|
|
129
142
|
submitMessage: submitMessageFor(locale, mode),
|
|
130
143
|
consentTextVersion: consent.consentTextVersion,
|
|
144
|
+
documentsAcknowledged: documentsRequired,
|
|
131
145
|
termsHash: createHash("sha256").update(terms.content).digest("hex"),
|
|
132
146
|
termsTemplateVersion: terms.version,
|
|
133
147
|
};
|
|
@@ -160,6 +174,7 @@ export async function recordConsent(
|
|
|
160
174
|
interval: details.price?.interval ?? null,
|
|
161
175
|
intervalCount: details.price?.intervalCount ?? null,
|
|
162
176
|
consentTextVersion: prepared.consentTextVersion,
|
|
177
|
+
...(prepared.documentsAcknowledged && { documentsAcknowledged: true }),
|
|
163
178
|
termsHash: prepared.termsHash,
|
|
164
179
|
termsTemplateVersion: prepared.termsTemplateVersion,
|
|
165
180
|
locale: prepared.locale,
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { type ConsumerProtectionOptions, isLegalLinkSet, type LegalLinkSet } from "../types.ts";
|
|
2
3
|
import type { ConsentLocale } from "../web/consumer-protection/consent-locale.ts";
|
|
3
4
|
|
|
4
5
|
export {
|
|
@@ -39,14 +40,52 @@ export const CONSENT_TEXTS: Readonly<Record<ConsentLocale, ConsentTexts>> = {
|
|
|
39
40
|
},
|
|
40
41
|
};
|
|
41
42
|
|
|
43
|
+
const DOCUMENTS_ACKNOWLEDGEMENT_TEXTS: Readonly<
|
|
44
|
+
Record<ConsentLocale, { readonly withRefund: string; readonly withoutRefund: string }>
|
|
45
|
+
> = {
|
|
46
|
+
de: {
|
|
47
|
+
withRefund:
|
|
48
|
+
"Ich habe die AGB, die Erstattungsrichtlinie und die Datenschutzerklärung gelesen. Mit den AGB und der Erstattungsrichtlinie bin ich einverstanden.",
|
|
49
|
+
withoutRefund:
|
|
50
|
+
"Ich habe die AGB und die Datenschutzerklärung gelesen. Mit den AGB bin ich einverstanden.",
|
|
51
|
+
},
|
|
52
|
+
en: {
|
|
53
|
+
withRefund:
|
|
54
|
+
"I have read the Terms, the Refund Policy and the Privacy Policy, and I agree to the Terms and the Refund Policy.",
|
|
55
|
+
withoutRefund: "I have read the Terms and the Privacy Policy, and I agree to the Terms.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function legalLinksPerLocale(
|
|
60
|
+
legalLinks: ConsumerProtectionOptions["legalLinks"],
|
|
61
|
+
): Readonly<Record<ConsentLocale, LegalLinkSet>> {
|
|
62
|
+
return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The documents checkbox text for a locale, or undefined when the app did not
|
|
66
|
+
* opt in. The variant follows whether that locale has a refund link. */
|
|
67
|
+
export function documentsAcknowledgementTextFor(
|
|
68
|
+
options: ConsumerProtectionOptions,
|
|
69
|
+
locale: ConsentLocale,
|
|
70
|
+
): string | undefined {
|
|
71
|
+
if (!options.documentsAcknowledgement) return undefined;
|
|
72
|
+
const texts = DOCUMENTS_ACKNOWLEDGEMENT_TEXTS[locale];
|
|
73
|
+
return legalLinksPerLocale(options.legalLinks)[locale].refund !== undefined
|
|
74
|
+
? texts.withRefund
|
|
75
|
+
: texts.withoutRefund;
|
|
76
|
+
}
|
|
77
|
+
|
|
42
78
|
const CONSENT_TEXT_VERSION_LENGTH = 16;
|
|
43
79
|
|
|
44
|
-
|
|
80
|
+
// The documents text joins the hash only when shown, so apps without the opt-in keep their version.
|
|
81
|
+
export function consentTextVersion(
|
|
82
|
+
locale: ConsentLocale,
|
|
83
|
+
documentsAcknowledgementText?: string,
|
|
84
|
+
): string {
|
|
45
85
|
const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
.slice(0, CONSENT_TEXT_VERSION_LENGTH);
|
|
86
|
+
const hash = createHash("sha256").update(`${earlyPerformance}\n${withdrawalLoss}`);
|
|
87
|
+
if (documentsAcknowledgementText !== undefined) hash.update(`\n${documentsAcknowledgementText}`);
|
|
88
|
+
return hash.digest("hex").slice(0, CONSENT_TEXT_VERSION_LENGTH);
|
|
50
89
|
}
|
|
51
90
|
|
|
52
91
|
export function submitMessageFor(locale: ConsentLocale, mode: "subscription" | "payment"): string {
|
|
@@ -139,6 +139,7 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
|
|
|
139
139
|
interval: z.string().nullable(),
|
|
140
140
|
intervalCount: z.number().nullable(),
|
|
141
141
|
consentTextVersion: z.string().min(1).max(64),
|
|
142
|
+
documentsAcknowledged: z.boolean().optional(),
|
|
142
143
|
termsHash: z.string().length(64),
|
|
143
144
|
termsTemplateVersion: z.number(),
|
|
144
145
|
locale: z.string().min(2).max(35),
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
type CONSENT_LOCALES,
|
|
14
14
|
CONSENT_TEXTS,
|
|
15
15
|
consentTextVersion,
|
|
16
|
+
documentsAcknowledgementTextFor,
|
|
17
|
+
legalLinksPerLocale,
|
|
16
18
|
} from "../consumer-protection/consent-text.ts";
|
|
17
19
|
import {
|
|
18
20
|
type BillingPlansBase,
|
|
@@ -21,14 +23,12 @@ import {
|
|
|
21
23
|
type ProviderPlanViews,
|
|
22
24
|
toBillingPlansResult,
|
|
23
25
|
} from "../plan-catalog.ts";
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
type LegalLinkSet,
|
|
31
|
-
type ResolvedBillingFoundationOptions,
|
|
26
|
+
import type {
|
|
27
|
+
BillingPlanCatalog,
|
|
28
|
+
BillingPlansResult,
|
|
29
|
+
BillingProviderView,
|
|
30
|
+
ConsumerProtectionOptions,
|
|
31
|
+
ResolvedBillingFoundationOptions,
|
|
32
32
|
} from "../types.ts";
|
|
33
33
|
|
|
34
34
|
const billingPlansSchema = z.object({}).strict();
|
|
@@ -52,8 +52,8 @@ export function createBillingPlansQuery(
|
|
|
52
52
|
...result,
|
|
53
53
|
consumerProtection: {
|
|
54
54
|
consentTexts: {
|
|
55
|
-
de: consentTextView("de"),
|
|
56
|
-
en: consentTextView("en"),
|
|
55
|
+
de: consentTextView(consumerProtection, "de"),
|
|
56
|
+
en: consentTextView(consumerProtection, "en"),
|
|
57
57
|
},
|
|
58
58
|
legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
|
|
59
59
|
},
|
|
@@ -116,13 +116,16 @@ async function providerView(
|
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function consentTextView(locale: (typeof CONSENT_LOCALES)[number]) {
|
|
119
|
+
function consentTextView(
|
|
120
|
+
consumerProtection: ConsumerProtectionOptions,
|
|
121
|
+
locale: (typeof CONSENT_LOCALES)[number],
|
|
122
|
+
) {
|
|
126
123
|
const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
|
|
127
|
-
|
|
124
|
+
const documentsAcknowledgement = documentsAcknowledgementTextFor(consumerProtection, locale);
|
|
125
|
+
return {
|
|
126
|
+
earlyPerformance,
|
|
127
|
+
withdrawalLoss,
|
|
128
|
+
...(documentsAcknowledgement !== undefined && { documentsAcknowledgement }),
|
|
129
|
+
consentTextVersion: consentTextVersion(locale, documentsAcknowledgement),
|
|
130
|
+
};
|
|
128
131
|
}
|
|
@@ -83,6 +83,7 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
83
83
|
"billing-foundation.consent.link.terms": { en: "Terms" },
|
|
84
84
|
"billing-foundation.consent.link.withdrawal": { en: "Withdrawal policy" },
|
|
85
85
|
"billing-foundation.consent.link.privacy": { en: "Privacy policy" },
|
|
86
|
+
"billing-foundation.consent.link.refund": { en: "Refund policy" },
|
|
86
87
|
"billing-foundation.consent.order": { en: "Order with obligation to pay" },
|
|
87
88
|
"billing-foundation.plans.priceFrom": { en: "from" },
|
|
88
89
|
"billing-foundation.consent.chooseProvider": { en: "Payment provider" },
|
|
@@ -459,6 +459,8 @@ export type LegalLinkSet = {
|
|
|
459
459
|
readonly terms: string;
|
|
460
460
|
readonly withdrawal: string;
|
|
461
461
|
readonly privacy: string;
|
|
462
|
+
/** Link to the refund policy; shown next to the other links when set. */
|
|
463
|
+
readonly refund?: string;
|
|
462
464
|
};
|
|
463
465
|
|
|
464
466
|
export function isLegalLinkSet(
|
|
@@ -510,6 +512,9 @@ export type ConsumerProtectionOptions = {
|
|
|
510
512
|
/** Root-relative paths or absolute https URLs. One set for every locale, or
|
|
511
513
|
* one set per consent locale (`de` and `en` required). */
|
|
512
514
|
readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
|
|
515
|
+
/** Adds a required first checkbox "documents read and accepted" (terms, privacy,
|
|
516
|
+
* and the refund policy when `legalLinks.refund` is set). Off by default. */
|
|
517
|
+
readonly documentsAcknowledgement?: boolean;
|
|
513
518
|
};
|
|
514
519
|
|
|
515
520
|
export type OneOffCheckoutRequest = {
|
|
@@ -621,6 +626,8 @@ export type BillingPlansResult = {
|
|
|
621
626
|
{
|
|
622
627
|
readonly earlyPerformance: string;
|
|
623
628
|
readonly withdrawalLoss: string;
|
|
629
|
+
/** Present only when `documentsAcknowledgement` is on. */
|
|
630
|
+
readonly documentsAcknowledgement?: string;
|
|
624
631
|
readonly consentTextVersion: string;
|
|
625
632
|
}
|
|
626
633
|
>
|
|
@@ -96,7 +96,9 @@ function validatePerLocaleLegalLinks(
|
|
|
96
96
|
|
|
97
97
|
function validateLegalLinkSet(set: LegalLinkSet, path: string): void {
|
|
98
98
|
for (const [key, link] of Object.entries(set)) {
|
|
99
|
-
|
|
99
|
+
// skip: refund is optional
|
|
100
|
+
if (key === "refund" && link === undefined) continue;
|
|
101
|
+
if (typeof link !== "string" || !isRootRelativeOrHttpsUrl(link)) {
|
|
100
102
|
throw new Error(
|
|
101
103
|
`createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`,
|
|
102
104
|
);
|
|
@@ -3,6 +3,7 @@ import type { WriteResult } from "@cosmicdrift/kumiko-renderer/headless";
|
|
|
3
3
|
import { ChoiceCards } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { type ReactNode, useState } from "react";
|
|
5
5
|
import type { BillingPlansResult } from "../types.ts";
|
|
6
|
+
import { ConsentCheckboxes, ConsentLinks, useConsentFieldState } from "./consent-fields.tsx";
|
|
6
7
|
import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
|
|
7
8
|
|
|
8
9
|
export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
|
|
@@ -12,6 +13,7 @@ type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection
|
|
|
12
13
|
export type CheckoutConsentPayload = {
|
|
13
14
|
readonly earlyPerformanceRequested: true;
|
|
14
15
|
readonly withdrawalLossAcknowledged: true;
|
|
16
|
+
readonly documentsAcknowledged?: true;
|
|
15
17
|
readonly consentTextVersion: string;
|
|
16
18
|
readonly locale: string;
|
|
17
19
|
};
|
|
@@ -60,36 +62,25 @@ export function CheckoutConsentDialog({
|
|
|
60
62
|
}: CheckoutConsentDialogProps): ReactNode {
|
|
61
63
|
const t = useTranslation();
|
|
62
64
|
const uiLocale = useLocale().locale();
|
|
63
|
-
const { Modal, Text,
|
|
64
|
-
const [earlyPerformance, setEarlyPerformance] = useState(false);
|
|
65
|
-
const [withdrawalLoss, setWithdrawalLoss] = useState(false);
|
|
65
|
+
const { Modal, Text, Button, Banner } = usePrimitives();
|
|
66
66
|
const [submitting, setSubmitting] = useState(false);
|
|
67
67
|
const [errorText, setErrorText] = useState<string | null>(null);
|
|
68
68
|
|
|
69
69
|
const consentLocale = resolveConsentLocale(uiLocale);
|
|
70
70
|
const texts = consumerProtection?.consentTexts[consentLocale];
|
|
71
71
|
const legalLinks = consumerProtection?.legalLinks[consentLocale];
|
|
72
|
-
const
|
|
72
|
+
const consentFields = useConsentFieldState(texts);
|
|
73
|
+
const consentGiven = consumerProtection === undefined || consentFields.allGiven;
|
|
73
74
|
|
|
74
75
|
async function order(): Promise<void> {
|
|
75
76
|
setSubmitting(true);
|
|
76
77
|
setErrorText(null);
|
|
77
|
-
const result = await onOrder(
|
|
78
|
-
texts === undefined
|
|
79
|
-
? undefined
|
|
80
|
-
: {
|
|
81
|
-
earlyPerformanceRequested: true,
|
|
82
|
-
withdrawalLossAcknowledged: true,
|
|
83
|
-
consentTextVersion: texts.consentTextVersion,
|
|
84
|
-
locale: consentLocale,
|
|
85
|
-
},
|
|
86
|
-
);
|
|
78
|
+
const result = await onOrder(consentFields.buildPayload(consentLocale));
|
|
87
79
|
if (result.isSuccess) return;
|
|
88
80
|
setSubmitting(false);
|
|
89
81
|
setErrorText(t(result.error.i18nKey, result.error.i18nParams));
|
|
90
82
|
if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
|
|
91
|
-
|
|
92
|
-
setWithdrawalLoss(false);
|
|
83
|
+
consentFields.reset();
|
|
93
84
|
onConsentTextOutdated();
|
|
94
85
|
}
|
|
95
86
|
}
|
|
@@ -135,42 +126,14 @@ export function CheckoutConsentDialog({
|
|
|
135
126
|
</Text>
|
|
136
127
|
)}
|
|
137
128
|
</div>
|
|
138
|
-
{legalLinks !== undefined &&
|
|
139
|
-
<div className="flex flex-wrap gap-4">
|
|
140
|
-
<Link href={legalLinks.terms} target="_blank">
|
|
141
|
-
{t("billing-foundation.consent.link.terms")}
|
|
142
|
-
</Link>
|
|
143
|
-
<Link href={legalLinks.withdrawal} target="_blank">
|
|
144
|
-
{t("billing-foundation.consent.link.withdrawal")}
|
|
145
|
-
</Link>
|
|
146
|
-
<Link href={legalLinks.privacy} target="_blank">
|
|
147
|
-
{t("billing-foundation.consent.link.privacy")}
|
|
148
|
-
</Link>
|
|
149
|
-
</div>
|
|
150
|
-
)}
|
|
129
|
+
{legalLinks !== undefined && <ConsentLinks legalLinks={legalLinks} />}
|
|
151
130
|
{texts !== undefined && (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
value={earlyPerformance}
|
|
159
|
-
onChange={setEarlyPerformance}
|
|
160
|
-
disabled={submitting}
|
|
161
|
-
/>
|
|
162
|
-
</Field>
|
|
163
|
-
<Field id="consent-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
|
|
164
|
-
<Input
|
|
165
|
-
kind="boolean"
|
|
166
|
-
id="consent-withdrawal-loss"
|
|
167
|
-
name="consent-withdrawal-loss"
|
|
168
|
-
value={withdrawalLoss}
|
|
169
|
-
onChange={setWithdrawalLoss}
|
|
170
|
-
disabled={submitting}
|
|
171
|
-
/>
|
|
172
|
-
</Field>
|
|
173
|
-
</>
|
|
131
|
+
<ConsentCheckboxes
|
|
132
|
+
idPrefix="consent"
|
|
133
|
+
texts={texts}
|
|
134
|
+
state={consentFields}
|
|
135
|
+
disabled={submitting}
|
|
136
|
+
/>
|
|
174
137
|
)}
|
|
175
138
|
{errorText !== null && (
|
|
176
139
|
<Banner variant="error" testId="checkout-consent-error">
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import type { BillingPlansResult, LegalLinkSet } from "../types.ts";
|
|
4
|
+
import type { CheckoutConsentPayload } from "./checkout-consent-dialog.tsx";
|
|
5
|
+
|
|
6
|
+
type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
|
|
7
|
+
export type ConsentTextView = ConsumerProtectionView["consentTexts"]["de"];
|
|
8
|
+
|
|
9
|
+
export type ConsentChecks = {
|
|
10
|
+
readonly documentsAcknowledged: boolean;
|
|
11
|
+
readonly earlyPerformance: boolean;
|
|
12
|
+
readonly withdrawalLoss: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const NOTHING_CHECKED: ConsentChecks = {
|
|
16
|
+
documentsAcknowledged: false,
|
|
17
|
+
earlyPerformance: false,
|
|
18
|
+
withdrawalLoss: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ConsentFieldState = {
|
|
22
|
+
readonly checks: ConsentChecks;
|
|
23
|
+
readonly setCheck: (key: keyof ConsentChecks, value: boolean) => void;
|
|
24
|
+
readonly reset: () => void;
|
|
25
|
+
readonly allGiven: boolean;
|
|
26
|
+
readonly buildPayload: (locale: string) => CheckoutConsentPayload | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function useConsentFieldState(texts: ConsentTextView | undefined): ConsentFieldState {
|
|
30
|
+
const [checks, setChecks] = useState<ConsentChecks>(NOTHING_CHECKED);
|
|
31
|
+
const documentsRequired = texts?.documentsAcknowledgement !== undefined;
|
|
32
|
+
return {
|
|
33
|
+
checks,
|
|
34
|
+
setCheck: (key, value) => setChecks((current) => ({ ...current, [key]: value })),
|
|
35
|
+
reset: () => setChecks(NOTHING_CHECKED),
|
|
36
|
+
allGiven:
|
|
37
|
+
checks.earlyPerformance &&
|
|
38
|
+
checks.withdrawalLoss &&
|
|
39
|
+
(!documentsRequired || checks.documentsAcknowledged),
|
|
40
|
+
buildPayload: (locale) =>
|
|
41
|
+
texts === undefined
|
|
42
|
+
? undefined
|
|
43
|
+
: {
|
|
44
|
+
earlyPerformanceRequested: true,
|
|
45
|
+
withdrawalLossAcknowledged: true,
|
|
46
|
+
...(documentsRequired && { documentsAcknowledged: true }),
|
|
47
|
+
consentTextVersion: texts.consentTextVersion,
|
|
48
|
+
locale,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ConsentLinks({ legalLinks }: { readonly legalLinks: LegalLinkSet }): ReactNode {
|
|
54
|
+
const t = useTranslation();
|
|
55
|
+
const { Link } = usePrimitives();
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex flex-wrap gap-4">
|
|
58
|
+
<Link href={legalLinks.terms} target="_blank">
|
|
59
|
+
{t("billing-foundation.consent.link.terms")}
|
|
60
|
+
</Link>
|
|
61
|
+
<Link href={legalLinks.withdrawal} target="_blank">
|
|
62
|
+
{t("billing-foundation.consent.link.withdrawal")}
|
|
63
|
+
</Link>
|
|
64
|
+
<Link href={legalLinks.privacy} target="_blank">
|
|
65
|
+
{t("billing-foundation.consent.link.privacy")}
|
|
66
|
+
</Link>
|
|
67
|
+
{legalLinks.refund !== undefined && (
|
|
68
|
+
<Link href={legalLinks.refund} target="_blank">
|
|
69
|
+
{t("billing-foundation.consent.link.refund")}
|
|
70
|
+
</Link>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ConsentCheckboxes({
|
|
77
|
+
idPrefix,
|
|
78
|
+
texts,
|
|
79
|
+
state,
|
|
80
|
+
disabled,
|
|
81
|
+
}: {
|
|
82
|
+
readonly idPrefix: string;
|
|
83
|
+
readonly texts: ConsentTextView;
|
|
84
|
+
readonly state: ConsentFieldState;
|
|
85
|
+
readonly disabled: boolean;
|
|
86
|
+
}): ReactNode {
|
|
87
|
+
const { Field, Input } = usePrimitives();
|
|
88
|
+
const boxes: readonly { key: keyof ConsentChecks; slug: string; label: string | undefined }[] = [
|
|
89
|
+
{ key: "documentsAcknowledged", slug: "documents", label: texts.documentsAcknowledgement },
|
|
90
|
+
{ key: "earlyPerformance", slug: "early-performance", label: texts.earlyPerformance },
|
|
91
|
+
{ key: "withdrawalLoss", slug: "withdrawal-loss", label: texts.withdrawalLoss },
|
|
92
|
+
];
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
{boxes.map(({ key, slug, label }) =>
|
|
96
|
+
label === undefined ? null : (
|
|
97
|
+
<Field key={key} id={`${idPrefix}-${slug}`} label={label} layout="inline">
|
|
98
|
+
<Input
|
|
99
|
+
kind="boolean"
|
|
100
|
+
id={`${idPrefix}-${slug}`}
|
|
101
|
+
name={`${idPrefix}-${slug}`}
|
|
102
|
+
value={state.checks[key]}
|
|
103
|
+
onChange={(value) => state.setCheck(key, value)}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
/>
|
|
106
|
+
</Field>
|
|
107
|
+
),
|
|
108
|
+
)}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -8,10 +8,8 @@ import {
|
|
|
8
8
|
import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
|
|
9
9
|
import { type ReactNode, useState } from "react";
|
|
10
10
|
import type { BillingPlansResult, PlanSwitchPreview } from "../types.ts";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
CONSENT_TEXT_OUTDATED_CODE,
|
|
14
|
-
} from "./checkout-consent-dialog.tsx";
|
|
11
|
+
import { CONSENT_TEXT_OUTDATED_CODE } from "./checkout-consent-dialog.tsx";
|
|
12
|
+
import { ConsentCheckboxes, ConsentLinks, useConsentFieldState } from "./consent-fields.tsx";
|
|
15
13
|
import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.ts";
|
|
16
14
|
import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
|
|
17
15
|
import { intervalKey } from "./plan-price.ts";
|
|
@@ -53,7 +51,7 @@ export function PlanSwitchConfirmDialog({
|
|
|
53
51
|
}: PlanSwitchConfirmDialogProps): ReactNode {
|
|
54
52
|
const t = useTranslation();
|
|
55
53
|
const locale = useLocale().locale();
|
|
56
|
-
const { Modal, Text,
|
|
54
|
+
const { Modal, Text, Button, Banner } = usePrimitives();
|
|
57
55
|
const previewQuery = useQuery<PlanSwitchPreviewReply>(
|
|
58
56
|
SubscriptionFoundationQueries.planSwitchPreview,
|
|
59
57
|
{ tier },
|
|
@@ -61,28 +59,19 @@ export function PlanSwitchConfirmDialog({
|
|
|
61
59
|
const switchMutation = useMutation<{ readonly applied: true }>(
|
|
62
60
|
SubscriptionFoundationHandlers.switchPlan,
|
|
63
61
|
);
|
|
64
|
-
const [earlyPerformance, setEarlyPerformance] = useState(false);
|
|
65
|
-
const [withdrawalLoss, setWithdrawalLoss] = useState(false);
|
|
66
62
|
const [submitting, setSubmitting] = useState(false);
|
|
67
63
|
const [errorText, setErrorText] = useState<string | null>(null);
|
|
68
64
|
|
|
69
65
|
const consentLocale = resolveConsentLocale(locale);
|
|
70
66
|
const texts = consumerProtection?.consentTexts[consentLocale];
|
|
71
67
|
const legalLinks = consumerProtection?.legalLinks[consentLocale];
|
|
72
|
-
const
|
|
68
|
+
const consentFields = useConsentFieldState(texts);
|
|
69
|
+
const consentGiven = consumerProtection === undefined || consentFields.allGiven;
|
|
73
70
|
|
|
74
71
|
async function confirm(): Promise<void> {
|
|
75
72
|
setSubmitting(true);
|
|
76
73
|
setErrorText(null);
|
|
77
|
-
const consent
|
|
78
|
-
texts === undefined
|
|
79
|
-
? undefined
|
|
80
|
-
: {
|
|
81
|
-
earlyPerformanceRequested: true,
|
|
82
|
-
withdrawalLossAcknowledged: true,
|
|
83
|
-
consentTextVersion: texts.consentTextVersion,
|
|
84
|
-
locale: consentLocale,
|
|
85
|
-
};
|
|
74
|
+
const consent = consentFields.buildPayload(consentLocale);
|
|
86
75
|
const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
|
|
87
76
|
if (result.isSuccess) {
|
|
88
77
|
onApplied();
|
|
@@ -91,8 +80,7 @@ export function PlanSwitchConfirmDialog({
|
|
|
91
80
|
setSubmitting(false);
|
|
92
81
|
setErrorText(t(result.error.i18nKey, result.error.i18nParams));
|
|
93
82
|
if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
|
|
94
|
-
|
|
95
|
-
setWithdrawalLoss(false);
|
|
83
|
+
consentFields.reset();
|
|
96
84
|
onConsentTextOutdated();
|
|
97
85
|
}
|
|
98
86
|
}
|
|
@@ -160,46 +148,14 @@ export function PlanSwitchConfirmDialog({
|
|
|
160
148
|
<Text variant="muted">{t("billing-foundation.planSwitch.pendingNote")}</Text>
|
|
161
149
|
</div>
|
|
162
150
|
)}
|
|
163
|
-
{legalLinks !== undefined &&
|
|
164
|
-
<div className="flex flex-wrap gap-4">
|
|
165
|
-
<Link href={legalLinks.terms} target="_blank">
|
|
166
|
-
{t("billing-foundation.consent.link.terms")}
|
|
167
|
-
</Link>
|
|
168
|
-
<Link href={legalLinks.withdrawal} target="_blank">
|
|
169
|
-
{t("billing-foundation.consent.link.withdrawal")}
|
|
170
|
-
</Link>
|
|
171
|
-
<Link href={legalLinks.privacy} target="_blank">
|
|
172
|
-
{t("billing-foundation.consent.link.privacy")}
|
|
173
|
-
</Link>
|
|
174
|
-
</div>
|
|
175
|
-
)}
|
|
151
|
+
{legalLinks !== undefined && <ConsentLinks legalLinks={legalLinks} />}
|
|
176
152
|
{texts !== undefined && (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<Input
|
|
184
|
-
kind="boolean"
|
|
185
|
-
id="plan-switch-early-performance"
|
|
186
|
-
name="plan-switch-early-performance"
|
|
187
|
-
value={earlyPerformance}
|
|
188
|
-
onChange={setEarlyPerformance}
|
|
189
|
-
disabled={submitting}
|
|
190
|
-
/>
|
|
191
|
-
</Field>
|
|
192
|
-
<Field id="plan-switch-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
|
|
193
|
-
<Input
|
|
194
|
-
kind="boolean"
|
|
195
|
-
id="plan-switch-withdrawal-loss"
|
|
196
|
-
name="plan-switch-withdrawal-loss"
|
|
197
|
-
value={withdrawalLoss}
|
|
198
|
-
onChange={setWithdrawalLoss}
|
|
199
|
-
disabled={submitting}
|
|
200
|
-
/>
|
|
201
|
-
</Field>
|
|
202
|
-
</>
|
|
153
|
+
<ConsentCheckboxes
|
|
154
|
+
idPrefix="plan-switch"
|
|
155
|
+
texts={texts}
|
|
156
|
+
state={consentFields}
|
|
157
|
+
disabled={submitting}
|
|
158
|
+
/>
|
|
203
159
|
)}
|
|
204
160
|
{errorText !== null && (
|
|
205
161
|
<Banner variant="error" testId="plan-switch-confirm-error">
|