@owlmeans/web-payment 0.1.18-rc.0
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 +45 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-payment/SKILL.md +48 -0
- package/build/@/components/ui/button.d.ts +10 -0
- package/build/@/components/ui/button.d.ts.map +1 -0
- package/build/@/components/ui/button.js +15 -0
- package/build/@/components/ui/button.js.map +1 -0
- package/build/@/components/ui/dialog.d.ts +11 -0
- package/build/@/components/ui/dialog.d.ts.map +1 -0
- package/build/@/components/ui/dialog.js +12 -0
- package/build/@/components/ui/dialog.js.map +1 -0
- package/build/@/components/ui/input.d.ts +3 -0
- package/build/@/components/ui/input.d.ts.map +1 -0
- package/build/@/components/ui/input.js +4 -0
- package/build/@/components/ui/input.js.map +1 -0
- package/build/@/components/ui/label.d.ts +4 -0
- package/build/@/components/ui/label.d.ts.map +1 -0
- package/build/@/components/ui/label.js +5 -0
- package/build/@/components/ui/label.js.map +1 -0
- package/build/@/lib/utils.d.ts +3 -0
- package/build/@/lib/utils.d.ts.map +1 -0
- package/build/@/lib/utils.js +4 -0
- package/build/@/lib/utils.js.map +1 -0
- package/build/amount.d.ts +5 -0
- package/build/amount.d.ts.map +1 -0
- package/build/amount.js +23 -0
- package/build/amount.js.map +1 -0
- package/build/dialog.d.ts +3 -0
- package/build/dialog.d.ts.map +1 -0
- package/build/dialog.js +43 -0
- package/build/dialog.js.map +1 -0
- package/build/hooks.d.ts +13 -0
- package/build/hooks.d.ts.map +1 -0
- package/build/hooks.js +48 -0
- package/build/hooks.js.map +1 -0
- package/build/i18n/be.json +1 -0
- package/build/i18n/de.json +1 -0
- package/build/i18n/en.json +1 -0
- package/build/i18n/es.json +1 -0
- package/build/i18n/pl.json +1 -0
- package/build/i18n/ru.json +1 -0
- package/build/i18n/uk.json +1 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +16 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/service.d.ts +6 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +11 -0
- package/build/service.js.map +1 -0
- package/build/types.d.ts +10 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/components.json +8 -0
- package/package.json +65 -0
- package/src/@/components/ui/button.tsx +20 -0
- package/src/@/components/ui/dialog.tsx +29 -0
- package/src/@/components/ui/input.tsx +12 -0
- package/src/@/components/ui/label.tsx +10 -0
- package/src/@/globals.css +1 -0
- package/src/@/lib/utils.ts +3 -0
- package/src/amount.ts +21 -0
- package/src/dialog.tsx +84 -0
- package/src/hooks.ts +56 -0
- package/src/i18n/be.json +1 -0
- package/src/i18n/de.json +1 -0
- package/src/i18n/en.json +1 -0
- package/src/i18n/es.json +1 -0
- package/src/i18n/pl.json +1 -0
- package/src/i18n/ru.json +1 -0
- package/src/i18n/uk.json +1 -0
- package/src/i18n.ts +16 -0
- package/src/index.ts +6 -0
- package/src/service.ts +11 -0
- package/src/types.ts +10 -0
- package/tests/amount.spec.ts +40 -0
- package/tests/context.ts +33 -0
- package/tests/dialog.spec.ts +66 -0
- package/tests/harness/index.html +5 -0
- package/tests/harness/mount.tsx +29 -0
- package/tests/i18n.spec.ts +21 -0
- package/tests/tsconfig.json +5 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/web-payment",
|
|
3
|
+
"version": "0.1.18-rc.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Protocol-bound payment hooks and themed amount checkout UI for OwlMeans web applications.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b",
|
|
9
|
+
"dev": "sleep 384 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
10
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
11
|
+
"test": "bun test ./tests"
|
|
12
|
+
},
|
|
13
|
+
"main": "build/index.js",
|
|
14
|
+
"module": "build/index.js",
|
|
15
|
+
"types": "build/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./build/index.js",
|
|
19
|
+
"require": "./build/index.js",
|
|
20
|
+
"default": "./build/index.js",
|
|
21
|
+
"module": "./build/index.js",
|
|
22
|
+
"types": "./build/index.d.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@owlmeans/client-i18n": "^0.1.18-rc.19",
|
|
27
|
+
"@owlmeans/client-payment": "^0.1.18-rc.24",
|
|
28
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.15",
|
|
29
|
+
"@owlmeans/i18n": "^0.1.18-rc.12",
|
|
30
|
+
"@owlmeans/payment": "^0.1.18-rc.17"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@radix-ui/react-dialog": "*",
|
|
34
|
+
"@radix-ui/react-label": "*",
|
|
35
|
+
"@radix-ui/react-slot": "*",
|
|
36
|
+
"class-variance-authority": "*",
|
|
37
|
+
"clsx": "*",
|
|
38
|
+
"react": "*",
|
|
39
|
+
"react-dom": "*",
|
|
40
|
+
"tailwind-merge": "*",
|
|
41
|
+
"tailwindcss": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
45
|
+
"@owlmeans/test-ui": "^0.1.18-rc.19",
|
|
46
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
47
|
+
"@radix-ui/react-label": "^2.1.0",
|
|
48
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
49
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
50
|
+
"@types/bun": "^1.4.0",
|
|
51
|
+
"@types/react": "^19.2.17",
|
|
52
|
+
"@types/react-dom": "^19.2.3",
|
|
53
|
+
"class-variance-authority": "^0.7.0",
|
|
54
|
+
"clsx": "^2.1.0",
|
|
55
|
+
"nodemon": "^3.1.14",
|
|
56
|
+
"react-dom": "^19.2.0",
|
|
57
|
+
"tailwind-merge": "^3.6.0",
|
|
58
|
+
"tailwindcss": "^4.0.0",
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vite": "^8.1.2"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
'inline-flex h-9 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
8
|
+
{ variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
|
11
|
+
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
|
12
|
+
},
|
|
13
|
+
}, defaultVariants: { variant: 'default' } },
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
export const Button = ({ className, variant, asChild = false, ...props }:
|
|
17
|
+
React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) => {
|
|
18
|
+
const Component = asChild ? Slot : 'button'
|
|
19
|
+
return <Component className={cn(buttonVariants({ variant, className }))} {...props} />
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
export const Dialog = (props: React.ComponentProps<typeof DialogPrimitive.Root>) => <DialogPrimitive.Root {...props} />
|
|
6
|
+
const DialogPortal = (props: React.ComponentProps<typeof DialogPrimitive.Portal>) => <DialogPrimitive.Portal {...props} />
|
|
7
|
+
const DialogOverlay = ({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) =>
|
|
8
|
+
<DialogPrimitive.Overlay className={cn('fixed inset-0 z-50 bg-black/50', className)} {...props} />
|
|
9
|
+
export const DialogContent = ({ className, children, closeLabel = 'Close', ...props }:
|
|
10
|
+
React.ComponentProps<typeof DialogPrimitive.Content> & { closeLabel?: string }) => <DialogPortal>
|
|
11
|
+
<DialogOverlay />
|
|
12
|
+
<DialogPrimitive.Content
|
|
13
|
+
className={cn('bg-background fixed left-1/2 top-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg', className)}
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
{children}
|
|
17
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring">
|
|
18
|
+
<span aria-hidden="true">×</span><span className="sr-only">{closeLabel}</span>
|
|
19
|
+
</DialogPrimitive.Close>
|
|
20
|
+
</DialogPrimitive.Content>
|
|
21
|
+
</DialogPortal>
|
|
22
|
+
export const DialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) =>
|
|
23
|
+
<div className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />
|
|
24
|
+
export const DialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) =>
|
|
25
|
+
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
|
26
|
+
export const DialogTitle = ({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) =>
|
|
27
|
+
<DialogPrimitive.Title className={cn('text-lg font-semibold leading-none', className)} {...props} />
|
|
28
|
+
export const DialogDescription = ({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) =>
|
|
29
|
+
<DialogPrimitive.Description className={cn('text-muted-foreground text-sm', className)} {...props} />
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
export const Input = ({ className, type, ...props }: React.ComponentProps<'input'>) => <input
|
|
5
|
+
type={type} data-slot="input"
|
|
6
|
+
className={cn(
|
|
7
|
+
'border-input bg-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 md:text-sm',
|
|
8
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
|
9
|
+
className,
|
|
10
|
+
)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as LabelPrimitive from '@radix-ui/react-label'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
export const Label = ({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) =>
|
|
6
|
+
<LabelPrimitive.Root
|
|
7
|
+
data-slot="label"
|
|
8
|
+
className={cn('flex items-center gap-2 text-sm leading-none font-medium select-none peer-disabled:opacity-50', className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
package/src/amount.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const decimalSeparator = (locale: string): string => new Intl.NumberFormat(locale)
|
|
2
|
+
.formatToParts(1.1).find(part => part.type === 'decimal')?.value ?? '.'
|
|
3
|
+
|
|
4
|
+
/** Parse a locale decimal directly to cents; floating-point rounding never enters the path. */
|
|
5
|
+
export const parseAmountMinor = (input: string, locale: string): number | null => {
|
|
6
|
+
const separator = decimalSeparator(locale)
|
|
7
|
+
let normalized = input.trim().replace(/[\s\u00a0]/g, '')
|
|
8
|
+
if (separator === ',') normalized = normalized.replace(',', '.')
|
|
9
|
+
else if (!normalized.includes('.') && normalized.includes(',')) normalized = normalized.replace(',', '.')
|
|
10
|
+
if (!/^\d+(?:\.\d{0,2})?$/.test(normalized)) return null
|
|
11
|
+
const [whole, decimal = ''] = normalized.split('.')
|
|
12
|
+
const value = Number(whole) * 100 + Number(decimal.padEnd(2, '0'))
|
|
13
|
+
return Number.isSafeInteger(value) ? value : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const inputAmount = (amountMinor: number, locale: string): string => {
|
|
17
|
+
const separator = decimalSeparator(locale)
|
|
18
|
+
const whole = Math.floor(amountMinor / 100)
|
|
19
|
+
const cents = String(amountMinor % 100).padStart(2, '0')
|
|
20
|
+
return `${whole}${separator}${cents}`
|
|
21
|
+
}
|
package/src/dialog.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
|
|
3
|
+
import { assertCheckoutAmount, chargeAmountMinor } from '@owlmeans/payment'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
7
|
+
} from '@/components/ui/dialog'
|
|
8
|
+
import { Input } from '@/components/ui/input'
|
|
9
|
+
import { Label } from '@/components/ui/label'
|
|
10
|
+
import { inputAmount, parseAmountMinor } from './amount.js'
|
|
11
|
+
import type { AmountCheckoutDialogProps } from './types.js'
|
|
12
|
+
|
|
13
|
+
const money = (minor: number, currency: string, locale: string): string => new Intl.NumberFormat(locale, {
|
|
14
|
+
style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
|
|
15
|
+
}).format(minor / 100)
|
|
16
|
+
|
|
17
|
+
export const AmountCheckoutDialog = ({
|
|
18
|
+
open, onOpenChange, policy, pending = false, onConfirm,
|
|
19
|
+
}: AmountCheckoutDialogProps) => {
|
|
20
|
+
const t = useI18nLib('web-payment', 'amount-checkout')
|
|
21
|
+
const [locale] = useLanguage()
|
|
22
|
+
const [value, setValue] = useState(() => inputAmount(policy.defaultMinor, locale))
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (open) setValue(inputAmount(policy.defaultMinor, locale))
|
|
25
|
+
}, [open, policy.defaultMinor, locale])
|
|
26
|
+
|
|
27
|
+
const amountMinor = useMemo(() => parseAmountMinor(value, locale), [value, locale])
|
|
28
|
+
const error = amountMinor == null
|
|
29
|
+
? t('invalid')
|
|
30
|
+
: amountMinor < policy.minimumMinor
|
|
31
|
+
? t('below', { amount: money(policy.minimumMinor, policy.currency, locale) })
|
|
32
|
+
: amountMinor > policy.maximumMinor
|
|
33
|
+
? t('above', { amount: money(policy.maximumMinor, policy.currency, locale) })
|
|
34
|
+
: null
|
|
35
|
+
const valid = error == null && amountMinor != null
|
|
36
|
+
const chargeMinor = valid ? chargeAmountMinor(amountMinor, policy) : 0
|
|
37
|
+
const adjustmentMinor = valid && amountMinor != null ? chargeMinor - amountMinor : 0
|
|
38
|
+
const submit = async () => {
|
|
39
|
+
if (amountMinor == null || !valid || pending) return
|
|
40
|
+
assertCheckoutAmount(policy, amountMinor)
|
|
41
|
+
await onConfirm(amountMinor)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return <Dialog open={open} onOpenChange={onOpenChange}>
|
|
45
|
+
<DialogContent closeLabel={t('close')}>
|
|
46
|
+
<DialogHeader>
|
|
47
|
+
<DialogTitle>{t('title')}</DialogTitle>
|
|
48
|
+
<DialogDescription>{t('description')}</DialogDescription>
|
|
49
|
+
</DialogHeader>
|
|
50
|
+
<div className="grid gap-5">
|
|
51
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('presets')}>
|
|
52
|
+
{policy.presetsMinor.map(preset => <Button
|
|
53
|
+
key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'}
|
|
54
|
+
disabled={pending} onClick={() => setValue(inputAmount(preset, locale))}
|
|
55
|
+
>{money(preset, policy.currency, locale)}</Button>)}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="grid gap-2">
|
|
58
|
+
<Label htmlFor="payment-amount">{t('custom')}</Label>
|
|
59
|
+
<Input
|
|
60
|
+
id="payment-amount" inputMode="decimal" autoComplete="off" value={value}
|
|
61
|
+
disabled={pending} aria-invalid={error != null} aria-describedby="payment-amount-help"
|
|
62
|
+
onChange={event => setValue(event.target.value)}
|
|
63
|
+
/>
|
|
64
|
+
<p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
|
|
65
|
+
{error ?? t('bounds', {
|
|
66
|
+
minimum: money(policy.minimumMinor, policy.currency, locale),
|
|
67
|
+
maximum: money(policy.maximumMinor, policy.currency, locale),
|
|
68
|
+
})}
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 rounded-lg border bg-muted/30 p-4 text-sm">
|
|
72
|
+
<dt className="text-muted-foreground">{t('credits')}</dt><dd>{valid && amountMinor != null ? money(amountMinor, policy.currency, locale) : '—'}</dd>
|
|
73
|
+
<dt className="text-muted-foreground">{t('adjustment')}</dt><dd>{valid ? money(adjustmentMinor, policy.currency, locale) : '—'}</dd>
|
|
74
|
+
<dt className="font-medium">{t('subtotal')}</dt><dd className="font-medium">{valid ? money(chargeMinor, policy.currency, locale) : '—'}</dd>
|
|
75
|
+
</dl>
|
|
76
|
+
<p className="text-muted-foreground text-xs">{t('tax-note')}</p>
|
|
77
|
+
</div>
|
|
78
|
+
<DialogFooter>
|
|
79
|
+
<Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>{t('cancel')}</Button>
|
|
80
|
+
<Button type="button" disabled={!valid || pending} onClick={() => void submit()}>{pending ? t('pending') : t('continue')}</Button>
|
|
81
|
+
</DialogFooter>
|
|
82
|
+
</DialogContent>
|
|
83
|
+
</Dialog>
|
|
84
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { CallArguments, RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
|
|
3
|
+
import { hasEntitlement } from '@owlmeans/payment'
|
|
4
|
+
import { openCheckout, type CheckoutTarget } from './service.js'
|
|
5
|
+
import type { CheckoutResult } from './types.js'
|
|
6
|
+
|
|
7
|
+
export const useCheckout = (target: CheckoutTarget = '_self') => {
|
|
8
|
+
const [pending, setPending] = useState(false)
|
|
9
|
+
const checkout = useCallback(async <Request extends RequestShape>(
|
|
10
|
+
entry: RegisteredEntrypoint<Request, CheckoutResult>, ...request: CallArguments<Request>
|
|
11
|
+
): Promise<CheckoutResult | null> => {
|
|
12
|
+
setPending(true)
|
|
13
|
+
try {
|
|
14
|
+
const result = await entry.call(...request)
|
|
15
|
+
if (result?.url != null) openCheckout(result.url, target)
|
|
16
|
+
return result ?? null
|
|
17
|
+
} finally { setPending(false) }
|
|
18
|
+
}, [target])
|
|
19
|
+
return { checkout, pending }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const usePaymentBalance = <T>(
|
|
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
|
+
)
|
package/src/i18n/be.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Папоўніць крэдыты","description":"Выберыце чысты кошт крэдытаў для арганізацыі.","presets":"Гатовыя сумы","custom":"Свая сума","invalid":"Увядзіце карэктную суму, не больш за два знакі пасля коскі.","below":"Мінімальная сума — {{amount}}.","above":"Максімальная сума — {{amount}}.","bounds":"Выберыце суму ад {{minimum}} да {{maximum}}.","credits":"Кошт крэдытаў","adjustment":"Карэкціроўка апрацоўкі","subtotal":"Сума да падатку","tax-note":"Stripe разлічыць падатак падчас аплаты.","cancel":"Скасаваць","continue":"Перайсці ў Stripe","pending":"Адкрываем аплату…","close":"Закрыць"}}
|
package/src/i18n/de.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Guthaben aufladen","description":"Wähle den Nettowert des Guthabens für deine Organisation.","presets":"Vorgegebene Beträge","custom":"Eigener Betrag","invalid":"Gib einen gültigen Betrag mit höchstens zwei Dezimalstellen ein.","below":"Der Mindestbetrag ist {{amount}}.","above":"Der Höchstbetrag ist {{amount}}.","bounds":"Wähle zwischen {{minimum}} und {{maximum}}.","credits":"Guthabenwert","adjustment":"Verarbeitungsaufschlag","subtotal":"Zwischensumme vor Steuern","tax-note":"Stripe berechnet anwendbare Steuern beim Bezahlen.","cancel":"Abbrechen","continue":"Weiter zu Stripe","pending":"Zahlung wird geöffnet…","close":"Schließen"}}
|
package/src/i18n/en.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Add credits","description":"Choose the net credit value to add to your organization.","presets":"Preset amounts","custom":"Custom amount","invalid":"Enter a valid amount with no more than two decimal places.","below":"The minimum amount is {{amount}}.","above":"The maximum amount is {{amount}}.","bounds":"Choose between {{minimum}} and {{maximum}}.","credits":"Credit value","adjustment":"Processing adjustment","subtotal":"Pre-tax subtotal","tax-note":"Stripe calculates any applicable tax at checkout.","cancel":"Cancel","continue":"Continue to Stripe","pending":"Opening checkout…","close":"Close"}}
|
package/src/i18n/es.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Añadir créditos","description":"Elige el valor neto de créditos para tu organización.","presets":"Importes predefinidos","custom":"Importe personalizado","invalid":"Introduce un importe válido con un máximo de dos decimales.","below":"El importe mínimo es {{amount}}.","above":"El importe máximo es {{amount}}.","bounds":"Elige entre {{minimum}} y {{maximum}}.","credits":"Valor de los créditos","adjustment":"Ajuste de procesamiento","subtotal":"Subtotal antes de impuestos","tax-note":"Stripe calcula los impuestos aplicables al pagar.","cancel":"Cancelar","continue":"Continuar a Stripe","pending":"Abriendo el pago…","close":"Cerrar"}}
|
package/src/i18n/pl.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Dodaj kredyty","description":"Wybierz wartość netto kredytów dla organizacji.","presets":"Kwoty sugerowane","custom":"Własna kwota","invalid":"Wpisz prawidłową kwotę z maksymalnie dwoma miejscami dziesiętnymi.","below":"Minimalna kwota to {{amount}}.","above":"Maksymalna kwota to {{amount}}.","bounds":"Wybierz od {{minimum}} do {{maximum}}.","credits":"Wartość kredytów","adjustment":"Korekta przetwarzania","subtotal":"Suma przed podatkiem","tax-note":"Stripe obliczy należny podatek podczas płatności.","cancel":"Anuluj","continue":"Przejdź do Stripe","pending":"Otwieranie płatności…","close":"Zamknij"}}
|
package/src/i18n/ru.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Пополнить кредиты","description":"Выберите чистую стоимость кредитов для организации.","presets":"Готовые суммы","custom":"Своя сумма","invalid":"Введите корректную сумму, не более двух знаков после запятой.","below":"Минимальная сумма — {{amount}}.","above":"Максимальная сумма — {{amount}}.","bounds":"Выберите сумму от {{minimum}} до {{maximum}}.","credits":"Стоимость кредитов","adjustment":"Корректировка обработки","subtotal":"Сумма до налога","tax-note":"Stripe рассчитает применимый налог при оплате.","cancel":"Отмена","continue":"Перейти в Stripe","pending":"Открываем оплату…","close":"Закрыть"}}
|
package/src/i18n/uk.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"amount-checkout":{"title":"Поповнити кредити","description":"Виберіть чисту вартість кредитів для організації.","presets":"Готові суми","custom":"Власна сума","invalid":"Введіть коректну суму, не більше двох знаків після коми.","below":"Мінімальна сума — {{amount}}.","above":"Максимальна сума — {{amount}}.","bounds":"Виберіть суму від {{minimum}} до {{maximum}}.","credits":"Вартість кредитів","adjustment":"Коригування обробки","subtotal":"Сума до податку","tax-note":"Stripe розрахує застосовний податок під час оплати.","cancel":"Скасувати","continue":"Перейти до Stripe","pending":"Відкриваємо оплату…","close":"Закрити"}}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { addI18nLib } from '@owlmeans/i18n'
|
|
2
|
+
import en from './i18n/en.json' with { type: 'json' }
|
|
3
|
+
import pl from './i18n/pl.json' with { type: 'json' }
|
|
4
|
+
import ru from './i18n/ru.json' with { type: 'json' }
|
|
5
|
+
import be from './i18n/be.json' with { type: 'json' }
|
|
6
|
+
import uk from './i18n/uk.json' with { type: 'json' }
|
|
7
|
+
import es from './i18n/es.json' with { type: 'json' }
|
|
8
|
+
import de from './i18n/de.json' with { type: 'json' }
|
|
9
|
+
|
|
10
|
+
addI18nLib('en', 'web-payment', en)
|
|
11
|
+
addI18nLib('pl', 'web-payment', pl)
|
|
12
|
+
addI18nLib('ru', 'web-payment', ru)
|
|
13
|
+
addI18nLib('be', 'web-payment', be)
|
|
14
|
+
addI18nLib('uk', 'web-payment', uk)
|
|
15
|
+
addI18nLib('es', 'web-payment', es)
|
|
16
|
+
addI18nLib('de', 'web-payment', de)
|
package/src/index.ts
ADDED
package/src/service.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type CheckoutTarget = '_self' | '_blank'
|
|
2
|
+
|
|
3
|
+
/** Same-window by default so browser return/cancel state stays in one application tab. */
|
|
4
|
+
export const openCheckout = (url: string, target: CheckoutTarget = '_self'): void => {
|
|
5
|
+
if (typeof window === 'undefined' || url === '') return
|
|
6
|
+
if (target === '_self') window.location.assign(url)
|
|
7
|
+
else window.open(url, target, 'noopener,noreferrer')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { makePaymentService, appendPaymentService } from '@owlmeans/client-payment'
|
|
11
|
+
export type { PaymentService } from '@owlmeans/client-payment'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AmountCheckoutPolicy, CreateCheckoutResponse } from '@owlmeans/payment'
|
|
2
|
+
|
|
3
|
+
export type CheckoutResult = CreateCheckoutResponse
|
|
4
|
+
export interface AmountCheckoutDialogProps {
|
|
5
|
+
open: boolean
|
|
6
|
+
onOpenChange: (open: boolean) => void
|
|
7
|
+
policy: AmountCheckoutPolicy
|
|
8
|
+
pending?: boolean
|
|
9
|
+
onConfirm: (amountMinor: number) => Promise<void> | void
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { inputAmount, parseAmountMinor } from '../src/amount.js'
|
|
3
|
+
import { openCheckout } from '../src/service.js'
|
|
4
|
+
|
|
5
|
+
describe('localized amount input', () => {
|
|
6
|
+
test('preserves cent precision', () => {
|
|
7
|
+
expect(parseAmountMinor('5.01', 'en')).toBe(501)
|
|
8
|
+
expect(inputAmount(501, 'en')).toBe('5.01')
|
|
9
|
+
})
|
|
10
|
+
test('accepts locale decimal separators', () => {
|
|
11
|
+
expect(parseAmountMinor('10,21', 'pl')).toBe(1021)
|
|
12
|
+
})
|
|
13
|
+
test('rejects excess precision and non-numeric input', () => {
|
|
14
|
+
expect(parseAmountMinor('5.001', 'en')).toBeNull()
|
|
15
|
+
expect(parseAmountMinor('five', 'en')).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('checkout navigation', () => {
|
|
20
|
+
test('redirects in the same window by default and supports an explicit new tab', () => {
|
|
21
|
+
const assigned: string[] = []
|
|
22
|
+
const opened: unknown[][] = []
|
|
23
|
+
const previous = globalThis.window
|
|
24
|
+
Object.defineProperty(globalThis, 'window', {
|
|
25
|
+
configurable: true,
|
|
26
|
+
value: {
|
|
27
|
+
location: { assign: (url: string) => assigned.push(url) },
|
|
28
|
+
open: (...args: unknown[]) => opened.push(args),
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
try {
|
|
32
|
+
openCheckout('https://checkout.example/session')
|
|
33
|
+
openCheckout('https://checkout.example/second', '_blank')
|
|
34
|
+
expect(assigned).toEqual(['https://checkout.example/session'])
|
|
35
|
+
expect(opened).toEqual([['https://checkout.example/second', '_blank', 'noopener,noreferrer']])
|
|
36
|
+
} finally {
|
|
37
|
+
Object.defineProperty(globalThis, 'window', { configurable: true, value: previous })
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
})
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import react from '@vitejs/plugin-react'
|
|
2
|
+
import { createServer, type ViteDevServer } from 'vite'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
let server: ViteDevServer | null = null
|
|
8
|
+
let url: string | null = null
|
|
9
|
+
|
|
10
|
+
export const harnessUrl = async (): Promise<string> => {
|
|
11
|
+
if (url != null) return url
|
|
12
|
+
server = await createServer({
|
|
13
|
+
configFile: false,
|
|
14
|
+
root: resolve(here, './harness'),
|
|
15
|
+
plugins: [react()],
|
|
16
|
+
resolve: {
|
|
17
|
+
alias: { '@': resolve(here, '../src/@') },
|
|
18
|
+
dedupe: ['react', 'react-dom'],
|
|
19
|
+
},
|
|
20
|
+
server: { port: 0 },
|
|
21
|
+
logLevel: 'warn',
|
|
22
|
+
})
|
|
23
|
+
await server.listen()
|
|
24
|
+
url = server.resolvedUrls?.local?.[0] ?? null
|
|
25
|
+
if (url == null) throw new Error('Vite did not expose a web-payment harness URL')
|
|
26
|
+
return url
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const closeHarness = async (): Promise<void> => {
|
|
30
|
+
await server?.close()
|
|
31
|
+
server = null
|
|
32
|
+
url = null
|
|
33
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
|
|
3
|
+
import { closeHarness, harnessUrl } from './context.js'
|
|
4
|
+
|
|
5
|
+
const TIMEOUT = 60_000
|
|
6
|
+
|
|
7
|
+
afterAll(async () => {
|
|
8
|
+
await closeBrowser()
|
|
9
|
+
await closeHarness()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('AmountCheckoutDialog', () => {
|
|
13
|
+
test('renders presets and the net, adjustment, and pre-tax amounts', async () => {
|
|
14
|
+
const { page, close } = await mountComponent({ url: await harnessUrl() })
|
|
15
|
+
try {
|
|
16
|
+
const dialog = page.getByRole('dialog')
|
|
17
|
+
await dialog.waitFor()
|
|
18
|
+
for (const amount of ['$10.00', '$20.00', '$50.00', '$100.00']) {
|
|
19
|
+
await expect(dialog.getByRole('button', { name: amount }).count()).resolves.toBe(1)
|
|
20
|
+
}
|
|
21
|
+
expect(await dialog.innerText()).toContain('Credit value')
|
|
22
|
+
expect(await dialog.innerText()).toContain('Processing adjustment')
|
|
23
|
+
expect(await dialog.innerText()).toContain('$0.21')
|
|
24
|
+
expect(await dialog.innerText()).toContain('$10.21')
|
|
25
|
+
expect(await dialog.innerText()).toContain('Stripe calculates any applicable tax')
|
|
26
|
+
} finally { await close() }
|
|
27
|
+
}, TIMEOUT)
|
|
28
|
+
|
|
29
|
+
test('keeps custom cents, reports both bounds without clamping, and submits minor units', async () => {
|
|
30
|
+
const { page, close } = await mountComponent({ url: await harnessUrl() })
|
|
31
|
+
try {
|
|
32
|
+
const input = page.getByLabel('Custom amount')
|
|
33
|
+
const confirm = page.getByRole('button', { name: 'Continue to Stripe' })
|
|
34
|
+
|
|
35
|
+
await input.fill('4.99')
|
|
36
|
+
expect(await input.getAttribute('aria-invalid')).toBe('true')
|
|
37
|
+
expect(await confirm.isDisabled()).toBe(true)
|
|
38
|
+
expect(await page.getByText('The minimum amount is $5.00.').count()).toBe(1)
|
|
39
|
+
|
|
40
|
+
await input.fill('500.01')
|
|
41
|
+
expect(await input.inputValue()).toBe('500.01')
|
|
42
|
+
expect(await page.getByText('The maximum amount is $500.00.').count()).toBe(1)
|
|
43
|
+
|
|
44
|
+
await input.fill('5.01')
|
|
45
|
+
expect(await input.getAttribute('aria-invalid')).toBe('false')
|
|
46
|
+
await confirm.click()
|
|
47
|
+
expect(await page.locator('#confirmed').textContent()).toBe('501')
|
|
48
|
+
} finally { await close() }
|
|
49
|
+
}, TIMEOUT)
|
|
50
|
+
|
|
51
|
+
test('disables changes while pending and closes on cancel otherwise', async () => {
|
|
52
|
+
const pendingMount = await mountComponent({ url: `${await harnessUrl()}?pending=true` })
|
|
53
|
+
try {
|
|
54
|
+
expect(await pendingMount.page.getByLabel('Custom amount').isDisabled()).toBe(true)
|
|
55
|
+
expect(await pendingMount.page.getByRole('button', { name: 'Opening checkout…' }).isDisabled()).toBe(true)
|
|
56
|
+
expect(await pendingMount.page.getByRole('button', { name: 'Cancel' }).isDisabled()).toBe(true)
|
|
57
|
+
} finally { await pendingMount.close() }
|
|
58
|
+
|
|
59
|
+
const readyMount = await mountComponent({ url: await harnessUrl() })
|
|
60
|
+
try {
|
|
61
|
+
await readyMount.page.getByRole('button', { name: 'Cancel' }).click()
|
|
62
|
+
expect(await readyMount.page.getByRole('dialog').count()).toBe(0)
|
|
63
|
+
expect(await readyMount.page.locator('#dialog-state').textContent()).toBe('closed')
|
|
64
|
+
} finally { await readyMount.close() }
|
|
65
|
+
}, TIMEOUT)
|
|
66
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import { I18nContext } from '@owlmeans/client-i18n'
|
|
4
|
+
import { AmountCheckoutDialog } from '../../src/index.js'
|
|
5
|
+
|
|
6
|
+
const params = new URLSearchParams(window.location.search)
|
|
7
|
+
const pending = params.get('pending') === 'true'
|
|
8
|
+
const policy = {
|
|
9
|
+
currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
|
|
10
|
+
presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const App = () => {
|
|
14
|
+
const [open, setOpen] = useState(true)
|
|
15
|
+
const [confirmed, setConfirmed] = useState<number | null>(null)
|
|
16
|
+
return <I18nContext config={{ service: 'web-payment-test', i18n: { defaultLng: 'en' } } as never}>
|
|
17
|
+
<output id="dialog-state">{open ? 'open' : 'closed'}</output>
|
|
18
|
+
<output id="confirmed">{confirmed ?? ''}</output>
|
|
19
|
+
<AmountCheckoutDialog
|
|
20
|
+
open={open}
|
|
21
|
+
onOpenChange={setOpen}
|
|
22
|
+
policy={policy}
|
|
23
|
+
pending={pending}
|
|
24
|
+
onConfirm={setConfirmed}
|
|
25
|
+
/>
|
|
26
|
+
</I18nContext>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createRoot(document.getElementById('root')!).render(<App />)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import en from '../src/i18n/en.json'
|
|
3
|
+
import pl from '../src/i18n/pl.json'
|
|
4
|
+
import ru from '../src/i18n/ru.json'
|
|
5
|
+
import be from '../src/i18n/be.json'
|
|
6
|
+
import uk from '../src/i18n/uk.json'
|
|
7
|
+
import es from '../src/i18n/es.json'
|
|
8
|
+
import de from '../src/i18n/de.json'
|
|
9
|
+
|
|
10
|
+
describe('web-payment localization', () => {
|
|
11
|
+
const translations = { en, pl, ru, be, uk, es, de }
|
|
12
|
+
|
|
13
|
+
test('ships all seven library languages with exact key parity', () => {
|
|
14
|
+
const expected = Object.keys(en['amount-checkout']).sort()
|
|
15
|
+
expect(Object.keys(translations).sort()).toEqual(['be', 'de', 'en', 'es', 'pl', 'ru', 'uk'])
|
|
16
|
+
for (const messages of Object.values(translations)) {
|
|
17
|
+
expect(Object.keys(messages['amount-checkout']).sort()).toEqual(expected)
|
|
18
|
+
expect(Object.values(messages['amount-checkout']).every(value => value.trim().length > 0)).toBe(true)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["@owlmeans/dep-config/tsconfig.base.json", "@owlmeans/dep-config/tsconfig.react.json"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"paths": { "@/*": ["./src/@/*"] },
|
|
5
|
+
"rootDir": "./src/", "outDir": "./build/"
|
|
6
|
+
},
|
|
7
|
+
"exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
|
|
8
|
+
}
|