@owlmeans/web-payment 0.1.18-rc.2 → 0.1.18-rc.21
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 +30 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +287 -20
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/checkbox.d.ts +5 -0
- package/build/@/components/ui/checkbox.d.ts.map +1 -0
- package/build/@/components/ui/checkbox.js +6 -0
- package/build/@/components/ui/checkbox.js.map +1 -0
- package/build/@/components/ui/progress.d.ts +5 -0
- package/build/@/components/ui/progress.d.ts.map +1 -0
- package/build/@/components/ui/progress.js +11 -0
- package/build/@/components/ui/progress.js.map +1 -0
- package/build/@/components/ui/select.d.ts +8 -0
- package/build/@/components/ui/select.d.ts.map +1 -0
- package/build/@/components/ui/select.js +9 -0
- package/build/@/components/ui/select.js.map +1 -0
- package/build/capability-list.d.ts +7 -0
- package/build/capability-list.d.ts.map +1 -0
- package/build/capability-list.js +24 -0
- package/build/capability-list.js.map +1 -0
- package/build/checkout-limit.d.ts +11 -0
- package/build/checkout-limit.d.ts.map +1 -0
- package/build/checkout-limit.js +34 -0
- package/build/checkout-limit.js.map +1 -0
- package/build/consumer/cancellation.d.ts +20 -0
- package/build/consumer/cancellation.d.ts.map +1 -0
- package/build/consumer/cancellation.js +115 -0
- package/build/consumer/cancellation.js.map +1 -0
- package/build/consumer/copy.d.ts +23 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +75 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/ensure.d.ts +56 -0
- package/build/consumer/ensure.d.ts.map +1 -0
- package/build/consumer/ensure.js +71 -0
- package/build/consumer/ensure.js.map +1 -0
- package/build/consumer/fields.d.ts +43 -0
- package/build/consumer/fields.d.ts.map +1 -0
- package/build/consumer/fields.js +19 -0
- package/build/consumer/fields.js.map +1 -0
- package/build/consumer/hooks.d.ts +113 -0
- package/build/consumer/hooks.d.ts.map +1 -0
- package/build/consumer/hooks.js +225 -0
- package/build/consumer/hooks.js.map +1 -0
- package/build/consumer/index.d.ts +14 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +12 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/legal.d.ts +53 -0
- package/build/consumer/legal.d.ts.map +1 -0
- package/build/consumer/legal.js +77 -0
- package/build/consumer/legal.js.map +1 -0
- package/build/consumer/performance-consent-dialog.d.ts +18 -0
- package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
- package/build/consumer/performance-consent-dialog.js +71 -0
- package/build/consumer/performance-consent-dialog.js.map +1 -0
- package/build/consumer/provider.d.ts +33 -0
- package/build/consumer/provider.d.ts.map +1 -0
- package/build/consumer/provider.js +26 -0
- package/build/consumer/provider.js.map +1 -0
- package/build/consumer/refusal.d.ts +35 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +98 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/subscription-start-dialog.d.ts +16 -0
- package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
- package/build/consumer/subscription-start-dialog.js +43 -0
- package/build/consumer/subscription-start-dialog.js.map +1 -0
- package/build/consumer/types.d.ts +114 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +29 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +140 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/copy.d.ts +14 -0
- package/build/copy.d.ts.map +1 -0
- package/build/copy.js +51 -0
- package/build/copy.js.map +1 -0
- package/build/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +46 -17
- package/build/dialog.js.map +1 -1
- package/build/entitlement.d.ts +26 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +42 -0
- package/build/entitlement.js.map +1 -0
- package/build/estimate-cache.d.ts +14 -0
- package/build/estimate-cache.d.ts.map +1 -0
- package/build/estimate-cache.js +39 -0
- package/build/estimate-cache.js.map +1 -0
- package/build/estimate-summary.d.ts +64 -0
- package/build/estimate-summary.d.ts.map +1 -0
- package/build/estimate-summary.js +94 -0
- package/build/estimate-summary.js.map +1 -0
- package/build/estimate.d.ts +34 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +92 -0
- package/build/estimate.js.map +1 -0
- package/build/format.d.ts +15 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +68 -0
- package/build/format.js.map +1 -0
- package/build/hooks.d.ts +0 -4
- package/build/hooks.d.ts.map +1 -1
- package/build/hooks.js +3 -30
- package/build/hooks.js.map +1 -1
- package/build/i18n/be.json +1 -1
- package/build/i18n/de.json +1 -1
- package/build/i18n/en.json +1 -1
- package/build/i18n/es.json +1 -1
- package/build/i18n/fr.json +145 -0
- package/build/i18n/pl.json +1 -1
- package/build/i18n/ru.json +1 -1
- package/build/i18n/uk.json +1 -1
- package/build/i18n.d.ts +2 -1
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +11 -7
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -0
- package/build/index.js.map +1 -1
- package/build/limit-meter.d.ts +7 -0
- package/build/limit-meter.d.ts.map +1 -0
- package/build/limit-meter.js +26 -0
- package/build/limit-meter.js.map +1 -0
- package/build/plan-card.d.ts +7 -0
- package/build/plan-card.d.ts.map +1 -0
- package/build/plan-card.js +29 -0
- package/build/plan-card.js.map +1 -0
- package/build/poll.d.ts +7 -0
- package/build/poll.d.ts.map +1 -0
- package/build/poll.js +28 -0
- package/build/poll.js.map +1 -0
- package/build/selectors.d.ts +18 -0
- package/build/selectors.d.ts.map +1 -0
- package/build/selectors.js +78 -0
- package/build/selectors.js.map +1 -0
- package/build/types.d.ts +120 -2
- package/build/types.d.ts.map +1 -1
- package/package.json +22 -7
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/checkbox.tsx +23 -0
- package/src/@/components/ui/progress.tsx +44 -0
- package/src/@/components/ui/select.tsx +62 -0
- package/src/@/globals.css +10 -0
- package/src/capability-list.tsx +50 -0
- package/src/checkout-limit.tsx +48 -0
- package/src/consumer/cancellation.tsx +214 -0
- package/src/consumer/copy.ts +93 -0
- package/src/consumer/ensure.ts +124 -0
- package/src/consumer/fields.tsx +89 -0
- package/src/consumer/hooks.ts +358 -0
- package/src/consumer/index.ts +13 -0
- package/src/consumer/legal.tsx +129 -0
- package/src/consumer/performance-consent-dialog.tsx +132 -0
- package/src/consumer/provider.tsx +49 -0
- package/src/consumer/refusal.ts +110 -0
- package/src/consumer/subscription-start-dialog.tsx +87 -0
- package/src/consumer/types.ts +121 -0
- package/src/consumer/withdrawal.tsx +279 -0
- package/src/copy.tsx +62 -0
- package/src/dialog.tsx +58 -25
- package/src/entitlement.ts +56 -0
- package/src/estimate-cache.ts +40 -0
- package/src/estimate-summary.tsx +172 -0
- package/src/estimate.ts +119 -0
- package/src/format.ts +71 -0
- package/src/hooks.ts +3 -35
- package/src/i18n/be.json +1 -1
- package/src/i18n/de.json +1 -1
- package/src/i18n/en.json +1 -1
- package/src/i18n/es.json +1 -1
- package/src/i18n/fr.json +145 -0
- package/src/i18n/pl.json +1 -1
- package/src/i18n/ru.json +1 -1
- package/src/i18n/uk.json +1 -1
- package/src/i18n.ts +12 -7
- package/src/index.ts +9 -0
- package/src/limit-meter.tsx +55 -0
- package/src/plan-card.tsx +67 -0
- package/src/poll.ts +27 -0
- package/src/selectors.ts +93 -0
- package/src/types.ts +142 -2
- package/tests/consent-refusal.spec.ts +83 -0
- package/tests/consumer-hooks.spec.ts +205 -0
- package/tests/consumer.spec.ts +343 -0
- package/tests/context.ts +9 -0
- package/tests/dialog.spec.ts +49 -0
- package/tests/entitlement.spec.ts +117 -0
- package/tests/estimate-cache.spec.ts +46 -0
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/entitlement.tsx +159 -0
- package/tests/harness/estimate-fixtures.ts +48 -0
- package/tests/harness/mount.tsx +19 -3
- package/tests/i18n.spec.ts +78 -8
- package/tests/pieces.spec.ts +134 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { CancellationKind } from '@owlmeans/payment'
|
|
3
|
+
import type { CancellationBody, CancellationReceipt } from '@owlmeans/payment'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { dayUtc, momentUtc } from '../format.js'
|
|
7
|
+
import { useLegalText, usePaymentText } from './copy.js'
|
|
8
|
+
import { Choice, Field, Honeypot, isEmail } from './fields.js'
|
|
9
|
+
import { ErrorLine, LanguageToggle, useShownLanguage } from './legal.js'
|
|
10
|
+
import type { CancellationFormProps, DeclarationStep } from './types.js'
|
|
11
|
+
|
|
12
|
+
const isCancellationReceipt = (receipt: object): receipt is CancellationReceipt => 'status' in receipt
|
|
13
|
+
|
|
14
|
+
/** Today as a UTC calendar day, `YYYY-MM-DD` — the earliest date a termination may name. */
|
|
15
|
+
const todayUtc = (): string => new Date().toISOString().slice(0, 10)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The cancellation function (§ 312k BGB "Verträge hier kündigen" → "Jetzt kündigen"; L215-1-1 /
|
|
19
|
+
* D215-1 "Résilier votre contrat" → a summary → "Notification de la résiliation"), for a public
|
|
20
|
+
* page reachable without a login and for the same function in-app.
|
|
21
|
+
*
|
|
22
|
+
* Step 1 asks for the kind (ordinary, or extraordinary with its reason), the name, the contract or
|
|
23
|
+
* customer reference, the date (the earliest possible, or a specific one) and the e-mail address
|
|
24
|
+
* the acknowledgement goes to; a honeypot field no person reaches rides along. Step 2 is the
|
|
25
|
+
* summary with the statutory confirm button; step 3 the receipt — date and time of receipt in UTC,
|
|
26
|
+
* the effective date when known — with a print button, so it can be kept.
|
|
27
|
+
*
|
|
28
|
+
* Selectors: `[data-cancellation-form]` / `[data-cancellation-step="form|review|receipt"]` (also
|
|
29
|
+
* `data-step`, `data-language`, `data-mode`), `[data-cancellation-field="kind|reason|name|contract|
|
|
30
|
+
* effective|date|email"]`, `[data-cancellation-continue]`, `[data-cancellation-confirm]`,
|
|
31
|
+
* `[data-cancellation-receipt]` (`data-status`, `data-declaration-id`, `data-received-at`,
|
|
32
|
+
* `data-effective-at`), `[data-cancellation-print]`.
|
|
33
|
+
*/
|
|
34
|
+
export const CancellationForm = ({
|
|
35
|
+
mode, language: contractLanguage, uiLanguage, defaults, pending = false, error, receipt, onSubmit, onPrint, onClose,
|
|
36
|
+
className,
|
|
37
|
+
}: CancellationFormProps) => {
|
|
38
|
+
const language = useShownLanguage(contractLanguage, uiLanguage)
|
|
39
|
+
const legal = useLegalText(language.shown)
|
|
40
|
+
const text = usePaymentText(language.shown)
|
|
41
|
+
|
|
42
|
+
const [step, setStep] = useState<'form' | 'review'>('form')
|
|
43
|
+
const [kind, setKind] = useState<CancellationKind>(CancellationKind.Ordinary)
|
|
44
|
+
const [reason, setReason] = useState('')
|
|
45
|
+
const [name, setName] = useState(defaults?.name ?? '')
|
|
46
|
+
const [contractRef, setContractRef] = useState(defaults?.contractRef ?? '')
|
|
47
|
+
const [effective, setEffective] = useState<'earliest' | 'date'>('earliest')
|
|
48
|
+
const [date, setDate] = useState('')
|
|
49
|
+
const [email, setEmail] = useState(defaults?.email ?? '')
|
|
50
|
+
const [honeypot, setHoneypot] = useState('')
|
|
51
|
+
const [checked, setChecked] = useState(false)
|
|
52
|
+
const [submitted, setSubmitted] = useState<CancellationBody | null>(null)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (receipt == null && submitted != null) setStep('form')
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
}, [receipt])
|
|
57
|
+
|
|
58
|
+
const current: DeclarationStep = receipt != null ? 'receipt' : step
|
|
59
|
+
const extraordinary = kind === CancellationKind.Extraordinary
|
|
60
|
+
const earliestDate = todayUtc()
|
|
61
|
+
const errors = {
|
|
62
|
+
reason: extraordinary && reason.trim() === '' ? text('consumer.required') : null,
|
|
63
|
+
name: name.trim() === '' ? text('consumer.required') : null,
|
|
64
|
+
date: effective === 'date' && (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date < earliestDate) ? text('consumer.date-invalid') : null,
|
|
65
|
+
email: email.trim() === '' ? text('consumer.required') : !isEmail(email.trim()) ? text('consumer.email-invalid') : null,
|
|
66
|
+
}
|
|
67
|
+
const shown = (key: keyof typeof errors) => checked ? errors[key] : null
|
|
68
|
+
const valid = Object.values(errors).every(value => value == null)
|
|
69
|
+
const body = (): CancellationBody => ({
|
|
70
|
+
kind,
|
|
71
|
+
...(extraordinary ? { reason: reason.trim() } : {}),
|
|
72
|
+
name: name.trim(),
|
|
73
|
+
...(contractRef.trim() !== '' ? { contractRef: contractRef.trim() } : {}),
|
|
74
|
+
...(defaults?.subscriptionId != null ? { subscriptionId: defaults.subscriptionId } : {}),
|
|
75
|
+
effective,
|
|
76
|
+
...(effective === 'date' ? { date } : {}),
|
|
77
|
+
email: email.trim(),
|
|
78
|
+
language: language.shown,
|
|
79
|
+
...(honeypot !== '' ? { honeypot } : {}),
|
|
80
|
+
})
|
|
81
|
+
const toReview = () => {
|
|
82
|
+
setChecked(true)
|
|
83
|
+
if (valid) setStep('review')
|
|
84
|
+
}
|
|
85
|
+
const confirm = async () => {
|
|
86
|
+
if (!valid || pending) return
|
|
87
|
+
const declared = body()
|
|
88
|
+
setSubmitted(declared)
|
|
89
|
+
await onSubmit(declared)
|
|
90
|
+
}
|
|
91
|
+
const print = () => {
|
|
92
|
+
if (onPrint != null) onPrint()
|
|
93
|
+
else if (typeof window !== 'undefined') window.print()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const kindLabel = (value: CancellationKind) => legal(value === CancellationKind.Extraordinary
|
|
97
|
+
? 'cancellation.kind-extraordinary' : 'cancellation.kind-ordinary')
|
|
98
|
+
const effectiveLabel = (declared: CancellationBody) => declared.effective === 'date' && declared.date != null
|
|
99
|
+
? dayUtc(`${declared.date}T00:00:00Z`, language.shown) : legal('cancellation.effective-earliest')
|
|
100
|
+
const summary = (declared: CancellationBody, hook?: string) =>
|
|
101
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 rounded-lg border bg-muted/30 p-4 text-sm"
|
|
102
|
+
{...(hook != null ? { [hook]: '' } : {})}>
|
|
103
|
+
<dt className="text-muted-foreground">{legal('cancellation.kind')}</dt><dd data-summary="kind">{kindLabel(declared.kind)}</dd>
|
|
104
|
+
{declared.reason != null && <>
|
|
105
|
+
<dt className="text-muted-foreground">{legal('cancellation.reason')}</dt>
|
|
106
|
+
<dd data-summary="reason" className="whitespace-pre-wrap">{declared.reason}</dd>
|
|
107
|
+
</>}
|
|
108
|
+
<dt className="text-muted-foreground">{legal('cancellation.name')}</dt><dd data-summary="name">{declared.name}</dd>
|
|
109
|
+
<dt className="text-muted-foreground">{legal('cancellation.contract')}</dt><dd data-summary="contract">{declared.contractRef ?? '—'}</dd>
|
|
110
|
+
<dt className="text-muted-foreground">{legal('cancellation.effective')}</dt><dd data-summary="effective">{effectiveLabel(declared)}</dd>
|
|
111
|
+
<dt className="text-muted-foreground">{legal('cancellation.email')}</dt><dd data-summary="email">{declared.email}</dd>
|
|
112
|
+
</dl>
|
|
113
|
+
|
|
114
|
+
let content: ReactNode
|
|
115
|
+
if (current === 'receipt' && receipt != null) {
|
|
116
|
+
const receivedAt = new Date(receipt.receivedAt)
|
|
117
|
+
const declared = submitted ?? body()
|
|
118
|
+
const status = isCancellationReceipt(receipt) ? receipt.status : undefined
|
|
119
|
+
const effectiveAt = isCancellationReceipt(receipt) && receipt.effectiveAt != null ? new Date(receipt.effectiveAt) : null
|
|
120
|
+
const effectiveKnown = effectiveAt != null && !Number.isNaN(effectiveAt.getTime())
|
|
121
|
+
content = <div className="grid gap-4" data-cancellation-receipt="" data-status={status ?? 'received'}
|
|
122
|
+
data-declaration-id={receipt.declarationId}
|
|
123
|
+
data-received-at={Number.isNaN(receivedAt.getTime()) ? undefined : receivedAt.toISOString()}
|
|
124
|
+
data-effective-at={effectiveKnown ? effectiveAt.toISOString() : undefined}>
|
|
125
|
+
<h3 className="text-sm font-medium">{text('consumer.receipt')}</h3>
|
|
126
|
+
<p className="text-sm">{receipt.mailed
|
|
127
|
+
? legal('cancellation.received', { date: momentUtc(receivedAt, language.shown), email: declared.email })
|
|
128
|
+
: `${text('consumer.received-at')}: ${momentUtc(receivedAt, language.shown)} (UTC). ${text('consumer.not-mailed')}`}</p>
|
|
129
|
+
{effectiveKnown && <p className="text-sm font-medium" data-cancellation-effective="">
|
|
130
|
+
{text('cancellation.effective-at', { date: dayUtc(effectiveAt, language.shown) })}
|
|
131
|
+
</p>}
|
|
132
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 rounded-lg border p-4 text-sm">
|
|
133
|
+
<dt className="text-muted-foreground">{text('consumer.reference')}</dt>
|
|
134
|
+
<dd data-cancellation-reference="">{receipt.declarationId}</dd>
|
|
135
|
+
<dt className="text-muted-foreground">{text('consumer.received-at')}</dt>
|
|
136
|
+
<dd data-cancellation-received-at="">{momentUtc(receivedAt, language.shown)} (UTC)</dd>
|
|
137
|
+
{status != null && <>
|
|
138
|
+
<dt className="text-muted-foreground">{text('consumer.status')}</dt>
|
|
139
|
+
<dd data-cancellation-status="">{text(`cancellation.status.${status}`)}</dd>
|
|
140
|
+
</>}
|
|
141
|
+
</dl>
|
|
142
|
+
{summary(declared)}
|
|
143
|
+
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end print:hidden">
|
|
144
|
+
{onClose != null && <Button type="button" variant="outline" onClick={onClose}>{text('consumer.close')}</Button>}
|
|
145
|
+
<Button type="button" variant="outline" onClick={print} data-cancellation-print="">{text('consumer.print')}</Button>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
} else if (current === 'review') {
|
|
149
|
+
content = <div className="grid gap-4">
|
|
150
|
+
<h3 className="text-sm font-medium">{text('consumer.review')}</h3>
|
|
151
|
+
<p className="text-sm">{legal('cancellation.summary')}</p>
|
|
152
|
+
{summary(body(), 'data-cancellation-summary')}
|
|
153
|
+
<ErrorLine error={error} text={text} />
|
|
154
|
+
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
155
|
+
<Button type="button" variant="outline" disabled={pending} onClick={() => setStep('form')}
|
|
156
|
+
data-cancellation-back="">{text('consumer.back')}</Button>
|
|
157
|
+
<Button type="button" disabled={pending || !valid} onClick={() => void confirm()} data-cancellation-confirm="">
|
|
158
|
+
{pending ? text('consumer.pending') : legal('cancellation.confirm')}
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
} else {
|
|
163
|
+
content = <form className="relative grid gap-4" noValidate onSubmit={event => { event.preventDefault(); toReview() }}>
|
|
164
|
+
<fieldset className="grid gap-2">
|
|
165
|
+
<legend className="mb-1 text-sm font-medium">{legal('cancellation.kind')}</legend>
|
|
166
|
+
{[CancellationKind.Ordinary, CancellationKind.Extraordinary].map(value => <Choice
|
|
167
|
+
key={value} id={`cancellation-kind-${value}`} name="cancellation-kind" checked={kind === value}
|
|
168
|
+
onSelect={() => setKind(value)} disabled={pending} hook={{ 'data-cancellation-field': 'kind', 'data-kind': value }}
|
|
169
|
+
>{kindLabel(value)}</Choice>)}
|
|
170
|
+
</fieldset>
|
|
171
|
+
{extraordinary && <Field id="cancellation-reason" label={legal('cancellation.reason')} value={reason} onChange={setReason}
|
|
172
|
+
error={shown('reason')} disabled={pending} multiline hook={{ 'data-cancellation-field': 'reason' }} />}
|
|
173
|
+
<Field id="cancellation-name" label={legal('cancellation.name')} value={name} onChange={setName} error={shown('name')}
|
|
174
|
+
autoComplete="name" disabled={pending} hook={{ 'data-cancellation-field': 'name' }} />
|
|
175
|
+
<Field id="cancellation-contract" label={legal('cancellation.contract')} value={contractRef} onChange={setContractRef}
|
|
176
|
+
disabled={pending} hook={{ 'data-cancellation-field': 'contract' }} />
|
|
177
|
+
<fieldset className="grid gap-2">
|
|
178
|
+
<legend className="mb-1 text-sm font-medium">{legal('cancellation.effective')}</legend>
|
|
179
|
+
<Choice id="cancellation-effective-earliest" name="cancellation-effective" checked={effective === 'earliest'}
|
|
180
|
+
onSelect={() => setEffective('earliest')} disabled={pending}
|
|
181
|
+
hook={{ 'data-cancellation-field': 'effective', 'data-effective': 'earliest' }}>
|
|
182
|
+
{legal('cancellation.effective-earliest')}
|
|
183
|
+
</Choice>
|
|
184
|
+
<Choice id="cancellation-effective-date" name="cancellation-effective" checked={effective === 'date'}
|
|
185
|
+
onSelect={() => setEffective('date')} disabled={pending}
|
|
186
|
+
hook={{ 'data-cancellation-field': 'effective', 'data-effective': 'date' }}>
|
|
187
|
+
{legal('cancellation.effective-date')}
|
|
188
|
+
</Choice>
|
|
189
|
+
{effective === 'date' && <Field id="cancellation-date" type="date" label={legal('cancellation.effective-date')}
|
|
190
|
+
value={date} onChange={setDate} min={earliestDate} error={shown('date')} disabled={pending}
|
|
191
|
+
hook={{ 'data-cancellation-field': 'date' }} />}
|
|
192
|
+
</fieldset>
|
|
193
|
+
<Field id="cancellation-email" type="email" label={legal('cancellation.email')} value={email} onChange={setEmail}
|
|
194
|
+
error={shown('email')} autoComplete="email" disabled={pending} hook={{ 'data-cancellation-field': 'email' }} />
|
|
195
|
+
<Honeypot id="cancellation-website" label={text('consumer.website')} value={honeypot} onChange={setHoneypot} />
|
|
196
|
+
<ErrorLine error={error} text={text} />
|
|
197
|
+
<div className="flex justify-end">
|
|
198
|
+
<Button type="submit" disabled={pending} data-cancellation-continue="">{text('consumer.continue')}</Button>
|
|
199
|
+
</div>
|
|
200
|
+
</form>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return <section className={cn('grid gap-4', className)} data-cancellation-section="" data-language={language.shown}>
|
|
204
|
+
<header className="grid gap-1.5" lang={language.shown}>
|
|
205
|
+
<h2 className="text-lg font-semibold leading-none">{legal('cancellation.title')}</h2>
|
|
206
|
+
{current === 'form' && <p className="text-muted-foreground text-sm">{legal('cancellation.intro')}</p>}
|
|
207
|
+
</header>
|
|
208
|
+
<LanguageToggle language={language} hook="data-cancellation-language-toggle" />
|
|
209
|
+
<div data-cancellation-form="" data-cancellation-step={current} data-step={current} data-mode={mode}
|
|
210
|
+
data-language={language.shown} lang={language.shown} className="grid gap-4">
|
|
211
|
+
{content}
|
|
212
|
+
</div>
|
|
213
|
+
</section>
|
|
214
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { LIB_NAMESPACE, resolveI18nResource } from '@owlmeans/i18n'
|
|
3
|
+
import { baseLanguageOf, consumerText } from '@owlmeans/payment'
|
|
4
|
+
import type { CopyValues } from '@owlmeans/payment'
|
|
5
|
+
import { WEB_PAYMENT_RESOURCE } from '../i18n.js'
|
|
6
|
+
|
|
7
|
+
interface Tree {
|
|
8
|
+
[key: string]: string | Tree
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const isTree = (value: unknown): value is Tree =>
|
|
12
|
+
value != null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
|
|
14
|
+
const merge = (base: Tree, over: Record<string, unknown> | null): Tree => {
|
|
15
|
+
const result: Tree = { ...base }
|
|
16
|
+
for (const [key, value] of Object.entries(over ?? {})) {
|
|
17
|
+
if (isTree(value)) {
|
|
18
|
+
const current = result[key]
|
|
19
|
+
result[key] = merge(isTree(current) ? current : {}, value)
|
|
20
|
+
} else if (typeof value === 'string') {
|
|
21
|
+
result[key] = value
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bundleOf = (lng: string): Tree => {
|
|
29
|
+
const base = baseLanguageOf(lng)
|
|
30
|
+
let tree = merge({}, resolveI18nResource('en', WEB_PAYMENT_RESOURCE, LIB_NAMESPACE))
|
|
31
|
+
if (base !== '' && base !== 'en') {
|
|
32
|
+
tree = merge(tree, resolveI18nResource(base, WEB_PAYMENT_RESOURCE, LIB_NAMESPACE))
|
|
33
|
+
}
|
|
34
|
+
if (lng !== base && lng.trim() !== '') {
|
|
35
|
+
tree = merge(tree, resolveI18nResource(lng, WEB_PAYMENT_RESOURCE, LIB_NAMESPACE))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return tree
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fill = (text: string, values: CopyValues = {}): string =>
|
|
42
|
+
text.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, name: string) => values[name] != null ? String(values[name]) : '')
|
|
43
|
+
|
|
44
|
+
/** A translator bound to ONE language, whatever the active i18n language is. */
|
|
45
|
+
export type FixedText = (key: string, values?: CopyValues) => string
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The package's own interface strings (`web-payment`, library tier, any application override
|
|
49
|
+
* included) in a FIXED language, merged key by key over English — no i18next instance and no
|
|
50
|
+
* draining (`resolveI18nResource`). What lets a consumer-rights dialog render wholly in the
|
|
51
|
+
* contract language while the rest of the page stays in the interface language.
|
|
52
|
+
*/
|
|
53
|
+
export const paymentTextOf = (lng: string): FixedText => {
|
|
54
|
+
const tree = bundleOf(lng)
|
|
55
|
+
|
|
56
|
+
return (key, values) => {
|
|
57
|
+
const value = key.split('.').reduce<unknown>((node, part) => isTree(node) ? node[part] : undefined, tree)
|
|
58
|
+
|
|
59
|
+
return typeof value === 'string' ? fill(value, values) : key
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The legal copy of `@owlmeans/payment` (`payment-consumer-rights`) in a fixed language. A text
|
|
65
|
+
* that cannot be rendered in the language (a broken application override) falls back to English
|
|
66
|
+
* rather than blanking the page; one that cannot be rendered at all is `''`.
|
|
67
|
+
*/
|
|
68
|
+
export const legalTextOf = (lng: string): FixedText => (path, values) => {
|
|
69
|
+
try {
|
|
70
|
+
return consumerText(lng, path, values)
|
|
71
|
+
} catch {
|
|
72
|
+
try {
|
|
73
|
+
return consumerText('en', path, values)
|
|
74
|
+
} catch {
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `paymentTextOf(lng)`, memoised for the language. */
|
|
81
|
+
export const usePaymentText = (lng: string): FixedText => useMemo(() => paymentTextOf(lng), [lng])
|
|
82
|
+
|
|
83
|
+
/** `legalTextOf(lng)`, memoised for the language. */
|
|
84
|
+
export const useLegalText = (lng: string): FixedText => useMemo(() => legalTextOf(lng), [lng])
|
|
85
|
+
|
|
86
|
+
/** A language's own name in another language (`de` in `en` → "German"); the code when unknown. */
|
|
87
|
+
export const languageNameOf = (lng: string, inLanguage: string): string => {
|
|
88
|
+
try {
|
|
89
|
+
return new Intl.DisplayNames([inLanguage], { type: 'language' }).of(lng) ?? lng
|
|
90
|
+
} catch {
|
|
91
|
+
return lng
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ConsentKind } from '@owlmeans/payment'
|
|
2
|
+
import { ConsentDeclined, isPerformanceConsentRefusal } from './refusal.js'
|
|
3
|
+
|
|
4
|
+
export interface AskerOptions<Args extends unknown[], View, Answer> {
|
|
5
|
+
/** Read the current view. */
|
|
6
|
+
load: (...args: Args) => Promise<View>
|
|
7
|
+
/** Whether the view asks the person anything. */
|
|
8
|
+
required: (view: View) => boolean
|
|
9
|
+
/** Show the view to the person (open the dialog). */
|
|
10
|
+
show: (view: View) => void
|
|
11
|
+
/**
|
|
12
|
+
* The answer when nothing needs asking — and when the read FAILS: the server's refusal stays
|
|
13
|
+
* the authority, and a flaky read must never block work that needs no consent.
|
|
14
|
+
*/
|
|
15
|
+
skip: Answer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Asker<Args extends unknown[], Answer> {
|
|
19
|
+
/** Read, and ask only when the view requires it. Concurrent calls share ONE pending answer. */
|
|
20
|
+
ask: (...args: Args) => Promise<Answer>
|
|
21
|
+
/** Answer the pending `ask` (the dialog confirmed or was declined); nothing when none waits. */
|
|
22
|
+
settle: (answer: Answer) => void
|
|
23
|
+
/** Whether an `ask` is waiting for the person. */
|
|
24
|
+
waiting: () => boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a view, show it when it asks for something, and answer once the person has — the state
|
|
29
|
+
* machine behind `usePerformanceConsent().ensure` and `useSubscriptionStart().ensure`, free of
|
|
30
|
+
* React so it is testable on its own. A burst of calls opens one dialog: they all get its answer.
|
|
31
|
+
*/
|
|
32
|
+
export const makeAsker = <Args extends unknown[], View, Answer>(
|
|
33
|
+
opts: AskerOptions<Args, View, Answer>,
|
|
34
|
+
): Asker<Args, Answer> => {
|
|
35
|
+
let inflight: Promise<Answer> | null = null
|
|
36
|
+
let resolver: ((answer: Answer) => void) | null = null
|
|
37
|
+
|
|
38
|
+
const run = async (args: Args): Promise<Answer> => {
|
|
39
|
+
let view: View
|
|
40
|
+
try {
|
|
41
|
+
view = await opts.load(...args)
|
|
42
|
+
} catch {
|
|
43
|
+
return opts.skip
|
|
44
|
+
}
|
|
45
|
+
if (!opts.required(view)) {
|
|
46
|
+
return opts.skip
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return await new Promise<Answer>(resolve => {
|
|
50
|
+
resolver = resolve
|
|
51
|
+
opts.show(view)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
ask: async (...args) => {
|
|
57
|
+
if (inflight == null) {
|
|
58
|
+
inflight = run(args).finally(() => {
|
|
59
|
+
inflight = null
|
|
60
|
+
resolver = null
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return await inflight
|
|
65
|
+
},
|
|
66
|
+
settle: answer => {
|
|
67
|
+
const resolve = resolver
|
|
68
|
+
resolver = null
|
|
69
|
+
resolve?.(answer)
|
|
70
|
+
},
|
|
71
|
+
waiting: () => resolver != null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface EnsureOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Asked after the server refused with a 428 although the last answer said nothing was needed.
|
|
78
|
+
* The view is always read fresh; `force` records why the caller asks.
|
|
79
|
+
*/
|
|
80
|
+
force?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ConsentGate {
|
|
84
|
+
/**
|
|
85
|
+
* `true` when nothing needs consent or the person confirmed and it was recorded; `false` when
|
|
86
|
+
* they declined or closed the dialog. A failed read answers `true`. One pending answer is shared.
|
|
87
|
+
*/
|
|
88
|
+
ensure: (opts?: EnsureOptions) => Promise<boolean>
|
|
89
|
+
/**
|
|
90
|
+
* Run `action`; when it is refused for the spend consent (`isPerformanceConsentRefusal` — the
|
|
91
|
+
* class, its marker, or a bare 428, also wrapped inside another error), ask with
|
|
92
|
+
* `ensure({ force: true })` and run it again exactly ONCE. A decline throws `ConsentDeclined`;
|
|
93
|
+
* a second refusal, or any other failure, propagates as it is.
|
|
94
|
+
*/
|
|
95
|
+
withConsent: <T>(action: () => Promise<T>) => Promise<T>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A gate over an `ensure` — what `useConsentGate` answers inside a `PerformanceConsentProvider`. */
|
|
99
|
+
export const makeConsentGate = (ensure: ConsentGate['ensure']): ConsentGate => ({
|
|
100
|
+
ensure,
|
|
101
|
+
withConsent: async <T>(action: () => Promise<T>): Promise<T> => {
|
|
102
|
+
try {
|
|
103
|
+
return await action()
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (!isPerformanceConsentRefusal(e)) {
|
|
106
|
+
throw e
|
|
107
|
+
}
|
|
108
|
+
if (!await ensure({ force: true })) {
|
|
109
|
+
throw new ConsentDeclined(ConsentKind.Performance)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return await action()
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The gate outside any provider: nothing can be asked, so `ensure` answers `true` and a refusal
|
|
119
|
+
* propagates unchanged.
|
|
120
|
+
*/
|
|
121
|
+
export const passThroughGate: ConsentGate = {
|
|
122
|
+
ensure: async () => true,
|
|
123
|
+
withConsent: async action => await action(),
|
|
124
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { Input } from '@/components/ui/input'
|
|
4
|
+
import { Label } from '@/components/ui/label'
|
|
5
|
+
|
|
6
|
+
/** The shape the platform validates an e-mail address with (`@owlmeans/payment` `EmailSchema`). */
|
|
7
|
+
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/
|
|
8
|
+
|
|
9
|
+
export const isEmail = (value: string): boolean => value.length >= 3 && value.length <= 254 && EMAIL_PATTERN.test(value)
|
|
10
|
+
|
|
11
|
+
export interface FieldProps {
|
|
12
|
+
id: string
|
|
13
|
+
label: string
|
|
14
|
+
value: string
|
|
15
|
+
onChange: (value: string) => void
|
|
16
|
+
error?: string | null
|
|
17
|
+
type?: 'text' | 'email' | 'date'
|
|
18
|
+
autoComplete?: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
min?: string
|
|
21
|
+
/** A `data-*` hook for tests (`data-withdrawal-field="name"`). */
|
|
22
|
+
hook?: Record<string, string>
|
|
23
|
+
multiline?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Field = ({
|
|
27
|
+
id, label, value, onChange, error, type = 'text', autoComplete, disabled, min, hook, multiline = false,
|
|
28
|
+
}: FieldProps) => <div className="grid gap-1.5">
|
|
29
|
+
<Label htmlFor={id}>{label}</Label>
|
|
30
|
+
{multiline
|
|
31
|
+
? <textarea
|
|
32
|
+
id={id} value={value} disabled={disabled} rows={3} maxLength={2000} aria-invalid={error != null}
|
|
33
|
+
aria-describedby={error != null ? `${id}-error` : undefined} onChange={event => onChange(event.target.value)}
|
|
34
|
+
className={cn(
|
|
35
|
+
'border-input bg-background placeholder:text-muted-foreground w-full min-w-0 rounded-md border px-3 py-2 text-sm shadow-xs outline-none',
|
|
36
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:opacity-50 aria-invalid:border-destructive',
|
|
37
|
+
)}
|
|
38
|
+
{...hook}
|
|
39
|
+
/>
|
|
40
|
+
: <Input
|
|
41
|
+
id={id} type={type} value={value} disabled={disabled} autoComplete={autoComplete} min={min}
|
|
42
|
+
aria-invalid={error != null} aria-describedby={error != null ? `${id}-error` : undefined}
|
|
43
|
+
onChange={event => onChange(event.target.value)} {...hook}
|
|
44
|
+
/>}
|
|
45
|
+
{error != null && <p id={`${id}-error`} className="text-destructive text-xs">{error}</p>}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
export interface ChoiceProps {
|
|
49
|
+
id: string
|
|
50
|
+
name: string
|
|
51
|
+
checked: boolean
|
|
52
|
+
onSelect: () => void
|
|
53
|
+
disabled?: boolean
|
|
54
|
+
hook?: Record<string, string>
|
|
55
|
+
children: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One option of a radio group — a native radio, so the group needs no extra primitive. */
|
|
59
|
+
export const Choice = ({ id, name, checked, onSelect, disabled, hook, children }: ChoiceProps) => <label
|
|
60
|
+
htmlFor={id}
|
|
61
|
+
className={cn('flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm', checked && 'border-primary bg-muted/30')}
|
|
62
|
+
>
|
|
63
|
+
<input
|
|
64
|
+
id={id} type="radio" name={name} checked={checked} disabled={disabled} onChange={onSelect}
|
|
65
|
+
className="accent-primary mt-0.5 size-4 shrink-0" {...hook}
|
|
66
|
+
/>
|
|
67
|
+
<span className="grid gap-0.5">{children}</span>
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
export interface HoneypotProps {
|
|
71
|
+
id: string
|
|
72
|
+
label: string
|
|
73
|
+
value: string
|
|
74
|
+
onChange: (value: string) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A field a person never sees or reaches: off screen, out of the tab order and hidden from
|
|
79
|
+
* assistive technology, with a plausible name a form-filling bot completes. A filled one marks the
|
|
80
|
+
* declaration as a bot's, which the server answers like any other (it never says so).
|
|
81
|
+
*/
|
|
82
|
+
export const Honeypot = ({ id, label, value, onChange }: HoneypotProps) => <div
|
|
83
|
+
aria-hidden="true" data-honeypot=""
|
|
84
|
+
style={{ position: 'absolute', left: '-10000px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }}
|
|
85
|
+
>
|
|
86
|
+
<label htmlFor={id}>{label}</label>
|
|
87
|
+
<input id={id} name="website" type="text" tabIndex={-1} autoComplete="off" value={value}
|
|
88
|
+
onChange={event => onChange(event.target.value)} />
|
|
89
|
+
</div>
|