taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit f9ccb9415739864321f3ea482ce94695f775b9af
parent 84634a4ab4a61174f1d2e76b26d189bf92902c48
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu, 21 Jul 2022 10:36:15 -0300

withdraw as module

Diffstat:
Dpackages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx | 291------------------------------------------------------------------------------
Dpackages/taler-wallet-webextension/src/cta/Withdraw.test.ts | 267-------------------------------------------------------------------------------
Apackages/taler-wallet-webextension/src/cta/Withdraw/index.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-webextension/src/cta/Withdraw/state.ts | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-webextension/src/cta/Withdraw/test.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-webextension/src/cta/Withdraw/views.tsx | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-webextension/src/cta/index.stories.ts | 2+-
Mpackages/taler-wallet-webextension/src/utils/index.ts | 22++++++++++++++++++++++
Mpackages/taler-wallet-webextension/src/wallet/Application.tsx | 2+-
10 files changed, 1192 insertions(+), 560 deletions(-)

diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx @@ -1,291 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 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 { createExample } from "../test-utils.js"; -import { TermsState } from "../utils/index.js"; -import { View as TestedComponent } from "./Withdraw.js"; - -export default { - title: "cta/withdraw", - component: TestedComponent, -}; - -const exchangeList = { - "exchange.demo.taler.net": "http://exchange.demo.taler.net (USD)", - "exchange.test.taler.net": "http://exchange.test.taler.net (KUDOS)", -}; - -const nullHandler = { - onClick: async (): Promise<void> => { - null; - }, -}; - -const normalTosState = { - terms: { - status: "accepted", - version: "", - } as TermsState, - onAccept: () => null, - onReview: () => null, - reviewed: false, - reviewing: false, -}; - -const ageRestrictionOptions: Record<string, string> = "6:12:18" - .split(":") - .reduce((p, c) => ({ ...p, [c]: `under ${c}` }), {}); - -ageRestrictionOptions["0"] = "Not restricted"; - -const ageRestrictionSelectField = { - list: ageRestrictionOptions, - value: "0", -}; - -export const TermsOfServiceNotYetLoaded = createExample(TestedComponent, { - state: { - hook: undefined, - status: "success", - cancelEditExchange: nullHandler, - confirmEditExchange: nullHandler, - ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - doWithdrawal: nullHandler, - editExchange: nullHandler, - exchange: { - list: exchangeList, - value: "exchange.demo.taler.net", - onChange: async () => { - null; - }, - }, - showExchangeSelection: false, - mustAcceptFirst: false, - withdrawalFee: { - currency: "USD", - fraction: 10000000, - value: 1, - }, - toBeReceived: { - currency: "USD", - fraction: 0, - value: 1, - }, - }, -}); - -export const WithSomeFee = createExample(TestedComponent, { - state: { - hook: undefined, - status: "success", - cancelEditExchange: nullHandler, - confirmEditExchange: nullHandler, - ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - doWithdrawal: nullHandler, - editExchange: nullHandler, - exchange: { - list: exchangeList, - value: "exchange.demo.taler.net", - onChange: async () => { - null; - }, - }, - showExchangeSelection: false, - mustAcceptFirst: false, - withdrawalFee: { - currency: "USD", - fraction: 10000000, - value: 1, - }, - toBeReceived: { - currency: "USD", - fraction: 0, - value: 1, - }, - tosProps: normalTosState, - }, -}); - -export const WithoutFee = createExample(TestedComponent, { - state: { - hook: undefined, - status: "success", - cancelEditExchange: nullHandler, - confirmEditExchange: nullHandler, - ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - doWithdrawal: nullHandler, - editExchange: nullHandler, - exchange: { - list: exchangeList, - value: "exchange.demo.taler.net", - onChange: async () => { - null; - }, - }, - showExchangeSelection: false, - mustAcceptFirst: false, - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - toBeReceived: { - currency: "USD", - fraction: 0, - value: 2, - }, - tosProps: normalTosState, - }, -}); - -export const EditExchangeUntouched = createExample(TestedComponent, { - state: { - hook: undefined, - status: "success", - cancelEditExchange: nullHandler, - confirmEditExchange: nullHandler, - ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - doWithdrawal: nullHandler, - editExchange: nullHandler, - exchange: { - list: exchangeList, - value: "exchange.demo.taler.net", - onChange: async () => { - null; - }, - }, - showExchangeSelection: true, - mustAcceptFirst: false, - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - toBeReceived: { - currency: "USD", - fraction: 0, - value: 2, - }, - tosProps: normalTosState, - }, -}); - -export const EditExchangeModified = createExample(TestedComponent, { - state: { - hook: undefined, - status: "success", - cancelEditExchange: nullHandler, - confirmEditExchange: nullHandler, - ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - doWithdrawal: nullHandler, - editExchange: nullHandler, - exchange: { - list: exchangeList, - isDirty: true, - value: "exchange.test.taler.net", - onChange: async () => { - null; - }, - }, - showExchangeSelection: true, - mustAcceptFirst: false, - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - toBeReceived: { - currency: "USD", - fraction: 0, - value: 2, - }, - tosProps: normalTosState, - }, -}); - -export const CompletedWithoutBankURL = createExample(TestedComponent, { - state: { - status: "completed", - hook: undefined, - }, -}); - -export const WithAgeRestrictionSelected = createExample(TestedComponent, { - state: { - hook: undefined, - status: "success", - cancelEditExchange: nullHandler, - confirmEditExchange: nullHandler, - ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - doWithdrawal: nullHandler, - editExchange: nullHandler, - exchange: { - list: exchangeList, - value: "exchange.demo.taler.net", - onChange: async () => { - null; - }, - }, - showExchangeSelection: false, - 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 @@ -1,267 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 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 { - Amounts, - ExchangeListItem, - GetExchangeTosResult, -} from "@gnu-taler/taler-util"; -import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core"; -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"); - if (!hook) expect.fail(); - if (!hook.hasError) expect.fail(); - if (hook.operational) expect.fail(); - expect(hook.message).eq("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).deep.equals({ - hasError: true, - operational: false, - message: "ERROR_NO-DEFAULT-EXCHANGE", - }); - } - - await assertNoPendingUpdate(); - }); - - it("should be able to withdraw if tos are ok", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler-withdraw://", { - listExchanges: async () => ({ exchanges }), - getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ - amount: "ARS:2", - possibleExchanges: exchanges, - }), - getExchangeWithdrawalInfo: - async (): Promise<ExchangeWithdrawDetails> => - ({ - withdrawalAmountRaw: "ARS:5", - withdrawalAmountEffective: "ARS:5", - } as any), - getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ - contentType: "text", - content: "just accept", - acceptedEtag: "v1", - currentEtag: "v1", - }), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading-uri"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const { status, hook } = getLastResultOrThrow(); - - expect(status).equals("loading-info"); - - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - expect(state.status).equals("success"); - if (state.status !== "success") return; - - expect(state.exchange.isDirty).false; - expect(state.exchange.value).equal("http://exchange.demo.taler.net"); - expect(state.exchange.list).deep.equal({ - "http://exchange.demo.taler.net": "http://exchange.demo.taler.net", - }); - expect(state.showExchangeSelection).false; - - expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); - - expect(state.doWithdrawal.onClick).not.undefined; - expect(state.mustAcceptFirst).false; - } - - await assertNoPendingUpdate(); - }); - - it("should be accept the tos before withdraw", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler-withdraw://", { - listExchanges: async () => ({ exchanges }), - getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ - amount: "ARS:2", - possibleExchanges: exchanges, - }), - getExchangeWithdrawalInfo: - async (): Promise<ExchangeWithdrawDetails> => - ({ - withdrawalAmountRaw: "ARS:5", - withdrawalAmountEffective: "ARS:5", - } as any), - getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ - contentType: "text", - content: "just accept", - acceptedEtag: "v1", - currentEtag: "v2", - }), - setExchangeTosAccepted: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading-uri"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const { status, hook } = getLastResultOrThrow(); - - expect(status).equals("loading-info"); - - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - expect(state.status).equals("success"); - if (state.status !== "success") return; - - expect(state.exchange.isDirty).false; - expect(state.exchange.value).equal("http://exchange.demo.taler.net"); - expect(state.exchange.list).deep.equal({ - "http://exchange.demo.taler.net": "http://exchange.demo.taler.net", - }); - expect(state.showExchangeSelection).false; - - expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); - - expect(state.doWithdrawal.onClick).undefined; - expect(state.mustAcceptFirst).true; - - // accept TOS - state.tosProps?.onAccept(true); - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - expect(state.status).equals("success"); - if (state.status !== "success") return; - - expect(state.exchange.isDirty).false; - expect(state.exchange.value).equal("http://exchange.demo.taler.net"); - expect(state.exchange.list).deep.equal({ - "http://exchange.demo.taler.net": "http://exchange.demo.taler.net", - }); - expect(state.showExchangeSelection).false; - - expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); - - expect(state.doWithdrawal.onClick).not.undefined; - expect(state.mustAcceptFirst).true; - } - - await assertNoPendingUpdate(); - }); -}); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -0,0 +1,98 @@ +/* + This file is part of GNU Taler + (C) 2022 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/> + */ + +import { AmountJson } from "@gnu-taler/taler-util"; +import { compose, StateViewMap } from "../../utils/index.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; +import { + Props as TermsOfServiceSectionProps +} from "../TermsOfServiceSection.js"; +import { CompletedView, LoadingExchangeView, LoadingInfoView, LoadingUriView, SuccessView } from "./views.js"; +import { useComponentState } from "./state.js"; + +/** + * Page shown to the user to confirm creation + * of a reserve, usually requested by the bank. + * + * @author sebasjm + */ + +export interface Props { + talerWithdrawUri: string | undefined; +} + +export type State = + | State.LoadingUri + | State.LoadingExchange + | State.LoadingInfoError + | State.Success + | State.Completed; + +export namespace State { + + export interface LoadingUri { + status: "loading-uri"; + hook: HookError | undefined; + } + export interface LoadingExchange { + status: "loading-exchange"; + hook: HookError | undefined; + } + export interface LoadingInfoError { + status: "loading-info"; + hook: HookError | undefined; + } + + export type Completed = { + status: "completed"; + hook: undefined; + }; + + export 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; + + ageRestriction: SelectFieldHandler; + }; +} + +const viewMapping: StateViewMap<State> = { + "loading-uri": LoadingUriView, + "loading-exchange": LoadingExchangeView, + "loading-info": LoadingInfoView, + completed: CompletedView, + success: SuccessView, +}; + +import * as wxApi from "../../wxApi.js"; + +export const WithdrawPage = compose("Withdraw", (p: Props) => useComponentState(p, wxApi), viewMapping) diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -0,0 +1,299 @@ +/* + This file is part of GNU Taler + (C) 2022 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/> + */ + +/** + * Page shown to the user to confirm creation + * of a reserve, usually requested by the bank. + * + * @author sebasjm + */ + +import { Amounts } from "@gnu-taler/taler-util"; +import { TalerError } from "@gnu-taler/taler-wallet-core"; +import { useMemo, useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; +import { buildTermsOfServiceState } from "../../utils/index.js"; +import * as wxApi from "../../wxApi.js"; +import { State, Props } from "./index.js"; + +export function useComponentState( + { talerWithdrawUri }: Props, + api: typeof wxApi, +): State { + const [customExchange, setCustomExchange] = useState<string | undefined>( + undefined, + ); + const [ageRestricted, setAgeRestricted] = useState(0); + + /** + * Ask the wallet about the withdraw URI + */ + 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 }; + }); + + /** + * Get the amount and select one exchange + */ + const uriHookDep = + !uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response + ? undefined + : uriInfoHook.response; + + const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => { + if (!uriHookDep) + return { + amount: undefined, + thisExchange: undefined, + thisCurrencyExchanges: [], + }; + + const { uriInfo, knownExchanges } = uriHookDep; + + const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined; + const thisCurrencyExchanges = + !amount || !knownExchanges + ? [] + : knownExchanges.filter((ex) => ex.currency === amount.currency); + + const thisExchange: string | undefined = + customExchange ?? + uriInfo?.defaultExchangeBaseUrl ?? + (thisCurrencyExchanges && thisCurrencyExchanges[0] + ? thisCurrencyExchanges[0].exchangeBaseUrl + : undefined); + + return { amount, thisExchange, thisCurrencyExchanges }; + }, [uriHookDep, customExchange]); + + /** + * For the exchange selected, bring the status of the terms of service + */ + const terms = useAsyncAsHook(async () => { + if (!thisExchange) return false; + + const exchangeTos = await api.getExchangeTos(thisExchange, ["text/xml"]); + + const state = buildTermsOfServiceState(exchangeTos); + + return { state }; + }, [thisExchange]); + + /** + * With the exchange and amount, ask the wallet the information + * about the withdrawal + */ + const info = useAsyncAsHook(async () => { + if (!thisExchange || !amount) return false; + + const info = await api.getExchangeWithdrawalInfo({ + exchangeBaseUrl: thisExchange, + amount, + tosAcceptedFormat: ["text/xml"], + }); + + const withdrawalFee = Amounts.sub( + Amounts.parseOrThrow(info.withdrawalAmountRaw), + Amounts.parseOrThrow(info.withdrawalAmountEffective), + ).amount; + + return { info, withdrawalFee }; + }, [thisExchange, amount]); + + const [reviewing, setReviewing] = useState<boolean>(false); + const [reviewed, setReviewed] = useState<boolean>(false); + + const [withdrawError, setWithdrawError] = useState<TalerError | undefined>( + undefined, + ); + const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); + const [withdrawCompleted, setWithdrawCompleted] = useState<boolean>(false); + + const [showExchangeSelection, setShowExchangeSelection] = useState(false); + const [nextExchange, setNextExchange] = useState<string | undefined>(); + + if (!uriInfoHook || uriInfoHook.hasError) { + return { + status: "loading-uri", + hook: uriInfoHook, + }; + } + + if (!thisExchange || !amount) { + return { + status: "loading-exchange", + hook: { + hasError: true, + operational: false, + message: "ERROR_NO-DEFAULT-EXCHANGE", + }, + }; + } + + const selectedExchange = thisExchange; + + async function doWithdrawAndCheckError(): Promise<void> { + try { + setDoingWithdraw(true); + if (!talerWithdrawUri) return; + const res = await api.acceptWithdrawal( + talerWithdrawUri, + selectedExchange, + !ageRestricted ? undefined : ageRestricted, + ); + if (res.confirmTransferUrl) { + document.location.href = res.confirmTransferUrl; + } + setWithdrawCompleted(true); + } catch (e) { + if (e instanceof TalerError) { + setWithdrawError(e); + } + } + setDoingWithdraw(false); + } + + 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, + }; + } + if (withdrawCompleted) { + return { + status: "completed", + hook: undefined, + }; + } + + const exchangeHandler: SelectFieldHandler = { + onChange: async (e) => setNextExchange(e), + value: nextExchange ?? thisExchange, + list: exchanges, + isDirty: nextExchange !== undefined, + }; + + const editExchange: ButtonHandler = { + onClick: async () => { + setShowExchangeSelection(true); + }, + }; + const cancelEditExchange: ButtonHandler = { + onClick: async () => { + setShowExchangeSelection(false); + }, + }; + const confirmEditExchange: ButtonHandler = { + onClick: async () => { + setCustomExchange(exchangeHandler.value); + setShowExchangeSelection(false); + setNextExchange(undefined); + }, + }; + + 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( + selectedExchange, + accepted ? termsState.version : undefined, + ); + setReviewed(accepted); + } catch (e) { + if (e instanceof Error) { + //FIXME: uncomment this and display error + // setErrorAccepting(e.message); + } + } + } + + const mustAcceptFirst = + termsState !== undefined && + (termsState.status === "changed" || termsState.status === "new"); + + const ageRestrictionOptions: Record<string, string> | undefined = "6:12:18" + .split(":") + .reduce((p, c) => ({ ...p, [c]: `under ${c}` }), {}); + + if (ageRestrictionOptions) { + ageRestrictionOptions["0"] = "Not restricted"; + } + + return { + status: "success", + hook: undefined, + exchange: exchangeHandler, + editExchange, + cancelEditExchange, + confirmEditExchange, + showExchangeSelection, + toBeReceived, + withdrawalFee, + chosenAmount: amount, + ageRestriction: { + list: ageRestrictionOptions, + value: String(ageRestricted), + onChange: async (v) => setAgeRestricted(parseInt(v, 10)), + }, + doWithdrawal: { + onClick: + doingWithdraw || (mustAcceptFirst && !reviewed) + ? undefined + : doWithdrawAndCheckError, + error: withdrawError, + }, + tosProps: !termsState + ? undefined + : { + onAccept, + onReview: setReviewing, + reviewed: reviewed, + reviewing: reviewing, + terms: termsState, + }, + mustAcceptFirst, + }; +} + diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -0,0 +1,276 @@ +/* + This file is part of GNU Taler + (C) 2022 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 { createExample } from "../../test-utils.js"; +import { TermsState } from "../../utils/index.js"; +import { CompletedView, SuccessView } from "./views.js"; + +export default { + title: "cta/withdraw", +}; + +const exchangeList = { + "exchange.demo.taler.net": "http://exchange.demo.taler.net (USD)", + "exchange.test.taler.net": "http://exchange.test.taler.net (KUDOS)", +}; + +const nullHandler = { + onClick: async (): Promise<void> => { + null; + }, +}; + +const normalTosState = { + terms: { + status: "accepted", + version: "", + } as TermsState, + onAccept: () => null, + onReview: () => null, + reviewed: false, + reviewing: false, +}; + +const ageRestrictionOptions: Record<string, string> = "6:12:18" + .split(":") + .reduce((p, c) => ({ ...p, [c]: `under ${c}` }), {}); + +ageRestrictionOptions["0"] = "Not restricted"; + +const ageRestrictionSelectField = { + list: ageRestrictionOptions, + value: "0", +}; + +export const TermsOfServiceNotYetLoaded = createExample(SuccessView, { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + ageRestriction: ageRestrictionSelectField, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: async () => { + null; + }, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 10000000, + value: 1, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 1, + }, +}); + +export const WithSomeFee = createExample(SuccessView, { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + ageRestriction: ageRestrictionSelectField, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: async () => { + null; + }, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 10000000, + value: 1, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 1, + }, + tosProps: normalTosState, +}); + +export const WithoutFee = createExample(SuccessView, { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + ageRestriction: ageRestrictionSelectField, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: async () => { + null; + }, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, +}); + +export const EditExchangeUntouched = createExample(SuccessView, { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + ageRestriction: ageRestrictionSelectField, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: async () => { + null; + }, + }, + showExchangeSelection: true, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, +}); + +export const EditExchangeModified = createExample(SuccessView, { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + ageRestriction: ageRestrictionSelectField, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + isDirty: true, + value: "exchange.test.taler.net", + onChange: async () => { + null; + }, + }, + showExchangeSelection: true, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, +}); + +export const CompletedWithoutBankURL = createExample(CompletedView, { + status: "completed", + hook: undefined, +}); + +export const WithAgeRestrictionSelected = createExample(SuccessView, { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + ageRestriction: ageRestrictionSelectField, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: async () => { + null; + }, + }, + showExchangeSelection: false, + 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 @@ -0,0 +1,267 @@ +/* + This file is part of GNU Taler + (C) 2022 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 { + Amounts, + ExchangeListItem, + GetExchangeTosResult, +} from "@gnu-taler/taler-util"; +import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core"; +import { expect } from "chai"; +import { mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.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({ talerWithdrawUri: 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"); + if (!hook) expect.fail(); + if (!hook.hasError) expect.fail(); + if (hook.operational) expect.fail(); + expect(hook.message).eq("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({ talerWithdrawUri: "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).deep.equals({ + hasError: true, + operational: false, + message: "ERROR_NO-DEFAULT-EXCHANGE", + }); + } + + await assertNoPendingUpdate(); + }); + + it("should be able to withdraw if tos are ok", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { + listExchanges: async () => ({ exchanges }), + getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ + amount: "ARS:2", + possibleExchanges: exchanges, + }), + getExchangeWithdrawalInfo: + async (): Promise<ExchangeWithdrawDetails> => + ({ + withdrawalAmountRaw: "ARS:5", + withdrawalAmountEffective: "ARS:5", + } as any), + getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ + contentType: "text", + content: "just accept", + acceptedEtag: "v1", + currentEtag: "v1", + }), + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow(); + expect(status).equals("loading-uri"); + expect(hook).undefined; + } + + await waitNextUpdate(); + + { + const { status, hook } = getLastResultOrThrow(); + + expect(status).equals("loading-info"); + + expect(hook).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + expect(state.status).equals("success"); + if (state.status !== "success") return; + + expect(state.exchange.isDirty).false; + expect(state.exchange.value).equal("http://exchange.demo.taler.net"); + expect(state.exchange.list).deep.equal({ + "http://exchange.demo.taler.net": "http://exchange.demo.taler.net", + }); + expect(state.showExchangeSelection).false; + + expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); + expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + + expect(state.doWithdrawal.onClick).not.undefined; + expect(state.mustAcceptFirst).false; + } + + await assertNoPendingUpdate(); + }); + + it("should be accept the tos before withdraw", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { + listExchanges: async () => ({ exchanges }), + getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ + amount: "ARS:2", + possibleExchanges: exchanges, + }), + getExchangeWithdrawalInfo: + async (): Promise<ExchangeWithdrawDetails> => + ({ + withdrawalAmountRaw: "ARS:5", + withdrawalAmountEffective: "ARS:5", + } as any), + getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ + contentType: "text", + content: "just accept", + acceptedEtag: "v1", + currentEtag: "v2", + }), + setExchangeTosAccepted: async () => ({}), + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow(); + expect(status).equals("loading-uri"); + expect(hook).undefined; + } + + await waitNextUpdate(); + + { + const { status, hook } = getLastResultOrThrow(); + + expect(status).equals("loading-info"); + + expect(hook).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + expect(state.status).equals("success"); + if (state.status !== "success") return; + + expect(state.exchange.isDirty).false; + expect(state.exchange.value).equal("http://exchange.demo.taler.net"); + expect(state.exchange.list).deep.equal({ + "http://exchange.demo.taler.net": "http://exchange.demo.taler.net", + }); + expect(state.showExchangeSelection).false; + + expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); + expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + + expect(state.doWithdrawal.onClick).undefined; + expect(state.mustAcceptFirst).true; + + // accept TOS + state.tosProps?.onAccept(true); + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + expect(state.status).equals("success"); + if (state.status !== "success") return; + + expect(state.exchange.isDirty).false; + expect(state.exchange.value).equal("http://exchange.demo.taler.net"); + expect(state.exchange.list).deep.equal({ + "http://exchange.demo.taler.net": "http://exchange.demo.taler.net", + }); + expect(state.showExchangeSelection).false; + + expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); + expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + + expect(state.doWithdrawal.onClick).not.undefined; + expect(state.mustAcceptFirst).true; + } + + await assertNoPendingUpdate(); + }); +}); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -0,0 +1,228 @@ +/* + This file is part of GNU Taler + (C) 2022 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/> + */ + +import { Fragment, h, VNode } from "preact"; +import { State } from "./index.js"; +import { useTranslationContext } from "../../context/translation.js"; +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 { LogoHeader } from "../../components/LogoHeader.js"; +import { Part } from "../../components/Part.js"; +import { SelectList } from "../../components/SelectList.js"; +import { + Input, + LinkSuccess, + SubTitle, + SuccessBox, + WalletAction, +} from "../../components/styled/index.js"; +import { Amounts } from "@gnu-taler/taler-util"; +import { TermsOfServiceSection } from "../TermsOfServiceSection.js"; +import { Button } from "../../mui/Button.js"; + +/** + * Page shown to the user to confirm creation + * of a reserve, usually requested by the bank. + * + * @author sebasjm + */ + +export function LoadingUriView(state: State.LoadingUri): VNode { + const { i18n } = useTranslationContext(); + if (!state.hook) return <Loading />; + + return ( + <LoadingError + title={ + <i18n.Translate>Could not get the info from the URI</i18n.Translate> + } + error={state.hook} + /> + ); +} + +export function LoadingExchangeView(state: State.LoadingExchange): VNode { + const { i18n } = useTranslationContext(); + if (!state.hook) return <Loading />; + + return ( + <LoadingError + title={<i18n.Translate>Could not get exchange</i18n.Translate>} + error={state.hook} + /> + ); +} + +export function LoadingInfoView(state: State.LoadingInfoError): VNode { + const { i18n } = useTranslationContext(); + if (!state.hook) return <Loading />; + return ( + <LoadingError + title={<i18n.Translate>Could not get info of withdrawal</i18n.Translate>} + error={state.hook} + /> + ); +} + +export function CompletedView(state: State.Completed): VNode { + const { i18n } = useTranslationContext(); + return ( + <WalletAction> + <LogoHeader /> + <SubTitle> + <i18n.Translate>Digital cash withdrawal</i18n.Translate> + </SubTitle> + <SuccessBox> + <h3> + <i18n.Translate>Withdrawal in process...</i18n.Translate> + </h3> + <p> + <i18n.Translate> + You can close the page now. Check your bank if the transaction need + a confirmation step to be completed + </i18n.Translate> + </p> + </SuccessBox> + </WalletAction> + ); +} + +export function SuccessView(state: State.Success): VNode { + const { i18n } = useTranslationContext(); + return ( + <WalletAction> + <LogoHeader /> + <SubTitle> + <i18n.Translate>Digital cash withdrawal</i18n.Translate> + </SubTitle> + + {state.doWithdrawal.error && ( + <ErrorTalerOperation + title={ + <i18n.Translate> + Could not finish the withdrawal operation + </i18n.Translate> + } + error={state.doWithdrawal.error.errorDetail} + /> + )} + + <section> + <Part + title={<i18n.Translate>Total to withdraw</i18n.Translate>} + text={<Amount value={state.toBeReceived} />} + kind="positive" + /> + {Amounts.isNonZero(state.withdrawalFee) && ( + <Fragment> + <Part + title={<i18n.Translate>Chosen amount</i18n.Translate>} + text={<Amount value={state.chosenAmount} />} + kind="neutral" + /> + <Part + title={<i18n.Translate>Exchange fee</i18n.Translate>} + text={<Amount value={state.withdrawalFee} />} + kind="negative" + /> + </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 + upperCased + style={{ fontSize: "small" }} + onClick={state.confirmEditExchange.onClick} + > + {state.exchange.isDirty ? ( + <i18n.Translate>Confirm exchange selection</i18n.Translate> + ) : ( + <i18n.Translate>Cancel exchange selection</i18n.Translate> + )} + </LinkSuccess> + </Fragment> + ) : ( + <LinkSuccess + style={{ fontSize: "small" }} + upperCased + onClick={state.editExchange.onClick} + > + <i18n.Translate>Edit exchange</i18n.Translate> + </LinkSuccess> + )} + </section> + <section> + <Input> + <SelectList + label={<i18n.Translate>Age restriction</i18n.Translate>} + list={state.ageRestriction.list} + name="age" + maxWidth + value={state.ageRestriction.value} + onChange={state.ageRestriction.onChange} + /> + </Input> + </section> + {state.tosProps && <TermsOfServiceSection {...state.tosProps} />} + {state.tosProps ? ( + <section> + {(state.tosProps.terms.status === "accepted" || + (state.mustAcceptFirst && state.tosProps.reviewed)) && ( + <Button + variant="contained" + color="success" + disabled={!state.doWithdrawal.onClick} + onClick={state.doWithdrawal.onClick} + > + <i18n.Translate>Confirm withdrawal</i18n.Translate> + </Button> + )} + {state.tosProps.terms.status === "notfound" && ( + <Button + variant="contained" + color="warning" + disabled={!state.doWithdrawal.onClick} + onClick={state.doWithdrawal.onClick} + > + <i18n.Translate>Withdraw anyway</i18n.Translate> + </Button> + )} + </section> + ) : ( + <section> + <i18n.Translate>Loading terms of service...</i18n.Translate> + </section> + )} + </WalletAction> + ); +} diff --git a/packages/taler-wallet-webextension/src/cta/index.stories.ts b/packages/taler-wallet-webextension/src/cta/index.stories.ts @@ -23,7 +23,7 @@ import * as a1 from "./Deposit.stories.jsx"; import * as a3 from "./Pay.stories.jsx"; import * as a4 from "./Refund.stories.jsx"; import * as a5 from "./Tip.stories.jsx"; -import * as a6 from "./Withdraw.stories.jsx"; +import * as a6 from "./Withdraw/stories.jsx"; import * as a7 from "./TermsOfServiceSection.stories.js"; export default [a1, a3, a4, a5, a6, a7]; diff --git a/packages/taler-wallet-webextension/src/utils/index.ts b/packages/taler-wallet-webextension/src/utils/index.ts @@ -19,6 +19,7 @@ import { Amounts, GetExchangeTosResult, } from "@gnu-taler/taler-util"; +import { VNode } from "preact"; function getJsonIfOk(r: Response): Promise<any> { if (r.ok) { @@ -190,3 +191,24 @@ export interface TermsDocumentPdf { type: "pdf"; location: URL; } + +export type StateFunc<S> = (p: S) => VNode; + +export type StateViewMap<StateType extends { status: string }> = { + [S in StateType as S["status"]]: StateFunc<S>; +}; + +export function compose<SType extends { status: string }, PType>( + name: string, + hook: (p: PType) => SType, + vs: StateViewMap<SType>, +): (p: PType) => VNode { + const Component = (p: PType): VNode => { + const state = hook(p); + const s = state.status as unknown as SType["status"]; + const c = vs[s] as unknown as StateFunc<SType>; + return c(state); + }; + Component.name = `${name}`; + return Component; +} diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx @@ -37,7 +37,7 @@ import { import { PayPage } from "../cta/Pay.js"; import { RefundPage } from "../cta/Refund.js"; import { TipPage } from "../cta/Tip.js"; -import { WithdrawPage } from "../cta/Withdraw.js"; +import { WithdrawPage } from "../cta/Withdraw/index.js"; import { DepositPage as DepositPageCTA } from "../cta/Deposit.js"; import { Pages, WalletNavBar } from "../NavigationBar.js"; import { DeveloperPage } from "./DeveloperPage.js";