From 614a3e3c8702bb7436398acb911880caae0fdee7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 30 Jul 2022 20:55:41 -0300 Subject: standarizing components --- packages/taler-wallet-webextension/package.json | 4 +- .../src/cta/Deposit.stories.tsx | 41 -- .../src/cta/Deposit.test.ts | 93 ---- .../taler-wallet-webextension/src/cta/Deposit.tsx | 221 -------- .../src/cta/Deposit/index.ts | 70 +++ .../src/cta/Deposit/state.ts | 76 +++ .../src/cta/Deposit/stories.tsx | 37 ++ .../src/cta/Deposit/test.ts | 94 ++++ .../src/cta/Deposit/views.tsx | 109 ++++ .../src/cta/Pay.stories.tsx | 396 ------------- .../taler-wallet-webextension/src/cta/Pay.test.ts | 449 --------------- packages/taler-wallet-webextension/src/cta/Pay.tsx | 612 --------------------- .../src/cta/Payment/index.ts | 93 ++++ .../src/cta/Payment/state.ts | 171 ++++++ .../src/cta/Payment/stories.tsx | 356 ++++++++++++ .../src/cta/Payment/test.ts | 447 +++++++++++++++ .../src/cta/Payment/views.tsx | 393 +++++++++++++ .../src/cta/Refund.stories.tsx | 105 ---- .../src/cta/Refund.test.ts | 268 --------- .../taler-wallet-webextension/src/cta/Refund.tsx | 364 ------------ .../src/cta/Refund/index.ts | 94 ++++ .../src/cta/Refund/state.ts | 104 ++++ .../src/cta/Refund/stories.tsx | 96 ++++ .../src/cta/Refund/test.ts | 265 +++++++++ .../src/cta/Refund/views.tsx | 172 ++++++ .../src/cta/Tip.stories.tsx | 52 -- .../taler-wallet-webextension/src/cta/Tip.test.ts | 193 ------- packages/taler-wallet-webextension/src/cta/Tip.tsx | 241 -------- .../taler-wallet-webextension/src/cta/Tip/index.ts | 84 +++ .../taler-wallet-webextension/src/cta/Tip/state.ts | 92 ++++ .../src/cta/Tip/stories.tsx | 46 ++ .../taler-wallet-webextension/src/cta/Tip/test.ts | 195 +++++++ .../src/cta/Tip/views.tsx | 118 ++++ .../taler-wallet-webextension/src/cta/Withdraw.tsx | 570 ------------------- .../src/cta/Withdraw/index.ts | 38 +- .../src/cta/Withdraw/state.ts | 32 +- .../src/cta/Withdraw/stories.tsx | 14 +- .../src/cta/Withdraw/test.ts | 50 +- .../src/cta/Withdraw/views.tsx | 25 +- .../src/cta/index.stories.ts | 8 +- .../src/wallet/Application.tsx | 10 +- 41 files changed, 3194 insertions(+), 3704 deletions(-) delete mode 100644 packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Deposit.test.ts delete mode 100644 packages/taler-wallet-webextension/src/cta/Deposit.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Deposit/index.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Deposit/state.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Deposit/test.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Deposit/views.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Pay.stories.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Pay.test.ts delete mode 100644 packages/taler-wallet-webextension/src/cta/Pay.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Payment/index.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Payment/state.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Payment/stories.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Payment/test.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Payment/views.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Refund.stories.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Refund.test.ts delete mode 100644 packages/taler-wallet-webextension/src/cta/Refund.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Refund/index.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Refund/state.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Refund/stories.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Refund/test.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Refund/views.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Tip.stories.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Tip.test.ts delete mode 100644 packages/taler-wallet-webextension/src/cta/Tip.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Tip/index.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Tip/state.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Tip/stories.tsx create mode 100644 packages/taler-wallet-webextension/src/cta/Tip/test.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Tip/views.tsx delete mode 100644 packages/taler-wallet-webextension/src/cta/Withdraw.tsx diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index d9940776c..b62bae081 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -9,7 +9,7 @@ "private": false, "scripts": { "clean": "rimraf dist lib tsconfig.tsbuildinfo", - "test": "pnpm compile && mocha --enable-source-maps 'dist/**/*.test.js'", + "test": "pnpm compile && mocha --enable-source-maps 'dist/**/*.test.js' 'dist/**/test.js'", "test:coverage": "nyc pnpm test", "compile": "tsc && ./build-fast-with-linaria.mjs", "prepare": "pnpm compile", @@ -81,4 +81,4 @@ "pogen": { "domain": "taler-wallet-webex" } -} +} \ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx b/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx deleted file mode 100644 index 269b33ce8..000000000 --- a/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx +++ /dev/null @@ -1,41 +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 } from "@gnu-taler/taler-util"; -import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./Deposit.js"; - -export default { - title: "cta/deposit", - component: TestedComponent, - argTypes: {}, -}; - -export const Ready = createExample(TestedComponent, { - state: { - status: "ready", - confirm: {}, - cost: Amounts.parseOrThrow("EUR:1.2"), - effective: Amounts.parseOrThrow("EUR:1"), - fee: Amounts.parseOrThrow("EUR:0.2"), - hook: undefined, - }, -}); diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.test.ts b/packages/taler-wallet-webextension/src/cta/Deposit.test.ts deleted file mode 100644 index 125a43427..000000000 --- a/packages/taler-wallet-webextension/src/cta/Deposit.test.ts +++ /dev/null @@ -1,93 +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, PrepareDepositResponse } from "@gnu-taler/taler-util"; -import { expect } from "chai"; -import { mountHook } from "../test-utils.js"; -import { useComponentState } from "./Deposit.jsx"; - -describe("Deposit CTA states", () => { - it("should tell the user that the URI is missing", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState(undefined, undefined, { - prepareRefund: async () => ({}), - applyRefund: async () => ({}), - onUpdateNotification: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const { status, hook } = getLastResultOrThrow(); - - expect(status).equals("loading"); - if (!hook) expect.fail(); - if (!hook.hasError) expect.fail(); - if (hook.operational) expect.fail(); - expect(hook.message).eq("ERROR_NO-URI-FOR-DEPOSIT"); - } - - await assertNoPendingUpdate(); - }); - - it("should be ready after loading", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("payto://refund/asdasdas", "EUR:1", { - prepareDeposit: async () => - ({ - effectiveDepositAmount: Amounts.parseOrThrow("EUR:1"), - totalDepositCost: Amounts.parseOrThrow("EUR:1.2"), - } as PrepareDepositResponse as any), - createDepositGroup: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "ready") expect.fail(); - if (state.hook) expect.fail(); - expect(state.confirm.onClick).not.undefined; - expect(state.cost).deep.eq(Amounts.parseOrThrow("EUR:1.2")); - expect(state.fee).deep.eq(Amounts.parseOrThrow("EUR:0.2")); - expect(state.effective).deep.eq(Amounts.parseOrThrow("EUR:1")); - } - - await assertNoPendingUpdate(); - }); -}); diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.tsx b/packages/taler-wallet-webextension/src/cta/Deposit.tsx deleted file mode 100644 index 2c5a94d51..000000000 --- a/packages/taler-wallet-webextension/src/cta/Deposit.tsx +++ /dev/null @@ -1,221 +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 - */ - -/** - * Page shown to the user to confirm entering - * a contract. - */ - -/** - * Imports. - */ - -import { - AmountJson, - Amounts, - AmountString, - CreateDepositGroupResponse, -} from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Amount } from "../components/Amount.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 { - ButtonSuccess, - SubTitle, - WalletAction, -} from "../components/styled/index.js"; -import { useTranslationContext } from "../context/translation.js"; -import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { Button } from "../mui/Button.js"; -import { ButtonHandler } from "../mui/handlers.js"; -import * as wxApi from "../wxApi.js"; - -interface Props { - talerDepositUri?: string; - amount: AmountString; - goBack: () => Promise; -} - -type State = Loading | Ready | Completed; -interface Loading { - status: "loading"; - hook: HookError | undefined; -} -interface Ready { - status: "ready"; - hook: undefined; - fee: AmountJson; - cost: AmountJson; - effective: AmountJson; - confirm: ButtonHandler; -} -interface Completed { - status: "completed"; - hook: undefined; -} - -export function useComponentState( - talerDepositUri: string | undefined, - amountStr: AmountString | undefined, - api: typeof wxApi, -): State { - const [result, setResult] = useState( - undefined, - ); - - const info = useAsyncAsHook(async () => { - if (!talerDepositUri) throw Error("ERROR_NO-URI-FOR-DEPOSIT"); - if (!amountStr) throw Error("ERROR_NO-AMOUNT-FOR-DEPOSIT"); - const amount = Amounts.parse(amountStr); - if (!amount) throw Error("ERROR_INVALID-AMOUNT-FOR-DEPOSIT"); - const deposit = await api.prepareDeposit( - talerDepositUri, - Amounts.stringify(amount), - ); - return { deposit, uri: talerDepositUri, amount }; - }); - - if (!info || info.hasError) { - return { - status: "loading", - hook: info, - }; - } - - const { deposit, uri, amount } = info.response; - async function doDeposit(): Promise { - const resp = await api.createDepositGroup(uri, Amounts.stringify(amount)); - setResult(resp); - } - - if (result !== undefined) { - return { - status: "completed", - hook: undefined, - }; - } - - return { - status: "ready", - hook: undefined, - confirm: { - onClick: doDeposit, - }, - fee: Amounts.sub(deposit.totalDepositCost, deposit.effectiveDepositAmount) - .amount, - cost: deposit.totalDepositCost, - effective: deposit.effectiveDepositAmount, - }; -} - -export function DepositPage({ talerDepositUri, amount, goBack }: Props): VNode { - const { i18n } = useTranslationContext(); - - const state = useComponentState(talerDepositUri, amount, wxApi); - - if (!talerDepositUri) { - return ( - - missing taler deposit uri - - ); - } - - return ; -} - -export interface ViewProps { - state: State; -} -export function View({ state }: ViewProps): VNode { - const { i18n } = useTranslationContext(); - - if (state.status === "loading") { - if (!state.hook) return ; - return ( - Could not load deposit status} - error={state.hook} - /> - ); - } - - if (state.status === "completed") { - return ( - - - - - Digital cash deposit - -
-

- deposit completed -

