@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.
Files changed (203) hide show
  1. package/README.md +30 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-payment/SKILL.md +287 -20
  4. package/build/@/components/ui/card.d.ts +10 -0
  5. package/build/@/components/ui/card.d.ts.map +1 -0
  6. package/build/@/components/ui/card.js +25 -0
  7. package/build/@/components/ui/card.js.map +1 -0
  8. package/build/@/components/ui/checkbox.d.ts +5 -0
  9. package/build/@/components/ui/checkbox.d.ts.map +1 -0
  10. package/build/@/components/ui/checkbox.js +6 -0
  11. package/build/@/components/ui/checkbox.js.map +1 -0
  12. package/build/@/components/ui/progress.d.ts +5 -0
  13. package/build/@/components/ui/progress.d.ts.map +1 -0
  14. package/build/@/components/ui/progress.js +11 -0
  15. package/build/@/components/ui/progress.js.map +1 -0
  16. package/build/@/components/ui/select.d.ts +8 -0
  17. package/build/@/components/ui/select.d.ts.map +1 -0
  18. package/build/@/components/ui/select.js +9 -0
  19. package/build/@/components/ui/select.js.map +1 -0
  20. package/build/capability-list.d.ts +7 -0
  21. package/build/capability-list.d.ts.map +1 -0
  22. package/build/capability-list.js +24 -0
  23. package/build/capability-list.js.map +1 -0
  24. package/build/checkout-limit.d.ts +11 -0
  25. package/build/checkout-limit.d.ts.map +1 -0
  26. package/build/checkout-limit.js +34 -0
  27. package/build/checkout-limit.js.map +1 -0
  28. package/build/consumer/cancellation.d.ts +20 -0
  29. package/build/consumer/cancellation.d.ts.map +1 -0
  30. package/build/consumer/cancellation.js +115 -0
  31. package/build/consumer/cancellation.js.map +1 -0
  32. package/build/consumer/copy.d.ts +23 -0
  33. package/build/consumer/copy.d.ts.map +1 -0
  34. package/build/consumer/copy.js +75 -0
  35. package/build/consumer/copy.js.map +1 -0
  36. package/build/consumer/ensure.d.ts +56 -0
  37. package/build/consumer/ensure.d.ts.map +1 -0
  38. package/build/consumer/ensure.js +71 -0
  39. package/build/consumer/ensure.js.map +1 -0
  40. package/build/consumer/fields.d.ts +43 -0
  41. package/build/consumer/fields.d.ts.map +1 -0
  42. package/build/consumer/fields.js +19 -0
  43. package/build/consumer/fields.js.map +1 -0
  44. package/build/consumer/hooks.d.ts +113 -0
  45. package/build/consumer/hooks.d.ts.map +1 -0
  46. package/build/consumer/hooks.js +225 -0
  47. package/build/consumer/hooks.js.map +1 -0
  48. package/build/consumer/index.d.ts +14 -0
  49. package/build/consumer/index.d.ts.map +1 -0
  50. package/build/consumer/index.js +12 -0
  51. package/build/consumer/index.js.map +1 -0
  52. package/build/consumer/legal.d.ts +53 -0
  53. package/build/consumer/legal.d.ts.map +1 -0
  54. package/build/consumer/legal.js +77 -0
  55. package/build/consumer/legal.js.map +1 -0
  56. package/build/consumer/performance-consent-dialog.d.ts +18 -0
  57. package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
  58. package/build/consumer/performance-consent-dialog.js +71 -0
  59. package/build/consumer/performance-consent-dialog.js.map +1 -0
  60. package/build/consumer/provider.d.ts +33 -0
  61. package/build/consumer/provider.d.ts.map +1 -0
  62. package/build/consumer/provider.js +26 -0
  63. package/build/consumer/provider.js.map +1 -0
  64. package/build/consumer/refusal.d.ts +35 -0
  65. package/build/consumer/refusal.d.ts.map +1 -0
  66. package/build/consumer/refusal.js +98 -0
  67. package/build/consumer/refusal.js.map +1 -0
  68. package/build/consumer/subscription-start-dialog.d.ts +16 -0
  69. package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
  70. package/build/consumer/subscription-start-dialog.js +43 -0
  71. package/build/consumer/subscription-start-dialog.js.map +1 -0
  72. package/build/consumer/types.d.ts +114 -0
  73. package/build/consumer/types.d.ts.map +1 -0
  74. package/build/consumer/types.js +2 -0
  75. package/build/consumer/types.js.map +1 -0
  76. package/build/consumer/withdrawal.d.ts +29 -0
  77. package/build/consumer/withdrawal.d.ts.map +1 -0
  78. package/build/consumer/withdrawal.js +140 -0
  79. package/build/consumer/withdrawal.js.map +1 -0
  80. package/build/copy.d.ts +14 -0
  81. package/build/copy.d.ts.map +1 -0
  82. package/build/copy.js +51 -0
  83. package/build/copy.js.map +1 -0
  84. package/build/dialog.d.ts +1 -1
  85. package/build/dialog.d.ts.map +1 -1
  86. package/build/dialog.js +46 -17
  87. package/build/dialog.js.map +1 -1
  88. package/build/entitlement.d.ts +26 -0
  89. package/build/entitlement.d.ts.map +1 -0
  90. package/build/entitlement.js +42 -0
  91. package/build/entitlement.js.map +1 -0
  92. package/build/estimate-cache.d.ts +14 -0
  93. package/build/estimate-cache.d.ts.map +1 -0
  94. package/build/estimate-cache.js +39 -0
  95. package/build/estimate-cache.js.map +1 -0
  96. package/build/estimate-summary.d.ts +64 -0
  97. package/build/estimate-summary.d.ts.map +1 -0
  98. package/build/estimate-summary.js +94 -0
  99. package/build/estimate-summary.js.map +1 -0
  100. package/build/estimate.d.ts +34 -0
  101. package/build/estimate.d.ts.map +1 -0
  102. package/build/estimate.js +92 -0
  103. package/build/estimate.js.map +1 -0
  104. package/build/format.d.ts +15 -0
  105. package/build/format.d.ts.map +1 -0
  106. package/build/format.js +68 -0
  107. package/build/format.js.map +1 -0
  108. package/build/hooks.d.ts +0 -4
  109. package/build/hooks.d.ts.map +1 -1
  110. package/build/hooks.js +3 -30
  111. package/build/hooks.js.map +1 -1
  112. package/build/i18n/be.json +1 -1
  113. package/build/i18n/de.json +1 -1
  114. package/build/i18n/en.json +1 -1
  115. package/build/i18n/es.json +1 -1
  116. package/build/i18n/fr.json +145 -0
  117. package/build/i18n/pl.json +1 -1
  118. package/build/i18n/ru.json +1 -1
  119. package/build/i18n/uk.json +1 -1
  120. package/build/i18n.d.ts +2 -1
  121. package/build/i18n.d.ts.map +1 -1
  122. package/build/i18n.js +11 -7
  123. package/build/i18n.js.map +1 -1
  124. package/build/index.d.ts +9 -0
  125. package/build/index.d.ts.map +1 -1
  126. package/build/index.js +9 -0
  127. package/build/index.js.map +1 -1
  128. package/build/limit-meter.d.ts +7 -0
  129. package/build/limit-meter.d.ts.map +1 -0
  130. package/build/limit-meter.js +26 -0
  131. package/build/limit-meter.js.map +1 -0
  132. package/build/plan-card.d.ts +7 -0
  133. package/build/plan-card.d.ts.map +1 -0
  134. package/build/plan-card.js +29 -0
  135. package/build/plan-card.js.map +1 -0
  136. package/build/poll.d.ts +7 -0
  137. package/build/poll.d.ts.map +1 -0
  138. package/build/poll.js +28 -0
  139. package/build/poll.js.map +1 -0
  140. package/build/selectors.d.ts +18 -0
  141. package/build/selectors.d.ts.map +1 -0
  142. package/build/selectors.js +78 -0
  143. package/build/selectors.js.map +1 -0
  144. package/build/types.d.ts +120 -2
  145. package/build/types.d.ts.map +1 -1
  146. package/package.json +22 -7
  147. package/src/@/components/ui/card.tsx +93 -0
  148. package/src/@/components/ui/checkbox.tsx +23 -0
  149. package/src/@/components/ui/progress.tsx +44 -0
  150. package/src/@/components/ui/select.tsx +62 -0
  151. package/src/@/globals.css +10 -0
  152. package/src/capability-list.tsx +50 -0
  153. package/src/checkout-limit.tsx +48 -0
  154. package/src/consumer/cancellation.tsx +214 -0
  155. package/src/consumer/copy.ts +93 -0
  156. package/src/consumer/ensure.ts +124 -0
  157. package/src/consumer/fields.tsx +89 -0
  158. package/src/consumer/hooks.ts +358 -0
  159. package/src/consumer/index.ts +13 -0
  160. package/src/consumer/legal.tsx +129 -0
  161. package/src/consumer/performance-consent-dialog.tsx +132 -0
  162. package/src/consumer/provider.tsx +49 -0
  163. package/src/consumer/refusal.ts +110 -0
  164. package/src/consumer/subscription-start-dialog.tsx +87 -0
  165. package/src/consumer/types.ts +121 -0
  166. package/src/consumer/withdrawal.tsx +279 -0
  167. package/src/copy.tsx +62 -0
  168. package/src/dialog.tsx +58 -25
  169. package/src/entitlement.ts +56 -0
  170. package/src/estimate-cache.ts +40 -0
  171. package/src/estimate-summary.tsx +172 -0
  172. package/src/estimate.ts +119 -0
  173. package/src/format.ts +71 -0
  174. package/src/hooks.ts +3 -35
  175. package/src/i18n/be.json +1 -1
  176. package/src/i18n/de.json +1 -1
  177. package/src/i18n/en.json +1 -1
  178. package/src/i18n/es.json +1 -1
  179. package/src/i18n/fr.json +145 -0
  180. package/src/i18n/pl.json +1 -1
  181. package/src/i18n/ru.json +1 -1
  182. package/src/i18n/uk.json +1 -1
  183. package/src/i18n.ts +12 -7
  184. package/src/index.ts +9 -0
  185. package/src/limit-meter.tsx +55 -0
  186. package/src/plan-card.tsx +67 -0
  187. package/src/poll.ts +27 -0
  188. package/src/selectors.ts +93 -0
  189. package/src/types.ts +142 -2
  190. package/tests/consent-refusal.spec.ts +83 -0
  191. package/tests/consumer-hooks.spec.ts +205 -0
  192. package/tests/consumer.spec.ts +343 -0
  193. package/tests/context.ts +9 -0
  194. package/tests/dialog.spec.ts +49 -0
  195. package/tests/entitlement.spec.ts +117 -0
  196. package/tests/estimate-cache.spec.ts +46 -0
  197. package/tests/harness/consumer-fixtures.ts +105 -0
  198. package/tests/harness/consumer.tsx +197 -0
  199. package/tests/harness/entitlement.tsx +159 -0
  200. package/tests/harness/estimate-fixtures.ts +48 -0
  201. package/tests/harness/mount.tsx +19 -3
  202. package/tests/i18n.spec.ts +78 -8
  203. 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>