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

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