From f9ccb9415739864321f3ea482ce94695f775b9af Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 21 Jul 2022 10:36:15 -0300 Subject: withdraw as module --- .../src/cta/Withdraw.stories.tsx | 291 -------------------- .../src/cta/Withdraw.test.ts | 267 ------------------ .../src/cta/Withdraw/index.ts | 98 +++++++ .../src/cta/Withdraw/state.ts | 299 +++++++++++++++++++++ .../src/cta/Withdraw/stories.tsx | 276 +++++++++++++++++++ .../src/cta/Withdraw/test.ts | 267 ++++++++++++++++++ .../src/cta/Withdraw/views.tsx | 228 ++++++++++++++++ .../src/cta/index.stories.ts | 2 +- .../taler-wallet-webextension/src/utils/index.ts | 22 ++ .../src/wallet/Application.tsx | 2 +- 10 files changed, 1192 insertions(+), 560 deletions(-) delete mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw.test.ts 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 create mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw/test.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx (limited to 'packages') 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.test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts deleted file mode 100644 index 7b66fb9e0..000000000 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts +++ /dev/null @@ -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 - */ - -/** - * - * @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 => - ({ - withdrawalAmountRaw: "ARS:5", - withdrawalAmountEffective: "ARS:5", - } as any), - getExchangeTos: async (): Promise => ({ - 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 => - ({ - withdrawalAmountRaw: "ARS:5", - withdrawalAmountEffective: "ARS:5", - } as any), - getExchangeTos: async (): Promise => ({ - 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 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 new file mode 100644 index 000000000..7726d8a59 --- /dev/null +++ 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 + */ + +/** + * + * @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 => + ({ + withdrawalAmountRaw: "ARS:5", + withdrawalAmountEffective: "ARS:5", + } as any), + getExchangeTos: async (): Promise => ({ + 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 => + ({ + withdrawalAmountRaw: "ARS:5", + withdrawalAmountEffective: "ARS:5", + } as any), + getExchangeTos: async (): Promise => ({ + 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 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"; -- cgit v1.2.3