Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(popup): add ouf of funds and add funds screens #439

Merged
merged 11 commits into from
Jul 23, 2024
43 changes: 43 additions & 0 deletions src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
5 changes: 5 additions & 0 deletions src/popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
},
Comment on lines +53 to +56
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good approach. Let's avoid having other folders in pages. For the future, defining sub-routes should happen like this.

{
path: ROUTES_PATH.SETTINGS,
lazy: () => import('./pages/Settings')
Expand Down
194 changes: 158 additions & 36 deletions src/popup/components/OutOfFunds.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,198 @@
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<WalletAddress, 'id' | 'assetCode' | 'assetScale'>
grantRecurring?: RecurringGrant['amount']
grantOneTime?: OneTimeGrant['amount']
requestAddFunds: (details: AddFundsPayload) => Promise<Response>
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 (
<div className="flex flex-col gap-4">
<ErrorMessage
error={t('outOfFunds_error_title')}
className="mb-0 text-error"
/>
<div className="px-2 text-xs text-medium">
<p>{t('outOfFunds_error_text')}</p>
<p>{t('outOfFunds_error_textHint')}</p>
{grantRecurring?.value && (
<p className="mt-1">
<RecurringAutoRenewInfo
info={info}
grantRecurring={grantRecurring}
/>
</p>
)}
</div>

<div className="w-100 h-0.5 bg-disabled" />

<Button onClick={() => onChooseOption(true)}>
{t('outOfFunds_action_optionRecurring')}
</Button>
<Button onClick={() => onChooseOption(false)}>
{t('outOfFunds_action_optionOneTime')}
</Button>
</div>
)
}

const requestTopUpOneTime = () => {
return requestAddFunds({ amount: amount, recurring: false })
}
interface AddFundsProps {
info: Pick<WalletAddress, 'id' | 'assetCode' | 'assetScale'>
recurring: false | 'P1M'
sidvishnoi marked this conversation as resolved.
Show resolved Hide resolved
defaultAmount: AmountValue
requestAddFunds: (details: AddFundsPayload) => Promise<Response>
}

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 (
<div className="flex flex-col gap-2">
<h2 className="text-xl">Out of funds</h2>

<h3 className="text-lg">
Top-up: <span>{currencySymbol + amount}</span>
</h3>
<RecurringAutoRenewInfo grantRecurring={grantRecurring} info={info} />
<Button onClick={() => requestTopUpRecurring()}>Recurring</Button>
<Button onClick={() => requestTopUpOneTime()}>One-time</Button>
</div>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(async (data) => {
const response = await requestAddFunds({
amount: data.amount,
recurring: !!recurring
})
if (!response.success) {
setError('root', { message: response.message })
}
})}
>
<Input
type="url"
label={t('outOfFundsAddFunds_label_walletAddress')}
value={info.id}
readOnly
disabled
/>

<Input
type="text"
inputMode="numeric"
addOn={currencySymbol}
label={t('outOfFundsAddFunds_label_amount')}
description={
recurring
? t('outOfFundsAddFunds_label_amountDescriptionRecurring', [
getNextOccurrenceDate('P1M')
])
: t('outOfFundsAddFunds_label_amountDescriptionOneTime')
}
placeholder="5.00"
onKeyDown={(e) => {
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<HTMLInputElement>) => {
setValue('amount', formatNumber(+e.currentTarget.value, 2))
}
})}
/>

{(errors.root || errors.amount) && (
<ErrorMessage
error={errors.root?.message || errors.amount?.message}
className="mb-0 py-1 text-xs text-error"
/>
)}

<Button type="submit" disabled={isSubmitting} loading={isSubmitting}>
{recurring
? t('outOfFundsAddFunds_action_addRecurring')
: t('outOfFundsAddFunds_action_addOneTime')}
</Button>
</form>
)
}

function RecurringAutoRenewInfo({
grantRecurring,
info
}: Pick<OutOfFundsProps, 'grantRecurring' | 'info'>) {
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 (
<p>
If you do nothing, you will have {currencySymbol}
{amount} available on{' '}
<time dateTime={renewDate.toISOString()}>
{renewDate.toLocaleString(undefined, {
dateStyle: 'long',
timeStyle: 'medium'
})}
</time>
</p>
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' })
}
8 changes: 8 additions & 0 deletions src/popup/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ const NavigationButton = () => {
return useMemo(() => {
if (!connected) return null

if (location.pathname.includes('/s/')) {
return (
<Link to={location.pathname.split('/s/')[0]}>
<ArrowBack className="h-6" />
</Link>
)
}

Comment on lines +15 to +22
Copy link
Member Author

@sidvishnoi sidvishnoi Jul 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solves #424.

We can store location in localStorage, and use that as default route (initialEntries) in future, instead of managing screens from within components (at expense of using more pages) - to handle cases when we need to re-open previous route after popup-reopen (#366)

return location.pathname === `${ROUTES_PATH.SETTINGS}` ? (
<Link to={ROUTES_PATH.HOME}>
<ArrowBack className="h-6" />
Expand Down
11 changes: 7 additions & 4 deletions src/popup/pages/OutOfFunds.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<OutOfFunds
info={walletAddress}
grantOneTime={grants.oneTime}
grantRecurring={grants.recurring}
requestAddFunds={async (data) => {
const res = await addFunds(data)
return res
onChooseOption={(recurring) => {
const state: State = { recurring }
navigate(ROUTES_PATH.OUT_OF_FUNDS_ADD_FUNDS, { state })
}}
/>
)
Expand Down
29 changes: 29 additions & 0 deletions src/popup/pages/OutOfFunds_AddFunds.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AddFunds
info={walletAddress}
defaultAmount={defaultAmount}
recurring={state.recurring ? 'P1M' : false}
requestAddFunds={async (data) => {
const res = await addFunds(data)
return res
}}
/>
)
}
Loading