@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.
- package/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +171 -31
- 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/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/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +42 -16
- package/build/dialog.js.map +1 -1
- package/build/estimate-summary.d.ts +9 -1
- package/build/estimate-summary.d.ts.map +1 -1
- package/build/estimate-summary.js +10 -7
- package/build/estimate-summary.js.map +1 -1
- package/build/estimate.d.ts +6 -0
- package/build/estimate.d.ts.map +1 -1
- package/build/estimate.js +26 -1
- package/build/estimate.js.map +1 -1
- 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/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 +110 -15
- 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 +10 -8
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/types.d.ts +22 -1
- package/build/types.d.ts.map +1 -1
- package/package.json +18 -7
- package/src/@/components/ui/checkbox.tsx +23 -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/dialog.tsx +53 -23
- package/src/estimate-summary.tsx +21 -11
- package/src/estimate.ts +27 -1
- package/src/format.ts +71 -0
- 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 +110 -15
- 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 +11 -8
- package/src/index.ts +2 -0
- package/src/types.ts +23 -1
- 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 +1 -1
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/mount.tsx +9 -2
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 =
|
|
30
|
-
?
|
|
31
|
-
: amountMinor
|
|
32
|
-
? t('
|
|
33
|
-
: amountMinor
|
|
34
|
-
? t('
|
|
35
|
-
:
|
|
36
|
-
|
|
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 ||
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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 ||
|
|
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>
|
package/src/estimate-summary.tsx
CHANGED
|
@@ -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 = ({
|
|
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
|
|
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
|
+
}
|