diff options
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx | 291 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw/index.ts | 98 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw/state.ts | 299 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx | 276 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw/test.ts (renamed from packages/taler-wallet-webextension/src/cta/Withdraw.test.ts) | 12 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx | 228 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/index.stories.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/utils/index.ts | 22 | ||||
-rw-r--r-- | packages/taler-wallet-webextension/src/wallet/Application.tsx | 2 |
9 files changed, 931 insertions, 299 deletions
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 <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/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 <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 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 <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 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 <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 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 <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 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<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 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"; |