@owlmeans/web-payment 0.1.18-rc.2 → 0.1.18-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +287 -20
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/checkbox.d.ts +5 -0
- package/build/@/components/ui/checkbox.d.ts.map +1 -0
- package/build/@/components/ui/checkbox.js +6 -0
- package/build/@/components/ui/checkbox.js.map +1 -0
- package/build/@/components/ui/progress.d.ts +5 -0
- package/build/@/components/ui/progress.d.ts.map +1 -0
- package/build/@/components/ui/progress.js +11 -0
- package/build/@/components/ui/progress.js.map +1 -0
- package/build/@/components/ui/select.d.ts +8 -0
- package/build/@/components/ui/select.d.ts.map +1 -0
- package/build/@/components/ui/select.js +9 -0
- package/build/@/components/ui/select.js.map +1 -0
- package/build/capability-list.d.ts +7 -0
- package/build/capability-list.d.ts.map +1 -0
- package/build/capability-list.js +24 -0
- package/build/capability-list.js.map +1 -0
- package/build/checkout-limit.d.ts +11 -0
- package/build/checkout-limit.d.ts.map +1 -0
- package/build/checkout-limit.js +34 -0
- package/build/checkout-limit.js.map +1 -0
- package/build/consumer/cancellation.d.ts +20 -0
- package/build/consumer/cancellation.d.ts.map +1 -0
- package/build/consumer/cancellation.js +115 -0
- package/build/consumer/cancellation.js.map +1 -0
- package/build/consumer/copy.d.ts +23 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +75 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/ensure.d.ts +56 -0
- package/build/consumer/ensure.d.ts.map +1 -0
- package/build/consumer/ensure.js +71 -0
- package/build/consumer/ensure.js.map +1 -0
- package/build/consumer/fields.d.ts +43 -0
- package/build/consumer/fields.d.ts.map +1 -0
- package/build/consumer/fields.js +19 -0
- package/build/consumer/fields.js.map +1 -0
- package/build/consumer/hooks.d.ts +113 -0
- package/build/consumer/hooks.d.ts.map +1 -0
- package/build/consumer/hooks.js +225 -0
- package/build/consumer/hooks.js.map +1 -0
- package/build/consumer/index.d.ts +14 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +12 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/legal.d.ts +53 -0
- package/build/consumer/legal.d.ts.map +1 -0
- package/build/consumer/legal.js +77 -0
- package/build/consumer/legal.js.map +1 -0
- package/build/consumer/performance-consent-dialog.d.ts +18 -0
- package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
- package/build/consumer/performance-consent-dialog.js +71 -0
- package/build/consumer/performance-consent-dialog.js.map +1 -0
- package/build/consumer/provider.d.ts +33 -0
- package/build/consumer/provider.d.ts.map +1 -0
- package/build/consumer/provider.js +26 -0
- package/build/consumer/provider.js.map +1 -0
- package/build/consumer/refusal.d.ts +35 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +98 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/subscription-start-dialog.d.ts +16 -0
- package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
- package/build/consumer/subscription-start-dialog.js +43 -0
- package/build/consumer/subscription-start-dialog.js.map +1 -0
- package/build/consumer/types.d.ts +114 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +29 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +140 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/copy.d.ts +14 -0
- package/build/copy.d.ts.map +1 -0
- package/build/copy.js +51 -0
- package/build/copy.js.map +1 -0
- package/build/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +46 -17
- package/build/dialog.js.map +1 -1
- package/build/entitlement.d.ts +26 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +42 -0
- package/build/entitlement.js.map +1 -0
- package/build/estimate-cache.d.ts +14 -0
- package/build/estimate-cache.d.ts.map +1 -0
- package/build/estimate-cache.js +39 -0
- package/build/estimate-cache.js.map +1 -0
- package/build/estimate-summary.d.ts +64 -0
- package/build/estimate-summary.d.ts.map +1 -0
- package/build/estimate-summary.js +94 -0
- package/build/estimate-summary.js.map +1 -0
- package/build/estimate.d.ts +34 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +92 -0
- package/build/estimate.js.map +1 -0
- package/build/format.d.ts +15 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +68 -0
- package/build/format.js.map +1 -0
- package/build/hooks.d.ts +0 -4
- package/build/hooks.d.ts.map +1 -1
- package/build/hooks.js +3 -30
- package/build/hooks.js.map +1 -1
- package/build/i18n/be.json +1 -1
- package/build/i18n/de.json +1 -1
- package/build/i18n/en.json +1 -1
- package/build/i18n/es.json +1 -1
- package/build/i18n/fr.json +145 -0
- package/build/i18n/pl.json +1 -1
- package/build/i18n/ru.json +1 -1
- package/build/i18n/uk.json +1 -1
- package/build/i18n.d.ts +2 -1
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +11 -7
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -0
- package/build/index.js.map +1 -1
- package/build/limit-meter.d.ts +7 -0
- package/build/limit-meter.d.ts.map +1 -0
- package/build/limit-meter.js +26 -0
- package/build/limit-meter.js.map +1 -0
- package/build/plan-card.d.ts +7 -0
- package/build/plan-card.d.ts.map +1 -0
- package/build/plan-card.js +29 -0
- package/build/plan-card.js.map +1 -0
- package/build/poll.d.ts +7 -0
- package/build/poll.d.ts.map +1 -0
- package/build/poll.js +28 -0
- package/build/poll.js.map +1 -0
- package/build/selectors.d.ts +18 -0
- package/build/selectors.d.ts.map +1 -0
- package/build/selectors.js +78 -0
- package/build/selectors.js.map +1 -0
- package/build/types.d.ts +120 -2
- package/build/types.d.ts.map +1 -1
- package/package.json +22 -7
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/checkbox.tsx +23 -0
- package/src/@/components/ui/progress.tsx +44 -0
- package/src/@/components/ui/select.tsx +62 -0
- package/src/@/globals.css +10 -0
- package/src/capability-list.tsx +50 -0
- package/src/checkout-limit.tsx +48 -0
- package/src/consumer/cancellation.tsx +214 -0
- package/src/consumer/copy.ts +93 -0
- package/src/consumer/ensure.ts +124 -0
- package/src/consumer/fields.tsx +89 -0
- package/src/consumer/hooks.ts +358 -0
- package/src/consumer/index.ts +13 -0
- package/src/consumer/legal.tsx +129 -0
- package/src/consumer/performance-consent-dialog.tsx +132 -0
- package/src/consumer/provider.tsx +49 -0
- package/src/consumer/refusal.ts +110 -0
- package/src/consumer/subscription-start-dialog.tsx +87 -0
- package/src/consumer/types.ts +121 -0
- package/src/consumer/withdrawal.tsx +279 -0
- package/src/copy.tsx +62 -0
- package/src/dialog.tsx +58 -25
- package/src/entitlement.ts +56 -0
- package/src/estimate-cache.ts +40 -0
- package/src/estimate-summary.tsx +172 -0
- package/src/estimate.ts +119 -0
- package/src/format.ts +71 -0
- package/src/hooks.ts +3 -35
- package/src/i18n/be.json +1 -1
- package/src/i18n/de.json +1 -1
- package/src/i18n/en.json +1 -1
- package/src/i18n/es.json +1 -1
- package/src/i18n/fr.json +145 -0
- package/src/i18n/pl.json +1 -1
- package/src/i18n/ru.json +1 -1
- package/src/i18n/uk.json +1 -1
- package/src/i18n.ts +12 -7
- package/src/index.ts +9 -0
- package/src/limit-meter.tsx +55 -0
- package/src/plan-card.tsx +67 -0
- package/src/poll.ts +27 -0
- package/src/selectors.ts +93 -0
- package/src/types.ts +142 -2
- package/tests/consent-refusal.spec.ts +83 -0
- package/tests/consumer-hooks.spec.ts +205 -0
- package/tests/consumer.spec.ts +343 -0
- package/tests/context.ts +9 -0
- package/tests/dialog.spec.ts +49 -0
- package/tests/entitlement.spec.ts +117 -0
- package/tests/estimate-cache.spec.ts +46 -0
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/entitlement.tsx +159 -0
- package/tests/harness/estimate-fixtures.ts +48 -0
- package/tests/harness/mount.tsx +19 -3
- package/tests/i18n.spec.ts +78 -8
- package/tests/pieces.spec.ts +134 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ResilientError } from '@owlmeans/error'
|
|
2
|
+
import { httpStatusOf } from '@owlmeans/api/status'
|
|
3
|
+
import { ConsentKind, PaymentError, consentRefusalOf } from '@owlmeans/payment'
|
|
4
|
+
|
|
5
|
+
/** Where a wrapping error keeps the one it wraps — `cause`, and the fields frameworks use for it. */
|
|
6
|
+
const NESTED_FIELDS = ['cause', 'error', 'original', 'originalError', 'inner', 'reason'] as const
|
|
7
|
+
|
|
8
|
+
/** How deep a wrapped chain is followed — a planning `CommitFailed` inside a transport error is two. */
|
|
9
|
+
const MAX_DEPTH = 5
|
|
10
|
+
|
|
11
|
+
const PRECONDITION_REQUIRED = 428
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which express request a failure asks for: `performance` (spend consent), `subscription-start`,
|
|
15
|
+
* `unknown` for a bare HTTP 428 that names neither (a production body carries only an incident
|
|
16
|
+
* id), or `null` for anything else.
|
|
17
|
+
*
|
|
18
|
+
* Read on the error itself and on whatever it wraps (`cause`, `error`, `original`, `inner`, an
|
|
19
|
+
* aggregate's `errors`), and on text as well as objects, because a refusal travels in three shapes:
|
|
20
|
+
* the class (a development body, rebuilt by the registry); its marker inside another error's
|
|
21
|
+
* message or a stored string (a planning commit that failed on the refusal answers
|
|
22
|
+
* `planning:commit-failed:<transition>:<the refusal's text>`); and the bare status of a production
|
|
23
|
+
* body (`@owlmeans/api`'s `ApiStatusError`, `api:client:status:428[:<incident>]`).
|
|
24
|
+
*/
|
|
25
|
+
export const consentRefusalKindOf = (error: unknown): ConsentKind | 'unknown' | null => {
|
|
26
|
+
let status = false
|
|
27
|
+
const seen = new Set<unknown>()
|
|
28
|
+
const visit = (value: unknown, depth: number): ConsentKind | null => {
|
|
29
|
+
if (value == null || depth > MAX_DEPTH) {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === 'string') {
|
|
33
|
+
const holder = { message: value }
|
|
34
|
+
if (httpStatusOf(holder) === PRECONDITION_REQUIRED) status = true
|
|
35
|
+
return consentRefusalOf(holder)
|
|
36
|
+
}
|
|
37
|
+
if (typeof value !== 'object' || seen.has(value)) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
seen.add(value)
|
|
41
|
+
const kind = consentRefusalOf(value)
|
|
42
|
+
if (kind != null) {
|
|
43
|
+
return kind
|
|
44
|
+
}
|
|
45
|
+
if (httpStatusOf(value) === PRECONDITION_REQUIRED) {
|
|
46
|
+
status = true
|
|
47
|
+
}
|
|
48
|
+
for (const field of NESTED_FIELDS) {
|
|
49
|
+
const nested = visit((value as Record<string, unknown>)[field], depth + 1)
|
|
50
|
+
if (nested != null) return nested
|
|
51
|
+
}
|
|
52
|
+
const errors = (value as { errors?: unknown }).errors
|
|
53
|
+
if (Array.isArray(errors)) {
|
|
54
|
+
for (const item of errors) {
|
|
55
|
+
const nested = visit(item, depth + 1)
|
|
56
|
+
if (nested != null) return nested
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
const kind = visit(error, 0)
|
|
63
|
+
|
|
64
|
+
return kind ?? (status ? 'unknown' : null)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a failure is a consent refusal — the class or its marker (`consentRefusalOf`), or an
|
|
69
|
+
* HTTP 428 (`httpStatusOf`), on the error or anything it wraps. What decides that the consent
|
|
70
|
+
* dialog opens and the action is retried once.
|
|
71
|
+
*/
|
|
72
|
+
export const isConsentRefusal = (error: unknown): boolean => consentRefusalKindOf(error) != null
|
|
73
|
+
|
|
74
|
+
/** A refusal the performance-consent dialog answers: the spend consent, or a bare 428. */
|
|
75
|
+
export const isPerformanceConsentRefusal = (error: unknown): boolean => {
|
|
76
|
+
const kind = consentRefusalKindOf(error)
|
|
77
|
+
|
|
78
|
+
return kind === ConsentKind.Performance || kind === 'unknown'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DECLINED_MARKER = 'consent-declined:'
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The person declined the express request, so the action that needed it did not run. Raised by
|
|
85
|
+
* `useConsentGate().withConsent` — never by a server, and never a refusal a gate retries.
|
|
86
|
+
*/
|
|
87
|
+
export class ConsentDeclined extends PaymentError {
|
|
88
|
+
public static override typeName: string = `${PaymentError.typeName}ConsentDeclined`
|
|
89
|
+
|
|
90
|
+
public kind: string = ConsentKind.Performance
|
|
91
|
+
|
|
92
|
+
constructor(kind: string = ConsentKind.Performance) {
|
|
93
|
+
super(`${DECLINED_MARKER}${kind}`)
|
|
94
|
+
this.type = ConsentDeclined.typeName
|
|
95
|
+
this.applyFields()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private applyFields(): void {
|
|
99
|
+
const at = this.message.lastIndexOf(DECLINED_MARKER)
|
|
100
|
+
if (at >= 0) this.kind = this.message.slice(at + DECLINED_MARKER.length) || ConsentKind.Performance
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override finalizeUnmarshal(): void {
|
|
104
|
+
this.applyFields()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ResilientError.registerErrorClass(ConsentDeclined)
|
|
109
|
+
|
|
110
|
+
export const isConsentDeclined = (error: unknown): error is ConsentDeclined => error instanceof ConsentDeclined
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
5
|
+
import {
|
|
6
|
+
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
7
|
+
} from '@/components/ui/dialog'
|
|
8
|
+
import { Label } from '@/components/ui/label'
|
|
9
|
+
import { useLegalText, usePaymentText } from './copy.js'
|
|
10
|
+
import { ErrorLine, LanguageToggle, LegalLinks, linksFor, useShownLanguage } from './legal.js'
|
|
11
|
+
import type { SubscriptionStartDialogProps } from './types.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The express request to start a subscription's services before its withdrawal period ends,
|
|
15
|
+
* asked BEFORE the subscription checkout (§ 357a Abs. 2 BGB, art. 35 UoPK, L221-25; CJEU C-641/19).
|
|
16
|
+
* Without it a withdrawal owes nothing for the time used.
|
|
17
|
+
*
|
|
18
|
+
* Same shape as the performance consent: the contract language first with a toggle, one
|
|
19
|
+
* unchecked checkbox carrying the whole statement, and the confirm button — "Continue to payment",
|
|
20
|
+
* the step before the paygate's own order button — disabled until it is ticked.
|
|
21
|
+
*
|
|
22
|
+
* Selectors: `[data-start-dialog]` (`data-language`, `data-contract-language`, `data-plan-sku`),
|
|
23
|
+
* `[data-start-checkbox]`, `[data-start-confirm]`, `[data-start-decline]`,
|
|
24
|
+
* `[data-start-language-toggle]`, `[data-legal-link]`.
|
|
25
|
+
*/
|
|
26
|
+
export const SubscriptionStartDialog = ({
|
|
27
|
+
open, onOpenChange, view, planTitle, price, uiLanguage, pending = false, error, onConfirm, onDecline, links,
|
|
28
|
+
onWithdraw, className,
|
|
29
|
+
}: SubscriptionStartDialogProps) => {
|
|
30
|
+
const language = useShownLanguage(view?.language ?? '', uiLanguage, open)
|
|
31
|
+
const legal = useLegalText(language.shown)
|
|
32
|
+
const text = usePaymentText(language.shown)
|
|
33
|
+
const [ticked, setTicked] = useState(false)
|
|
34
|
+
useEffect(() => { setTicked(false) }, [open, language.shown, view?.textVersion, view?.planSku])
|
|
35
|
+
if (view == null) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The same text `consentStatementOf(lng, 'subscription-start', { trader, plan })` renders on the server.
|
|
40
|
+
const checkbox = legal('subscription-start.checkbox', { trader: view.trader, plan: planTitle })
|
|
41
|
+
const shownLinks = linksFor(language.shown, view.links, links)
|
|
42
|
+
const confirm = async () => {
|
|
43
|
+
if (!ticked || pending) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
await onConfirm({ planSku: view.planSku, textVersion: view.textVersion, language: language.shown, acknowledged: true })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return <Dialog open={open} onOpenChange={onOpenChange}>
|
|
50
|
+
<DialogContent
|
|
51
|
+
closeLabel={text('consumer.close')}
|
|
52
|
+
data-start-dialog="" data-subscription-start="" data-language={language.shown}
|
|
53
|
+
data-contract-language={language.contract} data-plan-sku={view.planSku} data-text-version={view.textVersion}
|
|
54
|
+
className={cn('max-h-[90vh] overflow-y-auto', className)}
|
|
55
|
+
>
|
|
56
|
+
<DialogHeader>
|
|
57
|
+
<DialogTitle lang={language.shown}>{legal('subscription-start.title', { plan: planTitle })}</DialogTitle>
|
|
58
|
+
<DialogDescription lang={language.shown}>{legal('subscription-start.intro')}</DialogDescription>
|
|
59
|
+
</DialogHeader>
|
|
60
|
+
<LanguageToggle language={language} hook="data-start-language-toggle" />
|
|
61
|
+
<div className="grid gap-4" lang={language.shown}>
|
|
62
|
+
{price != null && <div className="text-sm" data-start-price="">{price}</div>}
|
|
63
|
+
<div className="flex items-start gap-3 rounded-md border p-3">
|
|
64
|
+
<Checkbox
|
|
65
|
+
id="subscription-start-checkbox" data-start-checkbox="" checked={ticked} disabled={pending}
|
|
66
|
+
onCheckedChange={value => setTicked(value === true)} className="mt-0.5"
|
|
67
|
+
/>
|
|
68
|
+
<Label htmlFor="subscription-start-checkbox" className="block text-sm leading-snug font-normal"
|
|
69
|
+
data-start-statement="">
|
|
70
|
+
{checkbox}
|
|
71
|
+
</Label>
|
|
72
|
+
</div>
|
|
73
|
+
<LegalLinks links={shownLinks} legal={legal} onWithdraw={onWithdraw} />
|
|
74
|
+
<ErrorLine error={error} text={text} />
|
|
75
|
+
</div>
|
|
76
|
+
<DialogFooter>
|
|
77
|
+
<Button type="button" variant="outline" data-start-decline="" disabled={pending}
|
|
78
|
+
onClick={() => onDecline != null ? onDecline() : onOpenChange(false)}>
|
|
79
|
+
{legal('subscription-start.decline')}
|
|
80
|
+
</Button>
|
|
81
|
+
<Button type="button" data-start-confirm="" disabled={!ticked || pending} onClick={() => void confirm()}>
|
|
82
|
+
{pending ? text('consumer.pending') : legal('subscription-start.confirm')}
|
|
83
|
+
</Button>
|
|
84
|
+
</DialogFooter>
|
|
85
|
+
</DialogContent>
|
|
86
|
+
</Dialog>
|
|
87
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
CancellationBody, CancellationReceipt, ConsumerRightsLinks, DeclarationReceipt, PerformanceConsentBody,
|
|
4
|
+
PerformanceConsentView, SubscriptionStartBody, SubscriptionStartView, WithdrawalBody, WithdrawalCandidateList,
|
|
5
|
+
WithdrawalReceipt,
|
|
6
|
+
} from '@owlmeans/payment'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The legal documents of a language: a record by language (`de`, `de-AT` → `de`) or a function (a
|
|
10
|
+
* policy read through `linksOf(policy, lng)`). Absent for a language, the view's own links are used.
|
|
11
|
+
*/
|
|
12
|
+
export type LegalLinksSource =
|
|
13
|
+
| Record<string, ConsumerRightsLinks | undefined>
|
|
14
|
+
| ((language: string) => ConsumerRightsLinks | null | undefined)
|
|
15
|
+
|
|
16
|
+
/** Formats an amount of minor units for a language; the default is `Intl.NumberFormat`. */
|
|
17
|
+
export type AmountFormatter = (minor: number, currency: string, language: string) => string
|
|
18
|
+
|
|
19
|
+
/** `true` shows the package's generic failure sentence; a node is shown as given. */
|
|
20
|
+
export type PieceError = ReactNode | boolean
|
|
21
|
+
|
|
22
|
+
/** What every consumer-rights dialog shares. */
|
|
23
|
+
export interface LegalDialogProps {
|
|
24
|
+
open: boolean
|
|
25
|
+
onOpenChange: (open: boolean) => void
|
|
26
|
+
/**
|
|
27
|
+
* The interface language — what the toggle switches to, and what the toggle itself is phrased
|
|
28
|
+
* in. Default: the active i18n language.
|
|
29
|
+
*/
|
|
30
|
+
uiLanguage?: string
|
|
31
|
+
pending?: boolean
|
|
32
|
+
error?: PieceError
|
|
33
|
+
/** Links per language; default: the view's own links. */
|
|
34
|
+
links?: LegalLinksSource
|
|
35
|
+
/**
|
|
36
|
+
* Opens the withdrawal function in-app. Absent, the "withdraw from contract here" link points at
|
|
37
|
+
* the links' `withdrawalFunction` page, and is left out when there is none.
|
|
38
|
+
*/
|
|
39
|
+
onWithdraw?: () => void
|
|
40
|
+
/** Called on the decline button; closing the dialog any other way is `onOpenChange(false)`. */
|
|
41
|
+
onDecline?: () => void
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PerformanceConsentDialogProps extends LegalDialogProps {
|
|
46
|
+
/** `null` renders nothing. */
|
|
47
|
+
view: PerformanceConsentView | null
|
|
48
|
+
/**
|
|
49
|
+
* The express request, recorded in the language it was SHOWN in (`language`), with the
|
|
50
|
+
* interface language beside it (`uiLanguage`) — pass it to the record protocol as the body.
|
|
51
|
+
*/
|
|
52
|
+
onConfirm: (body: PerformanceConsentBody) => Promise<unknown> | unknown
|
|
53
|
+
formatAmount?: AmountFormatter
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SubscriptionStartDialogProps extends LegalDialogProps {
|
|
57
|
+
view: SubscriptionStartView | null
|
|
58
|
+
/** The plan's title, as the application names it — it is part of the statement. */
|
|
59
|
+
planTitle: string
|
|
60
|
+
/** What the application shows about the price (amount, split, tax) above the statement. */
|
|
61
|
+
price?: ReactNode
|
|
62
|
+
onConfirm: (body: SubscriptionStartBody) => Promise<unknown> | unknown
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Where a declaration is made: signed in (a candidate list) or on the public page (a contract reference). */
|
|
66
|
+
export type DeclarationMode = 'in-app' | 'public'
|
|
67
|
+
|
|
68
|
+
/** The three steps every statutory function takes: the form, the review with the statutory button, the receipt. */
|
|
69
|
+
export type DeclarationStep = 'form' | 'review' | 'receipt'
|
|
70
|
+
|
|
71
|
+
export interface WithdrawalFormProps {
|
|
72
|
+
mode: DeclarationMode
|
|
73
|
+
/** The language of the contract (the billing country's); the form shows its legal copy in it. */
|
|
74
|
+
language: string
|
|
75
|
+
uiLanguage?: string
|
|
76
|
+
/** In-app: the contracts that can still be withdrawn from. */
|
|
77
|
+
list?: WithdrawalCandidateList | null
|
|
78
|
+
/** Prefill. In-app, `purchaseId` (or `contractRef`) preselects a candidate. */
|
|
79
|
+
defaults?: { name?: string, email?: string, contractRef?: string, purchaseId?: string }
|
|
80
|
+
pending?: boolean
|
|
81
|
+
error?: PieceError
|
|
82
|
+
/** The answer to the submitted declaration — its presence is the receipt step. */
|
|
83
|
+
receipt?: WithdrawalReceipt | DeclarationReceipt | null
|
|
84
|
+
onSubmit: (body: WithdrawalBody) => Promise<unknown> | unknown
|
|
85
|
+
/** Offered on the receipt step. */
|
|
86
|
+
onClose?: () => void
|
|
87
|
+
formatAmount?: AmountFormatter
|
|
88
|
+
className?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface WithdrawalDialogProps extends Omit<WithdrawalFormProps, 'onClose' | 'className'> {
|
|
92
|
+
open: boolean
|
|
93
|
+
onOpenChange: (open: boolean) => void
|
|
94
|
+
className?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface WithdrawalFunctionButtonProps {
|
|
98
|
+
/** The contract language: the statutory label is shown in it. */
|
|
99
|
+
language: string
|
|
100
|
+
/** Its label in this language becomes the `title` when the two differ. Default: the active language. */
|
|
101
|
+
uiLanguage?: string
|
|
102
|
+
onClick: () => void
|
|
103
|
+
disabled?: boolean
|
|
104
|
+
variant?: 'default' | 'outline'
|
|
105
|
+
className?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CancellationFormProps {
|
|
109
|
+
mode: DeclarationMode
|
|
110
|
+
language: string
|
|
111
|
+
uiLanguage?: string
|
|
112
|
+
defaults?: { name?: string, email?: string, contractRef?: string, subscriptionId?: string }
|
|
113
|
+
pending?: boolean
|
|
114
|
+
error?: PieceError
|
|
115
|
+
receipt?: CancellationReceipt | DeclarationReceipt | null
|
|
116
|
+
onSubmit: (body: CancellationBody) => Promise<unknown> | unknown
|
|
117
|
+
/** The receipt's print button; default `window.print()`. */
|
|
118
|
+
onPrint?: () => void
|
|
119
|
+
onClose?: () => void
|
|
120
|
+
className?: string
|
|
121
|
+
}
|
|
@@ -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/copy.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
|
|
3
|
+
import type { PromoView } from '@owlmeans/payment'
|
|
4
|
+
import { promoInscriptionOf } from './selectors.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A calendar date in UTC — the calendar limit windows are counted in, so "resets on" names the day
|
|
8
|
+
* the counter actually rolls. An unparseable date renders as nothing.
|
|
9
|
+
*/
|
|
10
|
+
const formatDay = (date: Date | string | null | undefined, locale: string): string => {
|
|
11
|
+
if (date == null) {
|
|
12
|
+
return ''
|
|
13
|
+
}
|
|
14
|
+
const value = date instanceof Date ? date : new Date(date)
|
|
15
|
+
if (Number.isNaN(value.getTime())) {
|
|
16
|
+
return ''
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(value)
|
|
20
|
+
} catch {
|
|
21
|
+
return value.toISOString().slice(0, 10)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const formatCount = (value: number, locale: string): string => {
|
|
26
|
+
try {
|
|
27
|
+
return new Intl.NumberFormat(locale).format(value)
|
|
28
|
+
} catch {
|
|
29
|
+
return String(value)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EntitlementCopy {
|
|
34
|
+
text: (key: string, values?: Record<string, string>) => string
|
|
35
|
+
day: (date: Date | string | null | undefined) => string
|
|
36
|
+
count: (value: number) => string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `entitlement` branch of the `web-payment` library resource, with locale-aware formatting. */
|
|
40
|
+
export const useEntitlementCopy = (): EntitlementCopy => {
|
|
41
|
+
const t = useI18nLib('web-payment', 'entitlement')
|
|
42
|
+
const [locale] = useLanguage()
|
|
43
|
+
|
|
44
|
+
return useMemo(() => ({
|
|
45
|
+
// React escapes what it renders; i18next escaping on top would print `/` for a slash.
|
|
46
|
+
text: (key, values) => t(key, { ...values, interpolation: { escapeValue: false } }),
|
|
47
|
+
day: date => formatDay(date, locale),
|
|
48
|
+
count: value => formatCount(value, locale),
|
|
49
|
+
}), [t, locale])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The inscription of a promo: free until its end, kept for a grandfathered plan, or ended. */
|
|
53
|
+
export const PromoNote = ({ promo, copy }: { promo?: PromoView, copy: EntitlementCopy }) => {
|
|
54
|
+
const kind = promoInscriptionOf(promo)
|
|
55
|
+
if (kind == null || promo == null) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return <span data-promo={kind} className={kind === 'ended' ? 'text-muted-foreground' : 'text-primary'}>
|
|
60
|
+
{copy.text(`promo.${kind}`, { date: copy.day(promo.until) })}
|
|
61
|
+
</span>
|
|
62
|
+
}
|