-
-
- ); - } - - return ( - - - - - Digital cash deposit - -
- {Amounts.isNonZero(state.cost) && ( - Cost} - text={} - kind="negative" - /> - )} - {Amounts.isNonZero(state.fee) && ( - Fee} - text={} - kind="negative" - /> - )} - To be received} - text={} - kind="positive" - /> -
-
- -
-
- ); -} diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/index.ts b/packages/taler-wallet-webextension/src/cta/Deposit/index.ts new file mode 100644 index 000000000..c2d700617 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Deposit/index.ts @@ -0,0 +1,70 @@ +/* + 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, AmountString } from "@gnu-taler/taler-util"; +import { Loading } from "../../components/Loading.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import * as wxApi from "../../wxApi.js"; +import { useComponentState } from "./state.js"; +import { CompletedView, LoadingUriView, ReadyView } from "./views.js"; + + + +export interface Props { + talerDepositUri: string | undefined, + amountStr: AmountString | undefined, +} + +export type State = + | State.Loading + | State.LoadingUriError + | State.Ready + | State.Completed; + +export namespace State { + + export interface Loading { + status: "loading"; + error: undefined; + } + export interface LoadingUriError { + status: "loading-uri"; + error: HookError; + } + export interface Ready { + status: "ready"; + error: undefined; + fee: AmountJson; + cost: AmountJson; + effective: AmountJson; + confirm: ButtonHandler; + } + export interface Completed { + status: "completed"; + error: undefined; + } +} + +const viewMapping: StateViewMap = { + "loading": Loading, + "loading-uri": LoadingUriView, + completed: CompletedView, + ready: ReadyView, +}; + +export const DepositPage = compose("Deposit", (p: Props) => useComponentState(p, wxApi), viewMapping) diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/state.ts b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts new file mode 100644 index 000000000..8876a2971 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts @@ -0,0 +1,76 @@ +/* + 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 { Amounts, CreateDepositGroupResponse } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import * as wxApi from "../../wxApi.js"; +import { Props, State } from "./index.js"; + +export function useComponentState( + { talerDepositUri, amountStr }: Props, + api: typeof wxApi, +): State { + const [result, setResult] = useState( + undefined, + ); + + const info = useAsyncAsHook(async () => { + if (!talerDepositUri) throw Error("ERROR_NO-URI-FOR-DEPOSIT"); + if (!amountStr) throw Error("ERROR_NO-AMOUNT-FOR-DEPOSIT"); + const amount = Amounts.parse(amountStr); + if (!amount) throw Error("ERROR_INVALID-AMOUNT-FOR-DEPOSIT"); + const deposit = await api.prepareDeposit( + talerDepositUri, + Amounts.stringify(amount), + ); + return { deposit, uri: talerDepositUri, amount }; + }); + + if (!info) return { status: "loading", error: undefined } + if (info.hasError) { + return { + status: "loading-uri", + error: info, + }; + } + + const { deposit, uri, amount } = info.response; + async function doDeposit(): Promise { + const resp = await api.createDepositGroup(uri, Amounts.stringify(amount)); + setResult(resp); + } + + if (result !== undefined) { + return { + status: "completed", + error: undefined, + }; + } + + return { + status: "ready", + error: undefined, + confirm: { + onClick: doDeposit, + }, + fee: Amounts.sub(deposit.totalDepositCost, deposit.effectiveDepositAmount) + .amount, + cost: deposit.totalDepositCost, + effective: deposit.effectiveDepositAmount, + }; +} \ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx b/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx new file mode 100644 index 000000000..a4168bcc2 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx @@ -0,0 +1,37 @@ +/* + 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 } from "@gnu-taler/taler-util"; +import { createExample } from "../../test-utils.js"; +import { ReadyView } from "./views.js"; + +export default { + title: "cta/deposit", +}; + +export const Ready = createExample(ReadyView, { + status: "ready", + confirm: {}, + cost: Amounts.parseOrThrow("EUR:1.2"), + effective: Amounts.parseOrThrow("EUR:1"), + fee: Amounts.parseOrThrow("EUR:0.2"), + error: undefined, +}); diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/test.ts b/packages/taler-wallet-webextension/src/cta/Deposit/test.ts new file mode 100644 index 000000000..6e7aaf237 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Deposit/test.ts @@ -0,0 +1,94 @@ +/* + 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, PrepareDepositResponse +} from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.js"; + +describe("Deposit CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerDepositUri: undefined, amountStr: undefined }, { + prepareRefund: async () => ({}), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}), + } as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equals("loading"); + } + + await waitNextUpdate(); + + { + const { status, error } = getLastResultOrThrow(); + + expect(status).equals("loading-uri"); + + if (!error) expect.fail(); + if (!error.hasError) expect.fail(); + if (error.operational) expect.fail(); + expect(error.message).eq("ERROR_NO-URI-FOR-DEPOSIT"); + } + + await assertNoPendingUpdate(); + }); + + it("should be ready after loading", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerDepositUri: "payto://refund/asdasdas", amountStr: "EUR:1" }, { + prepareDeposit: async () => + ({ + effectiveDepositAmount: Amounts.parseOrThrow("EUR:1"), + totalDepositCost: Amounts.parseOrThrow("EUR:1.2"), + } as PrepareDepositResponse as any), + createDepositGroup: async () => ({}), + } as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equals("loading"); + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.confirm.onClick).not.undefined; + expect(state.cost).deep.eq(Amounts.parseOrThrow("EUR:1.2")); + expect(state.fee).deep.eq(Amounts.parseOrThrow("EUR:0.2")); + expect(state.effective).deep.eq(Amounts.parseOrThrow("EUR:1")); + } + + await assertNoPendingUpdate(); + }); +}); diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx b/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx new file mode 100644 index 000000000..ba1ca58d6 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx @@ -0,0 +1,109 @@ +/* + 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 { Amounts } from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { Amount } from "../../components/Amount.js"; +import { LoadingError } from "../../components/LoadingError.js"; +import { LogoHeader } from "../../components/LogoHeader.js"; +import { Part } from "../../components/Part.js"; +import { SubTitle, WalletAction } from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; + +/** + * + * @author sebasjm + */ + +export function LoadingUriView({ error }: State.LoadingUriError): VNode { + const { i18n } = useTranslationContext(); + + return ( + Could not load deposit status} + error={error} + /> + ); +} +export function CompletedView(state: State.Completed): VNode { + const { i18n } = useTranslationContext(); + + return ( + + + + + Digital cash deposit + +
+

+ deposit completed +

+
+
+ ); +} + +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + + return ( + + + + + Digital cash deposit + +
+ {Amounts.isNonZero(state.cost) && ( + Cost} + text={} + kind="negative" + /> + )} + {Amounts.isNonZero(state.fee) && ( + Fee} + text={} + kind="negative" + /> + )} + To be received} + text={} + kind="positive" + /> +
+
+ +
+
+ ); +} diff --git a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx b/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx deleted file mode 100644 index 147ae6837..000000000 --- a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx +++ /dev/null @@ -1,396 +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, - ContractTerms, - PreparePayResultType, -} from "@gnu-taler/taler-util"; -import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./Pay.js"; - -export default { - title: "cta/pay", - component: TestedComponent, - argTypes: {}, -}; - -const noop = async (): Promise => { - return; -}; - -export const NoBalance = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: undefined, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0"), - payResult: undefined, - uri: "", - payStatus: { - status: PreparePayResultType.InsufficientBalance, - noncePriv: "", - proposalId: "proposal1234", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial as any, - amountRaw: "USD:10", - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const NoEnoughBalance = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 9, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0"), - payResult: undefined, - uri: "", - payStatus: { - status: PreparePayResultType.InsufficientBalance, - noncePriv: "", - proposalId: "proposal1234", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial as any, - amountRaw: "USD:10", - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const EnoughBalanceButRestricted = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 19, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0"), - payResult: undefined, - uri: "", - payStatus: { - status: PreparePayResultType.InsufficientBalance, - noncePriv: "", - proposalId: "proposal1234", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial as any, - amountRaw: "USD:10", - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const PaymentPossible = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 11, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0"), - payResult: undefined, - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.PaymentPossible, - amountEffective: "USD:10", - amountRaw: "USD:10", - noncePriv: "", - contractTerms: { - nonce: "123213123", - merchant: { - name: "someone", - }, - amount: "USD:10", - summary: "some beers", - } as Partial as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const PaymentPossibleWithFee = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 11, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0.20"), - payResult: undefined, - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.PaymentPossible, - amountEffective: "USD:10.20", - amountRaw: "USD:10", - noncePriv: "", - contractTerms: { - nonce: "123213123", - merchant: { - name: "someone", - }, - amount: "USD:10", - summary: "some beers", - } as Partial as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -import beer from "../../static-dev/beer.png"; - -export const TicketWithAProductList = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 11, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0.20"), - payResult: undefined, - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.PaymentPossible, - amountEffective: "USD:10.20", - amountRaw: "USD:10", - noncePriv: "", - contractTerms: { - nonce: "123213123", - merchant: { - name: "someone", - }, - amount: "USD:10", - summary: "some beers", - products: [ - { - description: "ten beers", - price: "USD:1", - quantity: 10, - image: beer, - }, - { - description: "beer without image", - price: "USD:1", - quantity: 10, - }, - { - description: "one brown beer", - price: "USD:2", - quantity: 1, - image: beer, - }, - ], - } as Partial as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const AlreadyConfirmedByOther = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 11, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0.20"), - payResult: undefined, - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: "USD:10", - amountRaw: "USD:10", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: false, - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const AlreadyPaidWithoutFulfillment = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 11, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0.20"), - payResult: undefined, - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: "USD:10", - amountRaw: "USD:10", - contractTerms: { - merchant: { - name: "someone", - }, - summary: "some beers", - amount: "USD:10", - } as Partial as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: true, - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); - -export const AlreadyPaidWithFulfillment = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("USD:10"), - balance: { - currency: "USD", - fraction: 40000000, - value: 11, - }, - payHandler: { - onClick: async () => { - null; - }, - }, - totalFees: Amounts.parseOrThrow("USD:0.20"), - payResult: undefined, - uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: "USD:10", - amountRaw: "USD:10", - contractTerms: { - merchant: { - name: "someone", - }, - fulfillment_message: - "congratulations! you are looking at the fulfillment message! ", - summary: "some beers", - amount: "USD:10", - } as Partial as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: true, - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.test.ts b/packages/taler-wallet-webextension/src/cta/Pay.test.ts deleted file mode 100644 index 42ab902b8..000000000 --- a/packages/taler-wallet-webextension/src/cta/Pay.test.ts +++ /dev/null @@ -1,449 +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 { - AmountJson, - Amounts, - BalancesResponse, - ConfirmPayResult, - ConfirmPayResultType, - NotificationType, - PreparePayResult, - PreparePayResultType, -} from "@gnu-taler/taler-util"; -import { expect } from "chai"; -import { mountHook } from "../test-utils.js"; -import * as wxApi from "../wxApi.js"; -import { useComponentState } from "./Pay.jsx"; - -const nullFunction: any = () => null; -type VoidFunction = () => void; - -type Subs = { - [key in NotificationType]?: VoidFunction; -}; - -export class SubsHandler { - private subs: Subs = {}; - - constructor() { - this.saveSubscription = this.saveSubscription.bind(this); - } - - saveSubscription( - messageTypes: NotificationType[], - callback: VoidFunction, - ): VoidFunction { - messageTypes.forEach((m) => { - this.subs[m] = callback; - }); - return nullFunction; - } - - notifyEvent(event: NotificationType): void { - const cb = this.subs[event]; - if (cb === undefined) - expect.fail(`Expected to have a subscription for ${event}`); - cb(); - } -} - -describe("Pay CTA states", () => { - it("should tell the user that the URI is missing", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState(undefined, { - onUpdateNotification: nullFunction, - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const { status, hook } = getLastResultOrThrow(); - - expect(status).equals("loading"); - if (hook === undefined) expect.fail(); - expect(hook.hasError).true; - expect(hook.operational).false; - } - - await assertNoPendingUpdate(); - }); - - it("should response with no balance", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: nullFunction, - preparePay: async () => - ({ - amountRaw: "USD:10", - status: PreparePayResultType.InsufficientBalance, - } as Partial), - getBalance: async () => - ({ - balances: [], - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).undefined; - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); - expect(r.payHandler.onClick).undefined; - } - - await assertNoPendingUpdate(); - }); - - it("should not be able to pay if there is no enough balance", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: nullFunction, - preparePay: async () => - ({ - amountRaw: "USD:10", - status: PreparePayResultType.InsufficientBalance, - } as Partial), - getBalance: async () => - ({ - balances: [ - { - available: "USD:5", - }, - ], - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:5")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); - expect(r.payHandler.onClick).undefined; - } - - await assertNoPendingUpdate(); - }); - - it("should be able to pay (without fee)", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: nullFunction, - preparePay: async () => - ({ - amountRaw: "USD:10", - amountEffective: "USD:10", - status: PreparePayResultType.PaymentPossible, - } as Partial), - getBalance: async () => - ({ - balances: [ - { - available: "USD:15", - }, - ], - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:0")); - expect(r.payHandler.onClick).not.undefined; - } - - await assertNoPendingUpdate(); - }); - - it("should be able to pay (with fee)", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: nullFunction, - preparePay: async () => - ({ - amountRaw: "USD:9", - amountEffective: "USD:10", - status: PreparePayResultType.PaymentPossible, - } as Partial), - getBalance: async () => - ({ - balances: [ - { - available: "USD:15", - }, - ], - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - expect(r.payHandler.onClick).not.undefined; - } - - await assertNoPendingUpdate(); - }); - - it("should get confirmation done after pay successfully", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: nullFunction, - preparePay: async () => - ({ - amountRaw: "USD:9", - amountEffective: "USD:10", - status: PreparePayResultType.PaymentPossible, - } as Partial), - getBalance: async () => - ({ - balances: [ - { - available: "USD:15", - }, - ], - } as Partial), - confirmPay: async () => - ({ - type: ConfirmPayResultType.Done, - contractTerms: {}, - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - if (r.payHandler.onClick === undefined) expect.fail(); - r.payHandler.onClick(); - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "confirmed") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - if (r.payResult.type !== ConfirmPayResultType.Done) expect.fail(); - expect(r.payResult.contractTerms).not.undefined; - expect(r.payHandler.onClick).undefined; - } - - await assertNoPendingUpdate(); - }); - - it("should not stay in ready state after pay with error", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: nullFunction, - preparePay: async () => - ({ - amountRaw: "USD:9", - amountEffective: "USD:10", - status: PreparePayResultType.PaymentPossible, - } as Partial), - getBalance: async () => - ({ - balances: [ - { - available: "USD:15", - }, - ], - } as Partial), - confirmPay: async () => - ({ - type: ConfirmPayResultType.Pending, - lastError: { code: 1 }, - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - if (r.payHandler.onClick === undefined) expect.fail(); - r.payHandler.onClick(); - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - expect(r.payHandler.onClick).undefined; - if (r.payHandler.error === undefined) expect.fail(); - //FIXME: error message here is bad - expect(r.payHandler.error.errorDetail.hint).eq( - "could not confirm payment", - ); - expect(r.payHandler.error.errorDetail.payResult).deep.equal({ - type: ConfirmPayResultType.Pending, - lastError: { code: 1 }, - }); - } - - await assertNoPendingUpdate(); - }); - - it("should update balance if a coins is withdraw", async () => { - const subscriptions = new SubsHandler(); - let availableBalance = Amounts.parseOrThrow("USD:10"); - - function notifyCoinWithdrawn(newAmount: AmountJson): void { - availableBalance = Amounts.add(availableBalance, newAmount).amount; - subscriptions.notifyEvent(NotificationType.CoinWithdrawn); - } - - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taller://pay", { - onUpdateNotification: subscriptions.saveSubscription, - preparePay: async () => - ({ - amountRaw: "USD:9", - amountEffective: "USD:10", - status: PreparePayResultType.PaymentPossible, - } as Partial), - getBalance: async () => - ({ - balances: [ - { - available: Amounts.stringify(availableBalance), - }, - ], - } as Partial), - } as Partial as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:10")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - expect(r.payHandler.onClick).not.undefined; - - notifyCoinWithdrawn(Amounts.parseOrThrow("USD:5")); - } - - await waitNextUpdate(); - - { - const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); - expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); - expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); - expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); - expect(r.payHandler.onClick).not.undefined; - } - - await assertNoPendingUpdate(); - }); -}); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx b/packages/taler-wallet-webextension/src/cta/Pay.tsx deleted file mode 100644 index df381832b..000000000 --- a/packages/taler-wallet-webextension/src/cta/Pay.tsx +++ /dev/null @@ -1,612 +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 - */ - -/** - * Page shown to the user to confirm entering - * a contract. - */ - -/** - * Imports. - */ - -import { - AmountJson, - Amounts, - ConfirmPayResult, - ConfirmPayResultType, - ContractTerms, - NotificationType, - PreparePayResult, - PreparePayResultType, - Product, - TalerErrorCode, -} from "@gnu-taler/taler-util"; -import { TalerError } from "@gnu-taler/taler-wallet-core"; -import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { Amount } from "../components/Amount.js"; -import { ErrorMessage } from "../components/ErrorMessage.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 { QR } from "../components/QR.js"; -import { - ButtonSuccess, - Link, - LinkSuccess, - SmallLightText, - SubTitle, - SuccessBox, - WalletAction, - WarningBox, -} from "../components/styled/index.js"; -import { useTranslationContext } from "../context/translation.js"; -import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { Button } from "../mui/Button.js"; -import { ButtonHandler } from "../mui/handlers.js"; -import * as wxApi from "../wxApi.js"; - -interface Props { - talerPayUri?: string; - goToWalletManualWithdraw: (currency?: string) => Promise; - goBack: () => Promise; -} - -type State = Loading | Ready | Confirmed; -interface Loading { - status: "loading"; - hook: HookError | undefined; -} -interface Ready { - status: "ready"; - hook: undefined; - uri: string; - amount: AmountJson; - totalFees: AmountJson; - payStatus: PreparePayResult; - balance: AmountJson | undefined; - payHandler: ButtonHandler; - payResult: undefined; -} - -interface Confirmed { - status: "confirmed"; - hook: undefined; - uri: string; - amount: AmountJson; - totalFees: AmountJson; - payStatus: PreparePayResult; - balance: AmountJson | undefined; - payResult: ConfirmPayResult; - payHandler: ButtonHandler; -} - -export function useComponentState( - talerPayUri: string | undefined, - api: typeof wxApi, -): State { - const [payResult, setPayResult] = useState( - undefined, - ); - const [payErrMsg, setPayErrMsg] = useState(undefined); - - const hook = useAsyncAsHook(async () => { - if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT"); - const payStatus = await api.preparePay(talerPayUri); - const balance = await api.getBalance(); - return { payStatus, balance, uri: talerPayUri }; - }); - - useEffect(() => { - api.onUpdateNotification([NotificationType.CoinWithdrawn], () => { - hook?.retry(); - }); - }); - - const hookResponse = !hook || hook.hasError ? undefined : hook.response; - - useEffect(() => { - if (!hookResponse) return; - const { payStatus } = hookResponse; - if ( - payStatus && - payStatus.status === PreparePayResultType.AlreadyConfirmed && - payStatus.paid - ) { - const fu = payStatus.contractTerms.fulfillment_url; - if (fu) { - setTimeout(() => { - document.location.href = fu; - }, 3000); - } - } - }, [hookResponse]); - - if (!hook || hook.hasError) { - return { - status: "loading", - hook, - }; - } - const { payStatus } = hook.response; - const amount = Amounts.parseOrThrow(payStatus.amountRaw); - - const foundBalance = hook.response.balance.balances.find( - (b) => Amounts.parseOrThrow(b.available).currency === amount.currency, - ); - const foundAmount = foundBalance - ? Amounts.parseOrThrow(foundBalance.available) - : undefined; - - async function doPayment(): Promise { - try { - if (payStatus.status !== "payment-possible") { - throw TalerError.fromUncheckedDetail({ - code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, - hint: `payment is not possible: ${payStatus.status}`, - }); - } - const res = await api.confirmPay(payStatus.proposalId, undefined); - if (res.type !== ConfirmPayResultType.Done) { - throw TalerError.fromUncheckedDetail({ - code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, - hint: `could not confirm payment`, - payResult: res, - }); - } - const fu = res.contractTerms.fulfillment_url; - if (fu) { - if (typeof window !== "undefined") { - document.location.href = fu; - } else { - console.log(`should d to ${fu}`); - } - } - setPayResult(res); - } catch (e) { - if (e instanceof TalerError) { - setPayErrMsg(e); - } - } - } - - const payDisabled = - payErrMsg || - !foundAmount || - payStatus.status === PreparePayResultType.InsufficientBalance; - - const payHandler: ButtonHandler = { - onClick: payDisabled ? undefined : doPayment, - error: payErrMsg, - }; - - let totalFees = Amounts.getZero(amount.currency); - if (payStatus.status === PreparePayResultType.PaymentPossible) { - const amountEffective: AmountJson = Amounts.parseOrThrow( - payStatus.amountEffective, - ); - totalFees = Amounts.sub(amountEffective, amount).amount; - } - - if (!payResult) { - return { - status: "ready", - hook: undefined, - uri: hook.response.uri, - amount, - totalFees, - balance: foundAmount, - payHandler, - payStatus: hook.response.payStatus, - payResult, - }; - } - - return { - status: "confirmed", - hook: undefined, - uri: hook.response.uri, - amount, - totalFees, - balance: foundAmount, - payStatus: hook.response.payStatus, - payResult, - payHandler: {}, - }; -} - -export function PayPage({ - talerPayUri, - goToWalletManualWithdraw, - goBack, -}: Props): VNode { - const { i18n } = useTranslationContext(); - - const state = useComponentState(talerPayUri, wxApi); - - if (state.status === "loading") { - if (!state.hook) return ; - return ( - Could not load pay status} - error={state.hook} - /> - ); - } - return ( - - ); -} - -export function View({ - state, - goBack, - goToWalletManualWithdraw, -}: { - state: Ready | Confirmed; - goToWalletManualWithdraw: (currency?: string) => Promise; - goBack: () => Promise; -}): VNode { - const { i18n } = useTranslationContext(); - const contractTerms: ContractTerms = state.payStatus.contractTerms; - - if (!contractTerms) { - return ( - - Could not load contract terms from merchant or wallet backend. - - } - /> - ); - } - - return ( - - - - - Digital cash payment - - - - -
- {state.payStatus.status !== PreparePayResultType.InsufficientBalance && - Amounts.isNonZero(state.totalFees) && ( - Total to pay} - text={} - kind="negative" - /> - )} - Purchase amount} - text={} - kind="neutral" - /> - {Amounts.isNonZero(state.totalFees) && ( - - Fee} - text={} - kind="negative" - /> - - )} - Merchant} - text={contractTerms.merchant.name} - kind="neutral" - /> - Purchase} - text={contractTerms.summary} - kind="neutral" - /> - {contractTerms.order_id && ( - Receipt} - text={`#${contractTerms.order_id}`} - kind="neutral" - /> - )} - {contractTerms.products && contractTerms.products.length > 0 && ( - - )} -
- -
- - Cancel - -
-
- ); -} - -export function ProductList({ products }: { products: Product[] }): VNode { - const { i18n } = useTranslationContext(); - return ( - - - List of products - -
- {products.map((p, i) => { - if (p.price) { - const pPrice = Amounts.parseOrThrow(p.price); - return ( -
-
- -
-
-
- {p.quantity ?? 1} x {p.description}{" "} - - {Amounts.stringify(pPrice)} - -
-
- - {Amounts.stringify( - Amounts.mult(pPrice, p.quantity ?? 1).amount, - )} - -
-
-
- ); - } - return ( -
-
- -
-
-
- {p.quantity ?? 1} x {p.description} -
-
- Total - {` `} - {p.price ? ( - `${Amounts.stringifyValue( - Amounts.mult( - Amounts.parseOrThrow(p.price), - p.quantity ?? 1, - ).amount, - )} ${p}` - ) : ( - free - )} -
-
-
- ); - })} -
-
- ); -} - -function ShowImportantMessage({ state }: { state: Ready | Confirmed }): VNode { - const { i18n } = useTranslationContext(); - const { payStatus } = state; - if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { - if (payStatus.paid) { - if (payStatus.contractTerms.fulfillment_url) { - return ( - - - Already paid, you are going to be redirected to{" "} - - {payStatus.contractTerms.fulfillment_url} - - - - ); - } - return ( - - Already paid - - ); - } - return ( - - Already claimed - - ); - } - - if (state.status == "confirmed") { - const { payResult, payHandler } = state; - if (payHandler.error) { - return ; - } - if (payResult.type === ConfirmPayResultType.Done) { - return ( - -

- Payment complete -

-

- {!payResult.contractTerms.fulfillment_message ? ( - payResult.contractTerms.fulfillment_url ? ( - - You are going to be redirected to $ - {payResult.contractTerms.fulfillment_url} - - ) : ( - You can close this page. - ) - ) : ( - payResult.contractTerms.fulfillment_message - )} -

-
- ); - } - } - return ; -} - -function PayWithMobile({ state }: { state: Ready }): VNode { - const { i18n } = useTranslationContext(); - - const [showQR, setShowQR] = useState(false); - - const privateUri = - state.payStatus.status !== PreparePayResultType.AlreadyConfirmed - ? `${state.uri}&n=${state.payStatus.noncePriv}` - : state.uri; - return ( -
- setShowQR((qr) => !qr)}> - {!showQR ? ( - Pay with a mobile phone - ) : ( - Hide QR - )} - - {showQR && ( -
- - - Scan the QR code or - - click here - - -
- )} -
- ); -} - -function ButtonsSection({ - state, - goToWalletManualWithdraw, -}: { - state: Ready | Confirmed; - goToWalletManualWithdraw: (currency: string) => Promise; -}): VNode { - const { i18n } = useTranslationContext(); - if (state.status === "ready") { - const { payStatus } = state; - if (payStatus.status === PreparePayResultType.PaymentPossible) { - return ( - -
- -
- -
- ); - } - if (payStatus.status === PreparePayResultType.InsufficientBalance) { - let BalanceMessage = ""; - if (!state.balance) { - BalanceMessage = i18n.str`You have no balance for this currency. Withdraw digital cash first.`; - } else { - const balanceShouldBeEnough = - Amounts.cmp(state.balance, state.amount) !== -1; - if (balanceShouldBeEnough) { - BalanceMessage = i18n.str`Could not find enough coins to pay this order. Even if you have enough ${state.balance.currency} some restriction may apply.`; - } else { - BalanceMessage = i18n.str`Your current balance is not enough for this order.`; - } - } - return ( - -
- {BalanceMessage} -
-
- -
- -
- ); - } - if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { - return ( - -
- {payStatus.paid && - state.payStatus.contractTerms.fulfillment_message && ( - Merchant message} - text={state.payStatus.contractTerms.fulfillment_message} - kind="neutral" - /> - )} -
- {!payStatus.paid && } -
- ); - } - } - - if (state.status === "confirmed") { - if (state.payResult.type === ConfirmPayResultType.Pending) { - return ( -
-
-

- Processing... -

-
-
- ); - } - } - - return ; -} diff --git a/packages/taler-wallet-webextension/src/cta/Payment/index.ts b/packages/taler-wallet-webextension/src/cta/Payment/index.ts new file mode 100644 index 000000000..0e67a4991 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Payment/index.ts @@ -0,0 +1,93 @@ +/* + 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, ConfirmPayResult, PreparePayResult } from "@gnu-taler/taler-util"; +import { Loading } from "../../components/Loading.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import * as wxApi from "../../wxApi.js"; +import { useComponentState } from "./state.js"; +import { LoadingUriView, BaseView } from "./views.js"; + + + +export interface Props { + talerPayUri?: string; + goToWalletManualWithdraw: (currency?: string) => Promise; + goBack: () => Promise; +} + +export type State = + | State.Loading + | State.LoadingUriError + | State.Ready + | State.NoEnoughBalance + | State.NoBalanceForCurrency + | State.Confirmed; + +export namespace State { + + export interface Loading { + status: "loading"; + error: undefined; + } + export interface LoadingUriError { + status: "loading-uri"; + error: HookError; + } + + interface BaseInfo { + amount: AmountJson; + totalFees: AmountJson; + payStatus: PreparePayResult; + uri: string; + error: undefined; + goToWalletManualWithdraw: (currency?: string) => Promise; + goBack: () => Promise; + } + export interface NoBalanceForCurrency extends BaseInfo { + status: "no-balance-for-currency" + balance: undefined; + } + export interface NoEnoughBalance extends BaseInfo { + status: "no-enough-balance" + balance: AmountJson; + } + export interface Ready extends BaseInfo { + status: "ready"; + payHandler: ButtonHandler; + balance: AmountJson; + } + + export interface Confirmed extends BaseInfo { + status: "confirmed"; + payResult: ConfirmPayResult; + payHandler: ButtonHandler; + balance: AmountJson; + } +} + +const viewMapping: StateViewMap = { + loading: Loading, + "loading-uri": LoadingUriView, + "no-balance-for-currency": BaseView, + "no-enough-balance": BaseView, + confirmed: BaseView, + ready: BaseView, +}; + +export const PaymentPage = compose("Payment", (p: Props) => useComponentState(p, wxApi), viewMapping) diff --git a/packages/taler-wallet-webextension/src/cta/Payment/state.ts b/packages/taler-wallet-webextension/src/cta/Payment/state.ts new file mode 100644 index 000000000..3c819ec8f --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Payment/state.ts @@ -0,0 +1,171 @@ +/* + 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, Amounts, ConfirmPayResult, ConfirmPayResultType, NotificationType, PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util"; +import { TalerError } from "@gnu-taler/taler-wallet-core"; +import { useEffect, useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler } from "../../mui/handlers.js"; +import * as wxApi from "../../wxApi.js"; +import { Props, State } from "./index.js"; + +export function useComponentState( + { talerPayUri, goBack, goToWalletManualWithdraw }: Props, + api: typeof wxApi, +): State { + const [payResult, setPayResult] = useState( + undefined, + ); + const [payErrMsg, setPayErrMsg] = useState(undefined); + + const hook = useAsyncAsHook(async () => { + if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT"); + const payStatus = await api.preparePay(talerPayUri); + const balance = await api.getBalance(); + return { payStatus, balance, uri: talerPayUri }; + }); + + useEffect(() => { + api.onUpdateNotification([NotificationType.CoinWithdrawn], () => { + hook?.retry(); + }); + }); + + const hookResponse = !hook || hook.hasError ? undefined : hook.response; + + useEffect(() => { + if (!hookResponse) return; + const { payStatus } = hookResponse; + if ( + payStatus && + payStatus.status === PreparePayResultType.AlreadyConfirmed && + payStatus.paid + ) { + const fu = payStatus.contractTerms.fulfillment_url; + if (fu) { + setTimeout(() => { + document.location.href = fu; + }, 3000); + } + } + }, [hookResponse]); + + if (!hook) return { status: "loading", error: undefined }; + if (hook.hasError) { + return { + status: "loading-uri", + error: hook, + }; + } + const { payStatus } = hook.response; + const amount = Amounts.parseOrThrow(payStatus.amountRaw); + + const foundBalance = hook.response.balance.balances.find( + (b) => Amounts.parseOrThrow(b.available).currency === amount.currency, + ); + + + let totalFees = Amounts.getZero(amount.currency); + if (payStatus.status === PreparePayResultType.PaymentPossible) { + const amountEffective: AmountJson = Amounts.parseOrThrow( + payStatus.amountEffective, + ); + totalFees = Amounts.sub(amountEffective, amount).amount; + } + + const baseResult = { + uri: hook.response.uri, + amount, + totalFees, + payStatus, + error: undefined, + goBack, goToWalletManualWithdraw + } + + if (!foundBalance) { + return { + status: "no-balance-for-currency", + balance: undefined, + ...baseResult, + } + } + + const foundAmount = Amounts.parseOrThrow(foundBalance.available); + + async function doPayment(): Promise { + try { + if (payStatus.status !== "payment-possible") { + throw TalerError.fromUncheckedDetail({ + code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, + hint: `payment is not possible: ${payStatus.status}`, + }); + } + const res = await api.confirmPay(payStatus.proposalId, undefined); + if (res.type !== ConfirmPayResultType.Done) { + throw TalerError.fromUncheckedDetail({ + code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, + hint: `could not confirm payment`, + payResult: res, + }); + } + const fu = res.contractTerms.fulfillment_url; + if (fu) { + if (typeof window !== "undefined") { + document.location.href = fu; + } else { + console.log(`should d to ${fu}`); + } + } + setPayResult(res); + } catch (e) { + if (e instanceof TalerError) { + setPayErrMsg(e); + } + } + } + + if (payStatus.status === PreparePayResultType.InsufficientBalance) { + return { + status: 'no-enough-balance', + balance: foundAmount, + ...baseResult, + } + } + + const payHandler: ButtonHandler = { + onClick: payErrMsg ? undefined : doPayment, + error: payErrMsg, + }; + + if (!payResult) { + return { + status: "ready", + payHandler, + ...baseResult, + balance: foundAmount + }; + } + + return { + status: "confirmed", + balance: foundAmount, + payResult, + payHandler: {}, + ...baseResult, + }; +} + diff --git a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx new file mode 100644 index 000000000..603a9cb33 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx @@ -0,0 +1,356 @@ +/* + 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, + ContractTerms, + PreparePayResultType, +} from "@gnu-taler/taler-util"; +import { createExample } from "../../test-utils.js"; +import { BaseView } from "./views.js"; + +export default { + title: "cta/payment", + component: BaseView, + argTypes: {}, +}; + +export const NoBalance = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: undefined, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0"), + + uri: "", + payStatus: { + status: PreparePayResultType.InsufficientBalance, + noncePriv: "", + proposalId: "proposal1234", + contractTerms: { + merchant: { + name: "someone", + }, + summary: "some beers", + amount: "USD:10", + } as Partial as any, + amountRaw: "USD:10", + }, +}); + +export const NoEnoughBalance = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 9, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0"), + + uri: "", + payStatus: { + status: PreparePayResultType.InsufficientBalance, + noncePriv: "", + proposalId: "proposal1234", + contractTerms: { + merchant: { + name: "someone", + }, + summary: "some beers", + amount: "USD:10", + } as Partial as any, + amountRaw: "USD:10", + }, +}); + +export const EnoughBalanceButRestricted = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 19, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0"), + + uri: "", + payStatus: { + status: PreparePayResultType.InsufficientBalance, + noncePriv: "", + proposalId: "proposal1234", + contractTerms: { + merchant: { + name: "someone", + }, + summary: "some beers", + amount: "USD:10", + } as Partial as any, + amountRaw: "USD:10", + }, +}); + +export const PaymentPossible = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0"), + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + status: PreparePayResultType.PaymentPossible, + amountEffective: "USD:10", + amountRaw: "USD:10", + noncePriv: "", + contractTerms: { + nonce: "123213123", + merchant: { + name: "someone", + }, + amount: "USD:10", + summary: "some beers", + } as Partial as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + }, +}); + +export const PaymentPossibleWithFee = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0.20"), + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + status: PreparePayResultType.PaymentPossible, + amountEffective: "USD:10.20", + amountRaw: "USD:10", + noncePriv: "", + contractTerms: { + nonce: "123213123", + merchant: { + name: "someone", + }, + amount: "USD:10", + summary: "some beers", + } as Partial as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + }, +}); + +import beer from "../../../static-dev/beer.png"; + +export const TicketWithAProductList = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0.20"), + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + status: PreparePayResultType.PaymentPossible, + amountEffective: "USD:10.20", + amountRaw: "USD:10", + noncePriv: "", + contractTerms: { + nonce: "123213123", + merchant: { + name: "someone", + }, + amount: "USD:10", + summary: "some beers", + products: [ + { + description: "ten beers", + price: "USD:1", + quantity: 10, + image: beer, + }, + { + description: "beer without image", + price: "USD:1", + quantity: 10, + }, + { + description: "one brown beer", + price: "USD:2", + quantity: 1, + image: beer, + }, + ], + } as Partial as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + }, +}); + +export const AlreadyConfirmedByOther = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0.20"), + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + status: PreparePayResultType.AlreadyConfirmed, + amountEffective: "USD:10", + amountRaw: "USD:10", + contractTerms: { + merchant: { + name: "someone", + }, + summary: "some beers", + amount: "USD:10", + } as Partial as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + paid: false, + }, +}); + +export const AlreadyPaidWithoutFulfillment = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0.20"), + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + status: PreparePayResultType.AlreadyConfirmed, + amountEffective: "USD:10", + amountRaw: "USD:10", + contractTerms: { + merchant: { + name: "someone", + }, + summary: "some beers", + amount: "USD:10", + } as Partial as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + paid: true, + }, +}); + +export const AlreadyPaidWithFulfillment = createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:10"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: async () => { + null; + }, + }, + totalFees: Amounts.parseOrThrow("USD:0.20"), + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + status: PreparePayResultType.AlreadyConfirmed, + amountEffective: "USD:10", + amountRaw: "USD:10", + contractTerms: { + merchant: { + name: "someone", + }, + fulfillment_message: + "congratulations! you are looking at the fulfillment message! ", + summary: "some beers", + amount: "USD:10", + } as Partial as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + paid: true, + }, +}); diff --git a/packages/taler-wallet-webextension/src/cta/Payment/test.ts b/packages/taler-wallet-webextension/src/cta/Payment/test.ts new file mode 100644 index 000000000..aea70b7ca --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Payment/test.ts @@ -0,0 +1,447 @@ +/* + 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 { + AmountJson, + Amounts, + BalancesResponse, + ConfirmPayResult, + ConfirmPayResultType, + NotificationType, + PreparePayResult, + PreparePayResultType, +} from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.js"; +import * as wxApi from "../../wxApi.js"; + +const nullFunction: any = () => null; +type VoidFunction = () => void; + +type Subs = { + [key in NotificationType]?: VoidFunction; +}; + +export class SubsHandler { + private subs: Subs = {}; + + constructor() { + this.saveSubscription = this.saveSubscription.bind(this); + } + + saveSubscription( + messageTypes: NotificationType[], + callback: VoidFunction, + ): VoidFunction { + messageTypes.forEach((m) => { + this.subs[m] = callback; + }); + return nullFunction; + } + + notifyEvent(event: NotificationType): void { + const cb = this.subs[event]; + if (cb === undefined) + expect.fail(`Expected to have a subscription for ${event}`); + cb(); + } +} + +describe("Payment CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: undefined, goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const { status, error } = getLastResultOrThrow(); + + expect(status).equals("loading-uri"); + if (error === undefined) expect.fail(); + expect(error.hasError).true; + expect(error.operational).false; + } + + await assertNoPendingUpdate(); + }); + + it("should response with no balance", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + preparePay: async () => + ({ + amountRaw: "USD:10", + status: PreparePayResultType.InsufficientBalance, + } as Partial), + getBalance: async () => + ({ + balances: [], + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "no-balance-for-currency") expect.fail(); + expect(r.balance).undefined; + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); + } + + await assertNoPendingUpdate(); + }); + + it("should not be able to pay if there is no enough balance", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + preparePay: async () => + ({ + amountRaw: "USD:10", + status: PreparePayResultType.InsufficientBalance, + } as Partial), + getBalance: async () => + ({ + balances: [ + { + available: "USD:5", + }, + ], + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "no-enough-balance") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:5")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); + } + + await assertNoPendingUpdate(); + }); + + it("should be able to pay (without fee)", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + preparePay: async () => + ({ + amountRaw: "USD:10", + amountEffective: "USD:10", + status: PreparePayResultType.PaymentPossible, + } as Partial), + getBalance: async () => + ({ + balances: [ + { + available: "USD:15", + }, + ], + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:0")); + expect(r.payHandler.onClick).not.undefined; + } + + await assertNoPendingUpdate(); + }); + + it("should be able to pay (with fee)", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + preparePay: async () => + ({ + amountRaw: "USD:9", + amountEffective: "USD:10", + status: PreparePayResultType.PaymentPossible, + } as Partial), + getBalance: async () => + ({ + balances: [ + { + available: "USD:15", + }, + ], + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + expect(r.payHandler.onClick).not.undefined; + } + + await assertNoPendingUpdate(); + }); + + it("should get confirmation done after pay successfully", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + preparePay: async () => + ({ + amountRaw: "USD:9", + amountEffective: "USD:10", + status: PreparePayResultType.PaymentPossible, + } as Partial), + getBalance: async () => + ({ + balances: [ + { + available: "USD:15", + }, + ], + } as Partial), + confirmPay: async () => + ({ + type: ConfirmPayResultType.Done, + contractTerms: {}, + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + if (r.payHandler.onClick === undefined) expect.fail(); + r.payHandler.onClick(); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "confirmed") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + if (r.payResult.type !== ConfirmPayResultType.Done) expect.fail(); + expect(r.payResult.contractTerms).not.undefined; + expect(r.payHandler.onClick).undefined; + } + + await assertNoPendingUpdate(); + }); + + it("should not stay in ready state after pay with error", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: nullFunction, + preparePay: async () => + ({ + amountRaw: "USD:9", + amountEffective: "USD:10", + status: PreparePayResultType.PaymentPossible, + } as Partial), + getBalance: async () => + ({ + balances: [ + { + available: "USD:15", + }, + ], + } as Partial), + confirmPay: async () => + ({ + type: ConfirmPayResultType.Pending, + lastError: { code: 1 }, + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + if (r.payHandler.onClick === undefined) expect.fail(); + r.payHandler.onClick(); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + expect(r.payHandler.onClick).undefined; + if (r.payHandler.error === undefined) expect.fail(); + //FIXME: error message here is bad + expect(r.payHandler.error.errorDetail.hint).eq( + "could not confirm payment", + ); + expect(r.payHandler.error.errorDetail.payResult).deep.equal({ + type: ConfirmPayResultType.Pending, + lastError: { code: 1 }, + }); + } + + await assertNoPendingUpdate(); + }); + + it("should update balance if a coins is withdraw", async () => { + const subscriptions = new SubsHandler(); + let availableBalance = Amounts.parseOrThrow("USD:10"); + + function notifyCoinWithdrawn(newAmount: AmountJson): void { + availableBalance = Amounts.add(availableBalance, newAmount).amount; + subscriptions.notifyEvent(NotificationType.CoinWithdrawn); + } + + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { + onUpdateNotification: subscriptions.saveSubscription, + preparePay: async () => + ({ + amountRaw: "USD:9", + amountEffective: "USD:10", + status: PreparePayResultType.PaymentPossible, + } as Partial), + getBalance: async () => + ({ + balances: [ + { + available: Amounts.stringify(availableBalance), + }, + ], + } as Partial), + } as Partial as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:10")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + expect(r.payHandler.onClick).not.undefined; + + notifyCoinWithdrawn(Amounts.parseOrThrow("USD:5")); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15")); + expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9")); + expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); + expect(r.payHandler.onClick).not.undefined; + } + + await assertNoPendingUpdate(); + }); +}); diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx new file mode 100644 index 000000000..a8c9a640a --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -0,0 +1,393 @@ +/* + 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 { + Amounts, + ConfirmPayResultType, + ContractTerms, + PreparePayResultType, + Product, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Amount } from "../../components/Amount.js"; +import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.js"; +import { LoadingError } from "../../components/LoadingError.js"; +import { LogoHeader } from "../../components/LogoHeader.js"; +import { Part } from "../../components/Part.js"; +import { QR } from "../../components/QR.js"; +import { + Link, + LinkSuccess, + SmallLightText, + SubTitle, + SuccessBox, + WalletAction, + WarningBox, +} from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; + +export function LoadingUriView({ error }: State.LoadingUriError): VNode { + const { i18n } = useTranslationContext(); + + return ( + Could not load pay status} + error={error} + /> + ); +} + +type SupportedStates = + | State.Ready + | State.Confirmed + | State.NoBalanceForCurrency + | State.NoEnoughBalance; + +export function BaseView(state: SupportedStates): VNode { + const { i18n } = useTranslationContext(); + const contractTerms: ContractTerms = state.payStatus.contractTerms; + + return ( + + + + + Digital cash payment + + + + +
+ {state.payStatus.status !== PreparePayResultType.InsufficientBalance && + Amounts.isNonZero(state.totalFees) && ( + Total to pay} + text={} + kind="negative" + /> + )} + Purchase amount} + text={} + kind="neutral" + /> + {Amounts.isNonZero(state.totalFees) && ( + + Fee} + text={} + kind="negative" + /> + + )} + Merchant} + text={contractTerms.merchant.name} + kind="neutral" + /> + Purchase} + text={contractTerms.summary} + kind="neutral" + /> + {contractTerms.order_id && ( + Receipt} + text={`#${contractTerms.order_id}`} + kind="neutral" + /> + )} + {contractTerms.products && contractTerms.products.length > 0 && ( + + )} +
+ +
+ + Cancel + +
+
+ ); +} + +export function ProductList({ products }: { products: Product[] }): VNode { + const { i18n } = useTranslationContext(); + return ( + + + List of products + +
+ {products.map((p, i) => { + if (p.price) { + const pPrice = Amounts.parseOrThrow(p.price); + return ( +
+
+ +
+
+
+ {p.quantity ?? 1} x {p.description}{" "} + + {Amounts.stringify(pPrice)} + +
+
+ + {Amounts.stringify( + Amounts.mult(pPrice, p.quantity ?? 1).amount, + )} + +
+
+
+ ); + } + return ( +
+
+ +
+
+
+ {p.quantity ?? 1} x {p.description} +
+
+ Total + {` `} + {p.price ? ( + `${Amounts.stringifyValue( + Amounts.mult( + Amounts.parseOrThrow(p.price), + p.quantity ?? 1, + ).amount, + )} ${p}` + ) : ( + free + )} +
+
+
+ ); + })} +
+
+ ); +} + +function ShowImportantMessage({ state }: { state: SupportedStates }): VNode { + const { i18n } = useTranslationContext(); + const { payStatus } = state; + if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { + if (payStatus.paid) { + if (payStatus.contractTerms.fulfillment_url) { + return ( + + + Already paid, you are going to be redirected to{" "} + + {payStatus.contractTerms.fulfillment_url} + + + + ); + } + return ( + + Already paid + + ); + } + return ( + + Already claimed + + ); + } + + if (state.status == "confirmed") { + const { payResult, payHandler } = state; + if (payHandler.error) { + return ; + } + if (payResult.type === ConfirmPayResultType.Done) { + return ( + +

+ Payment complete +

+

+ {!payResult.contractTerms.fulfillment_message ? ( + payResult.contractTerms.fulfillment_url ? ( + + You are going to be redirected to $ + {payResult.contractTerms.fulfillment_url} + + ) : ( + You can close this page. + ) + ) : ( + payResult.contractTerms.fulfillment_message + )} +

+
+ ); + } + } + return ; +} + +function PayWithMobile({ state }: { state: State.Ready }): VNode { + const { i18n } = useTranslationContext(); + + const [showQR, setShowQR] = useState(false); + + const privateUri = + state.payStatus.status !== PreparePayResultType.AlreadyConfirmed + ? `${state.uri}&n=${state.payStatus.noncePriv}` + : state.uri; + return ( +
+ setShowQR((qr) => !qr)}> + {!showQR ? ( + Pay with a mobile phone + ) : ( + Hide QR + )} + + {showQR && ( +
+ + + Scan the QR code or + + click here + + +
+ )} +
+ ); +} + +function ButtonsSection({ + state, + goToWalletManualWithdraw, +}: { + state: SupportedStates; + goToWalletManualWithdraw: (currency: string) => Promise; +}): VNode { + const { i18n } = useTranslationContext(); + if (state.status === "ready") { + const { payStatus } = state; + if (payStatus.status === PreparePayResultType.PaymentPossible) { + return ( + +
+ +
+ +
+ ); + } + if (payStatus.status === PreparePayResultType.InsufficientBalance) { + let BalanceMessage = ""; + if (!state.balance) { + BalanceMessage = i18n.str`You have no balance for this currency. Withdraw digital cash first.`; + } else { + const balanceShouldBeEnough = + Amounts.cmp(state.balance, state.amount) !== -1; + if (balanceShouldBeEnough) { + BalanceMessage = i18n.str`Could not find enough coins to pay this order. Even if you have enough ${state.balance.currency} some restriction may apply.`; + } else { + BalanceMessage = i18n.str`Your current balance is not enough for this order.`; + } + } + return ( + +
+ {BalanceMessage} +
+
+ +
+ +
+ ); + } + if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { + return ( + +
+ {payStatus.paid && + state.payStatus.contractTerms.fulfillment_message && ( + Merchant message} + text={state.payStatus.contractTerms.fulfillment_message} + kind="neutral" + /> + )} +
+ {!payStatus.paid && } +
+ ); + } + } + + if (state.status === "confirmed") { + if (state.payResult.type === ConfirmPayResultType.Pending) { + return ( +
+
+

+ Processing... +

+
+
+ ); + } + } + + return ; +} diff --git a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx deleted file mode 100644 index 28182c81a..000000000 --- a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx +++ /dev/null @@ -1,105 +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 } from "@gnu-taler/taler-util"; -import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./Refund.js"; - -export default { - title: "cta/refund", - component: TestedComponent, - argTypes: {}, -}; - -export const Complete = createExample(TestedComponent, { - state: { - status: "completed", - amount: Amounts.parseOrThrow("USD:1"), - granted: Amounts.parseOrThrow("USD:1"), - hook: undefined, - merchantName: "the merchant", - products: undefined, - }, -}); - -export const InProgress = createExample(TestedComponent, { - state: { - status: "in-progress", - hook: undefined, - amount: Amounts.parseOrThrow("USD:1"), - awaitingAmount: Amounts.parseOrThrow("USD:1"), - granted: Amounts.parseOrThrow("USD:0"), - merchantName: "the merchant", - products: undefined, - }, -}); - -export const Ready = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - accept: {}, - ignore: {}, - - amount: Amounts.parseOrThrow("USD:1"), - awaitingAmount: Amounts.parseOrThrow("USD:1"), - granted: Amounts.parseOrThrow("USD:0"), - merchantName: "the merchant", - products: [], - orderId: "abcdef", - }, -}); - -import beer from "../../static-dev/beer.png"; - -export const WithAProductList = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - accept: {}, - ignore: {}, - amount: Amounts.parseOrThrow("USD:1"), - awaitingAmount: Amounts.parseOrThrow("USD:1"), - granted: Amounts.parseOrThrow("USD:0"), - merchantName: "the merchant", - products: [ - { - description: "beer", - image: beer, - quantity: 2, - }, - { - description: "t-shirt", - price: "EUR:1", - quantity: 5, - }, - ], - orderId: "abcdef", - }, -}); - -export const Ignored = createExample(TestedComponent, { - state: { - status: "ignored", - hook: undefined, - merchantName: "the merchant", - }, -}); diff --git a/packages/taler-wallet-webextension/src/cta/Refund.test.ts b/packages/taler-wallet-webextension/src/cta/Refund.test.ts deleted file mode 100644 index 3eff42e90..000000000 --- a/packages/taler-wallet-webextension/src/cta/Refund.test.ts +++ /dev/null @@ -1,268 +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 { - AmountJson, - Amounts, - NotificationType, - PrepareRefundResult, -} from "@gnu-taler/taler-util"; -import { expect } from "chai"; -import { mountHook } from "../test-utils.js"; -import { SubsHandler } from "./Pay.test.js"; -import { useComponentState } from "./Refund.jsx"; - -// onUpdateNotification: subscriptions.saveSubscription, - -describe("Refund CTA states", () => { - it("should tell the user that the URI is missing", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState(undefined, { - prepareRefund: async () => ({}), - applyRefund: async () => ({}), - onUpdateNotification: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const { status, hook } = getLastResultOrThrow(); - - expect(status).equals("loading"); - if (!hook) expect.fail(); - if (!hook.hasError) expect.fail(); - if (hook.operational) expect.fail(); - expect(hook.message).eq("ERROR_NO-URI-FOR-REFUND"); - } - - await assertNoPendingUpdate(); - }); - - it("should be ready after loading", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler://refund/asdasdas", { - prepareRefund: async () => - ({ - effectivePaid: "EUR:2", - awaiting: "EUR:2", - gone: "EUR:0", - granted: "EUR:0", - pending: false, - proposalId: "1", - info: { - contractTermsHash: "123", - merchant: { - name: "the merchant name", - }, - orderId: "orderId1", - summary: "the summary", - }, - } as PrepareRefundResult as any), - applyRefund: async () => ({}), - onUpdateNotification: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "ready") expect.fail(); - if (state.hook) expect.fail(); - expect(state.accept.onClick).not.undefined; - expect(state.ignore.onClick).not.undefined; - expect(state.merchantName).eq("the merchant name"); - expect(state.orderId).eq("orderId1"); - expect(state.products).undefined; - } - - await assertNoPendingUpdate(); - }); - - it("should be ignored after clicking the ignore button", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler://refund/asdasdas", { - prepareRefund: async () => - ({ - effectivePaid: "EUR:2", - awaiting: "EUR:2", - gone: "EUR:0", - granted: "EUR:0", - pending: false, - proposalId: "1", - info: { - contractTermsHash: "123", - merchant: { - name: "the merchant name", - }, - orderId: "orderId1", - summary: "the summary", - }, - } as PrepareRefundResult as any), - applyRefund: async () => ({}), - onUpdateNotification: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "ready") expect.fail(); - if (state.hook) expect.fail(); - expect(state.accept.onClick).not.undefined; - expect(state.merchantName).eq("the merchant name"); - expect(state.orderId).eq("orderId1"); - expect(state.products).undefined; - - if (state.ignore.onClick === undefined) expect.fail(); - state.ignore.onClick(); - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "ignored") expect.fail(); - if (state.hook) expect.fail(); - expect(state.merchantName).eq("the merchant name"); - } - - await assertNoPendingUpdate(); - }); - - it("should be in progress when doing refresh", async () => { - let granted = Amounts.getZero("EUR"); - const unit: AmountJson = { currency: "EUR", value: 1, fraction: 0 }; - const refunded: AmountJson = { currency: "EUR", value: 2, fraction: 0 }; - let awaiting: AmountJson = refunded; - let pending = true; - - const subscriptions = new SubsHandler(); - - function notifyMelt(): void { - granted = Amounts.add(granted, unit).amount; - pending = granted.value < refunded.value; - awaiting = Amounts.sub(refunded, granted).amount; - subscriptions.notifyEvent(NotificationType.RefreshMelted); - } - - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler://refund/asdasdas", { - prepareRefund: async () => - ({ - awaiting: Amounts.stringify(awaiting), - effectivePaid: "EUR:2", - gone: "EUR:0", - granted: Amounts.stringify(granted), - pending, - proposalId: "1", - info: { - contractTermsHash: "123", - merchant: { - name: "the merchant name", - }, - orderId: "orderId1", - summary: "the summary", - }, - } as PrepareRefundResult as any), - applyRefund: async () => ({}), - onUpdateNotification: subscriptions.saveSubscription, - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "in-progress") expect.fail("1"); - if (state.hook) expect.fail(); - expect(state.merchantName).eq("the merchant name"); - expect(state.products).undefined; - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")); - // expect(state.progress).closeTo(1 / 3, 0.01) - - notifyMelt(); - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "in-progress") expect.fail("2"); - if (state.hook) expect.fail(); - expect(state.merchantName).eq("the merchant name"); - expect(state.products).undefined; - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")); - // expect(state.progress).closeTo(2 / 3, 0.01) - - notifyMelt(); - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "completed") expect.fail("3"); - if (state.hook) expect.fail(); - expect(state.merchantName).eq("the merchant name"); - expect(state.products).undefined; - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")); - } - - await assertNoPendingUpdate(); - }); -}); diff --git a/packages/taler-wallet-webextension/src/cta/Refund.tsx b/packages/taler-wallet-webextension/src/cta/Refund.tsx deleted file mode 100644 index 04873b1ce..000000000 --- a/packages/taler-wallet-webextension/src/cta/Refund.tsx +++ /dev/null @@ -1,364 +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 - */ - -/** - * Page that shows refund status for purchases. - * - * @author sebasjm - */ - -import { - AmountJson, - Amounts, - NotificationType, - Product, -} from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { Amount } from "../components/Amount.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 { - ButtonSuccess, - SubTitle, - WalletAction, -} from "../components/styled/index.js"; -import { useTranslationContext } from "../context/translation.js"; -import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { Button } from "../mui/Button.js"; -import { ButtonHandler } from "../mui/handlers.js"; -import * as wxApi from "../wxApi.js"; -import { ProductList } from "./Pay.js"; - -interface Props { - talerRefundUri?: string; -} -export interface ViewProps { - state: State; -} -export function View({ state }: ViewProps): VNode { - const { i18n } = useTranslationContext(); - if (state.status === "loading") { - if (!state.hook) { - return ; - } - return ( - Could not load refund status} - error={state.hook} - /> - ); - } - - if (state.status === "ignored") { - return ( - - - - - Digital cash refund - -
-

