diff options
author | Sebastian <sebasjm@gmail.com> | 2022-04-11 11:36:32 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2022-04-11 11:36:38 -0300 |
commit | ccb50c636054819f5af8778cc3ebe5258b1c2e87 (patch) | |
tree | 750f28869e1ebb5f1f52248d83671b13e4c89f09 /packages/taler-wallet-webextension/src/cta | |
parent | e09ed46675047d97d4f3d4237c23f6365eba6534 (diff) | |
download | wallet-core-ccb50c636054819f5af8778cc3ebe5258b1c2e87.tar.xz |
new test api to test hooks rendering iteration, testing state of withdraw page
Diffstat (limited to 'packages/taler-wallet-webextension/src/cta')
3 files changed, 657 insertions, 565 deletions
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx index eb18251fd..2191205c2 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx @@ -19,349 +19,203 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { amountFractionalBase, ExchangeListItem } from "@gnu-taler/taler-util"; import { createExample } from "../test-utils.js"; -import { termsHtml, termsPdf, termsPlain, termsXml } from "./termsExample.js"; +import { TermsState } from "../utils/index.js"; import { View as TestedComponent } from "./Withdraw.js"; -function parseFromString(s: string): Document { - if (typeof window === "undefined") { - return {} as Document; - } - return new window.DOMParser().parseFromString(s, "text/xml"); -} - export default { title: "cta/withdraw", component: TestedComponent, }; -const exchangeList: ExchangeListItem[] = [ - { - currency: "USD", - exchangeBaseUrl: "exchange.demo.taler.net", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "terms of service content", - contentType: "text/plain", - }, - paytoUris: ["asd"], - }, - { - currency: "USD", - exchangeBaseUrl: "exchange.test.taler.net", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "terms of service content", - contentType: "text/plain", - }, - paytoUris: ["asd"], - }, -]; - -export const NewTerms = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 1, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, +const exchangeList = { + "exchange.demo.taler.net": "http://exchange.demo.taler.net (USD)", + "exchange.test.taler.net": "http://exchange.test.taler.net (KUDOS)", +}; - onSwitchExchange: async () => { +const nullHandler = { + onClick: async (): Promise<void> => { null; }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), - }, - status: "new", - version: "", - }, -}); - -export const TermsReviewingPLAIN = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, +}; - onSwitchExchange: async () => { - null; - }, +const normalTosState = { terms: { - content: { - type: "plain", - content: termsPlain, - }, - status: "new", + status: "accepted", version: "", - }, - reviewing: true, -}); - -export const TermsReviewingHTML = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, + } as TermsState, + onAccept: () => null, + onReview: () => null, + reviewed: false, + reviewing: false, +}; - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "html", - href: new URL(`data:text/html;base64,${toBase64(termsHtml)}`), +export const TermsOfServiceNotYetLoaded = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, }, - version: "", - status: "new", - }, - reviewing: true, -}); - -function toBase64(str: string): string { - return btoa( - encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { - return String.fromCharCode(parseInt(p1, 16)); - }), - ); -} - -export const TermsReviewingPDF = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "pdf", - location: new URL(`data:text/html;base64,${toBase64(termsPdf)}`), + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, }, - status: "new", - version: "", - }, - reviewing: true, -}); - -export const TermsReviewingXML = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 10000000, + value: 1, }, - status: "new", - version: "", - }, - reviewing: true, -}); - -export const NewTermsAccepted = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), + toBeReceived: { + currency: "USD", + fraction: 0, + value: 1, }, - status: "new", - version: "", }, - reviewed: true, }); -export const TermsShowAgainXML = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), +export const WithSomeFee = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, }, - version: "", - status: "new", - }, - reviewed: true, - reviewing: true, -}); - -export const TermsChanged = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, }, - version: "", - status: "changed", + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 10000000, + value: 1, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 1, + }, + tosProps: normalTosState, }, }); -export const TermsNotFound = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: undefined, - status: "notfound", - version: "", +export const WithoutFee = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, }, }); -export const TermsAlreadyAccepted = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: amountFractionalBase * 0.5, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - status: "accepted", - content: undefined, - version: "", +export const EditExchangeUntouched = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, + }, + showExchangeSelection: true, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, }, }); -export const WithoutFee = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), +export const EditExchangeModified = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, }, - status: "accepted", - version: "", + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + isDirty: true, + value: "exchange.test.taler.net", + onChange: () => null, + }, + showExchangeSelection: true, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts new file mode 100644 index 000000000..5a28c4cf5 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts @@ -0,0 +1,122 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ExchangeListItem } from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../test-utils.js"; +import { useComponentState } from "./Withdraw.js"; + +const exchanges: ExchangeListItem[] = [{ + currency: 'ARS', + exchangeBaseUrl: 'http://exchange.demo.taler.net', + paytoUris: [], + tos: { + acceptedVersion: '', + } +}] + +describe("Withdraw CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(undefined, { + listExchanges: async () => ({ exchanges }), + getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ + amount: 'ARS:2', + possibleExchanges: exchanges, + }) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading-uri') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-uri') + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-URI-FOR-WITHDRAWAL" }); + } + await waitNextUpdate() + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-uri') + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-URI-FOR-WITHDRAWAL" }); + } + + await assertNoPendingUpdate() + }); + + it("should tell the user that there is not known exchange", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState('taler-withdraw://', { + listExchanges: async () => ({ exchanges }), + getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ + amount: 'EUR:2', + possibleExchanges: [], + }) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading-uri') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-exchange') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-exchange') + + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-DEFAULT-EXCHANGE" }); + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-exchange') + + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-DEFAULT-EXCHANGE" }); + } + + await assertNoPendingUpdate() + }); + +});
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx index 676c65d2d..9739e1a47 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx @@ -21,17 +21,14 @@ * @author sebasjm */ -import { - AmountJson, - Amounts, - ExchangeListItem, - WithdrawUriInfoResponse, -} from "@gnu-taler/taler-util"; +import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { TalerError } from "@gnu-taler/taler-wallet-core"; import { Fragment, h, VNode } from "preact"; -import { useCallback, useMemo, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; +import { Amount } from "../components/Amount.js"; +import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; import { Loading } from "../components/Loading.js"; import { LoadingError } from "../components/LoadingError.js"; -import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; import { LogoHeader } from "../components/LogoHeader.js"; import { Part } from "../components/Part.js"; import { SelectList } from "../components/SelectList.js"; @@ -42,72 +39,198 @@ import { SubTitle, WalletAction, } from "../components/styled/index.js"; -import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { useTranslationContext } from "../context/translation.js"; +import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { buildTermsOfServiceState } from "../utils/index.js"; import { - amountToString, - buildTermsOfServiceState, - TermsState, -} from "../utils/index.js"; + ButtonHandler, + SelectFieldHandler, +} from "../wallet/CreateManualWithdraw.js"; import * as wxApi from "../wxApi.js"; -import { TermsOfServiceSection } from "./TermsOfServiceSection.js"; -import { useTranslationContext } from "../context/translation.js"; -import { TalerError } from "@gnu-taler/taler-wallet-core"; +import { + Props as TermsOfServiceSectionProps, + TermsOfServiceSection, +} from "./TermsOfServiceSection.js"; interface Props { talerWithdrawUri?: string; } -export interface ViewProps { - withdrawalFee: AmountJson; - exchangeBaseUrl?: string; - amount: AmountJson; - onSwitchExchange: (ex: string) => void; - onWithdraw: () => Promise<void>; - onReview: (b: boolean) => void; - onAccept: (b: boolean) => void; - reviewing: boolean; - reviewed: boolean; - terms: TermsState; - knownExchanges: ExchangeListItem[]; +type State = LoadingUri | LoadingExchange | LoadingInfoError | Success; + +interface LoadingUri { + status: "loading-uri"; + hook: HookError | undefined; +} +interface LoadingExchange { + status: "loading-exchange"; + hook: HookError | undefined; +} +interface LoadingInfoError { + status: "loading-info"; + hook: HookError | undefined; } -export function View({ - withdrawalFee, - exchangeBaseUrl, - knownExchanges, - amount, - onWithdraw, - onSwitchExchange, - terms, - reviewing, - onReview, - onAccept, - reviewed, -}: ViewProps): VNode { - const { i18n } = useTranslationContext(); - const [withdrawError, setWithdrawError] = useState<TalerError | undefined>( +type Success = { + status: "success"; + hook: undefined; + + exchange: SelectFieldHandler; + + editExchange: ButtonHandler; + cancelEditExchange: ButtonHandler; + confirmEditExchange: ButtonHandler; + + showExchangeSelection: boolean; + chosenAmount: AmountJson; + withdrawalFee: AmountJson; + toBeReceived: AmountJson; + + doWithdrawal: ButtonHandler; + tosProps?: TermsOfServiceSectionProps; + mustAcceptFirst: boolean; +}; + +export function useComponentState( + talerWithdrawUri: string | undefined, + api: typeof wxApi, +): State { + const [customExchange, setCustomExchange] = useState<string | undefined>( undefined, ); - const [confirmDisabled, setConfirmDisabled] = useState<boolean>(false); - const needsReview = terms.status === "changed" || terms.status === "new"; + const uriInfoHook = useAsyncAsHook(async () => { + if (!talerWithdrawUri) throw Error("ERROR_NO-URI-FOR-WITHDRAWAL"); + + const uriInfo = await api.getWithdrawalDetailsForUri({ + talerWithdrawUri, + }); + const { exchanges: knownExchanges } = await api.listExchanges(); + + return { uriInfo, knownExchanges }; + }); + + const exchangeAndAmount = useAsyncAsHook( + async () => { + if (!uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response) return; + const { uriInfo, knownExchanges } = uriInfoHook.response; + + const amount = Amounts.parseOrThrow(uriInfo.amount); + + const thisCurrencyExchanges = knownExchanges.filter( + (ex) => ex.currency === amount.currency, + ); + + const thisExchange: string | undefined = + customExchange ?? + uriInfo.defaultExchangeBaseUrl ?? + (thisCurrencyExchanges[0] + ? thisCurrencyExchanges[0].exchangeBaseUrl + : undefined); + + if (!thisExchange) throw Error("ERROR_NO-DEFAULT-EXCHANGE"); + + return { amount, thisExchange, thisCurrencyExchanges }; + }, + [], + [!uriInfoHook || uriInfoHook.hasError ? undefined : uriInfoHook], + ); + + const terms = useAsyncAsHook( + async () => { + if ( + !exchangeAndAmount || + exchangeAndAmount.hasError || + !exchangeAndAmount.response + ) + return; + const { thisExchange } = exchangeAndAmount.response; + const exchangeTos = await api.getExchangeTos(thisExchange, ["text/xml"]); + + const state = buildTermsOfServiceState(exchangeTos); + + return { state }; + }, + [], + [ + !exchangeAndAmount || exchangeAndAmount.hasError + ? undefined + : exchangeAndAmount, + ], + ); + + const info = useAsyncAsHook( + async () => { + if ( + !exchangeAndAmount || + exchangeAndAmount.hasError || + !exchangeAndAmount.response + ) + return; + const { thisExchange, amount } = exchangeAndAmount.response; + + const info = await api.getExchangeWithdrawalInfo({ + exchangeBaseUrl: thisExchange, + amount, + tosAcceptedFormat: ["text/xml"], + }); + + const withdrawalFee = Amounts.sub( + Amounts.parseOrThrow(info.withdrawalAmountRaw), + Amounts.parseOrThrow(info.withdrawalAmountEffective), + ).amount; - const [switchingExchange, setSwitchingExchange] = useState(false); - const [nextExchange, setNextExchange] = useState<string | undefined>( + return { info, withdrawalFee }; + }, + [], + [ + !exchangeAndAmount || exchangeAndAmount.hasError + ? undefined + : exchangeAndAmount, + ], + ); + + const [reviewing, setReviewing] = useState<boolean>(false); + const [reviewed, setReviewed] = useState<boolean>(false); + + const [withdrawError, setWithdrawError] = useState<TalerError | undefined>( undefined, ); + const [confirmDisabled, setConfirmDisabled] = useState<boolean>(false); - const exchanges = knownExchanges - .filter((e) => e.currency === amount.currency) - .reduce( - (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), - {}, - ); + const [showExchangeSelection, setShowExchangeSelection] = useState(false); + const [nextExchange, setNextExchange] = useState<string | undefined>(); + + if (!uriInfoHook || uriInfoHook.hasError) { + return { + status: "loading-uri", + hook: uriInfoHook, + }; + } + + if (!exchangeAndAmount || exchangeAndAmount.hasError) { + return { + status: "loading-exchange", + hook: exchangeAndAmount, + }; + } + if (!exchangeAndAmount.response) { + return { + status: "loading-exchange", + hook: undefined, + }; + } + const { thisExchange, thisCurrencyExchanges, amount } = + exchangeAndAmount.response; async function doWithdrawAndCheckError(): Promise<void> { try { setConfirmDisabled(true); - await onWithdraw(); + if (!talerWithdrawUri) return; + const res = await api.acceptWithdrawal(talerWithdrawUri, thisExchange); + if (res.confirmTransferUrl) { + document.location.href = res.confirmTransferUrl; + } } catch (e) { if (e instanceof TalerError) { setWithdrawError(e); @@ -116,6 +239,107 @@ export function View({ } } + const exchanges = thisCurrencyExchanges.reduce( + (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), + {}, + ); + + if (!info || info.hasError) { + return { + status: "loading-info", + hook: info, + }; + } + if (!info.response) { + return { + status: "loading-info", + hook: undefined, + }; + } + + const exchangeHandler: SelectFieldHandler = { + onChange: setNextExchange, + value: nextExchange || thisExchange, + list: exchanges, + isDirty: nextExchange !== thisExchange, + }; + + const editExchange: ButtonHandler = { + onClick: async () => { + setShowExchangeSelection(true); + }, + }; + const cancelEditExchange: ButtonHandler = { + onClick: async () => { + setShowExchangeSelection(false); + }, + }; + const confirmEditExchange: ButtonHandler = { + onClick: async () => { + setCustomExchange(exchangeHandler.value); + setShowExchangeSelection(false); + }, + }; + + const { withdrawalFee } = info.response; + const toBeReceived = Amounts.sub(amount, withdrawalFee).amount; + + const { state: termsState } = (!terms + ? undefined + : terms.hasError + ? undefined + : terms.response) || { state: undefined }; + + async function onAccept(accepted: boolean): Promise<void> { + if (!termsState) return; + + try { + await api.setExchangeTosAccepted( + thisExchange, + accepted ? termsState.version : undefined, + ); + setReviewed(accepted); + } catch (e) { + if (e instanceof Error) { + //FIXME: uncomment this and display error + // setErrorAccepting(e.message); + } + } + } + + return { + status: "success", + hook: undefined, + exchange: exchangeHandler, + editExchange, + cancelEditExchange, + confirmEditExchange, + showExchangeSelection, + toBeReceived, + withdrawalFee, + chosenAmount: amount, + doWithdrawal: { + onClick: doWithdrawAndCheckError, + error: withdrawError, + disabled: confirmDisabled, + }, + tosProps: !termsState + ? undefined + : { + onAccept, + onReview: setReviewing, + reviewed: reviewed, + reviewing: reviewing, + terms: termsState, + }, + mustAcceptFirst: + termsState !== undefined && + (termsState.status === "changed" || termsState.status === "new"), + }; +} + +export function View({ state }: { state: Success }): VNode { + const { i18n } = useTranslationContext(); return ( <WalletAction> <LogoHeader /> @@ -123,267 +347,159 @@ export function View({ <i18n.Translate>Digital cash withdrawal</i18n.Translate> </SubTitle> - {withdrawError && ( + {state.doWithdrawal.error && ( <ErrorTalerOperation title={ <i18n.Translate> Could not finish the withdrawal operation </i18n.Translate> } - error={withdrawError.errorDetail} + error={state.doWithdrawal.error.errorDetail} /> )} <section> <Part title={<i18n.Translate>Total to withdraw</i18n.Translate>} - text={amountToString(Amounts.sub(amount, withdrawalFee).amount)} + text={<Amount value={state.toBeReceived} />} kind="positive" /> - {Amounts.isNonZero(withdrawalFee) && ( + {Amounts.isNonZero(state.withdrawalFee) && ( <Fragment> <Part title={<i18n.Translate>Chosen amount</i18n.Translate>} - text={amountToString(amount)} + text={<Amount value={state.chosenAmount} />} kind="neutral" /> <Part title={<i18n.Translate>Exchange fee</i18n.Translate>} - text={amountToString(withdrawalFee)} + text={<Amount value={state.withdrawalFee} />} kind="negative" /> </Fragment> )} - {exchangeBaseUrl && ( - <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={exchangeBaseUrl} - kind="neutral" - big - /> - )} - {!reviewing && - (switchingExchange ? ( - <Fragment> - <div> - <SelectList - label={<i18n.Translate>Known exchanges</i18n.Translate>} - list={exchanges} - value={nextExchange} - name="switchingExchange" - onChange={setNextExchange} - /> - </div> - <LinkSuccess - upperCased - style={{ fontSize: "small" }} - onClick={() => { - if (nextExchange !== undefined) { - onSwitchExchange(nextExchange); - } - setSwitchingExchange(false); - }} - > - {nextExchange === undefined ? ( - <i18n.Translate>Cancel exchange selection</i18n.Translate> - ) : ( - <i18n.Translate>Confirm exchange selection</i18n.Translate> - )} - </LinkSuccess> - </Fragment> - ) : ( + <Part + title={<i18n.Translate>Exchange</i18n.Translate>} + text={state.exchange.value} + kind="neutral" + big + /> + {state.showExchangeSelection ? ( + <Fragment> + <div> + <SelectList + label={<i18n.Translate>Known exchanges</i18n.Translate>} + list={state.exchange.list} + value={state.exchange.value} + name="switchingExchange" + onChange={state.exchange.onChange} + /> + </div> <LinkSuccess - style={{ fontSize: "small" }} upperCased - onClick={() => setSwitchingExchange(true)} + style={{ fontSize: "small" }} + onClick={state.confirmEditExchange.onClick} > - <i18n.Translate>Edit exchange</i18n.Translate> + {state.exchange.isDirty ? ( + <i18n.Translate>Confirm exchange selection</i18n.Translate> + ) : ( + <i18n.Translate>Cancel exchange selection</i18n.Translate> + )} </LinkSuccess> - ))} - </section> - <TermsOfServiceSection - reviewed={reviewed} - reviewing={reviewing} - terms={terms} - onAccept={onAccept} - onReview={onReview} - /> - <section> - {(terms.status === "accepted" || (needsReview && reviewed)) && ( - <ButtonSuccess - upperCased - disabled={!exchangeBaseUrl || confirmDisabled} - onClick={doWithdrawAndCheckError} - > - <i18n.Translate>Confirm withdrawal</i18n.Translate> - </ButtonSuccess> - )} - {terms.status === "notfound" && ( - <ButtonWarning + </Fragment> + ) : ( + <LinkSuccess + style={{ fontSize: "small" }} upperCased - disabled={!exchangeBaseUrl} - onClick={doWithdrawAndCheckError} + onClick={state.editExchange.onClick} > - <i18n.Translate>Withdraw anyway</i18n.Translate> - </ButtonWarning> + <i18n.Translate>Edit exchange</i18n.Translate> + </LinkSuccess> )} </section> + {state.tosProps && <TermsOfServiceSection {...state.tosProps} />} + {state.tosProps ? ( + <section> + {(state.tosProps.terms.status === "accepted" || + (state.mustAcceptFirst && state.tosProps.reviewed)) && ( + <ButtonSuccess + upperCased + disabled={state.doWithdrawal.disabled} + onClick={state.doWithdrawal.onClick} + > + <i18n.Translate>Confirm withdrawal</i18n.Translate> + </ButtonSuccess> + )} + {state.tosProps.terms.status === "notfound" && ( + <ButtonWarning + upperCased + disabled={state.doWithdrawal.disabled} + onClick={state.doWithdrawal.onClick} + > + <i18n.Translate>Withdraw anyway</i18n.Translate> + </ButtonWarning> + )} + </section> + ) : ( + <section> + <i18n.Translate>Loading terms of service...</i18n.Translate> + </section> + )} </WalletAction> ); } -export function WithdrawPageWithParsedURI({ - uri, - uriInfo, -}: { - uri: string; - uriInfo: WithdrawUriInfoResponse; -}): VNode { +export function WithdrawPage({ talerWithdrawUri }: Props): VNode { const { i18n } = useTranslationContext(); - const [customExchange, setCustomExchange] = useState<string | undefined>( - undefined, - ); - - const [reviewing, setReviewing] = useState<boolean>(false); - const [reviewed, setReviewed] = useState<boolean>(false); - - const knownExchangesHook = useAsyncAsHook(wxApi.listExchanges); - - const knownExchanges = useMemo( - () => - !knownExchangesHook || knownExchangesHook.hasError - ? [] - : knownExchangesHook.response.exchanges, - [knownExchangesHook], - ); - const withdrawAmount = useMemo( - () => Amounts.parseOrThrow(uriInfo.amount), - [uriInfo.amount], - ); - const thisCurrencyExchanges = useMemo( - () => - knownExchanges.filter((ex) => ex.currency === withdrawAmount.currency), - [knownExchanges, withdrawAmount.currency], - ); - - const exchange: string | undefined = useMemo( - () => - customExchange ?? - uriInfo.defaultExchangeBaseUrl ?? - (thisCurrencyExchanges[0] - ? thisCurrencyExchanges[0].exchangeBaseUrl - : undefined), - [customExchange, thisCurrencyExchanges, uriInfo.defaultExchangeBaseUrl], - ); - const detailsHook = useAsyncAsHook(async () => { - if (!exchange) throw Error("no default exchange"); - const tos = await wxApi.getExchangeTos(exchange, ["text/xml"]); + const state = useComponentState(talerWithdrawUri, wxApi); - const tosState = buildTermsOfServiceState(tos); - - const info = await wxApi.getExchangeWithdrawalInfo({ - exchangeBaseUrl: exchange, - amount: withdrawAmount, - tosAcceptedFormat: ["text/xml"], - }); - return { tos: tosState, info }; - }); + if (!talerWithdrawUri) { + return ( + <span> + <i18n.Translate>missing withdraw uri</i18n.Translate> + </span> + ); + } - if (!detailsHook) { + if (!state) { return <Loading />; } - if (detailsHook.hasError) { + + console.log(state); + if (state.status === "loading-uri") { + if (!state.hook) return <Loading />; + return ( <LoadingError title={ - <i18n.Translate>Could not load the withdrawal details</i18n.Translate> + <i18n.Translate>Could not get the info from the URI</i18n.Translate> } - error={detailsHook} + error={state.hook} /> ); } + if (state.status === "loading-exchange") { + if (!state.hook) return <Loading />; - const details = detailsHook.response; - - const onAccept = async (accepted: boolean): Promise<void> => { - if (!exchange) return; - try { - await wxApi.setExchangeTosAccepted( - exchange, - accepted ? details.tos.version : undefined, - ); - setReviewed(accepted); - } catch (e) { - if (e instanceof Error) { - //FIXME: uncomment this and display error - // setErrorAccepting(e.message); - } - } - }; - - const onWithdraw = async (): Promise<void> => { - if (!exchange) return; - const res = await wxApi.acceptWithdrawal(uri, exchange); - if (res.confirmTransferUrl) { - document.location.href = res.confirmTransferUrl; - } - }; - - const withdrawalFee = Amounts.sub( - Amounts.parseOrThrow(details.info.withdrawalAmountRaw), - Amounts.parseOrThrow(details.info.withdrawalAmountEffective), - ).amount; - - return ( - <View - onWithdraw={onWithdraw} - amount={withdrawAmount} - exchangeBaseUrl={exchange} - withdrawalFee={withdrawalFee} - terms={detailsHook.response.tos} - onSwitchExchange={setCustomExchange} - knownExchanges={knownExchanges} - reviewed={reviewed} - onAccept={onAccept} - reviewing={reviewing} - onReview={setReviewing} - /> - ); -} -export function WithdrawPage({ talerWithdrawUri }: Props): VNode { - const { i18n } = useTranslationContext(); - const uriInfoHook = useAsyncAsHook(() => - !talerWithdrawUri - ? Promise.reject(undefined) - : wxApi.getWithdrawalDetailsForUri({ talerWithdrawUri }), - ); - - if (!talerWithdrawUri) { return ( - <span> - <i18n.Translate>missing withdraw uri</i18n.Translate> - </span> + <LoadingError + title={<i18n.Translate>Could not get exchange</i18n.Translate>} + error={state.hook} + /> ); } - if (!uriInfoHook) { - return <Loading />; - } - if (uriInfoHook.hasError) { + if (state.status === "loading-info") { + if (!state.hook) return <Loading />; return ( <LoadingError title={ - <i18n.Translate>Could not get the info from the URI</i18n.Translate> + <i18n.Translate>Could not get info of withdrawal</i18n.Translate> } - error={uriInfoHook} + error={state.hook} /> ); } - return ( - <WithdrawPageWithParsedURI - uri={talerWithdrawUri} - uriInfo={uriInfoHook.response} - /> - ); + return <View state={state} />; } |