@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
package/src/dialog.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
|
|
3
|
-
import { assertCheckoutAmount, chargeAmountMinor } from '@owlmeans/payment'
|
|
3
|
+
import { assertCheckoutAmount, chargeAmountMinor, narrowAmountPolicy } from '@owlmeans/payment'
|
|
4
|
+
import type { AmountCheckoutPolicy, CheckoutLimitView } from '@owlmeans/payment'
|
|
4
5
|
import { Button } from '@/components/ui/button'
|
|
5
6
|
import {
|
|
6
7
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
@@ -8,76 +9,108 @@ import {
|
|
|
8
9
|
import { Input } from '@/components/ui/input'
|
|
9
10
|
import { Label } from '@/components/ui/label'
|
|
10
11
|
import { inputAmount, parseAmountMinor } from './amount.js'
|
|
12
|
+
import { CheckoutLimitNote } from './checkout-limit.js'
|
|
13
|
+
import { PriceEstimateSummary } from './estimate-summary.js'
|
|
14
|
+
import { money } from './format.js'
|
|
11
15
|
import type { AmountCheckoutDialogProps } from './types.js'
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The policy the dialog offers: `policy` narrowed by `limit` — the same `narrowAmountPolicy` the
|
|
19
|
+
* server refuses with. A policy the package cannot narrow (an invalid one) is offered as given.
|
|
20
|
+
*/
|
|
21
|
+
const effectivePolicyOf = (policy: AmountCheckoutPolicy, limit?: CheckoutLimitView | null): AmountCheckoutPolicy => {
|
|
22
|
+
if (limit == null || (!limit.narrowed && !limit.blocked)) {
|
|
23
|
+
return policy
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return narrowAmountPolicy(policy, [{
|
|
27
|
+
maximumMinor: limit.maximumMinor,
|
|
28
|
+
reason: limit.reason ?? 'limit',
|
|
29
|
+
...(limit.resetsAt != null ? { resetsAt: limit.resetsAt } : {}),
|
|
30
|
+
...(limit.remainingMinor != null ? { remainingMinor: limit.remainingMinor } : {}),
|
|
31
|
+
}]).policy
|
|
32
|
+
} catch {
|
|
33
|
+
return policy
|
|
34
|
+
}
|
|
35
|
+
}
|
|
16
36
|
|
|
17
37
|
export const AmountCheckoutDialog = ({
|
|
18
|
-
open, onOpenChange, policy, pending = false, onConfirm,
|
|
38
|
+
open, onOpenChange, policy: basePolicy, pending = false, onConfirm, estimate, limit, legalNote,
|
|
19
39
|
}: AmountCheckoutDialogProps) => {
|
|
20
40
|
const t = useI18nLib('web-payment', 'amount-checkout')
|
|
21
41
|
const [locale] = useLanguage()
|
|
42
|
+
const policy = useMemo(() => effectivePolicyOf(basePolicy, limit), [basePolicy, limit])
|
|
43
|
+
const blocked = limit?.blocked === true
|
|
44
|
+
// The limit, not the plan, set the maximum — also when `policy` arrives already narrowed.
|
|
45
|
+
const limited = limit != null && limit.narrowed && limit.maximumMinor <= policy.maximumMinor
|
|
22
46
|
const [value, setValue] = useState(() => inputAmount(policy.defaultMinor, locale))
|
|
23
47
|
useEffect(() => {
|
|
24
48
|
if (open) setValue(inputAmount(policy.defaultMinor, locale))
|
|
25
49
|
}, [open, policy.defaultMinor, locale])
|
|
26
50
|
|
|
27
51
|
const amountMinor = useMemo(() => parseAmountMinor(value, locale), [value, locale])
|
|
28
|
-
const error =
|
|
29
|
-
?
|
|
30
|
-
: amountMinor
|
|
31
|
-
? t('
|
|
32
|
-
: amountMinor
|
|
33
|
-
? t('
|
|
34
|
-
:
|
|
35
|
-
|
|
52
|
+
const error = blocked
|
|
53
|
+
? null
|
|
54
|
+
: amountMinor == null
|
|
55
|
+
? t('invalid')
|
|
56
|
+
: amountMinor < policy.minimumMinor
|
|
57
|
+
? t('below', { amount: money(policy.minimumMinor, policy.currency, locale) })
|
|
58
|
+
: amountMinor > policy.maximumMinor
|
|
59
|
+
? t(limited ? 'above-limit' : 'above', { amount: money(policy.maximumMinor, policy.currency, locale) })
|
|
60
|
+
: null
|
|
61
|
+
const valid = !blocked && error == null && amountMinor != null
|
|
36
62
|
const chargeMinor = valid ? chargeAmountMinor(amountMinor, policy) : 0
|
|
37
63
|
const adjustmentMinor = valid && amountMinor != null ? chargeMinor - amountMinor : 0
|
|
64
|
+
const locked = pending || blocked
|
|
38
65
|
const submit = async () => {
|
|
39
|
-
if (amountMinor == null || !valid ||
|
|
66
|
+
if (amountMinor == null || !valid || locked) return
|
|
40
67
|
assertCheckoutAmount(policy, amountMinor)
|
|
41
|
-
await onConfirm(amountMinor)
|
|
68
|
+
await onConfirm(amountMinor, estimate?.country)
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
return <Dialog open={open} onOpenChange={onOpenChange}>
|
|
45
|
-
<DialogContent closeLabel={t('close')}>
|
|
72
|
+
<DialogContent closeLabel={t('close')} data-amount-checkout="" data-blocked={blocked ? 'true' : 'false'}>
|
|
46
73
|
<DialogHeader>
|
|
47
74
|
<DialogTitle>{t('title')}</DialogTitle>
|
|
48
75
|
<DialogDescription>{t('description')}</DialogDescription>
|
|
49
76
|
</DialogHeader>
|
|
50
77
|
<div className="grid gap-5">
|
|
51
|
-
|
|
78
|
+
{limit != null && <CheckoutLimitNote limit={limit} />}
|
|
79
|
+
{policy.presetsMinor.length > 0 && <div className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('presets')}>
|
|
52
80
|
{policy.presetsMinor.map(preset => <Button
|
|
53
|
-
key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'}
|
|
54
|
-
disabled={
|
|
81
|
+
key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'} data-amount-preset={preset}
|
|
82
|
+
disabled={locked} onClick={() => setValue(inputAmount(preset, locale))}
|
|
55
83
|
>{money(preset, policy.currency, locale)}</Button>)}
|
|
56
|
-
</div>
|
|
84
|
+
</div>}
|
|
57
85
|
<div className="grid gap-2">
|
|
58
86
|
<Label htmlFor="payment-amount">{t('custom')}</Label>
|
|
59
87
|
<Input
|
|
60
88
|
id="payment-amount" inputMode="decimal" autoComplete="off" value={value}
|
|
61
|
-
disabled={
|
|
89
|
+
disabled={locked} aria-invalid={error != null} aria-describedby="payment-amount-help"
|
|
62
90
|
onChange={event => setValue(event.target.value)}
|
|
63
91
|
/>
|
|
64
|
-
<p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
|
|
92
|
+
{!blocked && <p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
|
|
65
93
|
{error ?? t('bounds', {
|
|
66
94
|
minimum: money(policy.minimumMinor, policy.currency, locale),
|
|
67
95
|
maximum: money(policy.maximumMinor, policy.currency, locale),
|
|
68
96
|
})}
|
|
69
|
-
</p>
|
|
97
|
+
</p>}
|
|
70
98
|
</div>
|
|
71
99
|
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 rounded-lg border bg-muted/30 p-4 text-sm">
|
|
72
100
|
<dt className="text-muted-foreground">{t('credits')}</dt><dd>{valid && amountMinor != null ? money(amountMinor, policy.currency, locale) : '—'}</dd>
|
|
73
101
|
<dt className="text-muted-foreground">{t('adjustment')}</dt><dd>{valid ? money(adjustmentMinor, policy.currency, locale) : '—'}</dd>
|
|
74
102
|
<dt className="font-medium">{t('subtotal')}</dt><dd className="font-medium">{valid ? money(chargeMinor, policy.currency, locale) : '—'}</dd>
|
|
75
103
|
</dl>
|
|
76
|
-
|
|
104
|
+
{estimate != null
|
|
105
|
+
? valid && <PriceEstimateSummary control={estimate} subtotalMinor={chargeMinor} currency={policy.currency} />
|
|
106
|
+
: <p className="text-muted-foreground text-xs">{t('tax-note')}</p>}
|
|
107
|
+
{legalNote != null && <div className="text-muted-foreground text-xs" data-legal-note="">{legalNote}</div>}
|
|
77
108
|
</div>
|
|
78
109
|
<DialogFooter>
|
|
79
110
|
<Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>{t('cancel')}</Button>
|
|
80
|
-
<Button type="button" disabled={!valid ||
|
|
111
|
+
<Button type="button" data-amount-confirm="" disabled={!valid || locked} onClick={() => void submit()}>
|
|
112
|
+
{pending ? t('pending') : t('continue')}
|
|
113
|
+
</Button>
|
|
81
114
|
</DialogFooter>
|
|
82
115
|
</DialogContent>
|
|
83
116
|
</Dialog>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
2
|
+
import type { CallArguments, RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
|
|
3
|
+
import { limitOf, reviveEntitlementView } from '@owlmeans/payment'
|
|
4
|
+
import type { EntitlementView, PortalFlow, PortalLinkResponse } from '@owlmeans/payment'
|
|
5
|
+
import { usePolled } from './poll.js'
|
|
6
|
+
import { capabilityStateOf, limitStatusOf } from './selectors.js'
|
|
7
|
+
import { openCheckout, type CheckoutTarget } from './service.js'
|
|
8
|
+
import type { LimitStatus, PortalArguments, PortalRequest } from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Poll an entitlement-view protocol — `null` until the first answer. The view is revived on the way
|
|
12
|
+
* in (the wire carries ISO strings), so every date in it is a `Date`.
|
|
13
|
+
*
|
|
14
|
+
* An application that already keeps the view in its own store reads it through the pure selectors
|
|
15
|
+
* (`capabilityStateOf`, `limitStatusOf`, `planStatusLineOf`) instead of polling a second time.
|
|
16
|
+
*/
|
|
17
|
+
export const useEntitlementView = <Request extends RequestShape>(
|
|
18
|
+
entry: RegisteredEntrypoint<Request, EntitlementView>,
|
|
19
|
+
intervalMs: number = 60_000,
|
|
20
|
+
deps: unknown[] = [],
|
|
21
|
+
...request: CallArguments<Request>
|
|
22
|
+
): EntitlementView | null => usePolled(
|
|
23
|
+
async () => reviveEntitlementView(await entry.call(...request)), intervalMs, deps,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** Whether the view grants a capability parameter; `null` while the view is unknown. */
|
|
27
|
+
export const useCapability = (view: EntitlementView | null | undefined, param: string): boolean | null =>
|
|
28
|
+
useMemo(() => capabilityStateOf(view, param), [view, param])
|
|
29
|
+
|
|
30
|
+
/** One limit of the view with `exhausted` and `ratio`; `null` while unknown or undeclared. */
|
|
31
|
+
export const useLimit = (view: EntitlementView | null | undefined, key: string): LimitStatus | null =>
|
|
32
|
+
useMemo(() => limitStatusOf(limitOf(view, key)), [view, key])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open a billing-portal flow: `portal(entry, request?)` calls the portal protocol with `body.flow`
|
|
36
|
+
* set to `flow`, then sends the browser to the returned URL (same window by default). Without a DOM
|
|
37
|
+
* the navigation is inert and the response is still returned.
|
|
38
|
+
*/
|
|
39
|
+
export const usePortal = (flow: PortalFlow, target: CheckoutTarget = '_self') => {
|
|
40
|
+
const [pending, setPending] = useState(false)
|
|
41
|
+
const portal = useCallback(async <Request extends PortalRequest>(
|
|
42
|
+
entry: RegisteredEntrypoint<Request, PortalLinkResponse>, ...request: PortalArguments<Request>
|
|
43
|
+
): Promise<PortalLinkResponse | null> => {
|
|
44
|
+
setPending(true)
|
|
45
|
+
try {
|
|
46
|
+
const [options] = request as [{ body?: object }?]
|
|
47
|
+
// The flow completes the body the caller's arguments deliberately leave out.
|
|
48
|
+
const args = [{ ...options, body: { ...options?.body, flow } }] as unknown as CallArguments<Request>
|
|
49
|
+
const result: PortalLinkResponse | undefined = await entry.call(...args)
|
|
50
|
+
if (result?.url != null) openCheckout(result.url, target)
|
|
51
|
+
return result ?? null
|
|
52
|
+
} finally { setPending(false) }
|
|
53
|
+
}, [flow, target])
|
|
54
|
+
|
|
55
|
+
return { portal, pending }
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small keyed TTL cache with in-flight de-duplication — the client-side twin of
|
|
3
|
+
* `@owlmeans/server-payment`'s `EstimateCache`, so opening the credit dialog twice in a row (or two
|
|
4
|
+
* mounts asking for the same plan/country) never issues a second request while the first is still
|
|
5
|
+
* on the wire, and a settled answer is reused for its TTL. A rejected fetch is never cached: the
|
|
6
|
+
* next call simply tries again.
|
|
7
|
+
*/
|
|
8
|
+
export class EstimateCache<T> {
|
|
9
|
+
private readonly store = new Map<string, { value: T, expiresAt: number }>()
|
|
10
|
+
private readonly inflight = new Map<string, Promise<T>>()
|
|
11
|
+
|
|
12
|
+
get(key: string): T | undefined {
|
|
13
|
+
const hit = this.store.get(key)
|
|
14
|
+
if (hit == null || hit.expiresAt <= Date.now()) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
return hit.value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async load(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T> {
|
|
21
|
+
const hit = this.get(key)
|
|
22
|
+
if (hit !== undefined) {
|
|
23
|
+
return hit
|
|
24
|
+
}
|
|
25
|
+
const pending = this.inflight.get(key)
|
|
26
|
+
if (pending != null) {
|
|
27
|
+
return pending
|
|
28
|
+
}
|
|
29
|
+
const promise = factory().then(value => {
|
|
30
|
+
this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
|
|
31
|
+
this.inflight.delete(key)
|
|
32
|
+
return value
|
|
33
|
+
}).catch(error => {
|
|
34
|
+
this.inflight.delete(key)
|
|
35
|
+
throw error
|
|
36
|
+
})
|
|
37
|
+
this.inflight.set(key, promise)
|
|
38
|
+
return promise
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Fragment, useMemo, type ReactNode } from 'react'
|
|
2
|
+
import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
|
|
3
|
+
import { COUNTRY_CODES, TaxEstimateStatus, estimateOf } from '@owlmeans/payment'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import { Label } from '@/components/ui/label'
|
|
6
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
7
|
+
import { money } from './format.js'
|
|
8
|
+
import type { PriceEstimateControl } from './types.js'
|
|
9
|
+
|
|
10
|
+
const localMoney = (amount: number, currency: string, locale: string): string => {
|
|
11
|
+
try {
|
|
12
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() }).format(amount)
|
|
13
|
+
} catch {
|
|
14
|
+
return `${amount.toFixed(2)} ${currency.toUpperCase()}`
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The country names a `<Select>` offers, in the viewer's own language and alphabetical order. */
|
|
19
|
+
const useCountryOptions = (locale: string): Array<{ code: string, name: string }> => useMemo(() => {
|
|
20
|
+
let names: Intl.DisplayNames | null = null
|
|
21
|
+
try { names = new Intl.DisplayNames([locale], { type: 'region' }) } catch { names = null }
|
|
22
|
+
const collator = new Intl.Collator(locale)
|
|
23
|
+
return COUNTRY_CODES
|
|
24
|
+
.map(code => ({ code, name: names?.of(code) ?? code }))
|
|
25
|
+
.sort((a, b) => collator.compare(a.name, b.name))
|
|
26
|
+
}, [locale])
|
|
27
|
+
|
|
28
|
+
export interface CountrySelectProps {
|
|
29
|
+
/** ISO 3166-1 alpha-2, or `''` for the placeholder. */
|
|
30
|
+
value: string
|
|
31
|
+
onChange: (country: string) => void
|
|
32
|
+
/** Defaults to the `estimate.country` string (also the field's own label). */
|
|
33
|
+
label?: string
|
|
34
|
+
id?: string
|
|
35
|
+
className?: string
|
|
36
|
+
/** The country cannot be changed here — a billing country locked by the first purchase. */
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/** A line under the picker — why it is disabled, typically. */
|
|
39
|
+
note?: ReactNode
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The billing-country picker alone — the piece `PriceEstimateSummary` composes for a single,
|
|
44
|
+
* self-contained estimate, and what a shared picker (several estimates, one country — a plan
|
|
45
|
+
* comparison table) renders once and drives every `usePriceEstimate({ country, onCountryChange })`
|
|
46
|
+
* with. Country names are the viewer's own language, alphabetically ordered.
|
|
47
|
+
*
|
|
48
|
+
* `[data-country-select]` carries `data-locked` (`"true"` while disabled) and the chosen
|
|
49
|
+
* `data-country`; the note is `[data-country-note]`.
|
|
50
|
+
*/
|
|
51
|
+
export const CountrySelect = ({
|
|
52
|
+
value, onChange, label, id = 'price-estimate-country', className, disabled = false, note,
|
|
53
|
+
}: CountrySelectProps) => {
|
|
54
|
+
const t = useI18nLib('web-payment', 'estimate')
|
|
55
|
+
const [locale] = useLanguage()
|
|
56
|
+
const countries = useCountryOptions(locale)
|
|
57
|
+
const text = label ?? t('country')
|
|
58
|
+
|
|
59
|
+
return <div className={cn('grid gap-2', className)} data-country-select="" data-locked={disabled ? 'true' : 'false'}
|
|
60
|
+
data-country={value}>
|
|
61
|
+
<Label htmlFor={id}>{text}</Label>
|
|
62
|
+
{/* Always controlled, `''` included (no `SelectItem` ever has that value) — switching to
|
|
63
|
+
`undefined` once a country is chosen is what trips React's controlled/uncontrolled warning. */}
|
|
64
|
+
<Select value={value} onValueChange={next => { if (!disabled) onChange(next) }} disabled={disabled}>
|
|
65
|
+
<SelectTrigger id={id} aria-label={text} aria-describedby={note != null ? `${id}-note` : undefined}>
|
|
66
|
+
<SelectValue placeholder={t('country-placeholder')} />
|
|
67
|
+
</SelectTrigger>
|
|
68
|
+
<SelectContent>
|
|
69
|
+
{countries.map(({ code, name }) => <SelectItem key={code} value={code}>{name}</SelectItem>)}
|
|
70
|
+
</SelectContent>
|
|
71
|
+
</Select>
|
|
72
|
+
{note != null && <p id={`${id}-note`} className="text-muted-foreground text-xs" data-country-note="">{note}</p>}
|
|
73
|
+
</div>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PriceEstimateAmountProps {
|
|
77
|
+
control: Pick<PriceEstimateControl, 'estimate' | 'loading' | 'failed'>
|
|
78
|
+
/** The amount to show tax and a total for — may differ from the estimate's own reference amount. */
|
|
79
|
+
subtotalMinor: number
|
|
80
|
+
currency: string
|
|
81
|
+
/** Appended after the total row, e.g. `"/ month"`. */
|
|
82
|
+
suffix?: string
|
|
83
|
+
className?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What Stripe Tax says at the estimate's country, with NO country picker of its own: the rate(s),
|
|
88
|
+
* an estimated total, and one sentence for whichever status leaves no number to trust (reverse
|
|
89
|
+
* charge, no tax, "at checkout", "choose a country"). Several of these may share one
|
|
90
|
+
* `CountrySelect` (a plan comparison table).
|
|
91
|
+
*
|
|
92
|
+
* A country whose currency differs from `currency` (Adaptive Pricing found a rate) shows the tax
|
|
93
|
+
* and the total in THAT currency instead, marked `≈` throughout — never both currencies at once,
|
|
94
|
+
* which would read as two different prices for the same line. The rate percentage itself is
|
|
95
|
+
* currency-agnostic and always shows as Stripe stated it.
|
|
96
|
+
*
|
|
97
|
+
* `data-price-estimate` / `data-status` are stable test hooks.
|
|
98
|
+
*/
|
|
99
|
+
export const PriceEstimateAmount = ({ control, subtotalMinor, currency, suffix, className }: PriceEstimateAmountProps) => {
|
|
100
|
+
const t = useI18nLib('web-payment', 'estimate')
|
|
101
|
+
const [locale] = useLanguage()
|
|
102
|
+
const { estimate, loading, failed } = control
|
|
103
|
+
|
|
104
|
+
const derived = estimate != null ? estimateOf(subtotalMinor, estimate) : null
|
|
105
|
+
const status = estimate?.tax.status
|
|
106
|
+
const numeric = status === TaxEstimateStatus.Taxed || status === TaxEstimateStatus.ReverseCharge
|
|
107
|
+
|| status === TaxEstimateStatus.None
|
|
108
|
+
const rateLabel = estimate != null && estimate.tax.rates.length > 0
|
|
109
|
+
? estimate.tax.rates
|
|
110
|
+
.map(rate => t('rate', { type: t(`tax-type.${rate.type}`), percentage: rate.percentage }))
|
|
111
|
+
.join(' + ')
|
|
112
|
+
: null
|
|
113
|
+
|
|
114
|
+
const local = derived?.local
|
|
115
|
+
const taxText = local != null
|
|
116
|
+
? (derived?.taxMinor != null ? localMoney(local.taxAmount, local.currency, locale) : null)
|
|
117
|
+
: (derived?.taxMinor != null ? money(derived.taxMinor, currency, locale) : null)
|
|
118
|
+
const totalText = local != null
|
|
119
|
+
? (derived?.totalMinor != null ? localMoney(local.totalAmount, local.currency, locale) : null)
|
|
120
|
+
: (derived?.totalMinor != null ? money(derived.totalMinor, currency, locale) : null)
|
|
121
|
+
const approx = local != null ? '≈ ' : ''
|
|
122
|
+
|
|
123
|
+
return <div data-price-estimate="" data-status={status ?? 'pending'} className={cn('grid gap-1.5', className)}>
|
|
124
|
+
{estimate == null && loading && <p className="text-muted-foreground text-xs">{t('loading')}</p>}
|
|
125
|
+
{estimate == null && !loading && failed && <p className="text-destructive text-xs">{t('failed')}</p>}
|
|
126
|
+
|
|
127
|
+
{estimate != null && <>
|
|
128
|
+
{numeric && taxText != null && totalText != null && <dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm" data-estimate-rows="">
|
|
129
|
+
{status === TaxEstimateStatus.Taxed && rateLabel != null && <Fragment>
|
|
130
|
+
<dt className="text-muted-foreground" data-estimate-rate="">{rateLabel}</dt>
|
|
131
|
+
<dd data-estimate-tax="">{approx}{taxText}</dd>
|
|
132
|
+
</Fragment>}
|
|
133
|
+
<dt className="font-medium">{t('total')}</dt>
|
|
134
|
+
<dd className="font-medium" data-estimate-total="">
|
|
135
|
+
{approx}{totalText}{suffix != null ? ` ${suffix}` : ''}
|
|
136
|
+
</dd>
|
|
137
|
+
</dl>}
|
|
138
|
+
{numeric && local != null && <p className="text-muted-foreground text-xs" data-estimate-converted="">
|
|
139
|
+
{t('converted-note')}
|
|
140
|
+
</p>}
|
|
141
|
+
<p className="text-muted-foreground text-xs" data-estimate-status="">
|
|
142
|
+
{t(`status.${status}`)}
|
|
143
|
+
</p>
|
|
144
|
+
</>}
|
|
145
|
+
</div>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface PriceEstimateSummaryProps {
|
|
149
|
+
control: PriceEstimateControl
|
|
150
|
+
/** The amount the summary shows tax and a total for — may differ from the estimate's own reference amount. */
|
|
151
|
+
subtotalMinor: number
|
|
152
|
+
currency: string
|
|
153
|
+
/** Appended after the total row, e.g. `"/ month"`. */
|
|
154
|
+
suffix?: string
|
|
155
|
+
className?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A self-contained estimate: `CountrySelect` bound to the control's own `country`/`onCountryChange`,
|
|
160
|
+
* plus `PriceEstimateAmount` beneath it. What a single estimate (the credit dialog) wants; several
|
|
161
|
+
* estimates sharing one picker (a plan comparison table) compose the two pieces separately instead.
|
|
162
|
+
*/
|
|
163
|
+
export const PriceEstimateSummary = ({ control, subtotalMinor, currency, suffix, className }: PriceEstimateSummaryProps) => {
|
|
164
|
+
const t = useI18nLib('web-payment', 'estimate')
|
|
165
|
+
const { country, onCountryChange, locked = false } = control
|
|
166
|
+
|
|
167
|
+
return <div className={cn('grid gap-3', className)}>
|
|
168
|
+
<CountrySelect value={country} onChange={onCountryChange} disabled={locked}
|
|
169
|
+
note={locked ? t('country-locked') : undefined} />
|
|
170
|
+
<PriceEstimateAmount control={control} subtotalMinor={subtotalMinor} currency={currency} suffix={suffix} />
|
|
171
|
+
</div>
|
|
172
|
+
}
|
package/src/estimate.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { RegisteredEntrypoint } from '@owlmeans/entrypoint'
|
|
3
|
+
import type { PriceEstimate } from '@owlmeans/payment'
|
|
4
|
+
import { EstimateCache } from './estimate-cache.js'
|
|
5
|
+
import type { PriceEstimateArguments, PriceEstimateControl, PriceEstimateRequest } from './types.js'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TTL_MS = 5 * 60_000
|
|
8
|
+
|
|
9
|
+
/** Shared across every mounted dialog/estimate card in the tab — the server already caches per TTL too. */
|
|
10
|
+
const cache = new EstimateCache<PriceEstimate>()
|
|
11
|
+
|
|
12
|
+
export interface UsePriceEstimateOptions {
|
|
13
|
+
/** Fetch (and refetch on a country change) only while `true` — a closed dialog costs nothing. */
|
|
14
|
+
enabled: boolean
|
|
15
|
+
ttlMs?: number
|
|
16
|
+
/**
|
|
17
|
+
* Controls the country from outside (several estimates sharing one picker — a plan comparison
|
|
18
|
+
* table). Given: the hook never owns `useState` for it, never preselects from `source:
|
|
19
|
+
* 'customer'` (the owner does, once, for whichever estimate answers first), and the returned
|
|
20
|
+
* control's `onCountryChange` calls this back instead of an internal setter. Absent (the
|
|
21
|
+
* default): the hook is self-contained, exactly as a standalone dialog needs.
|
|
22
|
+
*/
|
|
23
|
+
country?: string
|
|
24
|
+
onCountryChange?: (country: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A live `PriceEstimate` for one protocol/request, with a country the caller may change.
|
|
29
|
+
*
|
|
30
|
+
* Fetches once `enabled` and again on every `onCountryChange`, from a client-side cache keyed by
|
|
31
|
+
* the entry's alias, the request body and the country — a TTL match answers instantly, an identical
|
|
32
|
+
* in-flight request is shared. Uncontrolled (no `opts.country`): the first answer whose `source` is
|
|
33
|
+
* `'customer'` preselects its `country`, once, so a returning buyer sees their own country without
|
|
34
|
+
* picking it. Controlled (`opts.country` given): the country and its preselection are the caller's.
|
|
35
|
+
*
|
|
36
|
+
* An answer for a LOCKED billing country (`locked`, or `source: 'profile'`) wins over any pick: the
|
|
37
|
+
* hook adopts its country (controlled: through `opts.onCountryChange`, once), returns `locked:
|
|
38
|
+
* true`, and ignores `onCountryChange` from then on — the picker renders disabled.
|
|
39
|
+
*/
|
|
40
|
+
export const usePriceEstimate = <Request extends PriceEstimateRequest>(
|
|
41
|
+
entry: RegisteredEntrypoint<Request, PriceEstimate>, opts: UsePriceEstimateOptions,
|
|
42
|
+
...request: PriceEstimateArguments<Request>
|
|
43
|
+
): PriceEstimateControl => {
|
|
44
|
+
const entryRef = useRef(entry)
|
|
45
|
+
entryRef.current = entry
|
|
46
|
+
const base = (request[0] ?? {}) as { body?: Omit<Request['body'], 'country'> } & Record<string, unknown>
|
|
47
|
+
const bodyKey = JSON.stringify(base.body ?? {})
|
|
48
|
+
const controlled = opts.country !== undefined
|
|
49
|
+
|
|
50
|
+
const [internalCountry, setInternalCountry] = useState('')
|
|
51
|
+
const country = controlled ? opts.country! : internalCountry
|
|
52
|
+
const [estimate, setEstimate] = useState<PriceEstimate | null>(null)
|
|
53
|
+
const [loading, setLoading] = useState(false)
|
|
54
|
+
const [failed, setFailed] = useState(false)
|
|
55
|
+
const preselectedRef = useRef(false)
|
|
56
|
+
const lockedRef = useRef(false)
|
|
57
|
+
const locked = estimate != null && isLockedAnswer(estimate)
|
|
58
|
+
lockedRef.current = locked
|
|
59
|
+
const onCountryChangeRef = useRef(opts.onCountryChange)
|
|
60
|
+
onCountryChangeRef.current = opts.onCountryChange
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!opts.enabled) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
let active = true
|
|
67
|
+
const key = `${entryRef.current.alias}:${bodyKey}:${country}`
|
|
68
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
|
|
69
|
+
setLoading(true)
|
|
70
|
+
setFailed(false)
|
|
71
|
+
cache.load(key, ttlMs, async () => await entryRef.current.call({
|
|
72
|
+
...base, body: { ...(base.body ?? {}), ...(country !== '' ? { country } : {}) },
|
|
73
|
+
} as never)).then(result => {
|
|
74
|
+
if (!active) return
|
|
75
|
+
setEstimate(result)
|
|
76
|
+
setLoading(false)
|
|
77
|
+
if (isLockedAnswer(result) && result.country != null && result.country !== country) {
|
|
78
|
+
// A locked billing country overrides whatever was picked: adopt it, once per country.
|
|
79
|
+
preselectedRef.current = true
|
|
80
|
+
if (controlled) {
|
|
81
|
+
onCountryChangeRef.current?.(result.country)
|
|
82
|
+
} else {
|
|
83
|
+
setInternalCountry(result.country)
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (!controlled && !preselectedRef.current && country === '' && result.source === 'customer' && result.country != null) {
|
|
88
|
+
preselectedRef.current = true
|
|
89
|
+
setInternalCountry(result.country)
|
|
90
|
+
}
|
|
91
|
+
}).catch(() => {
|
|
92
|
+
if (active) {
|
|
93
|
+
setFailed(true)
|
|
94
|
+
setLoading(false)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
return () => { active = false }
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, [opts.enabled, opts.ttlMs, bodyKey, country])
|
|
100
|
+
|
|
101
|
+
const onCountryChange = useCallback((next: string) => {
|
|
102
|
+
if (lockedRef.current) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (controlled) {
|
|
106
|
+
opts.onCountryChange?.(next)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
preselectedRef.current = true
|
|
110
|
+
setInternalCountry(next)
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, [controlled, opts.onCountryChange])
|
|
113
|
+
|
|
114
|
+
return { estimate, country, loading, failed, locked, onCountryChange }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** An answer computed for the entity's locked billing country, which no picker may change. */
|
|
118
|
+
export const isLockedAnswer = (estimate: PriceEstimate): boolean =>
|
|
119
|
+
estimate.locked === true || estimate.source === 'profile'
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { lastWithdrawalDayOf } from '@owlmeans/payment'
|
|
2
|
+
|
|
3
|
+
/** Minor units as a currency amount in a locale; an unknown locale or currency still reads. */
|
|
4
|
+
export const money = (minor: number, currency: string, locale: string): string => {
|
|
5
|
+
try {
|
|
6
|
+
return new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
|
|
8
|
+
}).format(minor / 100)
|
|
9
|
+
} catch {
|
|
10
|
+
return `${(minor / 100).toFixed(2)} ${currency.toUpperCase()}`
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dateOf = (date: Date | string | null | undefined): Date | null => {
|
|
15
|
+
if (date == null) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
const value = date instanceof Date ? date : new Date(date)
|
|
19
|
+
|
|
20
|
+
return Number.isNaN(value.getTime()) ? null : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A calendar day in UTC (`dateStyle: 'long'`); `''` for no or an unparseable date. */
|
|
24
|
+
export const dayUtc = (date: Date | string | null | undefined, locale: string): string => {
|
|
25
|
+
const value = dateOf(date)
|
|
26
|
+
if (value == null) {
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(value)
|
|
31
|
+
} catch {
|
|
32
|
+
return value.toISOString().slice(0, 10)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The last day of a window whose deadline is EXCLUSIVE (the start of the next UTC day) — the day a
|
|
38
|
+
* consumer reads as "until". The rule is `@owlmeans/payment`'s `lastWithdrawalDayOf`, the one the
|
|
39
|
+
* server's e-mails and texts use, so the dialog and the mail never name different days.
|
|
40
|
+
*/
|
|
41
|
+
export const lastDayUtc = (deadline: Date | string | null | undefined, locale: string): string => {
|
|
42
|
+
const value = dateOf(deadline)
|
|
43
|
+
|
|
44
|
+
return value == null ? '' : dayUtc(lastWithdrawalDayOf(value), locale)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A date and time in UTC, to the second — the moment a declaration was received. */
|
|
48
|
+
export const momentUtc = (date: Date | string | null | undefined, locale: string): string => {
|
|
49
|
+
const value = dateOf(date)
|
|
50
|
+
if (value == null) {
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'medium', timeZone: 'UTC' }).format(value)
|
|
55
|
+
} catch {
|
|
56
|
+
return value.toISOString().replace('T', ' ').slice(0, 19)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A date and time in UTC to the minute, with the zone named — when a limit rises again. */
|
|
61
|
+
export const shortMomentUtc = (date: Date | string | null | undefined, locale: string): string => {
|
|
62
|
+
const value = dateOf(date)
|
|
63
|
+
if (value == null) {
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return `${new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(value)} UTC`
|
|
68
|
+
} catch {
|
|
69
|
+
return `${value.toISOString().replace('T', ' ').slice(0, 16)} UTC`
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useCallback,
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
2
|
import type { CallArguments, RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
|
|
3
|
-
import {
|
|
3
|
+
import { usePolled } from './poll.js'
|
|
4
4
|
import { openCheckout, type CheckoutTarget } from './service.js'
|
|
5
5
|
import type { CheckoutResult } from './types.js'
|
|
6
6
|
|
|
@@ -21,36 +21,4 @@ export const useCheckout = (target: CheckoutTarget = '_self') => {
|
|
|
21
21
|
|
|
22
22
|
export const usePaymentBalance = <T>(
|
|
23
23
|
entry: RegisteredEntrypoint<{}, T>, intervalMs: number = 60_000, deps: unknown[] = [],
|
|
24
|
-
): T | null =>
|
|
25
|
-
const [balance, setBalance] = useState<T | null>(null)
|
|
26
|
-
const entryRef = useRef(entry)
|
|
27
|
-
entryRef.current = entry
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
let active = true
|
|
30
|
-
const load = async () => {
|
|
31
|
-
try {
|
|
32
|
-
const value = await entryRef.current.call()
|
|
33
|
-
if (active) setBalance(value)
|
|
34
|
-
} catch { /* explicit actions own their errors */ }
|
|
35
|
-
}
|
|
36
|
-
void load()
|
|
37
|
-
const ticker = setInterval(load, intervalMs)
|
|
38
|
-
return () => { active = false; clearInterval(ticker) }
|
|
39
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
-
}, [intervalMs, ...deps])
|
|
41
|
-
return balance
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export const useEntitlements = <T extends { entitlements?: string[] }>(
|
|
45
|
-
entry: RegisteredEntrypoint<{}, T>, intervalMs: number = 60_000, deps: unknown[] = [],
|
|
46
|
-
): string[] | null => usePaymentBalance(entry, intervalMs, deps)?.entitlements ?? null
|
|
47
|
-
|
|
48
|
-
export const useEntitlement = (list: string[] | null, param: string): boolean => list != null && hasEntitlement(
|
|
49
|
-
list.map(item => {
|
|
50
|
-
const colon = item.indexOf(':')
|
|
51
|
-
return colon >= 0
|
|
52
|
-
? { scope: item.slice(0, colon), permissions: { [item.slice(colon + 1)]: true } }
|
|
53
|
-
: { scope: '', permissions: { [item]: true } }
|
|
54
|
-
}),
|
|
55
|
-
param,
|
|
56
|
-
)
|
|
24
|
+
): T | null => usePolled(() => entry.call(), intervalMs, deps)
|