@owlmeans/web-payment 0.1.18-rc.20 → 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 (136) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-payment/SKILL.md +171 -31
  4. package/build/@/components/ui/checkbox.d.ts +5 -0
  5. package/build/@/components/ui/checkbox.d.ts.map +1 -0
  6. package/build/@/components/ui/checkbox.js +6 -0
  7. package/build/@/components/ui/checkbox.js.map +1 -0
  8. package/build/checkout-limit.d.ts +11 -0
  9. package/build/checkout-limit.d.ts.map +1 -0
  10. package/build/checkout-limit.js +34 -0
  11. package/build/checkout-limit.js.map +1 -0
  12. package/build/consumer/cancellation.d.ts +20 -0
  13. package/build/consumer/cancellation.d.ts.map +1 -0
  14. package/build/consumer/cancellation.js +115 -0
  15. package/build/consumer/cancellation.js.map +1 -0
  16. package/build/consumer/copy.d.ts +23 -0
  17. package/build/consumer/copy.d.ts.map +1 -0
  18. package/build/consumer/copy.js +75 -0
  19. package/build/consumer/copy.js.map +1 -0
  20. package/build/consumer/ensure.d.ts +56 -0
  21. package/build/consumer/ensure.d.ts.map +1 -0
  22. package/build/consumer/ensure.js +71 -0
  23. package/build/consumer/ensure.js.map +1 -0
  24. package/build/consumer/fields.d.ts +43 -0
  25. package/build/consumer/fields.d.ts.map +1 -0
  26. package/build/consumer/fields.js +19 -0
  27. package/build/consumer/fields.js.map +1 -0
  28. package/build/consumer/hooks.d.ts +113 -0
  29. package/build/consumer/hooks.d.ts.map +1 -0
  30. package/build/consumer/hooks.js +225 -0
  31. package/build/consumer/hooks.js.map +1 -0
  32. package/build/consumer/index.d.ts +14 -0
  33. package/build/consumer/index.d.ts.map +1 -0
  34. package/build/consumer/index.js +12 -0
  35. package/build/consumer/index.js.map +1 -0
  36. package/build/consumer/legal.d.ts +53 -0
  37. package/build/consumer/legal.d.ts.map +1 -0
  38. package/build/consumer/legal.js +77 -0
  39. package/build/consumer/legal.js.map +1 -0
  40. package/build/consumer/performance-consent-dialog.d.ts +18 -0
  41. package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
  42. package/build/consumer/performance-consent-dialog.js +71 -0
  43. package/build/consumer/performance-consent-dialog.js.map +1 -0
  44. package/build/consumer/provider.d.ts +33 -0
  45. package/build/consumer/provider.d.ts.map +1 -0
  46. package/build/consumer/provider.js +26 -0
  47. package/build/consumer/provider.js.map +1 -0
  48. package/build/consumer/refusal.d.ts +35 -0
  49. package/build/consumer/refusal.d.ts.map +1 -0
  50. package/build/consumer/refusal.js +98 -0
  51. package/build/consumer/refusal.js.map +1 -0
  52. package/build/consumer/subscription-start-dialog.d.ts +16 -0
  53. package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
  54. package/build/consumer/subscription-start-dialog.js +43 -0
  55. package/build/consumer/subscription-start-dialog.js.map +1 -0
  56. package/build/consumer/types.d.ts +114 -0
  57. package/build/consumer/types.d.ts.map +1 -0
  58. package/build/consumer/types.js +2 -0
  59. package/build/consumer/types.js.map +1 -0
  60. package/build/consumer/withdrawal.d.ts +29 -0
  61. package/build/consumer/withdrawal.d.ts.map +1 -0
  62. package/build/consumer/withdrawal.js +140 -0
  63. package/build/consumer/withdrawal.js.map +1 -0
  64. package/build/dialog.d.ts +1 -1
  65. package/build/dialog.d.ts.map +1 -1
  66. package/build/dialog.js +42 -16
  67. package/build/dialog.js.map +1 -1
  68. package/build/estimate-summary.d.ts +9 -1
  69. package/build/estimate-summary.d.ts.map +1 -1
  70. package/build/estimate-summary.js +10 -7
  71. package/build/estimate-summary.js.map +1 -1
  72. package/build/estimate.d.ts +6 -0
  73. package/build/estimate.d.ts.map +1 -1
  74. package/build/estimate.js +26 -1
  75. package/build/estimate.js.map +1 -1
  76. package/build/format.d.ts +15 -0
  77. package/build/format.d.ts.map +1 -0
  78. package/build/format.js +68 -0
  79. package/build/format.js.map +1 -0
  80. package/build/i18n/be.json +1 -1
  81. package/build/i18n/de.json +1 -1
  82. package/build/i18n/en.json +1 -1
  83. package/build/i18n/es.json +1 -1
  84. package/build/i18n/fr.json +110 -15
  85. package/build/i18n/pl.json +1 -1
  86. package/build/i18n/ru.json +1 -1
  87. package/build/i18n/uk.json +1 -1
  88. package/build/i18n.d.ts +2 -1
  89. package/build/i18n.d.ts.map +1 -1
  90. package/build/i18n.js +10 -8
  91. package/build/i18n.js.map +1 -1
  92. package/build/index.d.ts +2 -0
  93. package/build/index.d.ts.map +1 -1
  94. package/build/index.js +2 -0
  95. package/build/index.js.map +1 -1
  96. package/build/types.d.ts +22 -1
  97. package/build/types.d.ts.map +1 -1
  98. package/package.json +18 -7
  99. package/src/@/components/ui/checkbox.tsx +23 -0
  100. package/src/checkout-limit.tsx +48 -0
  101. package/src/consumer/cancellation.tsx +214 -0
  102. package/src/consumer/copy.ts +93 -0
  103. package/src/consumer/ensure.ts +124 -0
  104. package/src/consumer/fields.tsx +89 -0
  105. package/src/consumer/hooks.ts +358 -0
  106. package/src/consumer/index.ts +13 -0
  107. package/src/consumer/legal.tsx +129 -0
  108. package/src/consumer/performance-consent-dialog.tsx +132 -0
  109. package/src/consumer/provider.tsx +49 -0
  110. package/src/consumer/refusal.ts +110 -0
  111. package/src/consumer/subscription-start-dialog.tsx +87 -0
  112. package/src/consumer/types.ts +121 -0
  113. package/src/consumer/withdrawal.tsx +279 -0
  114. package/src/dialog.tsx +53 -23
  115. package/src/estimate-summary.tsx +21 -11
  116. package/src/estimate.ts +27 -1
  117. package/src/format.ts +71 -0
  118. package/src/i18n/be.json +1 -1
  119. package/src/i18n/de.json +1 -1
  120. package/src/i18n/en.json +1 -1
  121. package/src/i18n/es.json +1 -1
  122. package/src/i18n/fr.json +110 -15
  123. package/src/i18n/pl.json +1 -1
  124. package/src/i18n/ru.json +1 -1
  125. package/src/i18n/uk.json +1 -1
  126. package/src/i18n.ts +11 -8
  127. package/src/index.ts +2 -0
  128. package/src/types.ts +23 -1
  129. package/tests/consent-refusal.spec.ts +83 -0
  130. package/tests/consumer-hooks.spec.ts +205 -0
  131. package/tests/consumer.spec.ts +343 -0
  132. package/tests/context.ts +1 -1
  133. package/tests/harness/consumer-fixtures.ts +105 -0
  134. package/tests/harness/consumer.tsx +197 -0
  135. package/tests/harness/mount.tsx +9 -2
  136. package/tests/i18n.spec.ts +41 -5
