diff options
Diffstat (limited to 'packages/taler-wallet-webextension')
15 files changed, 773 insertions, 605 deletions
diff --git a/packages/taler-wallet-webextension/dev.mjs b/packages/taler-wallet-webextension/dev.mjs new file mode 100755 index 000000000..6c88f8a24 --- /dev/null +++ b/packages/taler-wallet-webextension/dev.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/* eslint-disable no-undef */ + +import linaria from '@linaria/esbuild' +import esbuild from 'esbuild' +import { buildConfig } from "./build-fast-with-linaria.mjs" +import fs from 'fs'; +import WebSocket from "ws"; +import chokidar from "chokidar"; +import path from "path" + +const devServerBroadcastDelay = 500 +const devServerPort = 8002 +const wss = new WebSocket.Server({ port: devServerPort }); +const toWatch = ["./src"] + +function broadcast(file, event) { + setTimeout(() => { + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + console.log(new Date(), file) + client.send(JSON.stringify(event)); + } + }); + }, devServerBroadcastDelay); +} +wss.addListener("connection", () => { + console.log("new client") +}) + +const watcher = chokidar + .watch(toWatch, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 100, + }, + }) + .on("error", (error) => console.error(error)) + .on("change", async (file) => { + broadcast(file, { type: "RELOAD" }); + }) + .on("add", async (file) => { + broadcast(file, { type: "RELOAD" }); + }) + .on("unlink", async (file) => { + broadcast(file, { type: "RELOAD" }); + }); + + +fs.writeFileSync("dev-html/manifest.json", fs.readFileSync("manifest-v2.json")) +fs.writeFileSync("dev-html/mocha.css", fs.readFileSync("node_modules/mocha/mocha.css")) +fs.writeFileSync("dev-html/mocha.js", fs.readFileSync("node_modules/mocha/mocha.js")) +fs.writeFileSync("dev-html/mocha.js.map", fs.readFileSync("node_modules/mocha/mocha.js.map")) + +const server = await esbuild + .serve({ servedir: 'dev-html' }, { + ...buildConfig, outdir: 'dev-html/dist' + }) + .catch((e) => { + console.log(e) + process.exit(1) + }); + +console.log("ready!", server.port); + diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 1293c2b26..bf586834b 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -29,7 +29,8 @@ "preact": "^10.6.5", "preact-router": "3.2.1", "qrcode-generator": "^1.4.4", - "tslib": "^2.3.1" + "tslib": "^2.3.1", + "ws": "7.4.5" }, "devDependencies": { "@babel/core": "7.13.16", @@ -59,6 +60,7 @@ "babel-loader": "^8.2.3", "babel-plugin-transform-react-jsx": "^6.24.1", "chai": "^4.3.6", + "chokidar": "^3.5.3", "mocha": "^9.2.0", "nyc": "^15.1.0", "polished": "^4.1.4", diff --git a/packages/taler-wallet-webextension/serve-esbuild.mjs b/packages/taler-wallet-webextension/serve-esbuild.mjs deleted file mode 100755 index 68dff2c2d..000000000 --- a/packages/taler-wallet-webextension/serve-esbuild.mjs +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable no-undef */ - -import linaria from '@linaria/esbuild' -import esbuild from 'esbuild' -import { buildConfig } from "./build-fast-with-linaria.mjs" -import fs from 'fs'; - -fs.writeFileSync("dev-html/manifest.json", fs.readFileSync("manifest-v2.json")) -fs.writeFileSync("dev-html/mocha.css", fs.readFileSync("node_modules/mocha/mocha.css")) -fs.writeFileSync("dev-html/mocha.js", fs.readFileSync("node_modules/mocha/mocha.js")) -fs.writeFileSync("dev-html/mocha.js.map", fs.readFileSync("node_modules/mocha/mocha.js.map")) - -const server = await esbuild - .serve({ - servedir: 'dev-html', - }, { ...buildConfig, outdir: 'dev-html/dist' }) - .catch((e) => { - console.log(e) - process.exit(1) - }); - -console.log("ready!", server.port); - diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx b/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx index 923ea9e96..6432d532d 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx @@ -21,7 +21,7 @@ import { ContractTerms, PreparePayResultType } from "@gnu-taler/taler-util"; import { createExample } from "../test-utils.js"; -import { PaymentRequestView as TestedComponent } from "./Deposit.js"; +import { View as TestedComponent } from "./Deposit.js"; export default { title: "cta/deposit", @@ -29,140 +29,6 @@ export default { argTypes: {}, }; -export const NoBalance = createExample(TestedComponent, { - payStatus: { - status: PreparePayResultType.InsufficientBalance, - noncePriv: "", - proposalId: "proposal1234", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial<ContractTerms> as any, - amountRaw: "USD:10", - }, -}); - -export const NoEnoughBalance = createExample(TestedComponent, { - payStatus: { - status: PreparePayResultType.InsufficientBalance, - noncePriv: "", - proposalId: "proposal1234", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial<ContractTerms> as any, - amountRaw: "USD:10", - }, - balance: { - currency: "USD", - fraction: 40000000, - value: 9, - }, -}); - -export const PaymentPossible = createExample(TestedComponent, { - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.PaymentPossible, - amountEffective: "USD:10", - amountRaw: "USD:10", - noncePriv: "", - contractTerms: { - nonce: "123213123", - merchant: { - name: "someone", - }, - amount: "USD:10", - summary: "some beers", - } as Partial<ContractTerms> as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - }, -}); - -export const PaymentPossibleWithFee = createExample(TestedComponent, { - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.PaymentPossible, - amountEffective: "USD:10.20", - amountRaw: "USD:10", - noncePriv: "", - contractTerms: { - nonce: "123213123", - merchant: { - name: "someone", - }, - amount: "USD:10", - summary: "some beers", - } as Partial<ContractTerms> as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - }, -}); - -export const AlreadyConfirmedWithFullfilment = createExample(TestedComponent, { - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: "USD:10", - amountRaw: "USD:10", - contractTerms: { - merchant: { - name: "someone", - }, - fulfillment_message: - "congratulations! you are looking at the fulfillment message! ", - summary: "some beers", - amount: "USD:10", - } as Partial<ContractTerms> as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: false, - }, -}); - -export const AlreadyConfirmedWithoutFullfilment = createExample( - TestedComponent, - { - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: "USD:10", - amountRaw: "USD:10", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial<ContractTerms> as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: false, - }, - }, -); - -export const AlreadyPaid = createExample(TestedComponent, { - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: "USD:10", - amountRaw: "USD:10", - contractTerms: { - merchant: { - name: "someone", - }, - fulfillment_message: - "congratulations! you are looking at the fulfillment message! ", - summary: "some beers", - amount: "USD:10", - } as Partial<ContractTerms> as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: true, - }, +export const Simple = createExample(TestedComponent, { + state: { status: "ready" }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.tsx b/packages/taler-wallet-webextension/src/cta/Deposit.tsx index 541bc733b..23c557b0c 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit.tsx +++ b/packages/taler-wallet-webextension/src/cta/Deposit.tsx @@ -39,6 +39,8 @@ import { TalerError } from "@gnu-taler/taler-wallet-core"; import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; +import { Loading } from "../components/Loading.js"; +import { LoadingError } from "../components/LoadingError.js"; import { LogoHeader } from "../components/LogoHeader.js"; import { Part } from "../components/Part.js"; import { @@ -49,157 +51,50 @@ import { WarningBox, } from "../components/styled/index.js"; import { useTranslationContext } from "../context/translation.js"; -import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import * as wxApi from "../wxApi.js"; interface Props { - talerPayUri?: string; + talerDepositUri?: string; goBack: () => void; } -export function DepositPage({ talerPayUri, goBack }: Props): VNode { - const { i18n } = useTranslationContext(); - const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>( - undefined, - ); - const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>( - undefined, - ); - const [payErrMsg, setPayErrMsg] = useState<TalerError | string | undefined>( - undefined, - ); - - const balance = useAsyncAsHook(wxApi.getBalance, [ - NotificationType.CoinWithdrawn, - ]); - const balanceWithoutError = balance?.hasError - ? [] - : balance?.response.balances || []; - - const foundBalance = balanceWithoutError.find( - (b) => - payStatus && - Amounts.parseOrThrow(b.available).currency === - Amounts.parseOrThrow(payStatus?.amountRaw).currency, - ); - const foundAmount = foundBalance - ? Amounts.parseOrThrow(foundBalance.available) - : undefined; - // We use a string here so that dependency tracking for useEffect works properly - const foundAmountStr = foundAmount - ? Amounts.stringify(foundAmount) - : undefined; +type State = Loading | Ready; +interface Loading { + status: "loading"; + hook: HookError | undefined; +} +interface Ready { + status: "ready"; +} - useEffect(() => { - if (!talerPayUri) return; - const doFetch = async (): Promise<void> => { - try { - const p = await wxApi.preparePay(talerPayUri); - setPayStatus(p); - } catch (e) { - console.log("Got error while trying to pay", e); - if (e instanceof TalerError) { - setPayErrMsg(e); - } - if (e instanceof Error) { - setPayErrMsg(e.message); - } - } - }; - doFetch(); - }, [talerPayUri, foundAmountStr]); +function useComponentState(uri: string | undefined): State { + return { + status: "loading", + hook: undefined, + }; +} - if (!talerPayUri) { - return ( - <span> - <i18n.Translate>missing pay uri</i18n.Translate> - </span> - ); - } +export function DepositPage({ talerDepositUri, goBack }: Props): VNode { + const { i18n } = useTranslationContext(); - if (!payStatus) { - if (payErrMsg instanceof TalerError) { - return ( - <WalletAction> - <LogoHeader /> - <SubTitle> - <i18n.Translate>Digital cash payment</i18n.Translate> - </SubTitle> - <section> - <ErrorTalerOperation - title={ - <i18n.Translate> - Could not get the payment information for this order - </i18n.Translate> - } - error={payErrMsg?.errorDetail} - /> - </section> - </WalletAction> - ); - } - if (payErrMsg) { - return ( - <WalletAction> - <LogoHeader /> - <SubTitle> - <i18n.Translate>Digital cash payment</i18n.Translate> - </SubTitle> - <section> - <p> - <i18n.Translate> - Could not get the payment information for this order - </i18n.Translate> - </p> - <ErrorBox>{payErrMsg}</ErrorBox> - </section> - </WalletAction> - ); - } + const state = useComponentState(talerDepositUri); + if (state.status === "loading") { + if (!state.hook) return <Loading />; return ( - <span> - <i18n.Translate>Loading payment information</i18n.Translate> ... - </span> + <LoadingError + title={<i18n.Translate>Could not load pay status</i18n.Translate>} + error={state.hook} + /> ); } - - const onClick = async (): Promise<void> => { - // try { - // const res = await doPayment(payStatus); - // setPayResult(res); - // } catch (e) { - // console.error(e); - // if (e instanceof Error) { - // setPayErrMsg(e.message); - // } - // } - }; - - return ( - <PaymentRequestView - uri={talerPayUri} - payStatus={payStatus} - payResult={payResult} - onClick={onClick} - balance={foundAmount} - /> - ); + return <View state={state} />; } -export interface PaymentRequestViewProps { - payStatus: PreparePayResult; - payResult?: ConfirmPayResult; - onClick: () => void; - payErrMsg?: string; - uri: string; - balance: AmountJson | undefined; +export interface ViewProps { + state: State; } -export function PaymentRequestView({ - payStatus, - payResult, -}: PaymentRequestViewProps): VNode { - const totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw); - const contractTerms: ContractTerms = payStatus.contractTerms; +export function View({ state }: ViewProps): VNode { const { i18n } = useTranslationContext(); return ( @@ -209,78 +104,6 @@ export function PaymentRequestView({ <SubTitle> <i18n.Translate>Digital cash deposit</i18n.Translate> </SubTitle> - {payStatus.status === PreparePayResultType.AlreadyConfirmed && - (payStatus.paid ? ( - <SuccessBox> - <i18n.Translate>Already paid</i18n.Translate> - </SuccessBox> - ) : ( - <WarningBox> - <i18n.Translate>Already claimed</i18n.Translate> - </WarningBox> - ))} - {payResult && payResult.type === ConfirmPayResultType.Done && ( - <SuccessBox> - <h3> - <i18n.Translate>Payment complete</i18n.Translate> - </h3> - <p> - {!payResult.contractTerms.fulfillment_message ? ( - <i18n.Translate> - You will now be sent back to the merchant you came from. - </i18n.Translate> - ) : ( - payResult.contractTerms.fulfillment_message - )} - </p> - </SuccessBox> - )} - <section> - {payStatus.status !== PreparePayResultType.InsufficientBalance && - Amounts.isNonZero(totalFees) && ( - <Part - big - title={<i18n.Translate>Total to pay</i18n.Translate>} - text={amountToPretty( - Amounts.parseOrThrow(payStatus.amountEffective), - )} - kind="negative" - /> - )} - <Part - big - title={<i18n.Translate>Purchase amount</i18n.Translate>} - text={amountToPretty(Amounts.parseOrThrow(payStatus.amountRaw))} - kind="neutral" - /> - {Amounts.isNonZero(totalFees) && ( - <Fragment> - <Part - big - title={<i18n.Translate>Fee</i18n.Translate>} - text={amountToPretty(totalFees)} - kind="negative" - /> - </Fragment> - )} - <Part - title={<i18n.Translate>Merchant</i18n.Translate>} - text={contractTerms.merchant.name} - kind="neutral" - /> - <Part - title={<i18n.Translate>Purchase</i18n.Translate>} - text={contractTerms.summary} - kind="neutral" - /> - {contractTerms.order_id && ( - <Part - title={<i18n.Translate>Receipt</i18n.Translate>} - text={`#${contractTerms.order_id}`} - kind="neutral" - /> - )} - </section> </WalletAction> ); } diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx b/packages/taler-wallet-webextension/src/cta/Pay.tsx index 0d5d57378..832b4879c 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.tsx +++ b/packages/taler-wallet-webextension/src/cta/Pay.tsx @@ -65,7 +65,7 @@ import { useAsyncAsHook, useAsyncAsHook2, } from "../hooks/useAsyncAsHook.js"; -import { ButtonHandler } from "../wallet/CreateManualWithdraw.js"; +import { ButtonHandler } from "../mui/handlers.js"; import * as wxApi from "../wxApi.js"; interface Props { @@ -74,32 +74,6 @@ interface Props { goBack: () => void; } -async function doPayment( - payStatus: PreparePayResult, - api: typeof wxApi, -): Promise<ConfirmPayResultDone> { - if (payStatus.status !== "payment-possible") { - throw TalerError.fromUncheckedDetail({ - code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, - hint: `payment is not possible: ${payStatus.status}`, - }); - } - const proposalId = payStatus.proposalId; - const res = await api.confirmPay(proposalId, undefined); - if (res.type !== ConfirmPayResultType.Done) { - throw TalerError.fromUncheckedDetail({ - code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, - hint: `could not confirm payment`, - payResult: res, - }); - } - const fu = res.contractTerms.fulfillment_url; - if (fu) { - document.location.href = fu; - } - return res; -} - type State = Loading | Ready | Confirmed; interface Loading { status: "loading"; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx index 2191205c2..f2bc14f76 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx @@ -66,7 +66,9 @@ export const TermsOfServiceNotYetLoaded = createExample(TestedComponent, { exchange: { list: exchangeList, value: "exchange.demo.taler.net", - onChange: () => null, + onChange: async () => { + null; + }, }, showExchangeSelection: false, mustAcceptFirst: false, @@ -99,7 +101,9 @@ export const WithSomeFee = createExample(TestedComponent, { exchange: { list: exchangeList, value: "exchange.demo.taler.net", - onChange: () => null, + onChange: async () => { + null; + }, }, showExchangeSelection: false, mustAcceptFirst: false, @@ -133,7 +137,9 @@ export const WithoutFee = createExample(TestedComponent, { exchange: { list: exchangeList, value: "exchange.demo.taler.net", - onChange: () => null, + onChange: async () => { + null; + }, }, showExchangeSelection: false, mustAcceptFirst: false, @@ -167,7 +173,9 @@ export const EditExchangeUntouched = createExample(TestedComponent, { exchange: { list: exchangeList, value: "exchange.demo.taler.net", - onChange: () => null, + onChange: async () => { + null; + }, }, showExchangeSelection: true, mustAcceptFirst: false, @@ -202,7 +210,9 @@ export const EditExchangeModified = createExample(TestedComponent, { list: exchangeList, isDirty: true, value: "exchange.test.taler.net", - onChange: () => null, + onChange: async () => { + null; + }, }, showExchangeSelection: true, mustAcceptFirst: false, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx index 2293d6508..21f98ec9a 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx @@ -42,10 +42,7 @@ import { import { useTranslationContext } from "../context/translation.js"; import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { buildTermsOfServiceState } from "../utils/index.js"; -import { - ButtonHandler, - SelectFieldHandler, -} from "../wallet/CreateManualWithdraw.js"; +import { ButtonHandler, SelectFieldHandler } from "../mui/handlers.js"; import * as wxApi from "../wxApi.js"; import { Props as TermsOfServiceSectionProps, @@ -258,7 +255,7 @@ export function useComponentState( } const exchangeHandler: SelectFieldHandler = { - onChange: setNextExchange, + onChange: async (e) => setNextExchange(e), value: nextExchange ?? thisExchange, list: exchanges, isDirty: nextExchange !== undefined, diff --git a/packages/taler-wallet-webextension/src/mui/handlers.ts b/packages/taler-wallet-webextension/src/mui/handlers.ts new file mode 100644 index 000000000..f75070c9c --- /dev/null +++ b/packages/taler-wallet-webextension/src/mui/handlers.ts @@ -0,0 +1,21 @@ +import { TalerError } from "@gnu-taler/taler-wallet-core"; + +export interface TextFieldHandler { + onInput: (value: string) => Promise<void>; + value: string; + error?: string; +} + +export interface ButtonHandler { + onClick?: () => Promise<void>; + error?: TalerError; +} + +export interface SelectFieldHandler { + onChange: (value: string) => Promise<void>; + error?: string; + value: string; + isDirty?: boolean; + list: Record<string, string>; +} + diff --git a/packages/taler-wallet-webextension/src/stories.tsx b/packages/taler-wallet-webextension/src/stories.tsx index 3f74cf11b..1ad91a13b 100644 --- a/packages/taler-wallet-webextension/src/stories.tsx +++ b/packages/taler-wallet-webextension/src/stories.tsx @@ -69,10 +69,13 @@ const SideBar = styled.div` & > { ol { padding: 4px; - div { + div:first-child { background-color: lightcoral; cursor: pointer; } + div[data-hide="true"] { + display: none; + } dd { margin-left: 1em; padding: 4px; @@ -192,12 +195,12 @@ function ExampleList({ selected: ExampleItem | undefined; onSelectStory: (i: ExampleItem, id: string) => void; }): VNode { - const [open, setOpen] = useState(true); + const [isOpen, setOpen] = useState(selected && selected.group === name); return ( <ol> - <div onClick={() => setOpen(!open)}>{name}</div> - {open && - list.map((k) => ( + <div onClick={() => setOpen(!isOpen)}>{name}</div> + <div data-hide={!isOpen}> + {list.map((k) => ( <li key={k.name}> <dl> <dt>{k.name}</dt> @@ -215,6 +218,7 @@ function ExampleList({ href={`#${eId}`} onClick={(e) => { e.preventDefault(); + location.hash = `#${eId}`; onSelectStory(r, eId); }} > @@ -226,6 +230,7 @@ function ExampleList({ </dl> </li> ))} + </div> </ol> ); } @@ -335,6 +340,7 @@ function Application(): VNode { return ( <Page> + <LiveReload /> <SideBar> {allExamples.map((e) => ( <ExampleList @@ -382,3 +388,56 @@ function main(): void { } } } + +let liveReloadMounted = false; +function LiveReload({ port = 8002 }: { port?: number }): VNode { + const [isReloading, setIsReloading] = useState(false); + useEffect(() => { + if (!liveReloadMounted) { + setupLiveReload(port, () => { + setIsReloading(true); + window.location.reload(); + }); + liveReloadMounted = true; + } + }); + + if (isReloading) { + return ( + <div + style={{ + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "rgba(0,0,0,0.5)", + color: "white", + display: "flex", + justifyContent: "center", + }} + > + <h1 style={{ margin: "auto" }}>reloading...</h1> + </div> + ); + } + return <Fragment />; +} + +function setupLiveReload(port: number, onReload: () => void): void { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const host = location.hostname; + const socketPath = `${protocol}//${host}:${port}/socket`; + + const ws = new WebSocket(socketPath); + ws.onmessage = (message) => { + const event = JSON.parse(message.data); + if (event.type === "LOG") { + console.log(event.message); + } + if (event.type === "RELOAD") { + onReload(); + } + }; + ws.onerror = (error) => { + console.error(error); + }; +} diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts index f2bb4a7d2..a4b333f02 100644 --- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts +++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts @@ -21,8 +21,9 @@ */ import { expect } from "chai"; +import { SelectFieldHandler, TextFieldHandler } from "../mui/handlers.js"; import { mountHook } from "../test-utils.js"; -import { SelectFieldHandler, TextFieldHandler, useComponentState } from "./CreateManualWithdraw.js"; +import { useComponentState } from "./CreateManualWithdraw.js"; const exchangeListWithARSandUSD = { diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx index 0440c50a9..11bade6f5 100644 --- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx +++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx @@ -37,6 +37,7 @@ import { SubTitle, } from "../components/styled/index.js"; import { useTranslationContext } from "../context/translation.js"; +import { SelectFieldHandler, TextFieldHandler } from "../mui/handlers.js"; import { Pages } from "../NavigationBar.js"; export interface Props { @@ -55,25 +56,6 @@ export interface State { exchange: SelectFieldHandler; } -export interface TextFieldHandler { - onInput: (value: string) => void; - value: string; - error?: string; -} - -export interface ButtonHandler { - onClick?: () => Promise<void>; - error?: TalerError; -} - -export interface SelectFieldHandler { - onChange: (value: string) => void; - error?: string; - value: string; - isDirty?: boolean; - list: Record<string, string>; -} - export function useComponentState( exchangeUrlWithCurrency: Record<string, string>, initialAmount: string | undefined, @@ -109,12 +91,12 @@ export function useComponentState( const [amount, setAmount] = useState(initialAmount || ""); const parsedAmount = Amounts.parse(`${currency}:${amount}`); - function changeExchange(exchange: string): void { + async function changeExchange(exchange: string): Promise<void> { setExchange(exchange); setCurrency(exchangeUrlWithCurrency[exchange]); } - function changeCurrency(currency: string): void { + async function changeCurrency(currency: string): Promise<void> { setCurrency(currency); const found = Object.entries(exchangeUrlWithCurrency).find( (e) => e[1] === currency, @@ -140,7 +122,7 @@ export function useComponentState( }, amount: { value: amount, - onInput: (e: string) => setAmount(e), + onInput: async (e: string) => setAmount(e), }, parsedAmount, }; diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx index edc2f971f..5f7966417 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage.stories.tsx @@ -20,10 +20,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Balance, parsePaytoUri } from "@gnu-taler/taler-util"; +import { Amounts, Balance, parsePaytoUri } from "@gnu-taler/taler-util"; import type { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits.js"; import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./DepositPage.js"; +import { + createLabelsForBankAccount, + View as TestedComponent, +} from "./DepositPage.js"; export default { title: "wallet/deposit", @@ -41,23 +44,44 @@ async function alwaysReturnFeeToOne(): Promise<DepositGroupFees> { } export const WithEmptyAccountList = createExample(TestedComponent, { - accounts: [], - balances: [ - { - available: "USD:10", - } as Balance, - ], - currency: "USD", - onCalculateFee: alwaysReturnFeeToOne, + state: { + status: "no-accounts", + cancelHandler: {}, + }, + // accounts: [], + // balances: [ + // { + // available: "USD:10", + // } as Balance, + // ], + // currency: "USD", + // onCalculateFee: alwaysReturnFeeToOne, }); +const ac = parsePaytoUri("payto://iban/ES8877998399652238")!; +const accountMap = createLabelsForBankAccount([ac]); + export const WithSomeBankAccounts = createExample(TestedComponent, { - accounts: [parsePaytoUri("payto://iban/ES8877998399652238")!], - balances: [ - { - available: "USD:10", - } as Balance, - ], - currency: "USD", - onCalculateFee: alwaysReturnFeeToOne, + state: { + status: "ready", + account: { + list: accountMap, + value: accountMap[0], + onChange: async () => { + null; + }, + }, + currency: "USD", + amount: { + onInput: async () => { + null; + }, + value: "10:USD", + }, + cancelHandler: {}, + depositHandler: {}, + totalFee: Amounts.getZero("USD"), + totalToDeposit: Amounts.parseOrThrow("USD:10"), + // onCalculateFee: alwaysReturnFeeToOne, + }, }); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts index ac4e0ea93..c863b27d5 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts @@ -19,46 +19,390 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts, Balance } from "@gnu-taler/taler-util"; +import { Amounts, Balance, BalancesResponse, parsePaytoUri } from "@gnu-taler/taler-util"; import { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import { expect } from "chai"; import { mountHook } from "../test-utils.js"; import { useComponentState } from "./DepositPage.js"; +import * as wxApi from "../wxApi.js"; const currency = "EUR" -const feeCalculator = async (): Promise<DepositGroupFees> => ({ +const withoutFee = async (): Promise<DepositGroupFees> => ({ + coin: Amounts.parseOrThrow(`${currency}:0`), + wire: Amounts.parseOrThrow(`${currency}:0`), + refresh: Amounts.parseOrThrow(`${currency}:0`) +}) + +const withSomeFee = async (): Promise<DepositGroupFees> => ({ coin: Amounts.parseOrThrow(`${currency}:1`), wire: Amounts.parseOrThrow(`${currency}:1`), refresh: Amounts.parseOrThrow(`${currency}:1`) }) +const freeJustForIBAN = async (account: string): Promise<DepositGroupFees> => /IBAN/i.test(account) ? withoutFee() : withSomeFee() + const someBalance = [{ available: 'EUR:10' } as Balance] +const nullFunction: any = () => null; +type VoidFunction = () => void; + describe("DepositPage states", () => { - it("should have status 'no-balance' when balance is empty", () => { - const { getLastResultOrThrow } = mountHook(() => - useComponentState(currency, [], [], feeCalculator), + it("should have status 'no-balance' when balance is empty", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:0`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [] }) + } as Partial<typeof wxApi> as any) ); { const { status } = getLastResultOrThrow() + expect(status).equal("loading") + } + + await waitNextUpdate() + + { + const { status } = getLastResultOrThrow() expect(status).equal("no-balance") } + await assertNoPendingUpdate() + + }); + + it("should have status 'no-accounts' when balance is not empty and accounts is empty", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:1`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [] }) + } as Partial<typeof wxApi> as any) + ); + + { + const { status } = getLastResultOrThrow() + expect(status).equal("loading") + } + + await waitNextUpdate() + { + const r = getLastResultOrThrow() + if (r.status !== "no-accounts") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + } + + await assertNoPendingUpdate() + + }); + + const ibanPayto = parsePaytoUri("payto://iban/ES8877998399652238")!; + const talerBankPayto = parsePaytoUri("payto://x-taler-bank/ES8877998399652238")!; + + it("should have status 'ready' but unable to deposit ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:1`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }) + } as Partial<typeof wxApi> as any) + ); + + { + const { status } = getLastResultOrThrow() + expect(status).equal("loading") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("0") + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate() + }); + + it("should not be able to deposit more than the balance ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:1`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + getFeeForDeposit: withoutFee + } as Partial<typeof wxApi> as any) + ); + + { + const { status } = getLastResultOrThrow() + expect(status).equal("loading") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("0") + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + + r.amount.onInput("10") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("10") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate() + }); + + it("should calculate the fee upon entering amount ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:1`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + getFeeForDeposit: withSomeFee + } as Partial<typeof wxApi> as any) + ); + + { + const { status } = getLastResultOrThrow() + expect(status).equal("loading") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("0") + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + + r.amount.onInput("10") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("10") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)) + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate() + }); + + it("should calculate the fee upon selecting account ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:1`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto, talerBankPayto] }), + getFeeForDeposit: freeJustForIBAN + } as Partial<typeof wxApi> as any) + ); + + { + const { status } = getLastResultOrThrow() + expect(status).equal("loading") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("0") + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + + r.account.onChange("1") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("1") + expect(r.amount.value).eq("0") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + expect(r.depositHandler.onClick).undefined; + + r.amount.onInput("10") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("1") + expect(r.amount.value).eq("10") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)) + expect(r.depositHandler.onClick).undefined; + + r.account.onChange("0") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("10") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)) + expect(r.depositHandler.onClick).undefined; + + } + + await assertNoPendingUpdate() }); - it("should have status 'no-accounts' when balance is not empty and accounts is empty", () => { - const { getLastResultOrThrow } = mountHook(() => - useComponentState(currency, [], someBalance, feeCalculator), + + it("should be able to deposit if has the enough balance ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(currency, nullFunction, nullFunction, { + getBalance: async () => ({ + balances: [{ available: `${currency}:15`, }] + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + getFeeForDeposit: withSomeFee + } as Partial<typeof wxApi> as any) ); { const { status } = getLastResultOrThrow() - expect(status).equal("no-accounts") + expect(status).equal("loading") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("0") + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)) + + r.amount.onInput("10") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("10") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)) + expect(r.depositHandler.onClick).not.undefined; + + r.amount.onInput("13") + } + + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("13") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)) + expect(r.depositHandler.onClick).not.undefined; + + r.amount.onInput("15") } + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("15") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:12`)) + expect(r.depositHandler.onClick).not.undefined; + r.amount.onInput("17") + } + await waitNextUpdate() + + { + const r = getLastResultOrThrow() + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq("0") + expect(r.amount.value).eq("17") + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)) + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:14`)) + expect(r.depositHandler.onClick).undefined; + } + await assertNoPendingUpdate() }); + });
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx index 335dfd3c7..98328ae4a 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage.tsx @@ -15,16 +15,10 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - AmountJson, - Amounts, - AmountString, - Balance, - PaytoUri, -} from "@gnu-taler/taler-util"; +import { AmountJson, Amounts, PaytoUri } from "@gnu-taler/taler-util"; import { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { Loading } from "../components/Loading.js"; import { LoadingError } from "../components/LoadingError.js"; import { SelectList } from "../components/SelectList.js"; @@ -38,12 +32,13 @@ import { WarningBox, } from "../components/styled/index.js"; import { useTranslationContext } from "../context/translation.js"; -import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import * as wxApi from "../wxApi.js"; +import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { + ButtonHandler, SelectFieldHandler, TextFieldHandler, -} from "./CreateManualWithdraw.js"; +} from "../mui/handlers.js"; +import * as wxApi from "../wxApi.js"; interface Props { currency: string; @@ -51,119 +46,90 @@ interface Props { onSuccess: (currency: string) => void; } export function DepositPage({ currency, onCancel, onSuccess }: Props): VNode { - const state = useAsyncAsHook(async () => { - const { balances } = await wxApi.getBalance(); - const { accounts } = await wxApi.listKnownBankAccounts(currency); - return { accounts, balances }; - }); - - const { i18n } = useTranslationContext(); - - async function doSend(p: PaytoUri, a: AmountJson): Promise<void> { - const account = `payto://${p.targetType}/${p.targetPath}`; - const amount = Amounts.stringify(a); - await wxApi.createDepositGroup(account, amount); - onSuccess(currency); - } - - async function getFeeForAmount( - p: PaytoUri, - a: AmountJson, - ): Promise<DepositGroupFees> { - const account = `payto://${p.targetType}/${p.targetPath}`; - const amount = Amounts.stringify(a); - return await wxApi.getFeeForDeposit(account, amount); - } - - if (state === undefined) return <Loading />; + const state = useComponentState(currency, onCancel, onSuccess, wxApi); - if (state.hasError) { - return ( - <LoadingError - title={<i18n.Translate>Could not load deposit balance</i18n.Translate>} - error={state} - /> - ); - } - - return ( - <View - onCancel={() => onCancel(currency)} - currency={currency} - accounts={state.response.accounts} - balances={state.response.balances} - onSend={doSend} - onCalculateFee={getFeeForAmount} - /> - ); + return <View state={state} />; } interface ViewProps { - accounts: Array<PaytoUri>; - currency: string; - balances: Balance[]; - onCancel: () => void; - onSend: (account: PaytoUri, amount: AmountJson) => Promise<void>; - onCalculateFee: ( - account: PaytoUri, - amount: AmountJson, - ) => Promise<DepositGroupFees>; + state: State; } -type State = NoBalanceState | NoAccountsState | DepositState; +type State = Loading | NoBalanceState | NoAccountsState | DepositState; + +interface Loading { + status: "loading"; + hook: HookError | undefined; +} interface NoBalanceState { status: "no-balance"; } interface NoAccountsState { status: "no-accounts"; + cancelHandler: ButtonHandler; } interface DepositState { - status: "deposit"; + status: "ready"; + currency: string; amount: TextFieldHandler; account: SelectFieldHandler; totalFee: AmountJson; totalToDeposit: AmountJson; - unableToDeposit: boolean; - selectedAccount: PaytoUri; - parsedAmount: AmountJson | undefined; + // currentAccount: PaytoUri; + // parsedAmount: AmountJson | undefined; + cancelHandler: ButtonHandler; + depositHandler: ButtonHandler; +} + +async function getFeeForAmount( + p: PaytoUri, + a: AmountJson, + api: typeof wxApi, +): Promise<DepositGroupFees> { + const account = `payto://${p.targetType}/${p.targetPath}`; + const amount = Amounts.stringify(a); + return await api.getFeeForDeposit(account, amount); } export function useComponentState( currency: string, - accounts: PaytoUri[], - balances: Balance[], - onCalculateFee: ( - account: PaytoUri, - amount: AmountJson, - ) => Promise<DepositGroupFees>, + onCancel: (currency: string) => void, + onSuccess: (currency: string) => void, + api: typeof wxApi, ): State { - const accountMap = createLabelsForBankAccount(accounts); + const hook = useAsyncAsHook(async () => { + const { balances } = await api.getBalance(); + const { accounts } = await api.listKnownBankAccounts(currency); + const defaultSelectedAccount = + accounts.length > 0 ? accounts[0] : undefined; + return { accounts, balances, defaultSelectedAccount }; + }); + const [accountIdx, setAccountIdx] = useState(0); - const [amount, setAmount] = useState<number | undefined>(undefined); + const [amount, setAmount] = useState<number>(0); + + const [selectedAccount, setSelectedAccount] = useState< + PaytoUri | undefined + >(); + + const parsedAmount = Amounts.parse(`${currency}:${amount}`); + const [fee, setFee] = useState<DepositGroupFees | undefined>(undefined); - function updateAmount(num: number | undefined): void { - setAmount(num); - setFee(undefined); - } - const selectedAmountSTR: AmountString = `${currency}:${amount}`; - const totalFee = - fee !== undefined - ? Amounts.sum([fee.wire, fee.coin, fee.refresh]).amount - : Amounts.getZero(currency); + // const hookResponse = !hook || hook.hasError ? undefined : hook.response; - const selectedAccount = accounts.length ? accounts[accountIdx] : undefined; + // useEffect(() => {}, [hookResponse]); - const parsedAmount = - amount === undefined ? undefined : Amounts.parse(selectedAmountSTR); + if (!hook || hook.hasError) { + return { + status: "loading", + hook, + }; + } - useEffect(() => { - if (selectedAccount === undefined || parsedAmount === undefined) return; - onCalculateFee(selectedAccount, parsedAmount).then((result) => { - setFee(result); - }); - }, [amount, selectedAccount, parsedAmount, onCalculateFee]); + const { accounts, balances, defaultSelectedAccount } = hook.response; + const currentAccount = selectedAccount ?? defaultSelectedAccount; const bs = balances.filter((b) => b.available.startsWith(currency)); const balance = @@ -171,6 +137,63 @@ export function useComponentState( ? Amounts.parseOrThrow(bs[0].available) : Amounts.getZero(currency); + if (Amounts.isZero(balance)) { + return { + status: "no-balance", + }; + } + + if (!currentAccount) { + return { + status: "no-accounts", + cancelHandler: { + onClick: async () => { + onCancel(currency); + }, + }, + }; + } + const accountMap = createLabelsForBankAccount(accounts); + + async function updateAccount(accountStr: string): Promise<void> { + const idx = parseInt(accountStr, 10); + const newSelected = accounts.length > idx ? accounts[idx] : undefined; + if (accountIdx === idx || !newSelected) return; + + if (!parsedAmount) { + setAccountIdx(idx); + setSelectedAccount(newSelected); + } else { + const result = await getFeeForAmount(newSelected, parsedAmount, api); + setAccountIdx(idx); + setSelectedAccount(newSelected); + setFee(result); + } + } + + async function updateAmount(numStr: string): Promise<void> { + const num = parseFloat(numStr); + const newAmount = Number.isNaN(num) ? 0 : num; + if (amount === newAmount || !currentAccount) return; + const parsed = Amounts.parse(`${currency}:${newAmount}`); + if (!parsed) { + setAmount(newAmount); + } else { + const result = await getFeeForAmount(currentAccount, parsed, api); + setAmount(newAmount); + setFee(result); + } + } + + const totalFee = + fee !== undefined + ? Amounts.sum([fee.wire, fee.coin, fee.refresh]).amount + : Amounts.getZero(currency); + + const totalToDeposit = parsedAmount + ? Amounts.sub(parsedAmount, totalFee).amount + : Amounts.getZero(currency); + const isDirty = amount !== 0; const amountError = !isDirty ? undefined @@ -180,65 +203,63 @@ export function useComponentState( ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}` : undefined; - const totalToDeposit = parsedAmount - ? Amounts.sub(parsedAmount, totalFee).amount - : Amounts.getZero(currency); - const unableToDeposit = + !parsedAmount || Amounts.isZero(totalToDeposit) || fee === undefined || amountError !== undefined; - if (Amounts.isZero(balance)) { - return { - status: "no-balance", - }; - } + async function doSend(): Promise<void> { + if (!currentAccount || !parsedAmount) return; - if (!accounts || !accounts.length || !selectedAccount) { - return { - status: "no-accounts", - }; + const account = `payto://${currentAccount.targetType}/${currentAccount.targetPath}`; + const amount = Amounts.stringify(parsedAmount); + await api.createDepositGroup(account, amount); + onSuccess(currency); } return { - status: "deposit", + status: "ready", + currency, amount: { value: String(amount), - onInput: (e) => { - const num = parseFloat(e); - if (!Number.isNaN(num)) { - updateAmount(num); - } else { - updateAmount(undefined); - setFee(undefined); - } - }, + onInput: updateAmount, error: amountError, }, account: { list: accountMap, value: String(accountIdx), - onChange: (s) => setAccountIdx(parseInt(s, 10)), + onChange: updateAccount, + }, + cancelHandler: { + onClick: async () => { + onCancel(currency); + }, + }, + depositHandler: { + onClick: unableToDeposit ? undefined : doSend, }, totalFee, totalToDeposit, - unableToDeposit, - selectedAccount, - parsedAmount, + // currentAccount, + // parsedAmount, }; } -export function View({ - onCancel, - currency, - accounts, - balances, - onSend, - onCalculateFee, -}: ViewProps): VNode { +export function View({ state }: ViewProps): VNode { const { i18n } = useTranslationContext(); - const state = useComponentState(currency, accounts, balances, onCalculateFee); + + if (state === undefined) return <Loading />; + + if (state.status === "loading") { + if (!state.hook) return <Loading />; + return ( + <LoadingError + title={<i18n.Translate>Could not load deposit balance</i18n.Translate>} + error={state.hook} + /> + ); + } if (state.status === "no-balance") { return ( @@ -258,7 +279,7 @@ export function View({ </p> </WarningBox> <footer> - <Button onClick={onCancel}> + <Button onClick={state.cancelHandler.onClick}> <i18n.Translate>Cancel</i18n.Translate> </Button> </footer> @@ -269,7 +290,7 @@ export function View({ return ( <Fragment> <SubTitle> - <i18n.Translate>Send {currency} to your account</i18n.Translate> + <i18n.Translate>Send {state.currency} to your account</i18n.Translate> </SubTitle> <section> <Input> @@ -286,7 +307,7 @@ export function View({ <i18n.Translate>Amount</i18n.Translate> </label> <div> - <span>{currency}</span> + <span>{state.currency}</span> <input type="number" value={state.amount.value} @@ -302,7 +323,7 @@ export function View({ <i18n.Translate>Deposit fee</i18n.Translate> </label> <div> - <span>{currency}</span> + <span>{state.currency}</span> <input type="number" disabled @@ -316,7 +337,7 @@ export function View({ <i18n.Translate>Total deposit</i18n.Translate> </label> <div> - <span>{currency}</span> + <span>{state.currency}</span> <input type="number" disabled @@ -328,19 +349,18 @@ export function View({ } </section> <footer> - <Button onClick={onCancel}> + <Button onClick={state.cancelHandler.onClick}> <i18n.Translate>Cancel</i18n.Translate> </Button> - {state.unableToDeposit ? ( + {!state.depositHandler.onClick ? ( <ButtonPrimary disabled> <i18n.Translate>Deposit</i18n.Translate> </ButtonPrimary> ) : ( - <ButtonPrimary - onClick={() => onSend(state.selectedAccount, state.parsedAmount!)} - > + <ButtonPrimary onClick={state.depositHandler.onClick}> <i18n.Translate> - Deposit {Amounts.stringifyValue(state.totalToDeposit)} {currency} + Deposit {Amounts.stringifyValue(state.totalToDeposit)}{" "} + {state.currency} </i18n.Translate> </ButtonPrimary> )} @@ -349,7 +369,9 @@ export function View({ ); } -function createLabelsForBankAccount(knownBankAccounts: Array<PaytoUri>): { +export function createLabelsForBankAccount( + knownBankAccounts: Array<PaytoUri>, +): { [label: number]: string; } { if (!knownBankAccounts) return {}; |