@owlmeans/web-payment 0.1.18-rc.20 → 0.1.18-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +171 -31
- package/build/@/components/ui/checkbox.d.ts +5 -0
- package/build/@/components/ui/checkbox.d.ts.map +1 -0
- package/build/@/components/ui/checkbox.js +6 -0
- package/build/@/components/ui/checkbox.js.map +1 -0
- package/build/checkout-limit.d.ts +11 -0
- package/build/checkout-limit.d.ts.map +1 -0
- package/build/checkout-limit.js +34 -0
- package/build/checkout-limit.js.map +1 -0
- package/build/consumer/cancellation.d.ts +20 -0
- package/build/consumer/cancellation.d.ts.map +1 -0
- package/build/consumer/cancellation.js +115 -0
- package/build/consumer/cancellation.js.map +1 -0
- package/build/consumer/copy.d.ts +23 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +75 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/ensure.d.ts +56 -0
- package/build/consumer/ensure.d.ts.map +1 -0
- package/build/consumer/ensure.js +71 -0
- package/build/consumer/ensure.js.map +1 -0
- package/build/consumer/fields.d.ts +43 -0
- package/build/consumer/fields.d.ts.map +1 -0
- package/build/consumer/fields.js +19 -0
- package/build/consumer/fields.js.map +1 -0
- package/build/consumer/hooks.d.ts +113 -0
- package/build/consumer/hooks.d.ts.map +1 -0
- package/build/consumer/hooks.js +225 -0
- package/build/consumer/hooks.js.map +1 -0
- package/build/consumer/index.d.ts +14 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +12 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/legal.d.ts +53 -0
- package/build/consumer/legal.d.ts.map +1 -0
- package/build/consumer/legal.js +77 -0
- package/build/consumer/legal.js.map +1 -0
- package/build/consumer/performance-consent-dialog.d.ts +18 -0
- package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
- package/build/consumer/performance-consent-dialog.js +71 -0
- package/build/consumer/performance-consent-dialog.js.map +1 -0
- package/build/consumer/provider.d.ts +33 -0
- package/build/consumer/provider.d.ts.map +1 -0
- package/build/consumer/provider.js +26 -0
- package/build/consumer/provider.js.map +1 -0
- package/build/consumer/refusal.d.ts +35 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +98 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/subscription-start-dialog.d.ts +16 -0
- package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
- package/build/consumer/subscription-start-dialog.js +43 -0
- package/build/consumer/subscription-start-dialog.js.map +1 -0
- package/build/consumer/types.d.ts +114 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +29 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +140 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +42 -16
- package/build/dialog.js.map +1 -1
- package/build/estimate-summary.d.ts +9 -1
- package/build/estimate-summary.d.ts.map +1 -1
- package/build/estimate-summary.js +10 -7
- package/build/estimate-summary.js.map +1 -1
- package/build/estimate.d.ts +6 -0
- package/build/estimate.d.ts.map +1 -1
- package/build/estimate.js +26 -1
- package/build/estimate.js.map +1 -1
- package/build/format.d.ts +15 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +68 -0
- package/build/format.js.map +1 -0
- package/build/i18n/be.json +1 -1
- package/build/i18n/de.json +1 -1
- package/build/i18n/en.json +1 -1
- package/build/i18n/es.json +1 -1
- package/build/i18n/fr.json +110 -15
- package/build/i18n/pl.json +1 -1
- package/build/i18n/ru.json +1 -1
- package/build/i18n/uk.json +1 -1
- package/build/i18n.d.ts +2 -1
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +10 -8
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/types.d.ts +22 -1
- package/build/types.d.ts.map +1 -1
- package/package.json +18 -7
- package/src/@/components/ui/checkbox.tsx +23 -0
- package/src/checkout-limit.tsx +48 -0
- package/src/consumer/cancellation.tsx +214 -0
- package/src/consumer/copy.ts +93 -0
- package/src/consumer/ensure.ts +124 -0
- package/src/consumer/fields.tsx +89 -0
- package/src/consumer/hooks.ts +358 -0
- package/src/consumer/index.ts +13 -0
- package/src/consumer/legal.tsx +129 -0
- package/src/consumer/performance-consent-dialog.tsx +132 -0
- package/src/consumer/provider.tsx +49 -0
- package/src/consumer/refusal.ts +110 -0
- package/src/consumer/subscription-start-dialog.tsx +87 -0
- package/src/consumer/types.ts +121 -0
- package/src/consumer/withdrawal.tsx +279 -0
- package/src/dialog.tsx +53 -23
- package/src/estimate-summary.tsx +21 -11
- package/src/estimate.ts +27 -1
- package/src/format.ts +71 -0
- package/src/i18n/be.json +1 -1
- package/src/i18n/de.json +1 -1
- package/src/i18n/en.json +1 -1
- package/src/i18n/es.json +1 -1
- package/src/i18n/fr.json +110 -15
- package/src/i18n/pl.json +1 -1
- package/src/i18n/ru.json +1 -1
- package/src/i18n/uk.json +1 -1
- package/src/i18n.ts +11 -8
- package/src/index.ts +2 -0
- package/src/types.ts +23 -1
- package/tests/consent-refusal.spec.ts +83 -0
- package/tests/consumer-hooks.spec.ts +205 -0
- package/tests/consumer.spec.ts +343 -0
- package/tests/context.ts +1 -1
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/mount.tsx +9 -2
- package/tests/i18n.spec.ts +41 -5
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ConsentKind } from '@owlmeans/payment'
|
|
2
|
+
import { ConsentDeclined, isPerformanceConsentRefusal } from './refusal.js'
|
|
3
|
+
|
|
4
|
+
export interface AskerOptions<Args extends unknown[], View, Answer> {
|
|
5
|
+
/** Read the current view. */
|
|
6
|
+
load: (...args: Args) => Promise<View>
|
|
7
|
+
/** Whether the view asks the person anything. */
|
|
8
|
+
required: (view: View) => boolean
|
|
9
|
+
/** Show the view to the person (open the dialog). */
|
|
10
|
+
show: (view: View) => void
|
|
11
|
+
/**
|
|
12
|
+
* The answer when nothing needs asking — and when the read FAILS: the server's refusal stays
|
|
13
|
+
* the authority, and a flaky read must never block work that needs no consent.
|
|
14
|
+
*/
|
|
15
|
+
skip: Answer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Asker<Args extends unknown[], Answer> {
|
|
19
|
+
/** Read, and ask only when the view requires it. Concurrent calls share ONE pending answer. */
|
|
20
|
+
ask: (...args: Args) => Promise<Answer>
|
|
21
|
+
/** Answer the pending `ask` (the dialog confirmed or was declined); nothing when none waits. */
|
|
22
|
+
settle: (answer: Answer) => void
|
|
23
|
+
/** Whether an `ask` is waiting for the person. */
|
|
24
|
+
waiting: () => boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a view, show it when it asks for something, and answer once the person has — the state
|
|
29
|
+
* machine behind `usePerformanceConsent().ensure` and `useSubscriptionStart().ensure`, free of
|
|
30
|
+
* React so it is testable on its own. A burst of calls opens one dialog: they all get its answer.
|
|
31
|
+
*/
|
|
32
|
+
export const makeAsker = <Args extends unknown[], View, Answer>(
|
|
33
|
+
opts: AskerOptions<Args, View, Answer>,
|
|
34
|
+
): Asker<Args, Answer> => {
|
|
35
|
+
let inflight: Promise<Answer> | null = null
|
|
36
|
+
let resolver: ((answer: Answer) => void) | null = null
|
|
37
|
+
|
|
38
|
+
const run = async (args: Args): Promise<Answer> => {
|
|
39
|
+
let view: View
|
|
40
|
+
try {
|
|
41
|
+
view = await opts.load(...args)
|
|
42
|
+
} catch {
|
|
43
|
+
return opts.skip
|
|
44
|
+
}
|
|
45
|
+
if (!opts.required(view)) {
|
|
46
|
+
return opts.skip
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return await new Promise<Answer>(resolve => {
|
|
50
|
+
resolver = resolve
|
|
51
|
+
opts.show(view)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
ask: async (...args) => {
|
|
57
|
+
if (inflight == null) {
|
|
58
|
+
inflight = run(args).finally(() => {
|
|
59
|
+
inflight = null
|
|
60
|
+
resolver = null
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return await inflight
|
|
65
|
+
},
|
|
66
|
+
settle: answer => {
|
|
67
|
+
const resolve = resolver
|
|
68
|
+
resolver = null
|
|
69
|
+
resolve?.(answer)
|
|
70
|
+
},
|
|
71
|
+
waiting: () => resolver != null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface EnsureOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Asked after the server refused with a 428 although the last answer said nothing was needed.
|
|
78
|
+
* The view is always read fresh; `force` records why the caller asks.
|
|
79
|
+
*/
|
|
80
|
+
force?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ConsentGate {
|
|
84
|
+
/**
|
|
85
|
+
* `true` when nothing needs consent or the person confirmed and it was recorded; `false` when
|
|
86
|
+
* they declined or closed the dialog. A failed read answers `true`. One pending answer is shared.
|
|
87
|
+
*/
|
|
88
|
+
ensure: (opts?: EnsureOptions) => Promise<boolean>
|
|
89
|
+
/**
|
|
90
|
+
* Run `action`; when it is refused for the spend consent (`isPerformanceConsentRefusal` — the
|
|
91
|
+
* class, its marker, or a bare 428, also wrapped inside another error), ask with
|
|
92
|
+
* `ensure({ force: true })` and run it again exactly ONCE. A decline throws `ConsentDeclined`;
|
|
93
|
+
* a second refusal, or any other failure, propagates as it is.
|
|
94
|
+
*/
|
|
95
|
+
withConsent: <T>(action: () => Promise<T>) => Promise<T>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A gate over an `ensure` — what `useConsentGate` answers inside a `PerformanceConsentProvider`. */
|
|
99
|
+
export const makeConsentGate = (ensure: ConsentGate['ensure']): ConsentGate => ({
|
|
100
|
+
ensure,
|
|
101
|
+
withConsent: async <T>(action: () => Promise<T>): Promise<T> => {
|
|
102
|
+
try {
|
|
103
|
+
return await action()
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (!isPerformanceConsentRefusal(e)) {
|
|
106
|
+
throw e
|
|
107
|
+
}
|
|
108
|
+
if (!await ensure({ force: true })) {
|
|
109
|
+
throw new ConsentDeclined(ConsentKind.Performance)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return await action()
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The gate outside any provider: nothing can be asked, so `ensure` answers `true` and a refusal
|
|
119
|
+
* propagates unchanged.
|
|
120
|
+
*/
|
|
121
|
+
export const passThroughGate: ConsentGate = {
|
|
122
|
+
ensure: async () => true,
|
|
123
|
+
withConsent: async action => await action(),
|
|
124
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { Input } from '@/components/ui/input'
|
|
4
|
+
import { Label } from '@/components/ui/label'
|
|
5
|
+
|
|
6
|
+
/** The shape the platform validates an e-mail address with (`@owlmeans/payment` `EmailSchema`). */
|
|
7
|
+
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/
|
|
8
|
+
|
|
9
|
+
export const isEmail = (value: string): boolean => value.length >= 3 && value.length <= 254 && EMAIL_PATTERN.test(value)
|
|
10
|
+
|
|
11
|
+
export interface FieldProps {
|
|
12
|
+
id: string
|
|
13
|
+
label: string
|
|
14
|
+
value: string
|
|
15
|
+
onChange: (value: string) => void
|
|
16
|
+
error?: string | null
|
|
17
|
+
type?: 'text' | 'email' | 'date'
|
|
18
|
+
autoComplete?: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
min?: string
|
|
21
|
+
/** A `data-*` hook for tests (`data-withdrawal-field="name"`). */
|
|
22
|
+
hook?: Record<string, string>
|
|
23
|
+
multiline?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Field = ({
|
|
27
|
+
id, label, value, onChange, error, type = 'text', autoComplete, disabled, min, hook, multiline = false,
|
|
28
|
+
}: FieldProps) => <div className="grid gap-1.5">
|
|
29
|
+
<Label htmlFor={id}>{label}</Label>
|
|
30
|
+
{multiline
|
|
31
|
+
? <textarea
|
|
32
|
+
id={id} value={value} disabled={disabled} rows={3} maxLength={2000} aria-invalid={error != null}
|
|
33
|
+
aria-describedby={error != null ? `${id}-error` : undefined} onChange={event => onChange(event.target.value)}
|
|
34
|
+
className={cn(
|
|
35
|
+
'border-input bg-background placeholder:text-muted-foreground w-full min-w-0 rounded-md border px-3 py-2 text-sm shadow-xs outline-none',
|
|
36
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:opacity-50 aria-invalid:border-destructive',
|
|
37
|
+
)}
|
|
38
|
+
{...hook}
|
|
39
|
+
/>
|
|
40
|
+
: <Input
|
|
41
|
+
id={id} type={type} value={value} disabled={disabled} autoComplete={autoComplete} min={min}
|
|
42
|
+
aria-invalid={error != null} aria-describedby={error != null ? `${id}-error` : undefined}
|
|
43
|
+
onChange={event => onChange(event.target.value)} {...hook}
|
|
44
|
+
/>}
|
|
45
|
+
{error != null && <p id={`${id}-error`} className="text-destructive text-xs">{error}</p>}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
export interface ChoiceProps {
|
|
49
|
+
id: string
|
|
50
|
+
name: string
|
|
51
|
+
checked: boolean
|
|
52
|
+
onSelect: () => void
|
|
53
|
+
disabled?: boolean
|
|
54
|
+
hook?: Record<string, string>
|
|
55
|
+
children: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One option of a radio group — a native radio, so the group needs no extra primitive. */
|
|
59
|
+
export const Choice = ({ id, name, checked, onSelect, disabled, hook, children }: ChoiceProps) => <label
|
|
60
|
+
htmlFor={id}
|
|
61
|
+
className={cn('flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm', checked && 'border-primary bg-muted/30')}
|
|
62
|
+
>
|
|
63
|
+
<input
|
|
64
|
+
id={id} type="radio" name={name} checked={checked} disabled={disabled} onChange={onSelect}
|
|
65
|
+
className="accent-primary mt-0.5 size-4 shrink-0" {...hook}
|
|
66
|
+
/>
|
|
67
|
+
<span className="grid gap-0.5">{children}</span>
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
export interface HoneypotProps {
|
|
71
|
+
id: string
|
|
72
|
+
label: string
|
|
73
|
+
value: string
|
|
74
|
+
onChange: (value: string) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A field a person never sees or reaches: off screen, out of the tab order and hidden from
|
|
79
|
+
* assistive technology, with a plausible name a form-filling bot completes. A filled one marks the
|
|
80
|
+
* declaration as a bot's, which the server answers like any other (it never says so).
|
|
81
|
+
*/
|
|
82
|
+
export const Honeypot = ({ id, label, value, onChange }: HoneypotProps) => <div
|
|
83
|
+
aria-hidden="true" data-honeypot=""
|
|
84
|
+
style={{ position: 'absolute', left: '-10000px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }}
|
|
85
|
+
>
|
|
86
|
+
<label htmlFor={id}>{label}</label>
|
|
87
|
+
<input id={id} name="website" type="text" tabIndex={-1} autoComplete="off" value={value}
|
|
88
|
+
onChange={event => onChange(event.target.value)} />
|
|
89
|
+
</div>
|
|
@@ -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'
|