@@ -0,0 +1,279 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
2
+ import { baseLanguageOf } from '@owlmeans/payment'
3
+ import type { WithdrawalBody, WithdrawalCandidate, WithdrawalReceipt } from '@owlmeans/payment'
4
+ import { cn } from '@/lib/utils'
5
+ import { Button } from '@/components/ui/button'
6
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
7
+ import { dayUtc, lastDayUtc, momentUtc, money } from '../format.js'
8
+ import { legalTextOf, paymentTextOf, useLegalText, usePaymentText } from './copy.js'
9
+ import { Choice, Field, Honeypot, isEmail } from './fields.js'
10
+ import { ErrorLine, LanguageToggle, useShownLanguage, useUiLanguage } from './legal.js'
11
+ import type {
12
+ DeclarationStep, WithdrawalDialogProps, WithdrawalFormProps, WithdrawalFunctionButtonProps,
13
+ } from './types.js'
14
+
15
+ /**
16
+ * The withdrawal function's entry (CRD Art. 11a; § 356a BGB "Vertrag widerrufen"; L221-21
17
+ * "Renoncer au contrat ici"): the statutory label in the CONTRACT language, and the interface
18
+ * language's label as its `title` when the two differ.
19
+ *
20
+ * `[data-withdrawal-function]` with `data-language`.
21
+ */
22
+ export const WithdrawalFunctionButton = ({
23
+ language, uiLanguage, onClick, disabled, variant = 'outline', className,
24
+ }: WithdrawalFunctionButtonProps) => {
25
+ const ui = useUiLanguage(uiLanguage)
26
+ const contract = baseLanguageOf(language) || ui
27
+ const label = legalTextOf(contract)('withdrawal.function')
28
+ const title = contract !== ui ? legalTextOf(ui)('withdrawal.function') : undefined
29
+
30
+ return <Button type="button" variant={variant} onClick={onClick} disabled={disabled} title={title} lang={contract}
31
+ data-withdrawal-function="" data-language={contract} className={className}>
32
+ {label}
33
+ </Button>
34
+ }
35
+
36
+ const isWithdrawalReceipt = (receipt: object): receipt is WithdrawalReceipt => 'status' in receipt
37
+
38
+ interface Frame {
39
+ title: string
40
+ intro: string
41
+ language: string
42
+ step: DeclarationStep
43
+ }
44
+
45
+ interface StepsProps extends WithdrawalFormProps {
46
+ /** How the title and intro are rendered — a section heading, or a dialog's title. */
47
+ frame: (frame: Frame, body: ReactNode) => ReactNode
48
+ }
49
+
50
+ /** The three steps, framed by the caller. */
51
+ const WithdrawalSteps = ({
52
+ mode, language: contractLanguage, uiLanguage, list, defaults, pending = false, error, receipt, onSubmit, onClose,
53
+ formatAmount, frame,
54
+ }: StepsProps) => {
55
+ const language = useShownLanguage(contractLanguage, uiLanguage)
56
+ const legal = useLegalText(language.shown)
57
+ const text = usePaymentText(language.shown)
58
+ const candidates = useMemo(() => list?.candidates ?? [], [list])
59
+
60
+ const initialPurchase = (): string => {
61
+ const byId = candidates.find(candidate => candidate.purchaseId === defaults?.purchaseId)
62
+ const byRef = candidates.find(candidate => candidate.contractRef === defaults?.contractRef)
63
+
64
+ return (byId ?? byRef ?? (candidates.length === 1 ? candidates[0] : undefined))?.purchaseId ?? ''
65
+ }
66
+ const [step, setStep] = useState<'form' | 'review'>('form')
67
+ const [purchaseId, setPurchaseId] = useState(initialPurchase)
68
+ const [contractRef, setContractRef] = useState(defaults?.contractRef ?? '')
69
+ const [name, setName] = useState(defaults?.name ?? list?.name ?? '')
70
+ const [email, setEmail] = useState(defaults?.email ?? list?.email ?? '')
71
+ const [honeypot, setHoneypot] = useState('')
72
+ const [checked, setChecked] = useState(false)
73
+ const [submitted, setSubmitted] = useState<WithdrawalBody | null>(null)
74
+ useEffect(() => {
75
+ // The list may arrive after the form: preselect and prefill then, never over what was typed.
76
+ if (purchaseId === '' && candidates.length > 0) setPurchaseId(initialPurchase())
77
+ if (list?.name != null) setName(held => held === '' ? list.name ?? '' : held)
78
+ if (list?.email != null) setEmail(held => held === '' ? list.email ?? '' : held)
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [list])
81
+ useEffect(() => {
82
+ if (receipt == null) setStep(current => submitted == null ? current : 'form')
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [receipt])
85
+
86
+ const current: DeclarationStep = receipt != null ? 'receipt' : step
87
+
88
+ const format = (minor: number, currency: string) => formatAmount != null
89
+ ? formatAmount(minor, currency, language.shown) : money(minor, currency, language.shown)
90
+ const candidate: WithdrawalCandidate | undefined = candidates.find(item => item.purchaseId === purchaseId)
91
+ const contract = mode === 'in-app' ? candidate?.contractRef ?? '' : contractRef.trim()
92
+ const errors = {
93
+ contract: mode === 'in-app'
94
+ ? (candidate == null ? text('consumer.required') : null)
95
+ : (contract === '' ? text('consumer.required') : null),
96
+ name: name.trim() === '' ? text('consumer.required') : null,
97
+ email: email.trim() === '' ? text('consumer.required') : !isEmail(email.trim()) ? text('consumer.email-invalid') : null,
98
+ }
99
+ const shown = (key: keyof typeof errors) => checked ? errors[key] : null
100
+ const valid = Object.values(errors).every(value => value == null)
101
+ const body = (): WithdrawalBody => ({
102
+ ...(mode === 'in-app' && candidate != null
103
+ ? { purchaseId: candidate.purchaseId, contractRef: candidate.contractRef }
104
+ : { contractRef: contract }),
105
+ name: name.trim(),
106
+ email: email.trim(),
107
+ language: language.shown,
108
+ ...(honeypot !== '' ? { honeypot } : {}),
109
+ })
110
+ const toReview = () => {
111
+ setChecked(true)
112
+ if (valid) setStep('review')
113
+ }
114
+ const confirm = async () => {
115
+ if (!valid || pending) return
116
+ const declared = body()
117
+ setSubmitted(declared)
118
+ await onSubmit(declared)
119
+ }
120
+
121
+ const estimateLine = (item: WithdrawalCandidate) => item.estimate != null
122
+ ? <span data-withdrawal-estimate="" data-refund-minor={item.estimate.refundMinor}>
123
+ {text('withdrawal.estimate', { amount: format(item.estimate.refundMinor, item.estimate.currency) })}
124
+ </span>
125
+ : <span data-withdrawal-estimate="" className="text-muted-foreground">{text('withdrawal.estimate-unknown')}</span>
126
+
127
+ const summary = (declared: Pick<WithdrawalBody, 'name' | 'email'> & { contract: string }) =>
128
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 rounded-lg border bg-muted/30 p-4 text-sm">
129
+ <dt className="text-muted-foreground">{legal('withdrawal.name')}</dt><dd data-summary="name">{declared.name}</dd>
130
+ <dt className="text-muted-foreground">{legal('withdrawal.contract')}</dt><dd data-summary="contract">{declared.contract}</dd>
131
+ <dt className="text-muted-foreground">{legal('withdrawal.email')}</dt><dd data-summary="email">{declared.email}</dd>
132
+ </dl>
133
+
134
+ let content: ReactNode
135
+ if (current === 'receipt' && receipt != null) {
136
+ const receivedAt = new Date(receipt.receivedAt)
137
+ const declared = submitted ?? body()
138
+ const status = isWithdrawalReceipt(receipt) ? receipt.status : undefined
139
+ content = <div className="grid gap-4" data-withdrawal-receipt="" data-status={status ?? 'received'}
140
+ data-declaration-id={receipt.declarationId} data-received-at={Number.isNaN(receivedAt.getTime()) ? undefined : receivedAt.toISOString()}>
141
+ <h3 className="text-sm font-medium">{text('consumer.receipt')}</h3>
142
+ <p className="text-sm">{receipt.mailed
143
+ ? legal('withdrawal.received', { date: momentUtc(receivedAt, language.shown), email: declared.email })
144
+ : `${text('consumer.received-at')}: ${momentUtc(receivedAt, language.shown)} (UTC). ${text('consumer.not-mailed')}`}</p>
145
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 rounded-lg border p-4 text-sm">
146
+ <dt className="text-muted-foreground">{text('consumer.reference')}</dt>
147
+ <dd data-withdrawal-reference="">{receipt.declarationId}</dd>
148
+ <dt className="text-muted-foreground">{text('consumer.received-at')}</dt>
149
+ <dd data-withdrawal-received-at="">{momentUtc(receivedAt, language.shown)} (UTC)</dd>
150
+ <dt className="text-muted-foreground">{legal('withdrawal.name')}</dt><dd>{declared.name}</dd>
151
+ <dt className="text-muted-foreground">{legal('withdrawal.contract')}</dt><dd>{declared.contractRef ?? ''}</dd>
152
+ <dt className="text-muted-foreground">{legal('withdrawal.email')}</dt><dd>{declared.email}</dd>
153
+ {status != null && <>
154
+ <dt className="text-muted-foreground">{text('consumer.status')}</dt>
155
+ <dd data-withdrawal-status="">{text(`withdrawal.status.${status}`)}</dd>
156
+ </>}
157
+ {isWithdrawalReceipt(receipt) && receipt.refundMinor != null && receipt.currency != null && <>
158
+ <dt className="text-muted-foreground">{text('withdrawal.refund')}</dt>
159
+ <dd data-withdrawal-refund="" data-refund-minor={receipt.refundMinor}>{format(receipt.refundMinor, receipt.currency)}</dd>
160
+ </>}
161
+ </dl>
162
+ {isWithdrawalReceipt(receipt) && receipt.subscriptionCanceled === true
163
+ && <p className="text-sm">{text('withdrawal.subscription-canceled')}</p>}
164
+ {onClose != null && <div className="flex justify-end">
165
+ <Button type="button" variant="outline" onClick={onClose}>{text('consumer.close')}</Button>
166
+ </div>}
167
+ </div>
168
+ } else if (current === 'review') {
169
+ content = <div className="grid gap-4">
170
+ <h3 className="text-sm font-medium">{text('consumer.review')}</h3>
171
+ <p className="text-sm">{legal('withdrawal.review')}</p>
172
+ {summary({ name: name.trim(), email: email.trim(), contract })}
173
+ {mode === 'in-app' && candidate != null && <p className="text-sm">{estimateLine(candidate)}</p>}
174
+ <ErrorLine error={error} text={text} />
175
+ <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
176
+ <Button type="button" variant="outline" disabled={pending} onClick={() => setStep('form')}
177
+ data-withdrawal-back="">{text('consumer.back')}</Button>
178
+ <Button type="button" disabled={pending || !valid} onClick={() => void confirm()} data-withdrawal-confirm="">
179
+ {pending ? text('consumer.pending') : legal('withdrawal.confirm')}
180
+ </Button>
181
+ </div>
182
+ </div>
183
+ } else {
184
+ content = <form className="relative grid gap-4" noValidate onSubmit={event => { event.preventDefault(); toReview() }}>
185
+ {mode === 'in-app'
186
+ ? candidates.length === 0
187
+ ? <p className="text-muted-foreground text-sm" data-withdrawal-none="">{text('withdrawal.none')}</p>
188
+ : <fieldset className="grid gap-2">
189
+ <legend className="mb-1 text-sm font-medium">{legal('withdrawal.contract')}</legend>
190
+ {candidates.map(item => <Choice
191
+ key={item.purchaseId} id={`withdrawal-candidate-${item.purchaseId}`} name="withdrawal-candidate"
192
+ checked={item.purchaseId === purchaseId} onSelect={() => setPurchaseId(item.purchaseId)} disabled={pending}
193
+ hook={{ 'data-withdrawal-candidate-input': item.purchaseId }}
194
+ >
195
+ <span data-withdrawal-candidate="" data-purchase-id={item.purchaseId} data-contract-ref={item.contractRef}
196
+ className="grid gap-0.5">
197
+ <span className="font-medium">{item.contractRef} · {text(`withdrawal.kind.${item.kind}`)}</span>
198
+ <span className="text-muted-foreground text-xs">
199
+ {text('withdrawal.purchased', { date: dayUtc(item.purchasedAt, language.shown) })}
200
+ {' · '}{text('withdrawal.paid', { amount: format(item.amountTotalMinor, item.currency) })}
201
+ {' · '}{text('withdrawal.until', { date: lastDayUtc(item.deadline, language.shown) })}
202
+ </span>
203
+ <span className="text-xs">{estimateLine(item)}</span>
204
+ </span>
205
+ </Choice>)}
206
+ {shown('contract') != null && <p className="text-destructive text-xs">{shown('contract')}</p>}
207
+ </fieldset>
208
+ : <Field id="withdrawal-contract" label={legal('withdrawal.contract')} value={contractRef} onChange={setContractRef}
209
+ error={shown('contract')} disabled={pending} hook={{ 'data-withdrawal-field': 'contract' }} />}
210
+ <Field id="withdrawal-name" label={legal('withdrawal.name')} value={name} onChange={setName} error={shown('name')}
211
+ autoComplete="name" disabled={pending} hook={{ 'data-withdrawal-field': 'name' }} />
212
+ <Field id="withdrawal-email" type="email" label={legal('withdrawal.email')} value={email} onChange={setEmail}
213
+ error={shown('email')} autoComplete="email" disabled={pending} hook={{ 'data-withdrawal-field': 'email' }} />
214
+ {mode === 'public' && <Honeypot id="withdrawal-website" label={text('consumer.website')} value={honeypot} onChange={setHoneypot} />}
215
+ <ErrorLine error={error} text={text} />
216
+ <div className="flex justify-end">
217
+ <Button type="submit" disabled={pending || (mode === 'in-app' && candidates.length === 0)} data-withdrawal-continue="">
218
+ {text('consumer.continue')}
219
+ </Button>
220
+ </div>
221
+ </form>
222
+ }
223
+
224
+ return frame({ title: legal('withdrawal.title'), intro: legal('withdrawal.intro'), language: language.shown, step: current }, <>
225
+ <LanguageToggle language={language} hook="data-withdrawal-language-toggle" />
226
+ <div data-withdrawal-form="" data-withdrawal-step={current} data-step={current} data-mode={mode}
227
+ data-language={language.shown} lang={language.shown} className="grid gap-4">
228
+ {content}
229
+ </div>
230
+ </>)
231
+ }
232
+
233
+ /**
234
+ * The withdrawal function as a page section: the form (in-app a contract picker with the estimated
235
+ * refund, publicly a contract reference — and nothing but name, contract and e-mail is asked),
236
+ * the review with the statutory confirm button ("Confirm withdrawal", "Widerruf bestätigen",
237
+ * "Confirmer la rétractation"), and the receipt with the date and time of receipt in UTC. Legal
238
+ * copy in the contract language, with a toggle to the interface language.
239
+ *
240
+ * Selectors: `[data-withdrawal-form]` / `[data-withdrawal-step="form|review|receipt"]` (also
241
+ * `data-step`, `data-language`, `data-mode`), `[data-withdrawal-candidate]` (`data-purchase-id`),
242
+ * `[data-withdrawal-estimate]`, `[data-withdrawal-field="contract|name|email"]`,
243
+ * `[data-withdrawal-continue]`, `[data-withdrawal-confirm]`, `[data-withdrawal-receipt]`
244
+ * (`data-status`, `data-declaration-id`, `data-received-at`), `[data-withdrawal-reference]`.
245
+ */
246
+ export const WithdrawalForm = ({ className, ...props }: WithdrawalFormProps) => <WithdrawalSteps
247
+ {...props}
248
+ frame={(frame, body) => <section className={cn('grid gap-4', className)} data-withdrawal-section=""
249
+ data-language={frame.language}>
250
+ <header className="grid gap-1.5" lang={frame.language}>
251
+ <h2 className="text-lg font-semibold leading-none">{frame.title}</h2>
252
+ {frame.step === 'form' && <p className="text-muted-foreground text-sm">{frame.intro}</p>}
253
+ </header>
254
+ {body}
255
+ </section>}
256
+ />
257
+
258
+ /**
259
+ * `WithdrawalForm` in a dialog: `[data-withdrawal-dialog]` carries `data-language` and `data-step`.
260
+ * Closing it does not clear a receipt the application holds — reset it when the dialog closes.
261
+ */
262
+ export const WithdrawalDialog = ({ open, onOpenChange, className, ...props }: WithdrawalDialogProps) =>
263
+ <Dialog open={open} onOpenChange={onOpenChange}>
264
+ {open && <WithdrawalSteps
265
+ {...props}
266
+ onClose={() => onOpenChange(false)}
267
+ frame={(frame, body) => <DialogContent
268
+ closeLabel={paymentTextOf(frame.language)('consumer.close')}
269
+ data-withdrawal-dialog="" data-language={frame.language} data-step={frame.step}
270
+ className={cn('max-h-[90vh] overflow-y-auto', className)}
271
+ >
272
+ <div className="grid gap-2" lang={frame.language}>
273
+ <DialogTitle>{frame.title}</DialogTitle>
274
+ <DialogDescription className={frame.step === 'form' ? undefined : 'sr-only'}>{frame.intro}</DialogDescription>
275
+ </div>
276
+ {body}
277
+ </DialogContent>}
278
+ />}
279
+ </Dialog>
package/src/dialog.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
2
  import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
3
- import { assertCheckoutAmount, chargeAmountMinor } from '@owlmeans/payment'
3
+ import { assertCheckoutAmount, chargeAmountMinor, narrowAmountPolicy } from '@owlmeans/payment'
4
+ import type { AmountCheckoutPolicy, CheckoutLimitView } from '@owlmeans/payment'
4
5
  import { Button } from '@/components/ui/button'
5
6
  import {
6
7
  Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
@@ -8,66 +9,92 @@ import {
8
9
  import { Input } from '@/components/ui/input'
9
10
  import { Label } from '@/components/ui/label'
10
11
  import { inputAmount, parseAmountMinor } from './amount.js'
12
+ import { CheckoutLimitNote } from './checkout-limit.js'
11
13
  import { PriceEstimateSummary } from './estimate-summary.js'
14
+ import { money } from './format.js'
12
15
  import type { AmountCheckoutDialogProps } from './types.js'
13
16
 
14
- const money = (minor: number, currency: string, locale: string): string => new Intl.NumberFormat(locale, {
15
- style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
16
- }).format(minor / 100)
17
+ /**
18
+ * The policy the dialog offers: `policy` narrowed by `limit` — the same `narrowAmountPolicy` the
19
+ * server refuses with. A policy the package cannot narrow (an invalid one) is offered as given.
20
+ */
21
+ const effectivePolicyOf = (policy: AmountCheckoutPolicy, limit?: CheckoutLimitView | null): AmountCheckoutPolicy => {
22
+ if (limit == null || (!limit.narrowed && !limit.blocked)) {
23
+ return policy
24
+ }
25
+ try {
26
+ return narrowAmountPolicy(policy, [{
27
+ maximumMinor: limit.maximumMinor,
28
+ reason: limit.reason ?? 'limit',
29
+ ...(limit.resetsAt != null ? { resetsAt: limit.resetsAt } : {}),
30
+ ...(limit.remainingMinor != null ? { remainingMinor: limit.remainingMinor } : {}),
31
+ }]).policy
32
+ } catch {
33
+ return policy
34
+ }
35
+ }
17
36
 
18
37
  export const AmountCheckoutDialog = ({
19
- open, onOpenChange, policy, pending = false, onConfirm, estimate,
38
+ open, onOpenChange, policy: basePolicy, pending = false, onConfirm, estimate, limit, legalNote,
20
39
  }: AmountCheckoutDialogProps) => {
21
40
  const t = useI18nLib('web-payment', 'amount-checkout')
22
41
  const [locale] = useLanguage()
42
+ const policy = useMemo(() => effectivePolicyOf(basePolicy, limit), [basePolicy, limit])
43
+ const blocked = limit?.blocked === true
44
+ // The limit, not the plan, set the maximum — also when `policy` arrives already narrowed.
45
+ const limited = limit != null && limit.narrowed && limit.maximumMinor <= policy.maximumMinor
23
46
  const [value, setValue] = useState(() => inputAmount(policy.defaultMinor, locale))
24
47
  useEffect(() => {
25
48
  if (open) setValue(inputAmount(policy.defaultMinor, locale))
26
49
  }, [open, policy.defaultMinor, locale])
27
50
 
28
51
  const amountMinor = useMemo(() => parseAmountMinor(value, locale), [value, locale])
29
- const error = amountMinor == null
30
- ? t('invalid')
31
- : amountMinor < policy.minimumMinor
32
- ? t('below', { amount: money(policy.minimumMinor, policy.currency, locale) })
33
- : amountMinor > policy.maximumMinor
34
- ? t('above', { amount: money(policy.maximumMinor, policy.currency, locale) })
35
- : null
36
- const valid = error == null && amountMinor != null
52
+ const error = blocked
53
+ ? null
54
+ : amountMinor == null
55
+ ? t('invalid')
56
+ : amountMinor < policy.minimumMinor
57
+ ? t('below', { amount: money(policy.minimumMinor, policy.currency, locale) })
58
+ : amountMinor > policy.maximumMinor
59
+ ? t(limited ? 'above-limit' : 'above', { amount: money(policy.maximumMinor, policy.currency, locale) })
60
+ : null
61
+ const valid = !blocked && error == null && amountMinor != null
37
62
  const chargeMinor = valid ? chargeAmountMinor(amountMinor, policy) : 0
38
63
  const adjustmentMinor = valid && amountMinor != null ? chargeMinor - amountMinor : 0
64
+ const locked = pending || blocked
39
65
  const submit = async () => {
40
- if (amountMinor == null || !valid || pending) return
66
+ if (amountMinor == null || !valid || locked) return
41
67
  assertCheckoutAmount(policy, amountMinor)
42
68
  await onConfirm(amountMinor, estimate?.country)
43
69
  }
44
70
 
45
71
  return <Dialog open={open} onOpenChange={onOpenChange}>
46
- <DialogContent closeLabel={t('close')}>
72
+ <DialogContent closeLabel={t('close')} data-amount-checkout="" data-blocked={blocked ? 'true' : 'false'}>
47
73
  <DialogHeader>
48
74
  <DialogTitle>{t('title')}</DialogTitle>
49
75
  <DialogDescription>{t('description')}</DialogDescription>
50
76
  </DialogHeader>
51
77
  <div className="grid gap-5">
52
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('presets')}>
78
+ {limit != null && <CheckoutLimitNote limit={limit} />}
79
+ {policy.presetsMinor.length > 0 && <div className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('presets')}>
53
80
  {policy.presetsMinor.map(preset => <Button
54
- key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'}
55
- disabled={pending} onClick={() => setValue(inputAmount(preset, locale))}
81
+ key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'} data-amount-preset={preset}
82
+ disabled={locked} onClick={() => setValue(inputAmount(preset, locale))}
56
83
  >{money(preset, policy.currency, locale)}</Button>)}
57
- </div>
84
+ </div>}
58
85
  <div className="grid gap-2">
59
86
  <Label htmlFor="payment-amount">{t('custom')}</Label>
60
87
  <Input
61
88
  id="payment-amount" inputMode="decimal" autoComplete="off" value={value}
62
- disabled={pending} aria-invalid={error != null} aria-describedby="payment-amount-help"
89
+ disabled={locked} aria-invalid={error != null} aria-describedby="payment-amount-help"
63
90
  onChange={event => setValue(event.target.value)}
64
91
  />
65
- <p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
92
+ {!blocked && <p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
66
93
  {error ?? t('bounds', {
67
94
  minimum: money(policy.minimumMinor, policy.currency, locale),
68
95
  maximum: money(policy.maximumMinor, policy.currency, locale),
69
96
  })}
70
- </p>
97
+ </p>}
71
98
  </div>
72
99
  <dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 rounded-lg border bg-muted/30 p-4 text-sm">
73
100
  <dt className="text-muted-foreground">{t('credits')}</dt><dd>{valid && amountMinor != null ? money(amountMinor, policy.currency, locale) : '—'}</dd>
@@ -77,10 +104,13 @@ export const AmountCheckoutDialog = ({
77
104
  {estimate != null
78
105
  ? valid && <PriceEstimateSummary control={estimate} subtotalMinor={chargeMinor} currency={policy.currency} />
79
106
  : <p className="text-muted-foreground text-xs">{t('tax-note')}</p>}
107
+ {legalNote != null && <div className="text-muted-foreground text-xs" data-legal-note="">{legalNote}</div>}
80
108
  </div>
81
109
  <DialogFooter>
82
110
  <Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>{t('cancel')}</Button>
83
- <Button type="button" disabled={!valid || pending} onClick={() => void submit()}>{pending ? t('pending') : t('continue')}</Button>
111
+ <Button type="button" data-amount-confirm="" disabled={!valid || locked} onClick={() => void submit()}>
112
+ {pending ? t('pending') : t('continue')}
113
+ </Button>
84
114
  </DialogFooter>
85
115
  </DialogContent>
86
116
  </Dialog>
@@ -1,15 +1,12 @@
1
- import { Fragment, useMemo } from 'react'
1
+ import { Fragment, useMemo, type ReactNode } from 'react'
2
2
  import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
3
3
  import { COUNTRY_CODES, TaxEstimateStatus, estimateOf } from '@owlmeans/payment'
4
4
  import { cn } from '@/lib/utils'
5
5
  import { Label } from '@/components/ui/label'
6
6
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
7
+ import { money } from './format.js'
7
8
  import type { PriceEstimateControl } from './types.js'
8
9
 
9
- const money = (minor: number, currency: string, locale: string): string => new Intl.NumberFormat(locale, {
10
- style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
11
- }).format(minor / 100)
12
-
13
10
  const localMoney = (amount: number, currency: string, locale: string): string => {
14
11
  try {
15
12
  return new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() }).format(amount)
@@ -36,6 +33,10 @@ export interface CountrySelectProps {
36
33
  label?: string
37
34
  id?: string
38
35
  className?: string
36
+ /** The country cannot be changed here — a billing country locked by the first purchase. */
37
+ disabled?: boolean
38
+ /** A line under the picker — why it is disabled, typically. */
39
+ note?: ReactNode
39
40
  }
40
41
 
41
42
  /**
@@ -43,25 +44,32 @@ export interface CountrySelectProps {
43
44
  * self-contained estimate, and what a shared picker (several estimates, one country — a plan
44
45
  * comparison table) renders once and drives every `usePriceEstimate({ country, onCountryChange })`
45
46
  * with. Country names are the viewer's own language, alphabetically ordered.
47
+ *
48
+ * `[data-country-select]` carries `data-locked` (`"true"` while disabled) and the chosen
49
+ * `data-country`; the note is `[data-country-note]`.
46
50
  */
47
- export const CountrySelect = ({ value, onChange, label, id = 'price-estimate-country', className }: CountrySelectProps) => {
51
+ export const CountrySelect = ({
52
+ value, onChange, label, id = 'price-estimate-country', className, disabled = false, note,
53
+ }: CountrySelectProps) => {
48
54
  const t = useI18nLib('web-payment', 'estimate')
49
55
  const [locale] = useLanguage()
50
56
  const countries = useCountryOptions(locale)
51
57
  const text = label ?? t('country')
52
58
 
53
- return <div className={cn('grid gap-2', className)}>
59
+ return <div className={cn('grid gap-2', className)} data-country-select="" data-locked={disabled ? 'true' : 'false'}
60
+ data-country={value}>
54
61
  <Label htmlFor={id}>{text}</Label>
55
62
  {/* Always controlled, `''` included (no `SelectItem` ever has that value) — switching to
56
63
  `undefined` once a country is chosen is what trips React's controlled/uncontrolled warning. */}
57
- <Select value={value} onValueChange={onChange}>
58
- <SelectTrigger id={id} aria-label={text}>
64
+ <Select value={value} onValueChange={next => { if (!disabled) onChange(next) }} disabled={disabled}>
65
+ <SelectTrigger id={id} aria-label={text} aria-describedby={note != null ? `${id}-note` : undefined}>
59
66
  <SelectValue placeholder={t('country-placeholder')} />
60
67
  </SelectTrigger>
61
68
  <SelectContent>
62
69
  {countries.map(({ code, name }) => <SelectItem key={code} value={code}>{name}</SelectItem>)}
63
70
  </SelectContent>
64
71
  </Select>
72
+ {note != null && <p id={`${id}-note`} className="text-muted-foreground text-xs" data-country-note="">{note}</p>}
65
73
  </div>
66
74
  }
67
75
 
@@ -153,10 +161,12 @@ export interface PriceEstimateSummaryProps {
153
161
  * estimates sharing one picker (a plan comparison table) compose the two pieces separately instead.
154
162
  */
155
163
  export const PriceEstimateSummary = ({ control, subtotalMinor, currency, suffix, className }: PriceEstimateSummaryProps) => {
156
- const { country, onCountryChange } = control
164
+ const t = useI18nLib('web-payment', 'estimate')
165
+ const { country, onCountryChange, locked = false } = control
157
166
 
158
167
  return <div className={cn('grid gap-3', className)}>
159
- <CountrySelect value={country} onChange={onCountryChange} />
168
+ <CountrySelect value={country} onChange={onCountryChange} disabled={locked}
169
+ note={locked ? t('country-locked') : undefined} />
160
170
  <PriceEstimateAmount control={control} subtotalMinor={subtotalMinor} currency={currency} suffix={suffix} />
161
171
  </div>
162
172
  }
package/src/estimate.ts CHANGED
@@ -32,6 +32,10 @@ export interface UsePriceEstimateOptions {
32
32
  * in-flight request is shared. Uncontrolled (no `opts.country`): the first answer whose `source` is
33
33
  * `'customer'` preselects its `country`, once, so a returning buyer sees their own country without
34
34
  * picking it. Controlled (`opts.country` given): the country and its preselection are the caller's.
35
+ *
36
+ * An answer for a LOCKED billing country (`locked`, or `source: 'profile'`) wins over any pick: the
37
+ * hook adopts its country (controlled: through `opts.onCountryChange`, once), returns `locked:
38
+ * true`, and ignores `onCountryChange` from then on — the picker renders disabled.
35
39
  */
36
40
  export const usePriceEstimate = <Request extends PriceEstimateRequest>(
37
41
  entry: RegisteredEntrypoint<Request, PriceEstimate>, opts: UsePriceEstimateOptions,
@@ -49,6 +53,11 @@ export const usePriceEstimate = <Request extends PriceEstimateRequest>(
49
53
  const [loading, setLoading] = useState(false)
50
54
  const [failed, setFailed] = useState(false)
51
55
  const preselectedRef = useRef(false)
56
+ const lockedRef = useRef(false)
57
+ const locked = estimate != null && isLockedAnswer(estimate)
58
+ lockedRef.current = locked
59
+ const onCountryChangeRef = useRef(opts.onCountryChange)
60
+ onCountryChangeRef.current = opts.onCountryChange
52
61
 
53
62
  useEffect(() => {
54
63
  if (!opts.enabled) {
@@ -65,6 +74,16 @@ export const usePriceEstimate = <Request extends PriceEstimateRequest>(
65
74
  if (!active) return
66
75
  setEstimate(result)
67
76
  setLoading(false)
77
+ if (isLockedAnswer(result) && result.country != null && result.country !== country) {
78
+ // A locked billing country overrides whatever was picked: adopt it, once per country.
79
+ preselectedRef.current = true
80
+ if (controlled) {
81
+ onCountryChangeRef.current?.(result.country)
82
+ } else {
83
+ setInternalCountry(result.country)
84
+ }
85
+ return
86
+ }
68
87
  if (!controlled && !preselectedRef.current && country === '' && result.source === 'customer' && result.country != null) {
69
88
  preselectedRef.current = true
70
89
  setInternalCountry(result.country)
@@ -80,6 +99,9 @@ export const usePriceEstimate = <Request extends PriceEstimateRequest>(
80
99
  }, [opts.enabled, opts.ttlMs, bodyKey, country])
81
100
 
82
101
  const onCountryChange = useCallback((next: string) => {
102
+ if (lockedRef.current) {
103
+ return
104
+ }
83
105
  if (controlled) {
84
106
  opts.onCountryChange?.(next)
85
107
  return
@@ -89,5 +111,9 @@ export const usePriceEstimate = <Request extends PriceEstimateRequest>(
89
111
  // eslint-disable-next-line react-hooks/exhaustive-deps
90
112
  }, [controlled, opts.onCountryChange])
91
113
 
92
- return { estimate, country, loading, failed, onCountryChange }
114
+ return { estimate, country, loading, failed, locked, onCountryChange }
93
115
  }
116
+
117
+ /** An answer computed for the entity's locked billing country, which no picker may change. */
118
+ export const isLockedAnswer = (estimate: PriceEstimate): boolean =>
119
+ estimate.locked === true || estimate.source === 'profile'
package/src/format.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { lastWithdrawalDayOf } from '@owlmeans/payment'
2
+
3
+ /** Minor units as a currency amount in a locale; an unknown locale or currency still reads. */
4
+ export const money = (minor: number, currency: string, locale: string): string => {
5
+ try {
6
+ return new Intl.NumberFormat(locale, {
7
+ style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
8
+ }).format(minor / 100)
9
+ } catch {
10
+ return `${(minor / 100).toFixed(2)} ${currency.toUpperCase()}`
11
+ }
12
+ }
13
+
14
+ const dateOf = (date: Date | string | null | undefined): Date | null => {
15
+ if (date == null) {
16
+ return null
17
+ }
18
+ const value = date instanceof Date ? date : new Date(date)
19
+
20
+ return Number.isNaN(value.getTime()) ? null : value
21
+ }
22
+
23
+ /** A calendar day in UTC (`dateStyle: 'long'`); `''` for no or an unparseable date. */
24
+ export const dayUtc = (date: Date | string | null | undefined, locale: string): string => {
25
+ const value = dateOf(date)
26
+ if (value == null) {
27
+ return ''
28
+ }
29
+ try {
30
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(value)
31
+ } catch {
32
+ return value.toISOString().slice(0, 10)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The last day of a window whose deadline is EXCLUSIVE (the start of the next UTC day) — the day a
38
+ * consumer reads as "until". The rule is `@owlmeans/payment`'s `lastWithdrawalDayOf`, the one the
39
+ * server's e-mails and texts use, so the dialog and the mail never name different days.
40
+ */
41
+ export const lastDayUtc = (deadline: Date | string | null | undefined, locale: string): string => {
42
+ const value = dateOf(deadline)
43
+
44
+ return value == null ? '' : dayUtc(lastWithdrawalDayOf(value), locale)
45
+ }
46
+
47
+ /** A date and time in UTC, to the second — the moment a declaration was received. */
48
+ export const momentUtc = (date: Date | string | null | undefined, locale: string): string => {
49
+ const value = dateOf(date)
50
+ if (value == null) {
51
+ return ''
52
+ }
53
+ try {
54
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'medium', timeZone: 'UTC' }).format(value)
55
+ } catch {
56
+ return value.toISOString().replace('T', ' ').slice(0, 19)
57
+ }
58
+ }
59
+
60
+ /** A date and time in UTC to the minute, with the zone named — when a limit rises again. */
61
+ export const shortMomentUtc = (date: Date | string | null | undefined, locale: string): string => {
62
+ const value = dateOf(date)
63
+ if (value == null) {
64
+ return ''
65
+ }
66
+ try {
67
+ return `${new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(value)} UTC`
68
+ } catch {
69
+ return `${value.toISOString().replace('T', ' ').slice(0, 16)} UTC`
70
+ }
71
+ }