@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,129 @@
|
|
|
1
|
+
import { Fragment, useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { useLanguage } from '@owlmeans/client-i18n'
|
|
3
|
+
import { baseLanguageOf } from '@owlmeans/payment'
|
|
4
|
+
import type { ConsumerRightsLinks } from '@owlmeans/payment'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { languageNameOf, usePaymentText, type FixedText } from './copy.js'
|
|
7
|
+
import type { LegalLinksSource, PieceError } from './types.js'
|
|
8
|
+
|
|
9
|
+
/** The interface language: the one given, else the active i18n language. */
|
|
10
|
+
export const useUiLanguage = (uiLanguage?: string): string => {
|
|
11
|
+
const [active] = useLanguage()
|
|
12
|
+
|
|
13
|
+
return baseLanguageOf(uiLanguage ?? active) || 'en'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ShownLanguage {
|
|
17
|
+
/** The language the legal copy is shown in now (base code). */
|
|
18
|
+
shown: string
|
|
19
|
+
/** The contract language (base code). */
|
|
20
|
+
contract: string
|
|
21
|
+
ui: string
|
|
22
|
+
/** The contract and interface languages differ, so a toggle is offered. */
|
|
23
|
+
canToggle: boolean
|
|
24
|
+
toggle: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which language a legal text is shown in: the contract language first, with a toggle to the
|
|
29
|
+
* interface language and back. `resetKey` (the dialog's `open`, say) returns it to the contract
|
|
30
|
+
* language.
|
|
31
|
+
*/
|
|
32
|
+
export const useShownLanguage = (contractLanguage: string, uiLanguage?: string, resetKey?: unknown): ShownLanguage => {
|
|
33
|
+
const ui = useUiLanguage(uiLanguage)
|
|
34
|
+
const contract = baseLanguageOf(contractLanguage) || ui
|
|
35
|
+
const [showUi, setShowUi] = useState(false)
|
|
36
|
+
useEffect(() => { setShowUi(false) }, [contract, resetKey])
|
|
37
|
+
const canToggle = contract !== ui
|
|
38
|
+
const toggle = useCallback(() => setShowUi(value => !value), [])
|
|
39
|
+
|
|
40
|
+
return { shown: canToggle && showUi ? ui : contract, contract, ui, canToggle, toggle }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LanguageToggleProps {
|
|
44
|
+
language: ShownLanguage
|
|
45
|
+
/** An extra `data-*` hook of the piece that hosts the toggle (`data-consent-language-toggle`). */
|
|
46
|
+
hook?: string
|
|
47
|
+
className?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Switches a legal text between the contract and the interface language. Phrased in the
|
|
52
|
+
* INTERFACE language ("Show in German") — the reader who needs the toggle reads that one.
|
|
53
|
+
*/
|
|
54
|
+
export const LanguageToggle = ({ language, hook, className }: LanguageToggleProps) => {
|
|
55
|
+
const ui = usePaymentText(language.ui)
|
|
56
|
+
if (!language.canToggle) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
const target = language.shown === language.contract ? language.ui : language.contract
|
|
60
|
+
|
|
61
|
+
return <button
|
|
62
|
+
type="button" onClick={language.toggle} data-language-toggle="" data-target-language={target}
|
|
63
|
+
{...(hook != null ? { [hook]: '' } : {})}
|
|
64
|
+
className={cn('text-primary w-fit cursor-pointer text-xs underline-offset-4 hover:underline', className)}
|
|
65
|
+
>
|
|
66
|
+
{ui('consumer.show-in', { language: languageNameOf(target, language.ui) })}
|
|
67
|
+
</button>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The links of the shown language: the source's, else the view's own. */
|
|
71
|
+
export const linksFor = (language: string, own: ConsumerRightsLinks | undefined, source?: LegalLinksSource): ConsumerRightsLinks | undefined => {
|
|
72
|
+
if (source == null) {
|
|
73
|
+
return own
|
|
74
|
+
}
|
|
75
|
+
const picked = typeof source === 'function'
|
|
76
|
+
? source(language)
|
|
77
|
+
: source[language] ?? source[baseLanguageOf(language)]
|
|
78
|
+
|
|
79
|
+
return picked ?? own
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface LegalLinksProps {
|
|
83
|
+
links?: ConsumerRightsLinks
|
|
84
|
+
legal: FixedText
|
|
85
|
+
onWithdraw?: () => void
|
|
86
|
+
className?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* "Billing Terms · Withdrawal information · Withdraw from contract here", each in the shown
|
|
91
|
+
* language: `[data-legal-link="billing-terms" | "withdrawal-information" | "withdrawal-function"]`.
|
|
92
|
+
* The withdrawal function is a button when the application opens it in-app (`onWithdraw`), else a
|
|
93
|
+
* link to its public page, else absent.
|
|
94
|
+
*/
|
|
95
|
+
export const LegalLinks = ({ links, legal, onWithdraw, className }: LegalLinksProps) => {
|
|
96
|
+
const anchor = (key: string, href: string | undefined, label: string) => href == null || href === ''
|
|
97
|
+
? null
|
|
98
|
+
: <a key={key} href={href} target="_blank" rel="noopener noreferrer" data-legal-link={key}
|
|
99
|
+
className="text-primary underline-offset-4 hover:underline">{label}</a>
|
|
100
|
+
const items = [
|
|
101
|
+
anchor('billing-terms', links?.billingTerms, legal('links.billing-terms')),
|
|
102
|
+
anchor('withdrawal-information', links?.withdrawalInformation, legal('links.withdrawal-information')),
|
|
103
|
+
onWithdraw != null
|
|
104
|
+
? <button key="withdrawal-function" type="button" onClick={onWithdraw} data-legal-link="withdrawal-function"
|
|
105
|
+
className="text-primary cursor-pointer underline-offset-4 hover:underline">{legal('links.withdrawal-function')}</button>
|
|
106
|
+
: anchor('withdrawal-function', links?.withdrawalFunction, legal('links.withdrawal-function')),
|
|
107
|
+
].filter(item => item != null)
|
|
108
|
+
if (items.length === 0) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return <p className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-xs', className)} data-legal-links="">
|
|
113
|
+
{items.map((item, index) => <Fragment key={index}>
|
|
114
|
+
{index > 0 && <span aria-hidden="true" className="text-muted-foreground">·</span>}
|
|
115
|
+
{item}
|
|
116
|
+
</Fragment>)}
|
|
117
|
+
</p>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A piece's failure line: the application's own words, or the generic sentence for `true`. */
|
|
121
|
+
export const ErrorLine = ({ error, text }: { error?: PieceError, text: FixedText }) => {
|
|
122
|
+
if (error == null || error === false) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return <p role="alert" className="text-destructive text-sm" data-consumer-error="">
|
|
127
|
+
{error === true ? text('consumer.failed') : error}
|
|
128
|
+
</p>
|
|
129
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { PerformanceConsentView } from '@owlmeans/payment'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
6
|
+
import {
|
|
7
|
+
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
8
|
+
} from '@/components/ui/dialog'
|
|
9
|
+
import { Label } from '@/components/ui/label'
|
|
10
|
+
import { lastDayUtc, dayUtc, money } from '../format.js'
|
|
11
|
+
import { useLegalText, usePaymentText } from './copy.js'
|
|
12
|
+
import { ErrorLine, LanguageToggle, LegalLinks, linksFor, useShownLanguage } from './legal.js'
|
|
13
|
+
import type { PerformanceConsentDialogProps } from './types.js'
|
|
14
|
+
|
|
15
|
+
/** The latest deadline of the view — its own, else the latest of its purchases. */
|
|
16
|
+
const deadlineOf = (view: PerformanceConsentView): Date | undefined => {
|
|
17
|
+
if (view.deadline != null) {
|
|
18
|
+
return new Date(view.deadline)
|
|
19
|
+
}
|
|
20
|
+
const times = view.purchases
|
|
21
|
+
.map(purchase => purchase.deadline != null ? new Date(purchase.deadline).getTime() : NaN)
|
|
22
|
+
.filter(time => !Number.isNaN(time))
|
|
23
|
+
|
|
24
|
+
return times.length > 0 ? new Date(Math.max(...times)) : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The express request to start performing now, asked before credits bought inside their
|
|
29
|
+
* withdrawal window are spent (CRD Art. 16(m), § 356 Abs. 5 BGB, art. 38 UoPK, L221-28).
|
|
30
|
+
*
|
|
31
|
+
* The legal copy is shown in the CONTRACT language (`view.language`) with a toggle to the
|
|
32
|
+
* interface language; the purchases it covers are listed with their deadlines; ONE checkbox
|
|
33
|
+
* carries the whole statement and starts unchecked, and the confirm button stays disabled until
|
|
34
|
+
* it is ticked. Toggling the language unticks it — the statement agreed to is the one on screen —
|
|
35
|
+
* and `onConfirm` records the language actually shown.
|
|
36
|
+
*
|
|
37
|
+
* Selectors: `[data-consent-dialog]` (with `data-language`, `data-contract-language`),
|
|
38
|
+
* `[data-consent-purchase]` (`data-purchase-id`, `data-deadline`), `[data-consent-checkbox]`,
|
|
39
|
+
* `[data-consent-confirm]`, `[data-consent-decline]`, `[data-consent-language-toggle]`,
|
|
40
|
+
* `[data-legal-link]`.
|
|
41
|
+
*/
|
|
42
|
+
export const PerformanceConsentDialog = ({
|
|
43
|
+
open, onOpenChange, view, uiLanguage, pending = false, error, onConfirm, onDecline, links, onWithdraw,
|
|
44
|
+
formatAmount, className,
|
|
45
|
+
}: PerformanceConsentDialogProps) => {
|
|
46
|
+
const language = useShownLanguage(view?.language ?? '', uiLanguage, open)
|
|
47
|
+
const legal = useLegalText(language.shown)
|
|
48
|
+
const text = usePaymentText(language.shown)
|
|
49
|
+
const [ticked, setTicked] = useState(false)
|
|
50
|
+
useEffect(() => { setTicked(false) }, [open, language.shown, view?.textVersion])
|
|
51
|
+
if (view == null) {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const format = (minor: number, currency: string) => formatAmount != null
|
|
56
|
+
? formatAmount(minor, currency, language.shown) : money(minor, currency, language.shown)
|
|
57
|
+
// The same text `consentStatementOf(lng, 'performance', { trader })` renders on the server.
|
|
58
|
+
const checkbox = legal('performance-consent.checkbox', { trader: view.trader })
|
|
59
|
+
const deadline = deadlineOf(view)
|
|
60
|
+
const shownLinks = linksFor(language.shown, view.links, links)
|
|
61
|
+
const confirm = async () => {
|
|
62
|
+
if (!ticked || pending) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
await onConfirm({
|
|
66
|
+
purchaseIds: view.purchases.map(purchase => purchase.purchaseId),
|
|
67
|
+
textVersion: view.textVersion,
|
|
68
|
+
language: language.shown,
|
|
69
|
+
acknowledged: true,
|
|
70
|
+
uiLanguage: language.ui,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return <Dialog open={open} onOpenChange={onOpenChange}>
|
|
75
|
+
<DialogContent
|
|
76
|
+
closeLabel={text('consumer.close')}
|
|
77
|
+
data-consent-dialog="" data-performance-consent="" data-language={language.shown}
|
|
78
|
+
data-contract-language={language.contract} data-text-version={view.textVersion}
|
|
79
|
+
className={cn('max-h-[90vh] overflow-y-auto', className)}
|
|
80
|
+
{...(deadline == null ? { 'aria-describedby': undefined } : {})}
|
|
81
|
+
>
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle lang={language.shown}>{legal('performance-consent.title')}</DialogTitle>
|
|
84
|
+
{deadline != null && <DialogDescription lang={language.shown}>
|
|
85
|
+
{legal('performance-consent.intro', { count: view.purchases.length, deadline: lastDayUtc(deadline, language.shown) })}
|
|
86
|
+
</DialogDescription>}
|
|
87
|
+
</DialogHeader>
|
|
88
|
+
<LanguageToggle language={language} hook="data-consent-language-toggle" />
|
|
89
|
+
<div className="grid gap-4" lang={language.shown}>
|
|
90
|
+
{view.purchases.length > 0 && <section className="grid gap-2" aria-label={text('consumer.purchases')}>
|
|
91
|
+
<h3 className="text-sm font-medium">{text('consumer.purchases')}</h3>
|
|
92
|
+
<ul className="grid gap-1.5 text-sm">
|
|
93
|
+
{view.purchases.map(purchase => <li
|
|
94
|
+
key={purchase.purchaseId} data-consent-purchase="" data-purchase-id={purchase.purchaseId}
|
|
95
|
+
data-deadline={purchase.deadline != null ? new Date(purchase.deadline).toISOString() : undefined}
|
|
96
|
+
className="rounded-md border px-3 py-2"
|
|
97
|
+
>
|
|
98
|
+
{purchase.deadline != null
|
|
99
|
+
? legal('performance-consent.purchase', {
|
|
100
|
+
date: dayUtc(purchase.purchasedAt, language.shown),
|
|
101
|
+
amount: format(purchase.amountTotalMinor, purchase.currency),
|
|
102
|
+
deadline: lastDayUtc(purchase.deadline, language.shown),
|
|
103
|
+
})
|
|
104
|
+
: `${dayUtc(purchase.purchasedAt, language.shown)} (${format(purchase.amountTotalMinor, purchase.currency)})`}
|
|
105
|
+
</li>)}
|
|
106
|
+
</ul>
|
|
107
|
+
</section>}
|
|
108
|
+
<div className="flex items-start gap-3 rounded-md border p-3">
|
|
109
|
+
<Checkbox
|
|
110
|
+
id="performance-consent-checkbox" data-consent-checkbox="" checked={ticked} disabled={pending}
|
|
111
|
+
onCheckedChange={value => setTicked(value === true)} className="mt-0.5"
|
|
112
|
+
/>
|
|
113
|
+
<Label htmlFor="performance-consent-checkbox" className="block text-sm leading-snug font-normal"
|
|
114
|
+
data-consent-statement="">
|
|
115
|
+
{checkbox}
|
|
116
|
+
</Label>
|
|
117
|
+
</div>
|
|
118
|
+
<LegalLinks links={shownLinks} legal={legal} onWithdraw={onWithdraw} />
|
|
119
|
+
<ErrorLine error={error} text={text} />
|
|
120
|
+
</div>
|
|
121
|
+
<DialogFooter>
|
|
122
|
+
<Button type="button" variant="outline" data-consent-decline="" disabled={pending}
|
|
123
|
+
onClick={() => onDecline != null ? onDecline() : onOpenChange(false)}>
|
|
124
|
+
{legal('performance-consent.decline')}
|
|
125
|
+
</Button>
|
|
126
|
+
<Button type="button" data-consent-confirm="" disabled={!ticked || pending} onClick={() => void confirm()}>
|
|
127
|
+
{pending ? text('consumer.pending') : legal('performance-consent.confirm')}
|
|
128
|
+
</Button>
|
|
129
|
+
</DialogFooter>
|
|
130
|
+
</DialogContent>
|
|
131
|
+
</Dialog>
|
|
132
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
|
+
import type { RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
|
|
3
|
+
import type { PerformanceConsentBody, PerformanceConsentResponse, PerformanceConsentView } from '@owlmeans/payment'
|
|
4
|
+
import { makeConsentGate, passThroughGate, type ConsentGate } from './ensure.js'
|
|
5
|
+
import { usePerformanceConsent, type UsePerformanceConsentOptions } from './hooks.js'
|
|
6
|
+
import { PerformanceConsentDialog } from './performance-consent-dialog.js'
|
|
7
|
+
|
|
8
|
+
export interface ConsentGateValue extends ConsentGate {
|
|
9
|
+
/** From the last read of the view; `null` before any, and always outside a provider. */
|
|
10
|
+
required: boolean | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ConsentGateContext = createContext<ConsentGateValue | null>(null)
|
|
14
|
+
|
|
15
|
+
export interface PerformanceConsentProviderProps<RecordRequest extends RequestShape & { body: PerformanceConsentBody }> {
|
|
16
|
+
view: RegisteredEntrypoint<{}, PerformanceConsentView>
|
|
17
|
+
record: RegisteredEntrypoint<RecordRequest, PerformanceConsentResponse>
|
|
18
|
+
options?: UsePerformanceConsentOptions
|
|
19
|
+
children?: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ONE spend-consent dialog for the whole application, and the gate every action that may spend
|
|
24
|
+
* credits reads with `useConsentGate()`: however many buttons ask at once, one dialog opens and
|
|
25
|
+
* they all get its answer.
|
|
26
|
+
*/
|
|
27
|
+
export const PerformanceConsentProvider = <RecordRequest extends RequestShape & { body: PerformanceConsentBody }>({
|
|
28
|
+
view, record, options, children,
|
|
29
|
+
}: PerformanceConsentProviderProps<RecordRequest>) => {
|
|
30
|
+
const consent = usePerformanceConsent(view, record, options)
|
|
31
|
+
const { ensure, required } = consent
|
|
32
|
+
const value = useMemo<ConsentGateValue>(() => ({ ...makeConsentGate(ensure), required }), [ensure, required])
|
|
33
|
+
|
|
34
|
+
return <ConsentGateContext.Provider value={value}>
|
|
35
|
+
{children}
|
|
36
|
+
<PerformanceConsentDialog {...consent.dialog} />
|
|
37
|
+
</ConsentGateContext.Provider>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The application's consent gate: `ensure()` asks proactively, `withConsent(action)` runs an
|
|
42
|
+
* action and, when it is refused for the spend consent, asks and retries it exactly once
|
|
43
|
+
* (`ConsentDeclined` on a decline). Outside a `PerformanceConsentProvider` nothing can be asked:
|
|
44
|
+
* `ensure` answers `true` and a refusal propagates unchanged.
|
|
45
|
+
*/
|
|
46
|
+
export const useConsentGate = (): ConsentGateValue =>
|
|
47
|
+
useContext(ConsentGateContext) ?? PASS_THROUGH
|
|
48
|
+
|
|
49
|
+
const PASS_THROUGH: ConsentGateValue = { ...passThroughGate, required: null }
|
|
@@ -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
|
+
}
|