@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.
Files changed (203) hide show
  1. package/README.md +30 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-payment/SKILL.md +287 -20
  4. package/build/@/components/ui/card.d.ts +10 -0
  5. package/build/@/components/ui/card.d.ts.map +1 -0
  6. package/build/@/components/ui/card.js +25 -0
  7. package/build/@/components/ui/card.js.map +1 -0
  8. package/build/@/components/ui/checkbox.d.ts +5 -0
  9. package/build/@/components/ui/checkbox.d.ts.map +1 -0
  10. package/build/@/components/ui/checkbox.js +6 -0
  11. package/build/@/components/ui/checkbox.js.map +1 -0
  12. package/build/@/components/ui/progress.d.ts +5 -0
  13. package/build/@/components/ui/progress.d.ts.map +1 -0
  14. package/build/@/components/ui/progress.js +11 -0
  15. package/build/@/components/ui/progress.js.map +1 -0
  16. package/build/@/components/ui/select.d.ts +8 -0
  17. package/build/@/components/ui/select.d.ts.map +1 -0
  18. package/build/@/components/ui/select.js +9 -0
  19. package/build/@/components/ui/select.js.map +1 -0
  20. package/build/capability-list.d.ts +7 -0
  21. package/build/capability-list.d.ts.map +1 -0
  22. package/build/capability-list.js +24 -0
  23. package/build/capability-list.js.map +1 -0
  24. package/build/checkout-limit.d.ts +11 -0
  25. package/build/checkout-limit.d.ts.map +1 -0
  26. package/build/checkout-limit.js +34 -0
  27. package/build/checkout-limit.js.map +1 -0
  28. package/build/consumer/cancellation.d.ts +20 -0
  29. package/build/consumer/cancellation.d.ts.map +1 -0
  30. package/build/consumer/cancellation.js +115 -0
  31. package/build/consumer/cancellation.js.map +1 -0
  32. package/build/consumer/copy.d.ts +23 -0
  33. package/build/consumer/copy.d.ts.map +1 -0
  34. package/build/consumer/copy.js +75 -0
  35. package/build/consumer/copy.js.map +1 -0
  36. package/build/consumer/ensure.d.ts +56 -0
  37. package/build/consumer/ensure.d.ts.map +1 -0
  38. package/build/consumer/ensure.js +71 -0
  39. package/build/consumer/ensure.js.map +1 -0
  40. package/build/consumer/fields.d.ts +43 -0
  41. package/build/consumer/fields.d.ts.map +1 -0
  42. package/build/consumer/fields.js +19 -0
  43. package/build/consumer/fields.js.map +1 -0
  44. package/build/consumer/hooks.d.ts +113 -0
  45. package/build/consumer/hooks.d.ts.map +1 -0
  46. package/build/consumer/hooks.js +225 -0
  47. package/build/consumer/hooks.js.map +1 -0
  48. package/build/consumer/index.d.ts +14 -0
  49. package/build/consumer/index.d.ts.map +1 -0
  50. package/build/consumer/index.js +12 -0
  51. package/build/consumer/index.js.map +1 -0
  52. package/build/consumer/legal.d.ts +53 -0
  53. package/build/consumer/legal.d.ts.map +1 -0
  54. package/build/consumer/legal.js +77 -0
  55. package/build/consumer/legal.js.map +1 -0
  56. package/build/consumer/performance-consent-dialog.d.ts +18 -0
  57. package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
  58. package/build/consumer/performance-consent-dialog.js +71 -0
  59. package/build/consumer/performance-consent-dialog.js.map +1 -0
  60. package/build/consumer/provider.d.ts +33 -0
  61. package/build/consumer/provider.d.ts.map +1 -0
  62. package/build/consumer/provider.js +26 -0
  63. package/build/consumer/provider.js.map +1 -0
  64. package/build/consumer/refusal.d.ts +35 -0
  65. package/build/consumer/refusal.d.ts.map +1 -0
  66. package/build/consumer/refusal.js +98 -0
  67. package/build/consumer/refusal.js.map +1 -0
  68. package/build/consumer/subscription-start-dialog.d.ts +16 -0
  69. package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
  70. package/build/consumer/subscription-start-dialog.js +43 -0
  71. package/build/consumer/subscription-start-dialog.js.map +1 -0
  72. package/build/consumer/types.d.ts +114 -0
  73. package/build/consumer/types.d.ts.map +1 -0
  74. package/build/consumer/types.js +2 -0
  75. package/build/consumer/types.js.map +1 -0
  76. package/build/consumer/withdrawal.d.ts +29 -0
  77. package/build/consumer/withdrawal.d.ts.map +1 -0
  78. package/build/consumer/withdrawal.js +140 -0
  79. package/build/consumer/withdrawal.js.map +1 -0
  80. package/build/copy.d.ts +14 -0
  81. package/build/copy.d.ts.map +1 -0
  82. package/build/copy.js +51 -0
  83. package/build/copy.js.map +1 -0
  84. package/build/dialog.d.ts +1 -1
  85. package/build/dialog.d.ts.map +1 -1
  86. package/build/dialog.js +46 -17
  87. package/build/dialog.js.map +1 -1
  88. package/build/entitlement.d.ts +26 -0
  89. package/build/entitlement.d.ts.map +1 -0
  90. package/build/entitlement.js +42 -0
  91. package/build/entitlement.js.map +1 -0
  92. package/build/estimate-cache.d.ts +14 -0
  93. package/build/estimate-cache.d.ts.map +1 -0
  94. package/build/estimate-cache.js +39 -0
  95. package/build/estimate-cache.js.map +1 -0
  96. package/build/estimate-summary.d.ts +64 -0
  97. package/build/estimate-summary.d.ts.map +1 -0
  98. package/build/estimate-summary.js +94 -0
  99. package/build/estimate-summary.js.map +1 -0
  100. package/build/estimate.d.ts +34 -0
  101. package/build/estimate.d.ts.map +1 -0
  102. package/build/estimate.js +92 -0
  103. package/build/estimate.js.map +1 -0
  104. package/build/format.d.ts +15 -0
  105. package/build/format.d.ts.map +1 -0
  106. package/build/format.js +68 -0
  107. package/build/format.js.map +1 -0
  108. package/build/hooks.d.ts +0 -4
  109. package/build/hooks.d.ts.map +1 -1
  110. package/build/hooks.js +3 -30
  111. package/build/hooks.js.map +1 -1
  112. package/build/i18n/be.json +1 -1
  113. package/build/i18n/de.json +1 -1
  114. package/build/i18n/en.json +1 -1
  115. package/build/i18n/es.json +1 -1
  116. package/build/i18n/fr.json +145 -0
  117. package/build/i18n/pl.json +1 -1
  118. package/build/i18n/ru.json +1 -1
  119. package/build/i18n/uk.json +1 -1
  120. package/build/i18n.d.ts +2 -1
  121. package/build/i18n.d.ts.map +1 -1
  122. package/build/i18n.js +11 -7
  123. package/build/i18n.js.map +1 -1
  124. package/build/index.d.ts +9 -0
  125. package/build/index.d.ts.map +1 -1
  126. package/build/index.js +9 -0
  127. package/build/index.js.map +1 -1
  128. package/build/limit-meter.d.ts +7 -0
  129. package/build/limit-meter.d.ts.map +1 -0
  130. package/build/limit-meter.js +26 -0
  131. package/build/limit-meter.js.map +1 -0
  132. package/build/plan-card.d.ts +7 -0
  133. package/build/plan-card.d.ts.map +1 -0
  134. package/build/plan-card.js +29 -0
  135. package/build/plan-card.js.map +1 -0
  136. package/build/poll.d.ts +7 -0
  137. package/build/poll.d.ts.map +1 -0
  138. package/build/poll.js +28 -0
  139. package/build/poll.js.map +1 -0
  140. package/build/selectors.d.ts +18 -0
  141. package/build/selectors.d.ts.map +1 -0
  142. package/build/selectors.js +78 -0
  143. package/build/selectors.js.map +1 -0
  144. package/build/types.d.ts +120 -2
  145. package/build/types.d.ts.map +1 -1
  146. package/package.json +22 -7
  147. package/src/@/components/ui/card.tsx +93 -0
  148. package/src/@/components/ui/checkbox.tsx +23 -0
  149. package/src/@/components/ui/progress.tsx +44 -0
  150. package/src/@/components/ui/select.tsx +62 -0
  151. package/src/@/globals.css +10 -0
  152. package/src/capability-list.tsx +50 -0
  153. package/src/checkout-limit.tsx +48 -0
  154. package/src/consumer/cancellation.tsx +214 -0
  155. package/src/consumer/copy.ts +93 -0
  156. package/src/consumer/ensure.ts +124 -0
  157. package/src/consumer/fields.tsx +89 -0
  158. package/src/consumer/hooks.ts +358 -0
  159. package/src/consumer/index.ts +13 -0
  160. package/src/consumer/legal.tsx +129 -0
  161. package/src/consumer/performance-consent-dialog.tsx +132 -0
  162. package/src/consumer/provider.tsx +49 -0
  163. package/src/consumer/refusal.ts +110 -0
  164. package/src/consumer/subscription-start-dialog.tsx +87 -0
  165. package/src/consumer/types.ts +121 -0
  166. package/src/consumer/withdrawal.tsx +279 -0
  167. package/src/copy.tsx +62 -0
  168. package/src/dialog.tsx +58 -25
  169. package/src/entitlement.ts +56 -0
  170. package/src/estimate-cache.ts +40 -0
  171. package/src/estimate-summary.tsx +172 -0
  172. package/src/estimate.ts +119 -0
  173. package/src/format.ts +71 -0
  174. package/src/hooks.ts +3 -35
  175. package/src/i18n/be.json +1 -1
  176. package/src/i18n/de.json +1 -1
  177. package/src/i18n/en.json +1 -1
  178. package/src/i18n/es.json +1 -1
  179. package/src/i18n/fr.json +145 -0
  180. package/src/i18n/pl.json +1 -1
  181. package/src/i18n/ru.json +1 -1
  182. package/src/i18n/uk.json +1 -1
  183. package/src/i18n.ts +12 -7
  184. package/src/index.ts +9 -0
  185. package/src/limit-meter.tsx +55 -0
  186. package/src/plan-card.tsx +67 -0
  187. package/src/poll.ts +27 -0
  188. package/src/selectors.ts +93 -0
  189. package/src/types.ts +142 -2
  190. package/tests/consent-refusal.spec.ts +83 -0
  191. package/tests/consumer-hooks.spec.ts +205 -0
  192. package/tests/consumer.spec.ts +343 -0
  193. package/tests/context.ts +9 -0
  194. package/tests/dialog.spec.ts +49 -0
  195. package/tests/entitlement.spec.ts +117 -0
  196. package/tests/estimate-cache.spec.ts +46 -0
  197. package/tests/harness/consumer-fixtures.ts +105 -0
  198. package/tests/harness/consumer.tsx +197 -0
  199. package/tests/harness/entitlement.tsx +159 -0
  200. package/tests/harness/estimate-fixtures.ts +48 -0
  201. package/tests/harness/mount.tsx +19 -3
  202. package/tests/i18n.spec.ts +78 -8
  203. 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 `&#x2F;` 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
+ }