From f9ccb9415739864321f3ea482ce94695f775b9af Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 21 Jul 2022 10:36:15 -0300 Subject: [PATCH] withdraw as module --- .../src/cta/Withdraw.stories.tsx | 291 ----------------- .../src/cta/Withdraw/index.ts | 98 ++++++ .../src/cta/Withdraw/state.ts | 299 ++++++++++++++++++ .../src/cta/Withdraw/stories.tsx | 276 ++++++++++++++++ .../{Withdraw.test.ts => Withdraw/test.ts} | 12 +- .../src/cta/Withdraw/views.tsx | 228 +++++++++++++ .../src/cta/index.stories.ts | 2 +- .../src/utils/index.ts | 22 ++ .../src/wallet/Application.tsx | 2 +- 9 files changed, 931 insertions(+), 299 deletions(-) delete mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw/index.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw/state.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx rename packages/taler-wallet-webextension/src/cta/{Withdraw.test.ts => Withdraw/test.ts} (95%) create mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx deleted file mode 100644 index 93e8e936c..000000000 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx +++ /dev/null @@ -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 - */ - -/** - * - * @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 => { - null; - }, -}; - -const normalTosState = { - terms: { - status: "accepted", - version: "", - } as TermsState, - onAccept: () => null, - onReview: () => null, - reviewed: false, - reviewing: false, -}; - -const ageRestrictionOptions: Record = "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/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts new file mode 100644 index 000000000..75b44fe1e --- /dev/null +++ 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 + */ + +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 = { + "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 new file mode 100644 index 000000000..cfca3a0f7 --- /dev/null +++ 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 + */ + +/** + * 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( + 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(false); + const [reviewed, setReviewed] = useState(false); + + const [withdrawError, setWithdrawError] = useState( + undefined, + ); + const [doingWithdraw, setDoingWithdraw] = useState(false); + const [withdrawCompleted, setWithdrawCompleted] = useState(false); + + const [showExchangeSelection, setShowExchangeSelection] = useState(false); + const [nextExchange, setNextExchange] = useState(); + + 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 { + 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 { + 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 | 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 new file mode 100644 index 000000000..e221f9034 --- /dev/null +++ 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 + */ + +/** + * + * @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 => { + null; + }, +}; + +const normalTosState = { + terms: { + status: "accepted", + version: "", + } as TermsState, + onAccept: () => null, + onReview: () => null, + reviewed: false, + reviewing: false, +}; + +const ageRestrictionOptions: Record = "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 similarity index 95% rename from packages/taler-wallet-webextension/src/cta/Withdraw.test.ts rename to packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index 7b66fb9e0..7726d8a59 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -26,8 +26,8 @@ import { } 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"; +import { mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.js"; const exchanges: ExchangeListItem[] = [ { @@ -44,7 +44,7 @@ describe("Withdraw CTA states", () => { it("should tell the user that the URI is missing", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState(undefined, { + useComponentState({ talerWithdrawUri: undefined }, { listExchanges: async () => ({ exchanges }), getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ amount: "ARS:2", @@ -77,7 +77,7 @@ describe("Withdraw CTA states", () => { it("should tell the user that there is not known exchange", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler-withdraw://", { + useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { listExchanges: async () => ({ exchanges }), getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ amount: "EUR:2", @@ -112,7 +112,7 @@ describe("Withdraw CTA states", () => { it("should be able to withdraw if tos are ok", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler-withdraw://", { + useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { listExchanges: async () => ({ exchanges }), getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ amount: "ARS:2", @@ -177,7 +177,7 @@ describe("Withdraw CTA states", () => { it("should be accept the tos before withdraw", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler-withdraw://", { + useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { listExchanges: async () => ({ exchanges }), getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ amount: "ARS:2", diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx new file mode 100644 index 000000000..26e373205 --- /dev/null +++ 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 + */ + +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 ; + + return ( + Could not get the info from the URI + } + error={state.hook} + /> + ); +} + +export function LoadingExchangeView(state: State.LoadingExchange): VNode { + const { i18n } = useTranslationContext(); + if (!state.hook) return ; + + return ( + Could not get exchange} + error={state.hook} + /> + ); +} + +export function LoadingInfoView(state: State.LoadingInfoError): VNode { + const { i18n } = useTranslationContext(); + if (!state.hook) return ; + return ( + Could not get info of withdrawal} + error={state.hook} + /> + ); +} + +export function CompletedView(state: State.Completed): VNode { + const { i18n } = useTranslationContext(); + return ( + + + + Digital cash withdrawal + + +

+ Withdrawal in process... +

+

+ + You can close the page now. Check your bank if the transaction need + a confirmation step to be completed + +

+
+
+ ); +} + +export function SuccessView(state: State.Success): VNode { + const { i18n } = useTranslationContext(); + return ( + + + + Digital cash withdrawal + + + {state.doWithdrawal.error && ( + + Could not finish the withdrawal operation + + } + error={state.doWithdrawal.error.errorDetail} + /> + )} + +
+ Total to withdraw} + text={} + kind="positive" + /> + {Amounts.isNonZero(state.withdrawalFee) && ( + + Chosen amount} + text={} + kind="neutral" + /> + Exchange fee} + text={} + kind="negative" + /> + + )} + Exchange} + text={state.exchange.value} + kind="neutral" + big + /> + {state.showExchangeSelection ? ( + +
+ Known exchanges} + list={state.exchange.list} + value={state.exchange.value} + name="switchingExchange" + onChange={state.exchange.onChange} + /> +
+ + {state.exchange.isDirty ? ( + Confirm exchange selection + ) : ( + Cancel exchange selection + )} + +
+ ) : ( + + Edit exchange + + )} +
+
+ + Age restriction} + list={state.ageRestriction.list} + name="age" + maxWidth + value={state.ageRestriction.value} + onChange={state.ageRestriction.onChange} + /> + +
+ {state.tosProps && } + {state.tosProps ? ( +
+ {(state.tosProps.terms.status === "accepted" || + (state.mustAcceptFirst && state.tosProps.reviewed)) && ( + + )} + {state.tosProps.terms.status === "notfound" && ( + + )} +
+ ) : ( +
+ Loading terms of service... +
+ )} +
+ ); +} diff --git a/packages/taler-wallet-webextension/src/cta/index.stories.ts b/packages/taler-wallet-webextension/src/cta/index.stories.ts index 34771060e..29349db23 100644 --- 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 index aab748f90..a48352840 100644 --- 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 { if (r.ok) { @@ -190,3 +191,24 @@ export interface TermsDocumentPdf { type: "pdf"; location: URL; } + +export type StateFunc = (p: S) => VNode; + +export type StateViewMap = { + [S in StateType as S["status"]]: StateFunc; +}; + +export function compose( + name: string, + hook: (p: PType) => SType, + vs: StateViewMap, +): (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; + 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 index a6e62f140..99acb10c4 100644 --- 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";