- You've ignored the tip. -

-
-
- ); - } - - if (state.status === "in-progress") { - return ( - - - - - Digital cash refund - -
-

- The refund is in progress. -

-
-
- Total to refund} - text={} - kind="negative" - /> - Refunded} - text={} - kind="negative" - /> -
- {state.products && state.products.length ? ( -
- -
- ) : undefined} - {/*
- -
*/} -
- ); - } - - if (state.status === "completed") { - return ( - - - - - Digital cash refund - -
-

- this refund is already accepted. -

-
-
- Total to refunded} - text={} - kind="negative" - /> -
-
- ); - } - - return ( - - - - - Digital cash refund - -
-

- - The merchant "{state.merchantName}" is offering you - a refund. - -

-
-
- Order amount} - text={} - kind="neutral" - /> - {Amounts.isNonZero(state.granted) && ( - Already refunded} - text={} - kind="neutral" - /> - )} - Refund offered} - text={} - kind="positive" - /> -
- {state.products && state.products.length ? ( -
- -
- ) : undefined} -
- -
-
- ); -} - -type State = Loading | Ready | Ignored | InProgress | Completed; - -interface Loading { - status: "loading"; - hook: HookError | undefined; -} -interface Ready { - status: "ready"; - hook: undefined; - merchantName: string; - products: Product[] | undefined; - amount: AmountJson; - awaitingAmount: AmountJson; - granted: AmountJson; - accept: ButtonHandler; - ignore: ButtonHandler; - orderId: string; -} -interface Ignored { - status: "ignored"; - hook: undefined; - merchantName: string; -} -interface InProgress { - status: "in-progress"; - hook: undefined; - merchantName: string; - products: Product[] | undefined; - amount: AmountJson; - awaitingAmount: AmountJson; - granted: AmountJson; -} -interface Completed { - status: "completed"; - hook: undefined; - merchantName: string; - products: Product[] | undefined; - amount: AmountJson; - granted: AmountJson; -} - -export function useComponentState( - talerRefundUri: string | undefined, - api: typeof wxApi, -): State { - const [ignored, setIgnored] = useState(false); - - const info = useAsyncAsHook(async () => { - if (!talerRefundUri) throw Error("ERROR_NO-URI-FOR-REFUND"); - const refund = await api.prepareRefund({ talerRefundUri }); - return { refund, uri: talerRefundUri }; - }); - - useEffect(() => { - api.onUpdateNotification([NotificationType.RefreshMelted], () => { - info?.retry(); - }); - }); - - if (!info || info.hasError) { - return { - status: "loading", - hook: info, - }; - } - - const { refund, uri } = info.response; - - const doAccept = async (): Promise => { - await api.applyRefund(uri); - info.retry(); - }; - - const doIgnore = async (): Promise => { - setIgnored(true); - }; - - if (ignored) { - return { - status: "ignored", - hook: undefined, - merchantName: info.response.refund.info.merchant.name, - }; - } - - const awaitingAmount = Amounts.parseOrThrow(refund.awaiting); - - if (Amounts.isZero(awaitingAmount)) { - return { - status: "completed", - hook: undefined, - amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), - granted: Amounts.parseOrThrow(info.response.refund.granted), - merchantName: info.response.refund.info.merchant.name, - products: info.response.refund.info.products, - }; - } - - if (refund.pending) { - return { - status: "in-progress", - hook: undefined, - awaitingAmount, - amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), - granted: Amounts.parseOrThrow(info.response.refund.granted), - - merchantName: info.response.refund.info.merchant.name, - products: info.response.refund.info.products, - }; - } - - return { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), - granted: Amounts.parseOrThrow(info.response.refund.granted), - awaitingAmount, - merchantName: info.response.refund.info.merchant.name, - products: info.response.refund.info.products, - orderId: info.response.refund.info.orderId, - accept: { - onClick: doAccept, - }, - ignore: { - onClick: doIgnore, - }, - }; -} - -export function RefundPage({ talerRefundUri }: Props): VNode { - const { i18n } = useTranslationContext(); - - const state = useComponentState(talerRefundUri, wxApi); - - if (!talerRefundUri) { - return ( - - missing taler refund uri - - ); - } - - return ; -} - -function ProgressBar({ value }: { value: number }): VNode { - return ( -
-
-
- ); -} diff --git a/packages/taler-wallet-webextension/src/cta/Refund/index.ts b/packages/taler-wallet-webextension/src/cta/Refund/index.ts new file mode 100644 index 000000000..b122559a9 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Refund/index.ts @@ -0,0 +1,94 @@ +/* + 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, Product } from "@gnu-taler/taler-util"; +import { Loading } from "../../components/Loading.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import * as wxApi from "../../wxApi.js"; +import { useComponentState } from "./state.js"; +import { CompletedView, IgnoredView, InProgressView, LoadingUriView, ReadyView } from "./views.js"; + + + +export interface Props { + talerRefundUri?: string; +} + +export type State = + | State.Loading + | State.LoadingUriError + | State.Ready + | State.Ignored + | State.InProgress + | State.Completed; + +export namespace State { + + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "loading-uri"; + error: HookError; + } + + interface BaseInfo { + merchantName: string; + products: Product[] | undefined; + amount: AmountJson; + awaitingAmount: AmountJson; + granted: AmountJson; + } + + export interface Ready extends BaseInfo { + status: "ready"; + error: undefined; + + accept: ButtonHandler; + ignore: ButtonHandler; + orderId: string; + } + + export interface Ignored extends BaseInfo { + status: "ignored"; + error: undefined; + } + export interface InProgress extends BaseInfo { + status: "in-progress"; + error: undefined; + + } + export interface Completed extends BaseInfo { + status: "completed"; + error: undefined; + } + +} + +const viewMapping: StateViewMap = { + loading: Loading, + "loading-uri": LoadingUriView, + "in-progress": InProgressView, + completed: CompletedView, + ignored: IgnoredView, + ready: ReadyView, +}; + +export const RefundPage = compose("Refund", (p: Props) => useComponentState(p, wxApi), viewMapping) diff --git a/packages/taler-wallet-webextension/src/cta/Refund/state.ts b/packages/taler-wallet-webextension/src/cta/Refund/state.ts new file mode 100644 index 000000000..f8ce71a13 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Refund/state.ts @@ -0,0 +1,104 @@ +/* + 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 { Amounts, NotificationType } from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import * as wxApi from "../../wxApi.js"; +import { Props, State } from "./index.js"; + +export function useComponentState( + { talerRefundUri }: Props, + api: typeof wxApi, +): State { + const [ignored, setIgnored] = useState(false); + + const info = useAsyncAsHook(async () => { + if (!talerRefundUri) throw Error("ERROR_NO-URI-FOR-REFUND"); + const refund = await api.prepareRefund({ talerRefundUri }); + return { refund, uri: talerRefundUri }; + }); + + useEffect(() => { + api.onUpdateNotification([NotificationType.RefreshMelted], () => { + info?.retry(); + }); + }); + + if (!info) { + return { status: "loading", error: undefined } + } + if (info.hasError) { + return { + status: "loading-uri", + error: info, + }; + } + + const { refund, uri } = info.response; + + const doAccept = async (): Promise => { + await api.applyRefund(uri); + info.retry(); + }; + + const doIgnore = async (): Promise => { + setIgnored(true); + }; + + const baseInfo = { + amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), + granted: Amounts.parseOrThrow(info.response.refund.granted), + merchantName: info.response.refund.info.merchant.name, + products: info.response.refund.info.products, + awaitingAmount: Amounts.parseOrThrow(refund.awaiting), + error: undefined, + } + + if (ignored) { + return { + status: "ignored", + ...baseInfo, + }; + } + + if (Amounts.isZero(baseInfo.awaitingAmount)) { + return { + status: "completed", + ...baseInfo, + }; + } + + if (refund.pending) { + return { + status: "in-progress", + ...baseInfo, + }; + } + + return { + status: "ready", + ...baseInfo, + orderId: info.response.refund.info.orderId, + accept: { + onClick: doAccept, + }, + ignore: { + onClick: doIgnore, + }, + }; +} diff --git a/packages/taler-wallet-webextension/src/cta/Refund/stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund/stories.tsx new file mode 100644 index 000000000..d3a2302d9 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Refund/stories.tsx @@ -0,0 +1,96 @@ +/* + 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 } from "@gnu-taler/taler-util"; +import beer from "../../../static-dev/beer.png"; +import { createExample } from "../../test-utils.js"; +import { + CompletedView, + IgnoredView, + InProgressView, + ReadyView, +} from "./views.js"; +export default { + title: "cta/refund", +}; + +export const Complete = createExample(CompletedView, { + status: "completed", + amount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:1"), + error: undefined, + merchantName: "the merchant", + products: undefined, +}); + +export const InProgress = createExample(InProgressView, { + status: "in-progress", + error: undefined, + amount: Amounts.parseOrThrow("USD:1"), + awaitingAmount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:0"), + merchantName: "the merchant", + products: undefined, +}); + +export const Ready = createExample(ReadyView, { + status: "ready", + error: undefined, + accept: {}, + ignore: {}, + + amount: Amounts.parseOrThrow("USD:1"), + awaitingAmount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:0"), + merchantName: "the merchant", + products: [], + orderId: "abcdef", +}); + +export const WithAProductList = createExample(ReadyView, { + status: "ready", + error: undefined, + accept: {}, + ignore: {}, + amount: Amounts.parseOrThrow("USD:1"), + awaitingAmount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:0"), + merchantName: "the merchant", + products: [ + { + description: "beer", + image: beer, + quantity: 2, + }, + { + description: "t-shirt", + price: "EUR:1", + quantity: 5, + }, + ], + orderId: "abcdef", +}); + +export const Ignored = createExample(IgnoredView, { + status: "ignored", + error: undefined, + merchantName: "the merchant", +}); diff --git a/packages/taler-wallet-webextension/src/cta/Refund/test.ts b/packages/taler-wallet-webextension/src/cta/Refund/test.ts new file mode 100644 index 000000000..04c83b8f1 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Refund/test.ts @@ -0,0 +1,265 @@ +/* + 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 { + AmountJson, + Amounts, NotificationType, + PrepareRefundResult +} from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../../test-utils.js"; +import { SubsHandler } from "../Payment/test.js"; +import { useComponentState } from "./state.js"; + +describe("Refund CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerRefundUri: undefined }, { + prepareRefund: async () => ({}), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}), + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const { status, error } = getLastResultOrThrow(); + + expect(status).equals("loading-uri"); + if (!error) expect.fail(); + if (!error.hasError) expect.fail(); + if (error.operational) expect.fail(); + expect(error.message).eq("ERROR_NO-URI-FOR-REFUND"); + } + + await assertNoPendingUpdate(); + }); + + it("should be ready after loading", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { + prepareRefund: async () => + ({ + effectivePaid: "EUR:2", + awaiting: "EUR:2", + gone: "EUR:0", + granted: "EUR:0", + pending: false, + proposalId: "1", + info: { + contractTermsHash: "123", + merchant: { + name: "the merchant name", + }, + orderId: "orderId1", + summary: "the summary", + }, + } as PrepareRefundResult as any), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}), + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.accept.onClick).not.undefined; + expect(state.ignore.onClick).not.undefined; + expect(state.merchantName).eq("the merchant name"); + expect(state.orderId).eq("orderId1"); + expect(state.products).undefined; + } + + await assertNoPendingUpdate(); + }); + + it("should be ignored after clicking the ignore button", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { + prepareRefund: async () => + ({ + effectivePaid: "EUR:2", + awaiting: "EUR:2", + gone: "EUR:0", + granted: "EUR:0", + pending: false, + proposalId: "1", + info: { + contractTermsHash: "123", + merchant: { + name: "the merchant name", + }, + orderId: "orderId1", + summary: "the summary", + }, + } as PrepareRefundResult as any), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}), + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.accept.onClick).not.undefined; + expect(state.merchantName).eq("the merchant name"); + expect(state.orderId).eq("orderId1"); + expect(state.products).undefined; + + if (state.ignore.onClick === undefined) expect.fail(); + state.ignore.onClick(); + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "ignored") expect.fail(); + if (state.error) expect.fail(); + expect(state.merchantName).eq("the merchant name"); + } + + await assertNoPendingUpdate(); + }); + + it("should be in progress when doing refresh", async () => { + let granted = Amounts.getZero("EUR"); + const unit: AmountJson = { currency: "EUR", value: 1, fraction: 0 }; + const refunded: AmountJson = { currency: "EUR", value: 2, fraction: 0 }; + let awaiting: AmountJson = refunded; + let pending = true; + + const subscriptions = new SubsHandler(); + + function notifyMelt(): void { + granted = Amounts.add(granted, unit).amount; + pending = granted.value < refunded.value; + awaiting = Amounts.sub(refunded, granted).amount; + subscriptions.notifyEvent(NotificationType.RefreshMelted); + } + + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { + prepareRefund: async () => + ({ + awaiting: Amounts.stringify(awaiting), + effectivePaid: "EUR:2", + gone: "EUR:0", + granted: Amounts.stringify(granted), + pending, + proposalId: "1", + info: { + contractTermsHash: "123", + merchant: { + name: "the merchant name", + }, + orderId: "orderId1", + summary: "the summary", + }, + } as PrepareRefundResult as any), + applyRefund: async () => ({}), + onUpdateNotification: subscriptions.saveSubscription, + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "in-progress") expect.fail("1"); + if (state.error) expect.fail(); + expect(state.merchantName).eq("the merchant name"); + expect(state.products).undefined; + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")); + // expect(state.progress).closeTo(1 / 3, 0.01) + + notifyMelt(); + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "in-progress") expect.fail("2"); + if (state.error) expect.fail(); + expect(state.merchantName).eq("the merchant name"); + expect(state.products).undefined; + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")); + // expect(state.progress).closeTo(2 / 3, 0.01) + + notifyMelt(); + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "completed") expect.fail("3"); + if (state.error) expect.fail(); + expect(state.merchantName).eq("the merchant name"); + expect(state.products).undefined; + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")); + } + + await assertNoPendingUpdate(); + }); +}); diff --git a/packages/taler-wallet-webextension/src/cta/Refund/views.tsx b/packages/taler-wallet-webextension/src/cta/Refund/views.tsx new file mode 100644 index 000000000..e0c7bb553 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Refund/views.tsx @@ -0,0 +1,172 @@ +/* + 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 { Amounts } from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { Amount } from "../../components/Amount.js"; +import { LoadingError } from "../../components/LoadingError.js"; +import { LogoHeader } from "../../components/LogoHeader.js"; +import { Part } from "../../components/Part.js"; +import { SubTitle, WalletAction } from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Button } from "../../mui/Button.js"; +import { ProductList } from "../Payment/views.js"; +import { State } from "./index.js"; + +export function LoadingUriView({ error }: State.LoadingUriError): VNode { + const { i18n } = useTranslationContext(); + + return ( + Could not load refund status} + error={error} + /> + ); +} + +export function IgnoredView(state: State.Ignored): VNode { + const { i18n } = useTranslationContext(); + + return ( + + + + + Digital cash refund + +
+

+ You've ignored the tip. +

+
+
+ ); +} +export function InProgressView(state: State.InProgress): VNode { + const { i18n } = useTranslationContext(); + + return ( + + + + + Digital cash refund + +
+

+ The refund is in progress. +

+
+
+ Total to refund} + text={} + kind="negative" + /> + Refunded} + text={} + kind="negative" + /> +
+ {state.products && state.products.length ? ( +
+ +
+ ) : undefined} +
+ ); +} +export function CompletedView(state: State.Completed): VNode { + const { i18n } = useTranslationContext(); + + return ( + + + + + Digital cash refund + +
+

+ this refund is already accepted. +

+
+
+ Total to refunded} + text={} + kind="negative" + /> +
+
+ ); +} +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + return ( + + + + + Digital cash refund + +
+

+ + The merchant "{state.merchantName}" is offering you + a refund. + +

+
+
+ Order amount} + text={} + kind="neutral" + /> + {Amounts.isNonZero(state.granted) && ( + Already refunded} + text={} + kind="neutral" + /> + )} + Refund offered} + text={} + kind="positive" + /> +
+ {state.products && state.products.length ? ( +
+ +
+ ) : undefined} +
+ +
+
+ ); +} diff --git a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx b/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx deleted file mode 100644 index 40a89d1bf..000000000 --- a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx +++ /dev/null @@ -1,52 +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 } from "@gnu-taler/taler-util"; -import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./Tip.js"; - -export default { - title: "cta/tip", - component: TestedComponent, - argTypes: {}, -}; - -export const Accepted = createExample(TestedComponent, { - state: { - status: "accepted", - hook: undefined, - amount: Amounts.parseOrThrow("EUR:1"), - exchangeBaseUrl: "", - merchantBaseUrl: "", - }, -}); - -export const Ready = createExample(TestedComponent, { - state: { - status: "ready", - hook: undefined, - amount: Amounts.parseOrThrow("EUR:1"), - merchantBaseUrl: "http://merchant.url/", - exchangeBaseUrl: "http://exchange.url/", - accept: {}, - ignore: {}, - }, -}); diff --git a/packages/taler-wallet-webextension/src/cta/Tip.test.ts b/packages/taler-wallet-webextension/src/cta/Tip.test.ts deleted file mode 100644 index a77b59167..000000000 --- a/packages/taler-wallet-webextension/src/cta/Tip.test.ts +++ /dev/null @@ -1,193 +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, PrepareTipResult } from "@gnu-taler/taler-util"; -import { expect } from "chai"; -import { mountHook } from "../test-utils.js"; -import { useComponentState } from "./Tip.jsx"; - -describe("Tip CTA states", () => { - it("should tell the user that the URI is missing", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState(undefined, { - prepareTip: async () => ({}), - acceptTip: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const { status, hook } = getLastResultOrThrow(); - - expect(status).equals("loading"); - if (!hook) expect.fail(); - if (!hook.hasError) expect.fail(); - if (hook.operational) expect.fail(); - expect(hook.message).eq("ERROR_NO-URI-FOR-TIP"); - } - - await assertNoPendingUpdate(); - }); - - it("should be ready for accepting the tip", async () => { - let tipAccepted = false; - - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler://tip/asd", { - prepareTip: async () => - ({ - accepted: tipAccepted, - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - tipAmountEffective: "EUR:1", - walletTipId: "tip_id", - } as PrepareTipResult as any), - acceptTip: async () => { - tipAccepted = true; - }, - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "ready") expect.fail(); - if (state.hook) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - if (state.accept.onClick === undefined) expect.fail(); - - state.accept.onClick(); - } - - await waitNextUpdate(); - { - const state = getLastResultOrThrow(); - - if (state.status !== "accepted") expect.fail(); - if (state.hook) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - } - await assertNoPendingUpdate(); - }); - - it("should be ignored after clicking the ignore button", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler://tip/asd", { - prepareTip: async () => - ({ - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - tipAmountEffective: "EUR:1", - walletTipId: "tip_id", - } as PrepareTipResult as any), - acceptTip: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "ready") expect.fail(); - if (state.hook) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - if (state.ignore.onClick === undefined) expect.fail(); - - state.ignore.onClick(); - } - - await waitNextUpdate(); - { - const state = getLastResultOrThrow(); - - if (state.status !== "ignored") expect.fail(); - if (state.hook) expect.fail(); - } - await assertNoPendingUpdate(); - }); - - it("should render accepted if the tip has been used previously", async () => { - const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = - mountHook(() => - useComponentState("taler://tip/asd", { - prepareTip: async () => - ({ - accepted: true, - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - tipAmountEffective: "EUR:1", - walletTipId: "tip_id", - } as PrepareTipResult as any), - acceptTip: async () => ({}), - } as any), - ); - - { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading"); - expect(hook).undefined; - } - - await waitNextUpdate(); - - { - const state = getLastResultOrThrow(); - - if (state.status !== "accepted") expect.fail(); - if (state.hook) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - } - await assertNoPendingUpdate(); - }); -}); diff --git a/packages/taler-wallet-webextension/src/cta/Tip.tsx b/packages/taler-wallet-webextension/src/cta/Tip.tsx deleted file mode 100644 index 2feffcda4..000000000 --- a/packages/taler-wallet-webextension/src/cta/Tip.tsx +++ /dev/null @@ -1,241 +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 - */ - -/** - * Page shown to the user to accept or ignore a tip from a merchant. - * - * @author sebasjm - */ - -import { AmountJson, Amounts, PrepareTipResult } from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { Amount } from "../components/Amount.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 { - Button, - ButtonSuccess, - SubTitle, - WalletAction, -} from "../components/styled/index.js"; -import { useTranslationContext } from "../context/translation.js"; -import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { ButtonHandler } from "../mui/handlers.js"; -import * as wxApi from "../wxApi.js"; - -interface Props { - talerTipUri?: string; -} - -type State = Loading | Ready | Accepted | Ignored; - -interface Loading { - status: "loading"; - hook: HookError | undefined; -} - -interface Ignored { - status: "ignored"; - hook: undefined; -} -interface Accepted { - status: "accepted"; - hook: undefined; - merchantBaseUrl: string; - amount: AmountJson; - exchangeBaseUrl: string; -} -interface Ready { - status: "ready"; - hook: undefined; - merchantBaseUrl: string; - amount: AmountJson; - exchangeBaseUrl: string; - accept: ButtonHandler; - ignore: ButtonHandler; -} - -export function useComponentState( - talerTipUri: string | undefined, - api: typeof wxApi, -): State { - const [tipIgnored, setTipIgnored] = useState(false); - - const tipInfo = useAsyncAsHook(async () => { - if (!talerTipUri) throw Error("ERROR_NO-URI-FOR-TIP"); - const tip = await api.prepareTip({ talerTipUri }); - return { tip }; - }); - - if (!tipInfo || tipInfo.hasError) { - return { - status: "loading", - hook: tipInfo, - }; - } - - const { tip } = tipInfo.response; - - const doAccept = async (): Promise => { - await api.acceptTip({ walletTipId: tip.walletTipId }); - tipInfo.retry(); - }; - - const doIgnore = async (): Promise => { - setTipIgnored(true); - }; - - if (tipIgnored) { - return { - status: "ignored", - hook: undefined, - }; - } - - if (tip.accepted) { - return { - status: "accepted", - hook: undefined, - merchantBaseUrl: tip.merchantBaseUrl, - exchangeBaseUrl: tip.exchangeBaseUrl, - amount: Amounts.parseOrThrow(tip.tipAmountEffective), - }; - } - - return { - status: "ready", - hook: undefined, - merchantBaseUrl: tip.merchantBaseUrl, - exchangeBaseUrl: tip.exchangeBaseUrl, - accept: { - onClick: doAccept, - }, - ignore: { - onClick: doIgnore, - }, - amount: Amounts.parseOrThrow(tip.tipAmountEffective), - }; -} - -export function View({ state }: { state: State }): VNode { - const { i18n } = useTranslationContext(); - if (state.status === "loading") { - if (!state.hook) { - return ; - } - return ( - Could not load tip status} - error={state.hook} - /> - ); - } - - if (state.status === "ignored") { - return ( - - - - - Digital cash tip - - - You've ignored the tip. - - - ); - } - - if (state.status === "accepted") { - return ( - - - - - Digital cash tip - -
- - Tip from {state.merchantBaseUrl} accepted. Check your - transactions list for more details. - -
-
- ); - } - - return ( - - - - - Digital cash tip - - -
-

- The merchant is offering you a tip -

- Amount} - text={} - kind="positive" - big - /> - Merchant URL} - text={state.merchantBaseUrl} - kind="neutral" - /> - Exchange} - text={state.exchangeBaseUrl} - kind="neutral" - /> -
-
- - -
-
- ); -} - -export function TipPage({ talerTipUri }: Props): VNode { - const { i18n } = useTranslationContext(); - const state = useComponentState(talerTipUri, wxApi); - - if (!talerTipUri) { - return ( - - missing tip uri - - ); - } - - return ; -} diff --git a/packages/taler-wallet-webextension/src/cta/Tip/index.ts b/packages/taler-wallet-webextension/src/cta/Tip/index.ts new file mode 100644 index 000000000..24a7b1cff --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Tip/index.ts @@ -0,0 +1,84 @@ +/* + 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 { Loading } from "../../components/Loading.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import * as wxApi from "../../wxApi.js"; +import { + Props as TermsOfServiceSectionProps +} from "../TermsOfServiceSection.js"; +import { useComponentState } from "./state.js"; +import { AcceptedView, IgnoredView, LoadingUriView, ReadyView } from "./views.js"; + + + +export interface Props { + talerTipUri?: string; +} + +export type State = + | State.Loading + | State.LoadingUriError + | State.Ignored + | State.Accepted + | State.Ready + | State.Ignored; + +export namespace State { + + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "loading-uri"; + error: HookError; + } + + export interface BaseInfo { + merchantBaseUrl: string; + amount: AmountJson; + exchangeBaseUrl: string; + error: undefined; + } + + export interface Ignored extends BaseInfo { + status: "ignored"; + } + + export interface Accepted extends BaseInfo { + status: "accepted"; + } + export interface Ready extends BaseInfo { + status: "ready"; + accept: ButtonHandler; + ignore: ButtonHandler; + } +} + +const viewMapping: StateViewMap = { + loading: Loading, + "loading-uri": LoadingUriView, + "accepted": AcceptedView, + "ignored": IgnoredView, + "ready": ReadyView, +}; + +export const TipPage = compose("Tip", (p: Props) => useComponentState(p, wxApi), viewMapping) diff --git a/packages/taler-wallet-webextension/src/cta/Tip/state.ts b/packages/taler-wallet-webextension/src/cta/Tip/state.ts new file mode 100644 index 000000000..e5511074e --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Tip/state.ts @@ -0,0 +1,92 @@ +/* + 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 { Amounts } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import * as wxApi from "../../wxApi.js"; +import { Props, State } from "./index.js"; + +export function useComponentState( + { talerTipUri }: Props, + api: typeof wxApi, +): State { + const [tipIgnored, setTipIgnored] = useState(false); + + const tipInfo = useAsyncAsHook(async () => { + if (!talerTipUri) throw Error("ERROR_NO-URI-FOR-TIP"); + const tip = await api.prepareTip({ talerTipUri }); + return { tip }; + }); + + if (!tipInfo) { + return { + status: "loading", + error: undefined, + } + } + if (tipInfo.hasError) { + return { + status: "loading-uri", + error: tipInfo, + }; + } + + const { tip } = tipInfo.response; + + const doAccept = async (): Promise => { + await api.acceptTip({ walletTipId: tip.walletTipId }); + tipInfo.retry(); + }; + + const doIgnore = async (): Promise => { + setTipIgnored(true); + }; + + const baseInfo = { + merchantBaseUrl: tip.merchantBaseUrl, + exchangeBaseUrl: tip.exchangeBaseUrl, + amount: Amounts.parseOrThrow(tip.tipAmountEffective), + error: undefined, + } + + if (tipIgnored) { + return { + status: "ignored", + ...baseInfo, + }; + } + + if (tip.accepted) { + return { + status: "accepted", + ...baseInfo, + }; + } + + return { + status: "ready", + ...baseInfo, + accept: { + onClick: doAccept, + }, + ignore: { + onClick: doIgnore, + }, + }; +} + diff --git a/packages/taler-wallet-webextension/src/cta/Tip/stories.tsx b/packages/taler-wallet-webextension/src/cta/Tip/stories.tsx new file mode 100644 index 000000000..8c72a8812 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Tip/stories.tsx @@ -0,0 +1,46 @@ +/* + 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 } from "@gnu-taler/taler-util"; +import { createExample } from "../../test-utils.js"; +import { AcceptedView, ReadyView } from "./views.js"; + +export default { + title: "cta/tip", +}; + +export const Accepted = createExample(AcceptedView, { + status: "accepted", + error: undefined, + amount: Amounts.parseOrThrow("EUR:1"), + exchangeBaseUrl: "", + merchantBaseUrl: "", +}); + +export const Ready = createExample(ReadyView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("EUR:1"), + merchantBaseUrl: "http://merchant.url/", + exchangeBaseUrl: "http://exchange.url/", + accept: {}, + ignore: {}, +}); diff --git a/packages/taler-wallet-webextension/src/cta/Tip/test.ts b/packages/taler-wallet-webextension/src/cta/Tip/test.ts new file mode 100644 index 000000000..1c7d363f4 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Tip/test.ts @@ -0,0 +1,195 @@ +/* + 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, PrepareTipResult +} from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.js"; + +describe("Tip CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerTipUri: undefined }, { + prepareTip: async () => ({}), + acceptTip: async () => ({}), + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const { status, error } = getLastResultOrThrow(); + + expect(status).equals("loading-uri"); + if (!error) expect.fail(); + if (!error.hasError) expect.fail(); + if (error.operational) expect.fail(); + expect(error.message).eq("ERROR_NO-URI-FOR-TIP"); + } + + await assertNoPendingUpdate(); + }); + + it("should be ready for accepting the tip", async () => { + let tipAccepted = false; + + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerTipUri: "taler://tip/asd" }, { + prepareTip: async () => + ({ + accepted: tipAccepted, + exchangeBaseUrl: "exchange url", + merchantBaseUrl: "merchant url", + tipAmountEffective: "EUR:1", + walletTipId: "tip_id", + } as PrepareTipResult as any), + acceptTip: async () => { + tipAccepted = true; + }, + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + if (state.accept.onClick === undefined) expect.fail(); + + state.accept.onClick(); + } + + await waitNextUpdate(); + { + const state = getLastResultOrThrow(); + + if (state.status !== "accepted") expect.fail(); + if (state.error) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + } + await assertNoPendingUpdate(); + }); + + it("should be ignored after clicking the ignore button", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerTipUri: "taler://tip/asd" }, { + prepareTip: async () => + ({ + exchangeBaseUrl: "exchange url", + merchantBaseUrl: "merchant url", + tipAmountEffective: "EUR:1", + walletTipId: "tip_id", + } as PrepareTipResult as any), + acceptTip: async () => ({}), + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + if (state.ignore.onClick === undefined) expect.fail(); + + state.ignore.onClick(); + } + + await waitNextUpdate(); + { + const state = getLastResultOrThrow(); + + if (state.status !== "ignored") expect.fail(); + if (state.error) expect.fail(); + } + await assertNoPendingUpdate(); + }); + + it("should render accepted if the tip has been used previously", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ talerTipUri: "taler://tip/asd" }, { + prepareTip: async () => + ({ + accepted: true, + exchangeBaseUrl: "exchange url", + merchantBaseUrl: "merchant url", + tipAmountEffective: "EUR:1", + walletTipId: "tip_id", + } as PrepareTipResult as any), + acceptTip: async () => ({}), + } as any), + ); + + { + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; + } + + await waitNextUpdate(); + + { + const state = getLastResultOrThrow(); + + if (state.status !== "accepted") expect.fail(); + if (state.error) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + } + await assertNoPendingUpdate(); + }); +}); diff --git a/packages/taler-wallet-webextension/src/cta/Tip/views.tsx b/packages/taler-wallet-webextension/src/cta/Tip/views.tsx new file mode 100644 index 000000000..442d41d28 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Tip/views.tsx @@ -0,0 +1,118 @@ +/* + 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 { Amount } from "../../components/Amount.js"; +import { LoadingError } from "../../components/LoadingError.js"; +import { LogoHeader } from "../../components/LogoHeader.js"; +import { Part } from "../../components/Part.js"; +import { SubTitle, WalletAction } from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; + +export function LoadingUriView({ error }: State.LoadingUriError): VNode { + const { i18n } = useTranslationContext(); + + return ( + Could not load tip status} + error={error} + /> + ); +} + +export function IgnoredView(state: State.Ignored): VNode { + const { i18n } = useTranslationContext(); + return ( + + + + + Digital cash tip + + + You've ignored the tip. + + + ); +} + +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + return ( + + + + + Digital cash tip + + +
+

+ The merchant is offering you a tip +

+ Amount} + text={} + kind="positive" + big + /> + Merchant URL} + text={state.merchantBaseUrl} + kind="neutral" + /> + Exchange} + text={state.exchangeBaseUrl} + kind="neutral" + /> +
+
+ + +
+
+ ); +} + +export function AcceptedView(state: State.Accepted): VNode { + const { i18n } = useTranslationContext(); + return ( + + + + + Digital cash tip + +
+ + Tip from {state.merchantBaseUrl} accepted. Check your + transactions list for more details. + +
+
+ ); +} diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx deleted file mode 100644 index a27a214be..000000000 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx +++ /dev/null @@ -1,570 +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 - */ - -/** - * Page shown to the user to confirm creation - * of a reserve, usually requested by the bank. - * - * @author sebasjm - */ - -import { AmountJson, Amounts } from "@gnu-taler/taler-util"; -import { TalerError } from "@gnu-taler/taler-wallet-core"; -import { Fragment, h, VNode } from "preact"; -import { useMemo, useState } from "preact/hooks"; -import { Amount } from "../components/Amount.js"; -import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; -import { Loading } from "../components/Loading.js"; -import { LoadingError } from "../components/LoadingError.js"; -import { 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 { useTranslationContext } from "../context/translation.js"; -import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { Button } from "../mui/Button.js"; -import { ButtonHandler, SelectFieldHandler } from "../mui/handlers.js"; -import { buildTermsOfServiceState } from "../utils/index.js"; -import * as wxApi from "../wxApi.js"; -import { - Props as TermsOfServiceSectionProps, - TermsOfServiceSection, -} from "./TermsOfServiceSection.js"; - -interface Props { - talerWithdrawUri?: string; -} - -type State = - | LoadingUri - | LoadingExchange - | LoadingInfoError - | Success - | Completed; - -interface LoadingUri { - status: "loading-uri"; - hook: HookError | undefined; -} -interface LoadingExchange { - status: "loading-exchange"; - hook: HookError | undefined; -} -interface LoadingInfoError { - status: "loading-info"; - hook: HookError | undefined; -} - -type Completed = { - status: "completed"; - hook: undefined; -}; - -type Success = { - status: "success"; - hook: undefined; - - exchange: SelectFieldHandler; - - editExchange: ButtonHandler; - cancelEditExchange: ButtonHandler; - confirmEditExchange: ButtonHandler; - - showExchangeSelection: boolean; - chosenAmount: AmountJson; - withdrawalFee: AmountJson; - toBeReceived: AmountJson; - - doWithdrawal: ButtonHandler; - tosProps?: TermsOfServiceSectionProps; - mustAcceptFirst: boolean; - - ageRestriction: SelectFieldHandler; -}; - -export function useComponentState( - talerWithdrawUri: string | undefined, - 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, - }; -} - -export function View({ state }: { state: State }): VNode { - const { i18n } = useTranslationContext(); - if (state.status === "loading-uri") { - if (!state.hook) return ; - - return ( - Could not get the info from the URI - } - error={state.hook} - /> - ); - } - if (state.status === "loading-exchange") { - if (!state.hook) return ; - - return ( - Could not get exchange} - error={state.hook} - /> - ); - } - if (state.status === "loading-info") { - if (!state.hook) return ; - return ( - Could not get info of withdrawal - } - error={state.hook} - /> - ); - } - - if (state.status === "completed") { - 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 - -

-
-
- ); - } - - 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... -
- )} -
- ); -} - -export function WithdrawPage({ talerWithdrawUri }: Props): VNode { - const { i18n } = useTranslationContext(); - - const state = useComponentState(talerWithdrawUri, wxApi); - - if (!talerWithdrawUri) { - return ( - - missing withdraw uri - - ); - } - - if (!state) { - return ; - } - - return ; -} diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts index 75b44fe1e..1bf38721c 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -15,56 +15,57 @@ */ import { AmountJson } from "@gnu-taler/taler-util"; -import { compose, StateViewMap } from "../../utils/index.js"; +import { Loading } from "../../components/Loading.js"; import { HookError } from "../../hooks/useAsyncAsHook.js"; import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import * as wxApi from "../../wxApi.js"; import { Props as TermsOfServiceSectionProps } from "../TermsOfServiceSection.js"; -import { CompletedView, LoadingExchangeView, LoadingInfoView, LoadingUriView, SuccessView } from "./views.js"; import { useComponentState } from "./state.js"; +import { CompletedView, LoadingExchangeView, LoadingInfoView, LoadingUriView, SuccessView } from "./views.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.Loading + | State.LoadingUriError + | State.LoadingExchangeError | State.LoadingInfoError | State.Success | State.Completed; export namespace State { - export interface LoadingUri { + export interface Loading { + status: "loading"; + error: undefined; + } + export interface LoadingUriError { status: "loading-uri"; - hook: HookError | undefined; + error: HookError; } - export interface LoadingExchange { + export interface LoadingExchangeError { status: "loading-exchange"; - hook: HookError | undefined; + error: HookError; } export interface LoadingInfoError { status: "loading-info"; - hook: HookError | undefined; + error: HookError; } export type Completed = { status: "completed"; - hook: undefined; + error: undefined; }; export type Success = { status: "success"; - hook: undefined; + error: undefined; exchange: SelectFieldHandler; @@ -86,6 +87,7 @@ export namespace State { } const viewMapping: StateViewMap = { + loading: Loading, "loading-uri": LoadingUriView, "loading-exchange": LoadingExchangeView, "loading-info": LoadingInfoView, @@ -93,6 +95,4 @@ const viewMapping: StateViewMap = { 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 index cfca3a0f7..2e63c0f47 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -14,12 +14,6 @@ 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"; @@ -133,17 +127,18 @@ export function useComponentState( const [showExchangeSelection, setShowExchangeSelection] = useState(false); const [nextExchange, setNextExchange] = useState(); - if (!uriInfoHook || uriInfoHook.hasError) { + if (!uriInfoHook) return { status: "loading", error: undefined } + if (uriInfoHook.hasError) { return { status: "loading-uri", - hook: uriInfoHook, + error: uriInfoHook, }; } if (!thisExchange || !amount) { return { status: "loading-exchange", - hook: { + error: { hasError: true, operational: false, message: "ERROR_NO-DEFAULT-EXCHANGE", @@ -179,23 +174,20 @@ export function useComponentState( {}, ); - if (!info || info.hasError) { + if (!info) { + return { status: "loading", error: undefined } + } + if (info.hasError) { return { status: "loading-info", - hook: info, + error: info, }; } if (!info.response) { - return { - status: "loading-info", - hook: undefined, - }; + return { status: "loading", error: undefined }; } if (withdrawCompleted) { - return { - status: "completed", - hook: undefined, - }; + return { status: "completed", error: undefined }; } const exchangeHandler: SelectFieldHandler = { @@ -263,7 +255,7 @@ export function useComponentState( return { status: "success", - hook: undefined, + error: undefined, exchange: exchangeHandler, editExchange, cancelEditExchange, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx index e221f9034..3ecccd1b2 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -61,7 +61,7 @@ const ageRestrictionSelectField = { }; export const TermsOfServiceNotYetLoaded = createExample(SuccessView, { - hook: undefined, + error: undefined, status: "success", cancelEditExchange: nullHandler, confirmEditExchange: nullHandler, @@ -95,7 +95,7 @@ export const TermsOfServiceNotYetLoaded = createExample(SuccessView, { }); export const WithSomeFee = createExample(SuccessView, { - hook: undefined, + error: undefined, status: "success", cancelEditExchange: nullHandler, confirmEditExchange: nullHandler, @@ -130,7 +130,7 @@ export const WithSomeFee = createExample(SuccessView, { }); export const WithoutFee = createExample(SuccessView, { - hook: undefined, + error: undefined, status: "success", cancelEditExchange: nullHandler, confirmEditExchange: nullHandler, @@ -165,7 +165,7 @@ export const WithoutFee = createExample(SuccessView, { }); export const EditExchangeUntouched = createExample(SuccessView, { - hook: undefined, + error: undefined, status: "success", cancelEditExchange: nullHandler, confirmEditExchange: nullHandler, @@ -200,7 +200,7 @@ export const EditExchangeUntouched = createExample(SuccessView, { }); export const EditExchangeModified = createExample(SuccessView, { - hook: undefined, + error: undefined, status: "success", cancelEditExchange: nullHandler, confirmEditExchange: nullHandler, @@ -237,11 +237,11 @@ export const EditExchangeModified = createExample(SuccessView, { export const CompletedWithoutBankURL = createExample(CompletedView, { status: "completed", - hook: undefined, + error: undefined, }); export const WithAgeRestrictionSelected = createExample(SuccessView, { - hook: undefined, + error: undefined, status: "success", cancelEditExchange: nullHandler, confirmEditExchange: nullHandler, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index 7726d8a59..f335f46a8 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -54,21 +54,20 @@ describe("Withdraw CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading-uri"); - expect(hook).undefined; + const { status } = getLastResultOrThrow(); + expect(status).equals("loading"); } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = 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"); + if (status != "loading-uri") expect.fail(); + if (!error) expect.fail(); + if (!error.hasError) expect.fail(); + if (error.operational) expect.fail(); + expect(error.message).eq("ERROR_NO-URI-FOR-WITHDRAWAL"); } await assertNoPendingUpdate(); @@ -87,19 +86,18 @@ describe("Withdraw CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading-uri"); - expect(hook).undefined; + const { status } = getLastResultOrThrow(); + expect(status).equals("loading"); } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading-exchange"); - expect(hook).deep.equals({ + expect(error).deep.equals({ hasError: true, operational: false, message: "ERROR_NO-DEFAULT-EXCHANGE", @@ -134,19 +132,19 @@ describe("Withdraw CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading-uri"); - expect(hook).undefined; + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); - expect(status).equals("loading-info"); + expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -200,19 +198,19 @@ describe("Withdraw CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); - expect(status).equals("loading-uri"); - expect(hook).undefined; + const { status, error } = getLastResultOrThrow(); + expect(status).equals("loading"); + expect(error).undefined; } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); - expect(status).equals("loading-info"); + expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx index 26e373205..578e5e61f 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -35,46 +35,39 @@ 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 { +export function LoadingUriView({ error }: State.LoadingUriError): VNode { const { i18n } = useTranslationContext(); - if (!state.hook) return ; return ( Could not get the info from the URI } - error={state.hook} + error={error} /> ); } -export function LoadingExchangeView(state: State.LoadingExchange): VNode { +export function LoadingExchangeView({ + error, +}: State.LoadingExchangeError): VNode { const { i18n } = useTranslationContext(); - if (!state.hook) return ; return ( Could not get exchange} - error={state.hook} + error={error} /> ); } -export function LoadingInfoView(state: State.LoadingInfoError): VNode { +export function LoadingInfoView({ error }: State.LoadingInfoError): VNode { const { i18n } = useTranslationContext(); - if (!state.hook) return ; + return ( Could not get info of withdrawal} - error={state.hook} + error={error} /> ); } diff --git a/packages/taler-wallet-webextension/src/cta/index.stories.ts b/packages/taler-wallet-webextension/src/cta/index.stories.ts index 29349db23..92f4bbcb1 100644 --- a/packages/taler-wallet-webextension/src/cta/index.stories.ts +++ b/packages/taler-wallet-webextension/src/cta/index.stories.ts @@ -19,10 +19,10 @@ * @author Sebastian Javier Marchano (sebasjm) */ -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 a1 from "./Deposit/stories.jsx"; +import * as a3 from "./Payment/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 a7 from "./TermsOfServiceSection.stories.js"; diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx index 99acb10c4..603163cee 100644 --- a/packages/taler-wallet-webextension/src/wallet/Application.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Application.tsx @@ -34,11 +34,11 @@ import { TranslationProvider, useTranslationContext, } from "../context/translation.js"; -import { PayPage } from "../cta/Pay.js"; -import { RefundPage } from "../cta/Refund.js"; -import { TipPage } from "../cta/Tip.js"; +import { PaymentPage } from "../cta/Payment/index.js"; +import { RefundPage } from "../cta/Refund/index.js"; +import { TipPage } from "../cta/Tip/index.js"; import { WithdrawPage } from "../cta/Withdraw/index.js"; -import { DepositPage as DepositPageCTA } from "../cta/Deposit.js"; +import { DepositPage as DepositPageCTA } from "../cta/Deposit/index.js"; import { Pages, WalletNavBar } from "../NavigationBar.js"; import { DeveloperPage } from "./DeveloperPage.js"; import { BackupPage } from "./BackupPage.js"; @@ -202,7 +202,7 @@ export function Application(): VNode { */} redirectTo(Pages.balanceManualWithdraw({ currency })) } -- cgit v1.2.3