diff --git a/package.json b/package.json index 2c41578d..bbd8777a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@interledger/open-payments": "^6.13.1", "@noble/ed25519": "^2.1.0", "@noble/hashes": "^1.5.0", + "@radix-ui/react-tabs": "^1.1.1", "awilix": "^11.0.0", "class-variance-authority": "^0.7.0", "crypto-browserify": "^3.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6bf2028..b6a212a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@noble/hashes': specifier: ^1.5.0 version: 1.5.0 + '@radix-ui/react-tabs': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) awilix: specifier: ^11.0.0 version: 11.0.0 @@ -949,6 +952,155 @@ packages: engines: {node: '>=18'} hasBin: true + '@radix-ui/primitive@1.1.0': + resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + + '@radix-ui/react-collection@1.1.0': + resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.0': + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.0': + resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-presence@1.1.1': + resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.0.0': + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.0': + resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.1.0': + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.1': + resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@remix-run/router@1.19.2': resolution: {integrity: sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==} engines: {node: '>=14.0.0'} @@ -4936,6 +5088,129 @@ snapshots: dependencies: playwright: 1.47.2 + '@radix-ui/primitive@1.1.0': {} + + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.9 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-context@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-context@1.1.1(@types/react@18.3.9)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-direction@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-id@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-presence@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.9 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.9 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.9 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-slot@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-tabs@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-context': 1.1.1(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.9)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.9 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.9)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.9)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.9 + '@remix-run/router@1.19.2': {} '@rollup/plugin-inject@5.0.5(rollup@4.20.0)': diff --git a/src/assets/images/bg-tile.svg b/src/assets/images/bg-tile.svg new file mode 100644 index 00000000..7d74fc0c --- /dev/null +++ b/src/assets/images/bg-tile.svg @@ -0,0 +1 @@ + diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 6fd3ed94..02355e12 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -5,12 +5,8 @@ import { Switch } from '@/popup/components/ui/Switch'; import { Code } from '@/popup/components/ui/Code'; import { ErrorMessage } from '@/popup/components/ErrorMessage'; import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; -import { - charIsNumber, - formatNumber, - getCurrencySymbol, - toWalletAddressUrl, -} from '@/popup/lib/utils'; +import { InputAmount, validateAmount } from '@/popup/components/InputAmount'; +import { toWalletAddressUrl } from '@/popup/lib/utils'; import { useTranslation } from '@/popup/lib/context'; import { cn, @@ -107,11 +103,6 @@ export const ConnectWalletForm = ({ state?.status?.startsWith('connecting') || false, ); - const [currencySymbol, setCurrencySymbol] = React.useState<{ - symbol: string; - scale: number; - }>({ symbol: '$', scale: 2 }); - const getWalletInformation = React.useCallback( async (walletAddressUrl: string): Promise => { setErrors((prev) => ({ ...prev, walletAddressUrl: null })); @@ -153,25 +144,19 @@ export const ConnectWalletForm = ({ ); const handleAmountChange = React.useCallback( - (value: string, input: HTMLInputElement) => { - const error = validateAmount(value, currencySymbol.symbol); - setErrors((prev) => ({ ...prev, amount: toErrorInfo(error) })); - - const amountValue = formatNumber(+value, currencySymbol.scale); - if (!error) { - setAmount(amountValue); - input.value = amountValue; - } - saveValue('amount', error ? value : amountValue); + (amountValue: string) => { + setErrors((prev) => ({ ...prev, amount: null })); + setAmount(amountValue); + saveValue('amount', amountValue); }, - [saveValue, currencySymbol, toErrorInfo], + [saveValue], ); const handleSubmit = async (ev?: React.FormEvent) => { ev?.preventDefault(); const errWalletAddressUrl = validateWalletAddressUrl(walletAddressUrl); - const errAmount = validateAmount(amount, currencySymbol.symbol); + const errAmount = validateAmount(amount, walletAddressInfo!); if (errAmount || errWalletAddressUrl) { setErrors((prev) => ({ ...prev, @@ -221,14 +206,6 @@ export const ConnectWalletForm = ({ } }; - React.useEffect(() => { - if (!walletAddressInfo) return; - setCurrencySymbol({ - symbol: getCurrencySymbol(walletAddressInfo.assetCode), - scale: walletAddressInfo.assetScale, - }); - }, [walletAddressInfo]); - React.useEffect(() => { if (defaultValues.walletAddressUrl) { handleWalletAddressUrlChange(defaultValues.walletAddressUrl); @@ -332,27 +309,23 @@ export const ConnectWalletForm = ({ {t('connectWallet_labelGroup_amount')}
- {currencySymbol.symbol}} - aria-invalid={!!errors.amount} - aria-describedby={errors.amount?.message} - required={true} - onKeyDown={allowOnlyNumericInput} - onBlur={(ev) => { - const value = ev.currentTarget.value; - if (value === amount && !ev.currentTarget.required) { - return; - } - handleAmountChange(value, ev.currentTarget); + onError={(err) => { + setErrors((prev) => ({ ...prev, amount: toErrorInfo(err) })); }} + onChange={handleAmountChange} + className="max-w-32" + placeholder="5.00" /> ) { - if ( - (!charIsNumber(ev.key) && - ev.key !== 'Backspace' && - ev.key !== 'Delete' && - ev.key !== 'Enter' && - ev.key !== 'Tab') || - (ev.key === '.' && ev.currentTarget.value.includes('.')) - ) { - ev.preventDefault(); - } -} diff --git a/src/popup/components/Icons.tsx b/src/popup/components/Icons.tsx index 36a951a5..6c327633 100644 --- a/src/popup/components/Icons.tsx +++ b/src/popup/components/Icons.tsx @@ -72,7 +72,7 @@ export const Settings = (props: React.SVGProps) => { diff --git a/src/popup/components/InputAmount.tsx b/src/popup/components/InputAmount.tsx new file mode 100644 index 00000000..1ddf4494 --- /dev/null +++ b/src/popup/components/InputAmount.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Input } from './ui/Input'; +import type { WalletAddress } from '@interledger/open-payments'; +import { charIsNumber, formatNumber, getCurrencySymbol } from '../lib/utils'; +import { + errorWithKey, + ErrorWithKeyLike, + formatCurrency, +} from '@/shared/helpers'; + +interface Props { + id: string; + label: string | React.ReactNode; + walletAddress: Pick; + amount: string; + onChange: (amount: string, inputEl: HTMLInputElement) => void; + onError: (error: ErrorWithKeyLike) => void; + className?: string; + placeholder?: string; + errorMessage?: string; + readOnly?: boolean; + labelHidden?: boolean; + errorHidden?: boolean; + min?: number; + max?: number; + controls?: boolean; +} + +export const InputAmount = ({ + label, + id, + walletAddress, + amount, + className, + placeholder, + errorMessage, + onChange, + onError, + labelHidden, + errorHidden, + min = 0, + max, + readOnly, +}: Props) => { + const currencySymbol = getCurrencySymbol(walletAddress.assetCode); + return ( + {currencySymbol}} + errorMessage={errorHidden ? '' : errorMessage} + aria-invalid={errorHidden ? !!errorMessage : false} + required={true} + onKeyDown={allowOnlyNumericInput} + onBlur={(ev) => { + const input = ev.currentTarget; + const value = input.value; + if (value === amount && !input.required) { + return; + } + const error = validateAmount(value, walletAddress, min, max); + if (error) { + onError(error); + } else { + const amountValue = formatNumber(+value, walletAddress.assetScale); + input.value = amountValue; + onChange(amountValue, input); + } + }} + /> + ); +}; + +export function validateAmount( + value: string, + walletAddress: Pick, + min: number = 0, + _max?: number, +): null | ErrorWithKeyLike { + if (!value) { + return errorWithKey('connectWallet_error_amountRequired'); + } + const val = Number(value); + if (Number.isNaN(val)) { + return errorWithKey('connectWallet_error_amountInvalidNumber'); + } + if (val <= min) { + return errorWithKey('connectWallet_error_amountMinimum', [ + formatCurrency(min, walletAddress.assetCode, walletAddress.assetScale), + ]); + } + return null; +} + +function allowOnlyNumericInput(ev: React.KeyboardEvent) { + if ( + (!charIsNumber(ev.key) && + ev.key !== 'Backspace' && + ev.key !== 'Delete' && + ev.key !== 'Enter' && + ev.key !== 'Tab') || + (ev.key === '.' && ev.currentTarget.value.includes('.')) + ) { + ev.preventDefault(); + } +} diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 312f9330..d887d3ca 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -1,21 +1,15 @@ -import { Button } from '@/popup/components/ui/Button'; -import { Input } from '@/popup/components/ui/Input'; -import { useMessage, usePopupState } from '@/popup/lib/context'; -import { - getCurrencySymbol, - charIsNumber, - formatNumber, -} from '@/popup/lib/utils'; -import React, { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; +import React from 'react'; import { AnimatePresence, m } from 'framer-motion'; -import { Spinner } from './Icons'; -import { cn } from '@/shared/helpers'; -import { ErrorMessage } from './ErrorMessage'; +import { Button } from '@/popup/components/ui/Button'; +import { Spinner } from '@/popup/components/Icons'; +import { ErrorMessage } from '@/popup/components/ErrorMessage'; +import { InputAmount } from '@/popup/components/InputAmount'; +import { cn, ErrorWithKeyLike } from '@/shared/helpers'; +import { useMessage, usePopupState, useTranslation } from '@/popup/lib/context'; -interface PayWebsiteFormProps { - amount: string; -} +type ErrorInfo = { message: string; info?: ErrorWithKeyLike }; +type ErrorsParams = 'amount' | 'pay'; +type Errors = Record; const BUTTON_STATE = { idle: 'Send now', @@ -24,45 +18,65 @@ const BUTTON_STATE = { }; export const PayWebsiteForm = () => { + const t = useTranslation(); const message = useMessage(); const { state: { walletAddress, tab }, } = usePopupState(); + + const toErrorInfo = React.useCallback( + (err?: string | ErrorWithKeyLike | null): ErrorInfo | null => { + if (!err) return null; + if (typeof err === 'string') return { message: err }; + return { message: t(err), info: err }; + }, + [t], + ); + + const [amount, setAmount] = React.useState(''); + const [errors, setErrors] = React.useState({ + amount: null, + pay: null, + }); + + const form = React.useRef(null); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [buttonState, setButtonState] = React.useState('idle'); - const isIdle = useMemo(() => buttonState === 'idle', [buttonState]); + const isIdle = React.useMemo(() => buttonState === 'idle', [buttonState]); - const { - register, - formState: { errors, isSubmitting }, - setValue, - handleSubmit, - ...form - } = useForm(); - - const onSubmit = handleSubmit(async (data) => { + const onSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); if (buttonState !== 'idle') return; + setErrors({ amount: null, pay: null }); setButtonState('loading'); + setIsSubmitting(true); - const response = await message.send('PAY_WEBSITE', { amount: data.amount }); + const response = await message.send('PAY_WEBSITE', { amount }); if (!response.success) { setButtonState('idle'); - form.setError('root', { message: response.message }); + setErrors((prev) => ({ ...prev, pay: toErrorInfo(response.message) })); } else { setButtonState('success'); - form.reset(); + setAmount(''); + form.current?.reset(); setTimeout(() => { setButtonState('idle'); - }, 2000); + }, 3000); } - }); + setIsSubmitting(false); + }; return ( -
+ - {errors.root ? ( + {errors.pay ? ( { className="overflow-hidden" key="form-error" > - + ) : null} - - Pay {tab.url} + Support{' '} + {tab.url}

} + walletAddress={walletAddress} + amount={amount} placeholder="0.00" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - onSubmit(); - } else if ( - !charIsNumber(e.key) && - e.key !== 'Backspace' && - e.key !== 'Delete' && - e.key !== 'Tab' - ) { - e.preventDefault(); - } - }} errorMessage={errors.amount?.message} - {...register('amount', { - required: { value: true, message: 'Amount is required.' }, - valueAsNumber: true, - onBlur: (e: React.FocusEvent) => { - setValue( - 'amount', - formatNumber(+e.currentTarget.value, walletAddress.assetScale), - ); - }, - })} + onChange={(amountValue) => { + setErrors({ pay: null, amount: null }); + setAmount(amountValue); + }} + onError={(error) => + setErrors((prev) => ({ ...prev, amount: toErrorInfo(error) })) + } /> + + + +
+ + Advanced + + + + +
+ + +
+
+
+ ); +}; diff --git a/src/popup/components/WalletInformation.tsx b/src/popup/components/WalletInformation.tsx deleted file mode 100644 index 5f302f7d..00000000 --- a/src/popup/components/WalletInformation.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Input } from '@/popup/components/ui/Input'; -import { Label } from '@/popup/components/ui/Label'; -import React from 'react'; -import { Code } from '@/popup/components/ui/Code'; -import { PopupStore } from '@/shared/types'; -import { Button } from '@/popup/components/ui/Button'; -import { useMessage } from '@/popup/lib/context'; -import { useForm } from 'react-hook-form'; - -interface WalletInformationProps { - info: PopupStore; -} - -export const WalletInformation = ({ info }: WalletInformationProps) => { - const message = useMessage(); - const { - handleSubmit, - formState: { isSubmitting }, - } = useForm(); - - return ( -
-
- -

- Copy the public key below and paste it into your connected wallet. -

- -
- - {/* TODO: Improve error handling */} -
{ - await message.send('DISCONNECT_WALLET'); - window.location.reload(); - })} - > - -
-
- ); -}; diff --git a/src/popup/components/layout/MainLayout.tsx b/src/popup/components/layout/MainLayout.tsx index 5b5c3650..d0582be1 100644 --- a/src/popup/components/layout/MainLayout.tsx +++ b/src/popup/components/layout/MainLayout.tsx @@ -9,13 +9,10 @@ const Divider = () => { export const MainLayout = () => { return ( -
+
-
+
diff --git a/src/popup/components/ui/Code.tsx b/src/popup/components/ui/Code.tsx index 71998828..0e112751 100644 --- a/src/popup/components/ui/Code.tsx +++ b/src/popup/components/ui/Code.tsx @@ -11,7 +11,7 @@ export const Code = ({ value, className, ...props }: CodeProps) => { return (
(function Slider( aria-disabled={disabled ?? false} aria-invalid={!!errorMessage} aria-describedby={errorMessage} - value={disabled ? 0 : value} + value={value} onChange={onChange} {...props} /> diff --git a/src/popup/components/ui/Switch.tsx b/src/popup/components/ui/Switch.tsx index 24146770..054d86cd 100644 --- a/src/popup/components/ui/Switch.tsx +++ b/src/popup/components/ui/Switch.tsx @@ -22,6 +22,9 @@ const switchVariants = cva( 'peer-checked:before:left-4', ], }, + disabled: { + true: 'opacity-75', + }, }, defaultVariants: { size: 'default', @@ -33,12 +36,13 @@ export interface SwitchProps extends VariantProps, React.HTMLAttributes { checked?: boolean; + disabled?: boolean; label?: string; onChange?: (e: React.ChangeEvent) => void; } export const Switch = forwardRef(function Switch( - { size, label, className, onChange = () => {}, ...props }, + { size, label, className, disabled = false, onChange = () => {}, ...props }, ref, ) { return ( @@ -49,10 +53,11 @@ export const Switch = forwardRef(function Switch( type="checkbox" checked={props.checked} onChange={onChange} + disabled={disabled} {...props} className="peer pointer-events-none absolute -translate-x-[100%] opacity-0" /> -
+
{label ? {label} : null} ); diff --git a/src/popup/index.css b/src/popup/index.css index 4c8f633b..10acdfca 100644 --- a/src/popup/index.css +++ b/src/popup/index.css @@ -6,6 +6,8 @@ :root { /* Text colors */ --text-primary: 59 130 246; + --text-secondary: 86 183 181; + --text-secondary-dark: 52 152 152; --text-weak: 100 116 139; --text-medium: 51 65 85; --text-strong: 15 23 42; diff --git a/src/popup/lib/hooks.ts b/src/popup/lib/hooks.ts index 78d258ca..7ae850c6 100644 --- a/src/popup/lib/hooks.ts +++ b/src/popup/lib/hooks.ts @@ -13,7 +13,10 @@ import React from 'react'; export function useLocalStorage( key: string, defaultValue: T, - { maxAge = 1000 * 24 * 60 * 60 }: Partial<{ maxAge: number }> = {}, + { + maxAge = 1000 * 24 * 60 * 60, + validate = () => true, + }: Partial<{ maxAge: number; validate: (value: T) => boolean }> = {}, ) { const hasLocalStorage = typeof localStorage !== 'undefined'; maxAge *= 1000; @@ -33,7 +36,11 @@ export function useLocalStorage( try { const data = JSON.parse(storedValue); - if (isWellFormed(data) && data.expiresAt > Date.now()) { + if ( + isWellFormed(data) && + data.expiresAt > Date.now() && + validate(data.value) + ) { return data.value; } else { localStorage.removeItem(key); diff --git a/src/popup/pages/Home.tsx b/src/popup/pages/Home.tsx index 4615375d..4a4d4aef 100644 --- a/src/popup/pages/Home.tsx +++ b/src/popup/pages/Home.tsx @@ -1,68 +1,19 @@ import React from 'react'; -import { usePopupState, useMessage, useTranslation } from '@/popup/lib/context'; -import { WarningSign } from '@/popup/components/Icons'; -import { Slider } from '../components/ui/Slider'; -import { Label } from '../components/ui/Label'; -import { - formatNumber, - getCurrencySymbol, - roundWithPrecision, -} from '../lib/utils'; -import { PayWebsiteForm } from '../components/PayWebsiteForm'; +import { Link } from 'react-router-dom'; +import { usePopupState, useTranslation } from '@/popup/lib/context'; +import { Settings } from '@/popup/components/Icons'; +import { formatNumber, roundWithPrecision } from '../lib/utils'; +import { PayWebsiteForm } from '@/popup/components/PayWebsiteForm'; import { NotMonetized } from '@/popup/components/NotMonetized'; -import { debounceAsync } from '@/shared/helpers'; -import { Switch } from '../components/ui/Switch'; +import { formatCurrency } from '@/shared/helpers'; +import { ROUTES_PATH } from '@/popup/Popup'; export const Component = () => { const t = useTranslation(); - const message = useMessage(); const { - state: { - enabled, - rateOfPay, - minRateOfPay, - maxRateOfPay, - balance, - walletAddress, - tab, - }, - dispatch, + state: { tab }, } = usePopupState(); - const rate = React.useMemo(() => { - const r = Number(rateOfPay) / 10 ** walletAddress.assetScale; - const roundedR = roundWithPrecision(r, walletAddress.assetScale); - - return formatNumber(roundedR, walletAddress.assetScale, true); - }, [rateOfPay, walletAddress.assetScale]); - - const remainingBalance = React.useMemo(() => { - const val = Number(balance) / 10 ** walletAddress.assetScale; - const rounded = roundWithPrecision(val, walletAddress.assetScale); - return formatNumber(rounded, walletAddress.assetScale, true); - }, [balance, walletAddress.assetScale]); - - const updateRateOfPay = React.useRef( - debounceAsync(async (rateOfPay: string) => { - const response = await message.send('UPDATE_RATE_OF_PAY', { rateOfPay }); - if (!response.success) { - // TODO: Maybe reset to old state, but not while user is active (avoid - // sluggishness in UI) - } - }, 1000), - ); - - const onRateChange = async (event: React.ChangeEvent) => { - const rateOfPay = event.currentTarget.value; - dispatch({ type: 'UPDATE_RATE_OF_PAY', data: { rateOfPay } }); - void updateRateOfPay.current(rateOfPay); - }; - - const onChangeWM = () => { - message.send('TOGGLE_WM'); - dispatch({ type: 'TOGGLE_WM', data: {} }); - }; - if (tab.status !== 'monetized') { switch (tab.status) { case 'all_sessions_invalid': @@ -80,46 +31,69 @@ export const Component = () => { } return ( -
- {enabled ? ( -
- - -
- - {rate} {getCurrencySymbol(walletAddress.assetCode)} per hour - - - Remaining balance: {getCurrencySymbol(walletAddress.assetCode)} - {remainingBalance} - -
+
+
+

Pay as you browse

+

Support content you love

+
+ + +
+ ); +}; + +const InfoBanner = () => { + const { + state: { rateOfPay, balance, walletAddress }, + } = usePopupState(); + + const rate = React.useMemo(() => { + const r = Number(rateOfPay) / 10 ** walletAddress.assetScale; + const roundedR = roundWithPrecision(r, walletAddress.assetScale); + + return formatCurrency( + formatNumber(roundedR, walletAddress.assetScale, true), + walletAddress.assetCode, + walletAddress.assetScale, + ); + }, [rateOfPay, walletAddress.assetCode, walletAddress.assetScale]); + + const remainingBalance = React.useMemo(() => { + const val = Number(balance) / 10 ** walletAddress.assetScale; + const rounded = roundWithPrecision(val, walletAddress.assetScale); + return formatCurrency( + formatNumber(rounded, walletAddress.assetScale, true), + walletAddress.assetCode, + walletAddress.assetScale, + ); + }, [balance, walletAddress.assetCode, walletAddress.assetScale]); + + return ( +
+
+
+
Hourly rate
+
{rate}
- ) : ( -
- -

- Web Monetization has been turned off. -

+
+
Balance
+
{remainingBalance}
- )} - - -
+
- {tab.url ? : null} +

+ + To adjust your budget or rate of pay, click on{' '} + + +

); }; diff --git a/src/popup/pages/Settings.tsx b/src/popup/pages/Settings.tsx index 4133de42..d620d8c3 100644 --- a/src/popup/pages/Settings.tsx +++ b/src/popup/pages/Settings.tsx @@ -1,9 +1,85 @@ import React from 'react'; -import { WalletInformation } from '@/popup/components/WalletInformation'; +import { useLocation } from 'react-router-dom'; +import * as Tabs from '@radix-ui/react-tabs'; +import { WalletInformation } from '@/popup/components/Settings/WalletInformation'; +import { BudgetScreen } from '@/popup/components/Settings/Budget'; +import { RateOfPayScreen } from '@/popup/components/Settings/RateOfPay'; +import { cn } from '@/shared/helpers'; import { usePopupState } from '@/popup/lib/context'; +import { useLocalStorage } from '@/popup/lib/hooks'; + +const TABS = [ + { id: 'wallet', title: 'Wallet' }, + { id: 'budget', title: 'Budget' }, + { id: 'wmRate', title: 'Rate' }, +]; + +const isValidTabId = (id: string) => { + return TABS.some((e) => e.id === id); +}; export const Component = () => { - const { state } = usePopupState(); + const { + state: { balance, grants, publicKey, walletAddress }, + } = usePopupState(); + const location = useLocation(); + const [storedTabId, setStoredTabId] = useLocalStorage( + 'settings.tabId', + TABS[0].id, + { maxAge: 10 * 60 * 1000, validate: isValidTabId }, + ); + const tabIdFromState = + location.state?.tabId && isValidTabId(location.state?.tabId) + ? (location.state.tabId as string) + : null; + const [currentTabId, setCurrentTabId] = React.useState( + tabIdFromState ?? storedTabId ?? TABS[0].id, + ); + + return ( + { + setCurrentTabId(id); + setStoredTabId(id); + }} + > + + {TABS.map(({ id, title }) => ( + + {title} + + ))} + + + + + + + + + - return ; + + + + + ); }; diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 2a4ea470..7a5005be 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -18,12 +18,17 @@ export const cn = (...inputs: CxOptions) => { return twMerge(cx(inputs)); }; -export const formatCurrency = (value: any): string => { - if (value < 1) { - return `${Math.round(value * 100)}c`; - } else { - return `$${parseFloat(value).toFixed(2)}`; - } +export const formatCurrency = ( + value: string | number, + currency: string, + maximumFractionDigits = 2, + locale?: string, +): string => { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + maximumFractionDigits, + }).format(Number(value)); }; const isWalletAddress = (o: any): o is WalletAddress => { diff --git a/tailwind.config.ts b/tailwind.config.ts index b40ced6a..48ab8629 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -16,6 +16,8 @@ module.exports = { }, textColor: { primary: 'rgb(var(--text-primary) / )', + secondary: 'rgb(var(--text-secondary) / )', + 'secondary-dark': 'rgb(var(--text-secondary-dark) / )', weak: 'rgb(var(--text-weak) / )', medium: 'rgb(var(--text-medium) / )', strong: 'rgb(var(--text-strong) / )', diff --git a/tests/e2e/simple.spec.ts b/tests/e2e/simple.spec.ts index 3add4e6c..bc6330b1 100644 --- a/tests/e2e/simple.spec.ts +++ b/tests/e2e/simple.spec.ts @@ -38,7 +38,4 @@ test('should monetize site with single wallet address', async ({ await expect(popup.getByRole('button', { name: 'Send now' })).toBeVisible(); expect(await popup.getByRole('textbox').all()).toHaveLength(1); - - await expect(popup.getByLabel('Continuous payment stream')).toBeVisible(); - expect(await popup.getByRole('switch').all()).toHaveLength(1); });