@owlmeans/web-payment 0.1.18-rc.20 → 0.1.18-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-payment/SKILL.md +171 -31
  4. package/build/@/components/ui/checkbox.d.ts +5 -0
  5. package/build/@/components/ui/checkbox.d.ts.map +1 -0
  6. package/build/@/components/ui/checkbox.js +6 -0
  7. package/build/@/components/ui/checkbox.js.map +1 -0
  8. package/build/checkout-limit.d.ts +11 -0
  9. package/build/checkout-limit.d.ts.map +1 -0
  10. package/build/checkout-limit.js +34 -0
  11. package/build/checkout-limit.js.map +1 -0
  12. package/build/consumer/cancellation.d.ts +20 -0
  13. package/build/consumer/cancellation.d.ts.map +1 -0
  14. package/build/consumer/cancellation.js +115 -0
  15. package/build/consumer/cancellation.js.map +1 -0
  16. package/build/consumer/copy.d.ts +23 -0
  17. package/build/consumer/copy.d.ts.map +1 -0
  18. package/build/consumer/copy.js +75 -0
  19. package/build/consumer/copy.js.map +1 -0
  20. package/build/consumer/ensure.d.ts +56 -0
  21. package/build/consumer/ensure.d.ts.map +1 -0
  22. package/build/consumer/ensure.js +71 -0
  23. package/build/consumer/ensure.js.map +1 -0
  24. package/build/consumer/fields.d.ts +43 -0
  25. package/build/consumer/fields.d.ts.map +1 -0
  26. package/build/consumer/fields.js +19 -0
  27. package/build/consumer/fields.js.map +1 -0
  28. package/build/consumer/hooks.d.ts +113 -0
  29. package/build/consumer/hooks.d.ts.map +1 -0
  30. package/build/consumer/hooks.js +225 -0
  31. package/build/consumer/hooks.js.map +1 -0
  32. package/build/consumer/index.d.ts +14 -0
  33. package/build/consumer/index.d.ts.map +1 -0
  34. package/build/consumer/index.js +12 -0
  35. package/build/consumer/index.js.map +1 -0
  36. package/build/consumer/legal.d.ts +53 -0
  37. package/build/consumer/legal.d.ts.map +1 -0
  38. package/build/consumer/legal.js +77 -0
  39. package/build/consumer/legal.js.map +1 -0
  40. package/build/consumer/performance-consent-dialog.d.ts +18 -0
  41. package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
  42. package/build/consumer/performance-consent-dialog.js +71 -0
  43. package/build/consumer/performance-consent-dialog.js.map +1 -0
  44. package/build/consumer/provider.d.ts +33 -0
  45. package/build/consumer/provider.d.ts.map +1 -0
  46. package/build/consumer/provider.js +26 -0
  47. package/build/consumer/provider.js.map +1 -0
  48. package/build/consumer/refusal.d.ts +35 -0
  49. package/build/consumer/refusal.d.ts.map +1 -0
  50. package/build/consumer/refusal.js +98 -0
  51. package/build/consumer/refusal.js.map +1 -0
  52. package/build/consumer/subscription-start-dialog.d.ts +16 -0
  53. package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
  54. package/build/consumer/subscription-start-dialog.js +43 -0
  55. package/build/consumer/subscription-start-dialog.js.map +1 -0
  56. package/build/consumer/types.d.ts +114 -0
  57. package/build/consumer/types.d.ts.map +1 -0
  58. package/build/consumer/types.js +2 -0
  59. package/build/consumer/types.js.map +1 -0
  60. package/build/consumer/withdrawal.d.ts +29 -0
  61. package/build/consumer/withdrawal.d.ts.map +1 -0
  62. package/build/consumer/withdrawal.js +140 -0
  63. package/build/consumer/withdrawal.js.map +1 -0
  64. package/build/dialog.d.ts +1 -1
  65. package/build/dialog.d.ts.map +1 -1
  66. package/build/dialog.js +42 -16
  67. package/build/dialog.js.map +1 -1
  68. package/build/estimate-summary.d.ts +9 -1
  69. package/build/estimate-summary.d.ts.map +1 -1
  70. package/build/estimate-summary.js +10 -7
  71. package/build/estimate-summary.js.map +1 -1
  72. package/build/estimate.d.ts +6 -0
  73. package/build/estimate.d.ts.map +1 -1
  74. package/build/estimate.js +26 -1
  75. package/build/estimate.js.map +1 -1
  76. package/build/format.d.ts +15 -0
  77. package/build/format.d.ts.map +1 -0
  78. package/build/format.js +68 -0
  79. package/build/format.js.map +1 -0
  80. package/build/i18n/be.json +1 -1
  81. package/build/i18n/de.json +1 -1
  82. package/build/i18n/en.json +1 -1
  83. package/build/i18n/es.json +1 -1
  84. package/build/i18n/fr.json +110 -15
  85. package/build/i18n/pl.json +1 -1
  86. package/build/i18n/ru.json +1 -1
  87. package/build/i18n/uk.json +1 -1
  88. package/build/i18n.d.ts +2 -1
  89. package/build/i18n.d.ts.map +1 -1
  90. package/build/i18n.js +10 -8
  91. package/build/i18n.js.map +1 -1
  92. package/build/index.d.ts +2 -0
  93. package/build/index.d.ts.map +1 -1
  94. package/build/index.js +2 -0
  95. package/build/index.js.map +1 -1
  96. package/build/types.d.ts +22 -1
  97. package/build/types.d.ts.map +1 -1
  98. package/package.json +18 -7
  99. package/src/@/components/ui/checkbox.tsx +23 -0
  100. package/src/checkout-limit.tsx +48 -0
  101. package/src/consumer/cancellation.tsx +214 -0
  102. package/src/consumer/copy.ts +93 -0
  103. package/src/consumer/ensure.ts +124 -0
  104. package/src/consumer/fields.tsx +89 -0
  105. package/src/consumer/hooks.ts +358 -0
  106. package/src/consumer/index.ts +13 -0
  107. package/src/consumer/legal.tsx +129 -0
  108. package/src/consumer/performance-consent-dialog.tsx +132 -0
  109. package/src/consumer/provider.tsx +49 -0
  110. package/src/consumer/refusal.ts +110 -0
  111. package/src/consumer/subscription-start-dialog.tsx +87 -0
  112. package/src/consumer/types.ts +121 -0
  113. package/src/consumer/withdrawal.tsx +279 -0
  114. package/src/dialog.tsx +53 -23
  115. package/src/estimate-summary.tsx +21 -11
  116. package/src/estimate.ts +27 -1
  117. package/src/format.ts +71 -0
  118. package/src/i18n/be.json +1 -1
  119. package/src/i18n/de.json +1 -1
  120. package/src/i18n/en.json +1 -1
  121. package/src/i18n/es.json +1 -1
  122. package/src/i18n/fr.json +110 -15
  123. package/src/i18n/pl.json +1 -1
  124. package/src/i18n/ru.json +1 -1
  125. package/src/i18n/uk.json +1 -1
  126. package/src/i18n.ts +11 -8
  127. package/src/index.ts +2 -0
  128. package/src/types.ts +23 -1
  129. package/tests/consent-refusal.spec.ts +83 -0
  130. package/tests/consumer-hooks.spec.ts +205 -0
  131. package/tests/consumer.spec.ts +343 -0
  132. package/tests/context.ts +1 -1
  133. package/tests/harness/consumer-fixtures.ts +105 -0
  134. package/tests/harness/consumer.tsx +197 -0
  135. package/tests/harness/mount.tsx +9 -2
  136. package/tests/i18n.spec.ts +41 -5
@@ -0,0 +1,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
+ }