@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,358 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import type { CallArguments, RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
3
+ import {
4
+ reviveConsentResponse, reviveConsentView, reviveReceipt, reviveStartResponse, reviveWithdrawalList,
5
+ } from '@owlmeans/payment'
6
+ import type {
7
+ CancellationBody, DeclarationReceipt, PerformanceConsentBody, PerformanceConsentResponse, PerformanceConsentView,
8
+ SubscriptionStartBody, SubscriptionStartQuery, SubscriptionStartResponse, SubscriptionStartView, WithdrawalBody,
9
+ WithdrawalCandidateList,
10
+ } from '@owlmeans/payment'
11
+ import { makeAsker, type EnsureOptions } from './ensure.js'
12
+ import { isConsentRefusal } from './refusal.js'
13
+ import type {
14
+ LegalLinksSource, PerformanceConsentDialogProps, SubscriptionStartDialogProps,
15
+ } from './types.js'
16
+
17
+ /** A protocol whose request carries `body`: the record, withdraw and cancel routes. */
18
+ type BodyRequest<Body> = RequestShape & { body: Body }
19
+
20
+ /** One call argument built by the hook — the protocol's own `CallArguments`, completed here. */
21
+ const argsOf = <Request extends RequestShape>(request: object): CallArguments<Request> =>
22
+ [request] as unknown as CallArguments<Request>
23
+
24
+ export interface UsePerformanceConsentOptions {
25
+ /** Read the view on mount, so `required` is known before anything asks. Default `false`. */
26
+ enabled?: boolean
27
+ uiLanguage?: string
28
+ links?: LegalLinksSource
29
+ /** Open the withdrawal function; the consent dialog closes (declined) first. */
30
+ onWithdraw?: () => void
31
+ /** After the consent was recorded — refresh an account feed, say. */
32
+ onRecorded?: (response: PerformanceConsentResponse) => void
33
+ }
34
+
35
+ export interface PerformanceConsentControl {
36
+ view: PerformanceConsentView | null
37
+ /** From the last read; `null` before any. */
38
+ required: boolean | null
39
+ /**
40
+ * `true` when no consent is needed or it was confirmed and recorded, `false` on a decline or a
41
+ * closed dialog. Reads the view fresh; a failed read answers `true`. Concurrent calls share one
42
+ * pending answer — one dialog.
43
+ */
44
+ ensure: (opts?: EnsureOptions) => Promise<boolean>
45
+ refresh: () => Promise<PerformanceConsentView | null>
46
+ /** Spread into ONE `PerformanceConsentDialog`. */
47
+ dialog: PerformanceConsentDialogProps
48
+ }
49
+
50
+ /**
51
+ * The spend consent over two protocols — the view (`consent` GET) and the record (`giveConsent`
52
+ * POST) of `makeConsumerRightsProtocols` — bound with `ctx.entrypoint(protocol)`.
53
+ */
54
+ export const usePerformanceConsent = <RecordRequest extends BodyRequest<PerformanceConsentBody>>(
55
+ view: RegisteredEntrypoint<{}, PerformanceConsentView>,
56
+ record: RegisteredEntrypoint<RecordRequest, PerformanceConsentResponse>,
57
+ opts: UsePerformanceConsentOptions = {},
58
+ ): PerformanceConsentControl => {
59
+ const viewRef = useRef(view)
60
+ viewRef.current = view
61
+ const recordRef = useRef(record)
62
+ recordRef.current = record
63
+ const optsRef = useRef(opts)
64
+ optsRef.current = opts
65
+
66
+ const [current, setCurrent] = useState<PerformanceConsentView | null>(null)
67
+ const [open, setOpen] = useState(false)
68
+ const [pending, setPending] = useState(false)
69
+ const [error, setError] = useState(false)
70
+
71
+ const load = useCallback(async (): Promise<PerformanceConsentView> => {
72
+ const next = reviveConsentView(await viewRef.current.call())
73
+ setCurrent(next)
74
+
75
+ return next
76
+ }, [])
77
+ const asker = useMemo(() => makeAsker<[], PerformanceConsentView, boolean>({
78
+ load, required: next => next.required, skip: true,
79
+ show: () => { setError(false); setOpen(true) },
80
+ }), [load])
81
+
82
+ useEffect(() => {
83
+ if (opts.enabled === true) void load().catch(() => undefined)
84
+ }, [opts.enabled, load])
85
+
86
+ const decline = useCallback(() => {
87
+ setOpen(false)
88
+ asker.settle(false)
89
+ }, [asker])
90
+
91
+ const onConfirm = useCallback(async (body: PerformanceConsentBody) => {
92
+ setPending(true)
93
+ setError(false)
94
+ try {
95
+ const response = reviveConsentResponse(await recordRef.current.call(...argsOf<RecordRequest>({ body })))
96
+ setCurrent(held => held == null ? held : { ...held, required: false, purchases: [] })
97
+ setOpen(false)
98
+ asker.settle(true)
99
+ optsRef.current.onRecorded?.(response)
100
+ } catch (e) {
101
+ setError(true)
102
+ // A stale text version is refused with a 428: show the current wording to confirm again.
103
+ if (isConsentRefusal(e)) void load().catch(() => undefined)
104
+ } finally {
105
+ setPending(false)
106
+ }
107
+ }, [asker, load])
108
+
109
+ const refresh = useCallback(async () => {
110
+ try {
111
+ return await load()
112
+ } catch {
113
+ return null
114
+ }
115
+ }, [load])
116
+
117
+ const ensure = useCallback(async () => await asker.ask(), [asker])
118
+
119
+ const onWithdraw = opts.onWithdraw
120
+ const dialog: PerformanceConsentDialogProps = {
121
+ open, view: current, pending, error, onConfirm, onDecline: decline,
122
+ onOpenChange: next => { if (next) setOpen(true); else decline() },
123
+ uiLanguage: opts.uiLanguage,
124
+ links: opts.links,
125
+ onWithdraw: onWithdraw != null ? () => { decline(); onWithdraw() } : undefined,
126
+ }
127
+
128
+ return { view: current, required: current?.required ?? null, ensure, refresh, dialog }
129
+ }
130
+
131
+ export interface UseSubscriptionStartOptions {
132
+ uiLanguage?: string
133
+ links?: LegalLinksSource
134
+ onWithdraw?: () => void
135
+ }
136
+
137
+ export interface SubscriptionStartParams {
138
+ /** The plan's title as the application names it — part of the statement. */
139
+ planTitle: string
140
+ /** The price line the dialog shows above the statement. */
141
+ price?: ReactNode
142
+ }
143
+
144
+ export interface SubscriptionStartControl {
145
+ /**
146
+ * The start request id to send with the subscription checkout (`CreateCheckoutBody.
147
+ * startRequestId`); `''` when none is needed (or the view could not be read — the checkout's
148
+ * own refusal then decides); `null` when the person declined or closed the dialog.
149
+ */
150
+ ensure: (planSku: string, params: SubscriptionStartParams) => Promise<string | null>
151
+ view: SubscriptionStartView | null
152
+ /** Spread into ONE `SubscriptionStartDialog`. */
153
+ dialog: SubscriptionStartDialogProps
154
+ }
155
+
156
+ /**
157
+ * The subscription start request over the `start` GET (`?planSku`) and `requestStart` POST
158
+ * protocols, asked right before a subscription checkout.
159
+ */
160
+ export const useSubscriptionStart = <
161
+ ViewRequest extends RequestShape & { query: SubscriptionStartQuery },
162
+ RecordRequest extends BodyRequest<SubscriptionStartBody>,
163
+ >(
164
+ view: RegisteredEntrypoint<ViewRequest, SubscriptionStartView>,
165
+ record: RegisteredEntrypoint<RecordRequest, SubscriptionStartResponse>,
166
+ opts: UseSubscriptionStartOptions = {},
167
+ ): SubscriptionStartControl => {
168
+ const viewRef = useRef(view)
169
+ viewRef.current = view
170
+ const recordRef = useRef(record)
171
+ recordRef.current = record
172
+
173
+ const [current, setCurrent] = useState<SubscriptionStartView | null>(null)
174
+ const [params, setParams] = useState<SubscriptionStartParams>({ planTitle: '' })
175
+ const [open, setOpen] = useState(false)
176
+ const [pending, setPending] = useState(false)
177
+ const [error, setError] = useState(false)
178
+
179
+ const asker = useMemo(() => makeAsker<[string, SubscriptionStartParams], SubscriptionStartView, string | null>({
180
+ load: async (planSku, next) => {
181
+ const answer = await viewRef.current.call(...argsOf<ViewRequest>({ query: { planSku } }))
182
+ setCurrent(answer)
183
+ setParams(next)
184
+
185
+ return answer
186
+ },
187
+ required: next => next.required,
188
+ skip: '',
189
+ show: () => { setError(false); setOpen(true) },
190
+ }), [])
191
+
192
+ const decline = useCallback(() => {
193
+ setOpen(false)
194
+ asker.settle(null)
195
+ }, [asker])
196
+
197
+ const onConfirm = useCallback(async (body: SubscriptionStartBody) => {
198
+ setPending(true)
199
+ setError(false)
200
+ try {
201
+ const response = reviveStartResponse(await recordRef.current.call(...argsOf<RecordRequest>({ body })))
202
+ setOpen(false)
203
+ asker.settle(response.startRequestId)
204
+ } catch {
205
+ setError(true)
206
+ } finally {
207
+ setPending(false)
208
+ }
209
+ }, [asker])
210
+
211
+ const ensure = useCallback(async (planSku: string, next: SubscriptionStartParams) => await asker.ask(planSku, next), [asker])
212
+
213
+ const onWithdraw = opts.onWithdraw
214
+ const dialog: SubscriptionStartDialogProps = {
215
+ open, view: current, planTitle: params.planTitle, price: params.price, pending, error, onConfirm,
216
+ onDecline: decline,
217
+ onOpenChange: next => { if (next) setOpen(true); else decline() },
218
+ uiLanguage: opts.uiLanguage,
219
+ links: opts.links,
220
+ onWithdraw: onWithdraw != null ? () => { decline(); onWithdraw() } : undefined,
221
+ }
222
+
223
+ return { ensure, view: current, dialog }
224
+ }
225
+
226
+ export interface DeclarationControl<Body, Receipt> {
227
+ /** Send the declaration; the revived receipt, or `null` when it failed (`error` holds why). */
228
+ submit: (body: Body) => Promise<Receipt | null>
229
+ receipt: Receipt | null
230
+ pending: boolean
231
+ error: unknown
232
+ /** Forget the receipt and the error — before the form is shown again. */
233
+ reset: () => void
234
+ }
235
+
236
+ /** The submit half both statutory functions share: one protocol, one receipt. */
237
+ const useDeclaration = <Body, Request extends BodyRequest<Body>, Receipt extends DeclarationReceipt>(
238
+ entry: RegisteredEntrypoint<Request, Receipt>,
239
+ ): DeclarationControl<Body, Receipt> => {
240
+ const entryRef = useRef(entry)
241
+ entryRef.current = entry
242
+ const [receipt, setReceipt] = useState<Receipt | null>(null)
243
+ const [pending, setPending] = useState(false)
244
+ const [error, setError] = useState<unknown>(null)
245
+
246
+ const submit = useCallback(async (body: Body): Promise<Receipt | null> => {
247
+ setPending(true)
248
+ setError(null)
249
+ try {
250
+ const answer = reviveReceipt(await entryRef.current.call(...argsOf<Request>({ body })))
251
+ setReceipt(answer)
252
+
253
+ return answer
254
+ } catch (e) {
255
+ setError(e)
256
+
257
+ return null
258
+ } finally {
259
+ setPending(false)
260
+ }
261
+ }, [])
262
+ const reset = useCallback(() => {
263
+ setReceipt(null)
264
+ setError(null)
265
+ }, [])
266
+
267
+ return { submit, receipt, pending, error, reset }
268
+ }
269
+
270
+ export interface WithdrawalControl<Receipt extends DeclarationReceipt> extends DeclarationControl<WithdrawalBody, Receipt> {
271
+ /** In-app: the contracts that can still be withdrawn from; `null` before the first read. */
272
+ list: WithdrawalCandidateList | null
273
+ load: () => Promise<WithdrawalCandidateList | null>
274
+ /** Spread into a `WithdrawalForm` / `WithdrawalDialog` (with `mode` and `language`). */
275
+ form: {
276
+ list: WithdrawalCandidateList | null
277
+ pending: boolean
278
+ error: boolean
279
+ receipt: Receipt | null
280
+ onSubmit: (body: WithdrawalBody) => Promise<Receipt | null>
281
+ }
282
+ }
283
+
284
+ /**
285
+ * The withdrawal function over the `withdrawals` GET (in-app; `null` on the public page) and a
286
+ * `withdraw` POST — the account one answers a `WithdrawalReceipt`, the public one a
287
+ * `DeclarationReceipt`.
288
+ */
289
+ export const useWithdrawal = <Request extends BodyRequest<WithdrawalBody>, Receipt extends DeclarationReceipt>(
290
+ list: RegisteredEntrypoint<{}, WithdrawalCandidateList> | null,
291
+ withdraw: RegisteredEntrypoint<Request, Receipt>,
292
+ opts: { enabled?: boolean } = {},
293
+ ): WithdrawalControl<Receipt> => {
294
+ const listRef = useRef(list)
295
+ listRef.current = list
296
+ const [candidates, setCandidates] = useState<WithdrawalCandidateList | null>(null)
297
+ const declaration = useDeclaration<WithdrawalBody, Request, Receipt>(withdraw)
298
+
299
+ const load = useCallback(async () => {
300
+ if (listRef.current == null) {
301
+ return null
302
+ }
303
+ try {
304
+ const next = reviveWithdrawalList(await listRef.current.call())
305
+ setCandidates(next)
306
+
307
+ return next
308
+ } catch {
309
+ return null
310
+ }
311
+ }, [])
312
+ useEffect(() => {
313
+ if (opts.enabled === true) void load()
314
+ }, [opts.enabled, load])
315
+
316
+ return {
317
+ ...declaration,
318
+ list: candidates,
319
+ load,
320
+ form: {
321
+ list: candidates,
322
+ pending: declaration.pending,
323
+ error: declaration.error != null,
324
+ receipt: declaration.receipt,
325
+ onSubmit: declaration.submit,
326
+ },
327
+ }
328
+ }
329
+
330
+ export interface CancellationControl<Receipt extends DeclarationReceipt> extends DeclarationControl<CancellationBody, Receipt> {
331
+ /** Spread into a `CancellationForm` (with `mode` and `language`). */
332
+ form: {
333
+ pending: boolean
334
+ error: boolean
335
+ receipt: Receipt | null
336
+ onSubmit: (body: CancellationBody) => Promise<Receipt | null>
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The cancellation function over a `cancel` POST — the account one answers a
342
+ * `CancellationReceipt`, the public one a `DeclarationReceipt`.
343
+ */
344
+ export const useCancellation = <Request extends BodyRequest<CancellationBody>, Receipt extends DeclarationReceipt>(
345
+ cancel: RegisteredEntrypoint<Request, Receipt>,
346
+ ): CancellationControl<Receipt> => {
347
+ const declaration = useDeclaration<CancellationBody, Request, Receipt>(cancel)
348
+
349
+ return {
350
+ ...declaration,
351
+ form: {
352
+ pending: declaration.pending,
353
+ error: declaration.error != null,
354
+ receipt: declaration.receipt,
355
+ onSubmit: declaration.submit,
356
+ },
357
+ }
358
+ }
@@ -0,0 +1,13 @@
1
+ import '../i18n.js'
2
+ export type * from './types.js'
3
+ export * from './refusal.js'
4
+ export * from './ensure.js'
5
+ export * from './copy.js'
6
+ export { LanguageToggle, LegalLinks, linksFor, useShownLanguage, useUiLanguage } from './legal.js'
7
+ export type { LanguageToggleProps, LegalLinksProps, ShownLanguage } from './legal.js'
8
+ export * from './performance-consent-dialog.js'
9
+ export * from './subscription-start-dialog.js'
10
+ export * from './withdrawal.js'
11
+ export * from './cancellation.js'
12
+ export * from './hooks.js'
13
+ export * from './provider.js'
@@ -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 }