diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 8c6c5966..e1c2b83b 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -40,6 +40,49 @@ "pay_error_notEnoughFunds": { "message": "Not enough funds to facilitate payment." }, + "outOfFunds_error_title": { + "message": "Out of funds" + }, + "outOfFunds_error_text": { + "message": "Funds have been depleted. You can no longer make payments." + }, + "outOfFunds_error_textHint": { + "message": "Please add funds." + }, + "outOfFunds_error_textDoNothing": { + "message": "If you do nothing, $AMOUNT$ will automatically be renewed and available on $AUTO_RENEW_DATE$", + "placeholders": { + "AMOUNT": { "content": "$1", "example": "$2.05" }, + "AUTO_RENEW_DATE": { "content": "$2", "example": "Aug 22, 2024, 6:05 PM" } + } + }, + "outOfFunds_action_optionOneTime": { + "message": "Let me top-up funds one time" + }, + "outOfFunds_action_optionRecurring": { + "message": "Let me add funds & auto-renew monthly" + }, + "outOfFundsAddFunds_label_walletAddress": { + "message": "Connected wallet address" + }, + "outOfFundsAddFunds_label_amount": { + "message": "Amount" + }, + "outOfFundsAddFunds_label_amountDescriptionOneTime": { + "message": "Enter the amount to add from the wallet." + }, + "outOfFundsAddFunds_label_amountDescriptionRecurring": { + "message": "Enter the amount to add from the wallet. This amount will renew automatically every month (next: $NEXT_RENEW_DATE$).", + "placeholders": { + "NEXT_RENEW_DATE": { "content": "$1", "example": "Aug 22, 2024" } + } + }, + "outOfFundsAddFunds_action_addOneTime": { + "message": "Add funds" + }, + "outOfFundsAddFunds_action_addRecurring": { + "message": "Add funds" + }, "connectWallet_error_invalidClient": { "message": "Failed to connect. Please make sure you have added the public key to the correct wallet address." } diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx index da6cf5e4..19897e9d 100644 --- a/src/popup/Popup.tsx +++ b/src/popup/Popup.tsx @@ -19,6 +19,7 @@ export const ROUTES_PATH = { SETTINGS: '/settings', MISSING_HOST_PERMISSION: '/missing-host-permission', OUT_OF_FUNDS: '/out-of-funds', + OUT_OF_FUNDS_ADD_FUNDS: '/out-of-funds/s/add-funds', ERROR_KEY_REVOKED: '/error/key-revoked' } as const @@ -49,6 +50,10 @@ export const routes = [ path: ROUTES_PATH.OUT_OF_FUNDS, lazy: () => import('./pages/OutOfFunds') }, + { + path: ROUTES_PATH.OUT_OF_FUNDS_ADD_FUNDS, + lazy: () => import('./pages/OutOfFunds_AddFunds') + }, { path: ROUTES_PATH.SETTINGS, lazy: () => import('./pages/Settings') diff --git a/src/popup/components/OutOfFunds.tsx b/src/popup/components/OutOfFunds.tsx index b14fd67c..ca7367bd 100644 --- a/src/popup/components/OutOfFunds.tsx +++ b/src/popup/components/OutOfFunds.tsx @@ -1,53 +1,169 @@ import React from 'react' -import { Button } from './ui/Button' -import type { RecurringGrant, OneTimeGrant } from '@/shared/types' +import { useForm } from 'react-hook-form' +import type { RecurringGrant, OneTimeGrant, AmountValue } from '@/shared/types' import type { AddFundsPayload, Response } from '@/shared/messages' import type { WalletAddress } from '@interledger/open-payments' -import { getCurrencySymbol, transformBalance } from '../lib/utils' +import { + charIsNumber, + formatNumber, + getCurrencySymbol, + transformBalance +} from '@/popup/lib/utils' +import { useTranslation } from '@/popup/lib/context' import { getNextOccurrence } from '@/shared/helpers' +import { ErrorMessage } from '@/popup/components/ErrorMessage' +import { Button } from '@/popup/components/ui/Button' +import { Input } from '@/popup/components/ui/Input' interface OutOfFundsProps { info: Pick grantRecurring?: RecurringGrant['amount'] grantOneTime?: OneTimeGrant['amount'] - requestAddFunds: (details: AddFundsPayload) => Promise + onChooseOption: (recurring: boolean) => void } export const OutOfFunds = ({ info, grantOneTime, grantRecurring, - requestAddFunds + onChooseOption }: OutOfFundsProps) => { if (!grantOneTime && !grantRecurring) { throw new Error('Provide at least one of grantOneTime and grantRecurring') } + const t = useTranslation() - const currencySymbol = getCurrencySymbol(info.assetCode) - const amount = transformBalance( - grantRecurring?.value || grantOneTime!.value, - info.assetScale + return ( +
+ +
+

{t('outOfFunds_error_text')}

+

{t('outOfFunds_error_textHint')}

+ {grantRecurring?.value && ( +

+ +

+ )} +
+ +
+ + + +
) +} - const requestTopUpOneTime = () => { - return requestAddFunds({ amount: amount, recurring: false }) - } +interface AddFundsProps { + info: Pick + recurring: boolean + defaultAmount: AmountValue + requestAddFunds: (details: AddFundsPayload) => Promise +} - const requestTopUpRecurring = () => { - return requestAddFunds({ amount: amount, recurring: true }) - } +export function AddFunds({ + info, + defaultAmount, + recurring, + requestAddFunds +}: AddFundsProps) { + const t = useTranslation() + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + setValue + } = useForm({ + criteriaMode: 'firstError', + mode: 'onSubmit', + reValidateMode: 'onBlur', + defaultValues: { + amount: transformBalance(defaultAmount, info.assetScale) + } + }) + + const currencySymbol = getCurrencySymbol(info.assetCode) return ( -
-

Out of funds

- -

- Top-up: {currencySymbol + amount} -

- - - -
+
{ + const response = await requestAddFunds({ + amount: data.amount, + recurring: !!recurring + }) + if (!response.success) { + setError('root', { message: response.message }) + } + })} + > + + + { + if ( + !charIsNumber(e.key) && + e.key !== 'Backspace' && + e.key !== 'ArrowLeft' && + e.key !== 'ArrowRight' && + e.key !== 'Delete' && + e.key !== 'Tab' + ) { + e.preventDefault() + } + }} + errorMessage={errors.amount?.message} + {...register('amount', { + required: { value: true, message: 'Amount is required.' }, + valueAsNumber: false, + onBlur: (e: React.FocusEvent) => { + setValue('amount', formatNumber(+e.currentTarget.value, 2)) + } + })} + /> + + {(errors.root || errors.amount) && ( + + )} + + + ) } @@ -55,22 +171,28 @@ function RecurringAutoRenewInfo({ grantRecurring, info }: Pick) { + const t = useTranslation() + if (!grantRecurring) return null const currencySymbol = getCurrencySymbol(info.assetCode) const amount = transformBalance(grantRecurring.value, info.assetScale) const renewDate = getNextOccurrence(grantRecurring.interval, new Date()) + const renewDateLocalized = renewDate.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short' + }) - return ( -

- If you do nothing, you will have {currencySymbol} - {amount} available on{' '} - -

+ return t('outOfFunds_error_textDoNothing', [ + `${currencySymbol}${amount}`, + renewDateLocalized + ]) +} + +function getNextOccurrenceDate(period: 'P1M', baseDate = new Date()) { + const date = getNextOccurrence( + `R/${baseDate.toISOString()}/${period}`, + baseDate ) + return date.toLocaleDateString(undefined, { dateStyle: 'medium' }) } diff --git a/src/popup/components/layout/header.tsx b/src/popup/components/layout/header.tsx index dfb237ab..eb6e1645 100644 --- a/src/popup/components/layout/header.tsx +++ b/src/popup/components/layout/header.tsx @@ -12,6 +12,14 @@ const NavigationButton = () => { return useMemo(() => { if (!connected) return null + if (location.pathname.includes('/s/')) { + return ( + + + + ) + } + return location.pathname === `${ROUTES_PATH.SETTINGS}` ? ( diff --git a/src/popup/pages/OutOfFunds.tsx b/src/popup/pages/OutOfFunds.tsx index 8c4d1d3a..174537a0 100644 --- a/src/popup/pages/OutOfFunds.tsx +++ b/src/popup/pages/OutOfFunds.tsx @@ -1,21 +1,24 @@ import React from 'react' +import { useNavigate } from 'react-router-dom' import { OutOfFunds } from '@/popup/components/OutOfFunds' import { usePopupState } from '@/popup/lib/context' -import { addFunds } from '@/popup/lib/messages' +import { ROUTES_PATH } from '@/popup/Popup' +import type { State } from '@/popup/pages/OutOfFunds_AddFunds' export const Component = () => { const { state: { grants, walletAddress } } = usePopupState() + const navigate = useNavigate() return ( { - const res = await addFunds(data) - return res + onChooseOption={(recurring) => { + const state: State = { recurring } + navigate(ROUTES_PATH.OUT_OF_FUNDS_ADD_FUNDS, { state }) }} /> ) diff --git a/src/popup/pages/OutOfFunds_AddFunds.tsx b/src/popup/pages/OutOfFunds_AddFunds.tsx new file mode 100644 index 00000000..02d6c435 --- /dev/null +++ b/src/popup/pages/OutOfFunds_AddFunds.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { useLocation } from 'react-router-dom' +import { AddFunds } from '@/popup/components/OutOfFunds' +import { usePopupState } from '@/popup/lib/context' +import { addFunds } from '@/popup/lib/messages' + +export type State = { recurring: boolean } + +export const Component = () => { + const { + state: { grants, walletAddress } + } = usePopupState() + const location = useLocation() + + const state: State = { recurring: false, ...location.state } + const defaultAmount = grants.recurring?.value ?? grants.oneTime!.value + + return ( + { + const res = await addFunds(data) + return res + }} + /> + ) +}