@owlmeans/web-payment 0.1.18-rc.2 → 0.1.18-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +287 -20
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/checkbox.d.ts +5 -0
- package/build/@/components/ui/checkbox.d.ts.map +1 -0
- package/build/@/components/ui/checkbox.js +6 -0
- package/build/@/components/ui/checkbox.js.map +1 -0
- package/build/@/components/ui/progress.d.ts +5 -0
- package/build/@/components/ui/progress.d.ts.map +1 -0
- package/build/@/components/ui/progress.js +11 -0
- package/build/@/components/ui/progress.js.map +1 -0
- package/build/@/components/ui/select.d.ts +8 -0
- package/build/@/components/ui/select.d.ts.map +1 -0
- package/build/@/components/ui/select.js +9 -0
- package/build/@/components/ui/select.js.map +1 -0
- package/build/capability-list.d.ts +7 -0
- package/build/capability-list.d.ts.map +1 -0
- package/build/capability-list.js +24 -0
- package/build/capability-list.js.map +1 -0
- package/build/checkout-limit.d.ts +11 -0
- package/build/checkout-limit.d.ts.map +1 -0
- package/build/checkout-limit.js +34 -0
- package/build/checkout-limit.js.map +1 -0
- package/build/consumer/cancellation.d.ts +20 -0
- package/build/consumer/cancellation.d.ts.map +1 -0
- package/build/consumer/cancellation.js +115 -0
- package/build/consumer/cancellation.js.map +1 -0
- package/build/consumer/copy.d.ts +23 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +75 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/ensure.d.ts +56 -0
- package/build/consumer/ensure.d.ts.map +1 -0
- package/build/consumer/ensure.js +71 -0
- package/build/consumer/ensure.js.map +1 -0
- package/build/consumer/fields.d.ts +43 -0
- package/build/consumer/fields.d.ts.map +1 -0
- package/build/consumer/fields.js +19 -0
- package/build/consumer/fields.js.map +1 -0
- package/build/consumer/hooks.d.ts +113 -0
- package/build/consumer/hooks.d.ts.map +1 -0
- package/build/consumer/hooks.js +225 -0
- package/build/consumer/hooks.js.map +1 -0
- package/build/consumer/index.d.ts +14 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +12 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/legal.d.ts +53 -0
- package/build/consumer/legal.d.ts.map +1 -0
- package/build/consumer/legal.js +77 -0
- package/build/consumer/legal.js.map +1 -0
- package/build/consumer/performance-consent-dialog.d.ts +18 -0
- package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
- package/build/consumer/performance-consent-dialog.js +71 -0
- package/build/consumer/performance-consent-dialog.js.map +1 -0
- package/build/consumer/provider.d.ts +33 -0
- package/build/consumer/provider.d.ts.map +1 -0
- package/build/consumer/provider.js +26 -0
- package/build/consumer/provider.js.map +1 -0
- package/build/consumer/refusal.d.ts +35 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +98 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/subscription-start-dialog.d.ts +16 -0
- package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
- package/build/consumer/subscription-start-dialog.js +43 -0
- package/build/consumer/subscription-start-dialog.js.map +1 -0
- package/build/consumer/types.d.ts +114 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +29 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +140 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/copy.d.ts +14 -0
- package/build/copy.d.ts.map +1 -0
- package/build/copy.js +51 -0
- package/build/copy.js.map +1 -0
- package/build/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +46 -17
- package/build/dialog.js.map +1 -1
- package/build/entitlement.d.ts +26 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +42 -0
- package/build/entitlement.js.map +1 -0
- package/build/estimate-cache.d.ts +14 -0
- package/build/estimate-cache.d.ts.map +1 -0
- package/build/estimate-cache.js +39 -0
- package/build/estimate-cache.js.map +1 -0
- package/build/estimate-summary.d.ts +64 -0
- package/build/estimate-summary.d.ts.map +1 -0
- package/build/estimate-summary.js +94 -0
- package/build/estimate-summary.js.map +1 -0
- package/build/estimate.d.ts +34 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +92 -0
- package/build/estimate.js.map +1 -0
- package/build/format.d.ts +15 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +68 -0
- package/build/format.js.map +1 -0
- package/build/hooks.d.ts +0 -4
- package/build/hooks.d.ts.map +1 -1
- package/build/hooks.js +3 -30
- package/build/hooks.js.map +1 -1
- package/build/i18n/be.json +1 -1
- package/build/i18n/de.json +1 -1
- package/build/i18n/en.json +1 -1
- package/build/i18n/es.json +1 -1
- package/build/i18n/fr.json +145 -0
- package/build/i18n/pl.json +1 -1
- package/build/i18n/ru.json +1 -1
- package/build/i18n/uk.json +1 -1
- package/build/i18n.d.ts +2 -1
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +11 -7
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -0
- package/build/index.js.map +1 -1
- package/build/limit-meter.d.ts +7 -0
- package/build/limit-meter.d.ts.map +1 -0
- package/build/limit-meter.js +26 -0
- package/build/limit-meter.js.map +1 -0
- package/build/plan-card.d.ts +7 -0
- package/build/plan-card.d.ts.map +1 -0
- package/build/plan-card.js +29 -0
- package/build/plan-card.js.map +1 -0
- package/build/poll.d.ts +7 -0
- package/build/poll.d.ts.map +1 -0
- package/build/poll.js +28 -0
- package/build/poll.js.map +1 -0
- package/build/selectors.d.ts +18 -0
- package/build/selectors.d.ts.map +1 -0
- package/build/selectors.js +78 -0
- package/build/selectors.js.map +1 -0
- package/build/types.d.ts +120 -2
- package/build/types.d.ts.map +1 -1
- package/package.json +22 -7
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/checkbox.tsx +23 -0
- package/src/@/components/ui/progress.tsx +44 -0
- package/src/@/components/ui/select.tsx +62 -0
- package/src/@/globals.css +10 -0
- package/src/capability-list.tsx +50 -0
- package/src/checkout-limit.tsx +48 -0
- package/src/consumer/cancellation.tsx +214 -0
- package/src/consumer/copy.ts +93 -0
- package/src/consumer/ensure.ts +124 -0
- package/src/consumer/fields.tsx +89 -0
- package/src/consumer/hooks.ts +358 -0
- package/src/consumer/index.ts +13 -0
- package/src/consumer/legal.tsx +129 -0
- package/src/consumer/performance-consent-dialog.tsx +132 -0
- package/src/consumer/provider.tsx +49 -0
- package/src/consumer/refusal.ts +110 -0
- package/src/consumer/subscription-start-dialog.tsx +87 -0
- package/src/consumer/types.ts +121 -0
- package/src/consumer/withdrawal.tsx +279 -0
- package/src/copy.tsx +62 -0
- package/src/dialog.tsx +58 -25
- package/src/entitlement.ts +56 -0
- package/src/estimate-cache.ts +40 -0
- package/src/estimate-summary.tsx +172 -0
- package/src/estimate.ts +119 -0
- package/src/format.ts +71 -0
- package/src/hooks.ts +3 -35
- package/src/i18n/be.json +1 -1
- package/src/i18n/de.json +1 -1
- package/src/i18n/en.json +1 -1
- package/src/i18n/es.json +1 -1
- package/src/i18n/fr.json +145 -0
- package/src/i18n/pl.json +1 -1
- package/src/i18n/ru.json +1 -1
- package/src/i18n/uk.json +1 -1
- package/src/i18n.ts +12 -7
- package/src/index.ts +9 -0
- package/src/limit-meter.tsx +55 -0
- package/src/plan-card.tsx +67 -0
- package/src/poll.ts +27 -0
- package/src/selectors.ts +93 -0
- package/src/types.ts +142 -2
- package/tests/consent-refusal.spec.ts +83 -0
- package/tests/consumer-hooks.spec.ts +205 -0
- package/tests/consumer.spec.ts +343 -0
- package/tests/context.ts +9 -0
- package/tests/dialog.spec.ts +49 -0
- package/tests/entitlement.spec.ts +117 -0
- package/tests/estimate-cache.spec.ts +46 -0
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/entitlement.tsx +159 -0
- package/tests/harness/estimate-fixtures.ts +48 -0
- package/tests/harness/mount.tsx +19 -3
- package/tests/i18n.spec.ts +78 -8
- package/tests/pieces.spec.ts +134 -0
|
@@ -0,0 +1,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 }
|