diff options
author | Sebastian <sebasjm@gmail.com> | 2022-07-30 20:55:41 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2022-08-01 10:55:17 -0300 |
commit | 614a3e3c8702bb7436398acb911880caae0fdee7 (patch) | |
tree | 18aed0268f98642f2ca4bc7b7ac23297ad4f2cc8 /packages/taler-wallet-webextension | |
parent | 979cd2daf2cca2ff14a8e8a2d68712358344e9c4 (diff) |
standarizing components
Diffstat (limited to 'packages/taler-wallet-webextension')
34 files changed, 1878 insertions, 2388 deletions
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.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 <http://www.gnu.org/licenses/> - */ - -/** - * 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<void>; -} - -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<CreateDepositGroupResponse | undefined>( - 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<void> { - 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 ( - <span> - <i18n.Translate>missing taler deposit uri</i18n.Translate> - </span> - ); - } - - return <View state={state} />; -} - -export interface ViewProps { - state: State; -} -export function View({ state }: ViewProps): VNode { - const { i18n } = useTranslationContext(); - - if (state.status === "loading") { - if (!state.hook) return <Loading />; - return ( - <LoadingError - title={<i18n.Translate>Could not load deposit status</i18n.Translate>} - error={state.hook} - /> - ); - } - - if (state.status === "completed") { - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash deposit</i18n.Translate> - </SubTitle> - <section> - <p> - <i18n.Translate>deposit completed</i18n.Translate> - </p> - </section> - </WalletAction> - ); - } - - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash deposit</i18n.Translate> - </SubTitle> - <section> - {Amounts.isNonZero(state.cost) && ( - <Part - big - title={<i18n.Translate>Cost</i18n.Translate>} - text={<Amount value={state.cost} />} - kind="negative" - /> - )} - {Amounts.isNonZero(state.fee) && ( - <Part - big - title={<i18n.Translate>Fee</i18n.Translate>} - text={<Amount value={state.fee} />} - kind="negative" - /> - )} - <Part - big - title={<i18n.Translate>To be received</i18n.Translate>} - text={<Amount value={state.effective} />} - kind="positive" - /> - </section> - <section> - <Button - variant="contained" - color="success" - onClick={state.confirm.onClick} - > - <i18n.Translate> - Deposit {<Amount value={state.effective} />} - </i18n.Translate> - </Button> - </section> - </WalletAction> - ); -} 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 <http://www.gnu.org/licenses/> + */ + +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<State> = { + "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 <http://www.gnu.org/licenses/> + */ + + +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<CreateDepositGroupResponse | undefined>( + 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<void> { + 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 index 269b33ce8..a4168bcc2 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx @@ -20,22 +20,18 @@ */ import { Amounts } from "@gnu-taler/taler-util"; -import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./Deposit.js"; +import { createExample } from "../../test-utils.js"; +import { ReadyView } from "./views.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, - }, +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 index 125a43427..6e7aaf237 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Deposit/test.ts @@ -19,16 +19,18 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts, PrepareDepositResponse } from "@gnu-taler/taler-util"; +import { + Amounts, PrepareDepositResponse +} from "@gnu-taler/taler-util"; import { expect } from "chai"; -import { mountHook } from "../test-utils.js"; -import { useComponentState } from "./Deposit.jsx"; +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(undefined, undefined, { + useComponentState({ talerDepositUri: undefined, amountStr: undefined }, { prepareRefund: async () => ({}), applyRefund: async () => ({}), onUpdateNotification: async () => ({}), @@ -36,21 +38,21 @@ describe("Deposit CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = 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"); + 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(); @@ -59,7 +61,7 @@ describe("Deposit CTA states", () => { it("should be ready after loading", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("payto://refund/asdasdas", "EUR:1", { + useComponentState({ talerDepositUri: "payto://refund/asdasdas", amountStr: "EUR:1" }, { prepareDeposit: async () => ({ effectiveDepositAmount: Amounts.parseOrThrow("EUR:1"), @@ -70,9 +72,8 @@ describe("Deposit CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; } await waitNextUpdate(); @@ -81,7 +82,7 @@ describe("Deposit CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ready") expect.fail(); - if (state.hook) 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")); 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 <http://www.gnu.org/licenses/> + */ + +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 ( + <LoadingError + title={<i18n.Translate>Could not load deposit status</i18n.Translate>} + error={error} + /> + ); +} +export function CompletedView(state: State.Completed): VNode { + const { i18n } = useTranslationContext(); + + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash deposit</i18n.Translate> + </SubTitle> + <section> + <p> + <i18n.Translate>deposit completed</i18n.Translate> + </p> + </section> + </WalletAction> + ); +} + +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash deposit</i18n.Translate> + </SubTitle> + <section> + {Amounts.isNonZero(state.cost) && ( + <Part + big + title={<i18n.Translate>Cost</i18n.Translate>} + text={<Amount value={state.cost} />} + kind="negative" + /> + )} + {Amounts.isNonZero(state.fee) && ( + <Part + big + title={<i18n.Translate>Fee</i18n.Translate>} + text={<Amount value={state.fee} />} + kind="negative" + /> + )} + <Part + big + title={<i18n.Translate>To be received</i18n.Translate>} + text={<Amount value={state.effective} />} + kind="positive" + /> + </section> + <section> + <Button + variant="contained" + color="success" + onClick={state.confirm.onClick} + > + <i18n.Translate> + Deposit {<Amount value={state.effective} />} + </i18n.Translate> + </Button> + </section> + </WalletAction> + ); +} 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 <http://www.gnu.org/licenses/> - */ - -/** - * - * @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<void> => { - 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> as any, - contractTermsHash: "123456", - proposalId: "proposal1234", - paid: true, - }, - }, - goBack: noop, - goToWalletManualWithdraw: noop, -}); 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 <http://www.gnu.org/licenses/> + */ + +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<void>; + goBack: () => Promise<void>; +} + +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<void>; + goBack: () => Promise<void>; + } + 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<State> = { + 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 <http://www.gnu.org/licenses/> + */ + + +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<ConfirmPayResult | undefined>( + undefined, + ); + const [payErrMsg, setPayErrMsg] = useState<TalerError | undefined>(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<void> { + 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 <http://www.gnu.org/licenses/> + */ + +/** + * + * @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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> 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<ContractTerms> as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + paid: true, + }, +}); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.test.ts b/packages/taler-wallet-webextension/src/cta/Payment/test.ts index 42ab902b8..aea70b7ca 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/test.ts @@ -30,9 +30,9 @@ import { 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"; +import { mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.js"; +import * as wxApi from "../../wxApi.js"; const nullFunction: any = () => null; type VoidFunction = () => void; @@ -66,30 +66,30 @@ export class SubsHandler { } } -describe("Pay CTA states", () => { +describe("Payment CTA states", () => { it("should tell the user that the URI is missing", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState(undefined, { + useComponentState({ talerPayUri: undefined, goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, } as Partial<typeof wxApi> as any), ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); - expect(status).equals("loading"); - if (hook === undefined) expect.fail(); - expect(hook.hasError).true; - expect(hook.operational).false; + expect(status).equals("loading-uri"); + if (error === undefined) expect.fail(); + expect(error.hasError).true; + expect(error.operational).false; } await assertNoPendingUpdate(); @@ -98,7 +98,7 @@ describe("Pay CTA states", () => { it("should response with no balance", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, preparePay: async () => ({ @@ -113,19 +113,18 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); { const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); + if (r.status !== "no-balance-for-currency") expect.fail(); expect(r.balance).undefined; expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10")); - expect(r.payHandler.onClick).undefined; } await assertNoPendingUpdate(); @@ -134,7 +133,7 @@ describe("Pay CTA states", () => { it("should not be able to pay if there is no enough balance", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, preparePay: async () => ({ @@ -153,19 +152,18 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); { const r = getLastResultOrThrow(); - if (r.status !== "ready") expect.fail(); + 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")); - expect(r.payHandler.onClick).undefined; } await assertNoPendingUpdate(); @@ -174,7 +172,7 @@ describe("Pay CTA states", () => { it("should be able to pay (without fee)", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, preparePay: async () => ({ @@ -194,9 +192,9 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -216,7 +214,7 @@ describe("Pay CTA states", () => { it("should be able to pay (with fee)", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, preparePay: async () => ({ @@ -236,9 +234,9 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -258,7 +256,7 @@ describe("Pay CTA states", () => { it("should get confirmation done after pay successfully", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, preparePay: async () => ({ @@ -283,9 +281,9 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -319,7 +317,7 @@ describe("Pay CTA states", () => { it("should not stay in ready state after pay with error", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: nullFunction, preparePay: async () => ({ @@ -344,9 +342,9 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -395,7 +393,7 @@ describe("Pay CTA states", () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taller://pay", { + useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { onUpdateNotification: subscriptions.saveSubscription, preparePay: async () => ({ @@ -415,9 +413,9 @@ describe("Pay CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx index df381832b..a8c9a640a 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.tsx +++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -14,40 +14,22 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -/** - * 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 { 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 { - ButtonSuccess, Link, LinkSuccess, SmallLightText, @@ -55,233 +37,32 @@ import { 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<void>; - goBack: () => Promise<void>; -} - -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<ConfirmPayResult | undefined>( - undefined, - ); - const [payErrMsg, setPayErrMsg] = useState<TalerError | undefined>(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<void> { - 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, - }; +} from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; - 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 { +export function LoadingUriView({ error }: State.LoadingUriError): VNode { const { i18n } = useTranslationContext(); - const state = useComponentState(talerPayUri, wxApi); - - if (state.status === "loading") { - if (!state.hook) return <Loading />; - return ( - <LoadingError - title={<i18n.Translate>Could not load pay status</i18n.Translate>} - error={state.hook} - /> - ); - } return ( - <View - state={state} - goBack={goBack} - goToWalletManualWithdraw={goToWalletManualWithdraw} + <LoadingError + title={<i18n.Translate>Could not load pay status</i18n.Translate>} + error={error} /> ); } -export function View({ - state, - goBack, - goToWalletManualWithdraw, -}: { - state: Ready | Confirmed; - goToWalletManualWithdraw: (currency?: string) => Promise<void>; - goBack: () => Promise<void>; -}): VNode { +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; - if (!contractTerms) { - return ( - <ErrorMessage - title={ - <i18n.Translate> - Could not load contract terms from merchant or wallet backend. - </i18n.Translate> - } - /> - ); - } - return ( <WalletAction> <LogoHeader /> @@ -341,10 +122,10 @@ export function View({ </section> <ButtonsSection state={state} - goToWalletManualWithdraw={goToWalletManualWithdraw} + goToWalletManualWithdraw={state.goToWalletManualWithdraw} /> <section> - <Link upperCased onClick={goBack}> + <Link upperCased onClick={state.goBack}> <i18n.Translate>Cancel</i18n.Translate> </Link> </section> @@ -421,7 +202,7 @@ export function ProductList({ products }: { products: Product[] }): VNode { ); } -function ShowImportantMessage({ state }: { state: Ready | Confirmed }): VNode { +function ShowImportantMessage({ state }: { state: SupportedStates }): VNode { const { i18n } = useTranslationContext(); const { payStatus } = state; if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { @@ -483,7 +264,7 @@ function ShowImportantMessage({ state }: { state: Ready | Confirmed }): VNode { return <Fragment />; } -function PayWithMobile({ state }: { state: Ready }): VNode { +function PayWithMobile({ state }: { state: State.Ready }): VNode { const { i18n } = useTranslationContext(); const [showQR, setShowQR] = useState<boolean>(false); @@ -520,7 +301,7 @@ function ButtonsSection({ state, goToWalletManualWithdraw, }: { - state: Ready | Confirmed; + state: SupportedStates; goToWalletManualWithdraw: (currency: string) => Promise<void>; }): VNode { const { i18n } = useTranslationContext(); 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 <http://www.gnu.org/licenses/> - */ - -/** - * - * @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.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 <http://www.gnu.org/licenses/> - */ - -/** - * 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 <Loading />; - } - return ( - <LoadingError - title={<i18n.Translate>Could not load refund status</i18n.Translate>} - error={state.hook} - /> - ); - } - - if (state.status === "ignored") { - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash refund</i18n.Translate> - </SubTitle> - <section> - <p> - <i18n.Translate>You've ignored the tip.</i18n.Translate> - </p> - </section> - </WalletAction> - ); - } - - if (state.status === "in-progress") { - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash refund</i18n.Translate> - </SubTitle> - <section> - <p> - <i18n.Translate>The refund is in progress.</i18n.Translate> - </p> - </section> - <section> - <Part - big - title={<i18n.Translate>Total to refund</i18n.Translate>} - text={<Amount value={state.awaitingAmount} />} - kind="negative" - /> - <Part - big - title={<i18n.Translate>Refunded</i18n.Translate>} - text={<Amount value={state.amount} />} - kind="negative" - /> - </section> - {state.products && state.products.length ? ( - <section> - <ProductList products={state.products} /> - </section> - ) : undefined} - {/* <section> - <ProgressBar value={state.progress} /> - </section> */} - </WalletAction> - ); - } - - if (state.status === "completed") { - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash refund</i18n.Translate> - </SubTitle> - <section> - <p> - <i18n.Translate>this refund is already accepted.</i18n.Translate> - </p> - </section> - <section> - <Part - big - title={<i18n.Translate>Total to refunded</i18n.Translate>} - text={<Amount value={state.granted} />} - kind="negative" - /> - </section> - </WalletAction> - ); - } - - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash refund</i18n.Translate> - </SubTitle> - <section> - <p> - <i18n.Translate> - The merchant "<b>{state.merchantName}</b>" is offering you - a refund. - </i18n.Translate> - </p> - </section> - <section> - <Part - big - title={<i18n.Translate>Order amount</i18n.Translate>} - text={<Amount value={state.amount} />} - kind="neutral" - /> - {Amounts.isNonZero(state.granted) && ( - <Part - big - title={<i18n.Translate>Already refunded</i18n.Translate>} - text={<Amount value={state.granted} />} - kind="neutral" - /> - )} - <Part - big - title={<i18n.Translate>Refund offered</i18n.Translate>} - text={<Amount value={state.awaitingAmount} />} - kind="positive" - /> - </section> - {state.products && state.products.length ? ( - <section> - <ProductList products={state.products} /> - </section> - ) : undefined} - <section> - <Button variant="contained" onClick={state.accept.onClick}> - <i18n.Translate>Confirm refund</i18n.Translate> - </Button> - </section> - </WalletAction> - ); -} - -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<void> => { - await api.applyRefund(uri); - info.retry(); - }; - - const doIgnore = async (): Promise<void> => { - 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 ( - <span> - <i18n.Translate>missing taler refund uri</i18n.Translate> - </span> - ); - } - - return <View state={state} />; -} - -function ProgressBar({ value }: { value: number }): VNode { - return ( - <div - style={{ - width: 400, - height: 20, - backgroundColor: "white", - border: "solid black 1px", - }} - > - <div - style={{ - width: `${value * 100}%`, - height: "100%", - backgroundColor: "lightgreen", - }} - ></div> - </div> - ); -} 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 <http://www.gnu.org/licenses/> + */ + +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<State> = { + 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 <http://www.gnu.org/licenses/> + */ + + +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<void> => { + await api.applyRefund(uri); + info.retry(); + }; + + const doIgnore = async (): Promise<void> => { + 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 <http://www.gnu.org/licenses/> + */ + +/** + * + * @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 index 3eff42e90..04c83b8f1 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Refund/test.ts @@ -21,22 +21,19 @@ import { AmountJson, - Amounts, - NotificationType, - PrepareRefundResult, + 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, +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(undefined, { + useComponentState({ talerRefundUri: undefined }, { prepareRefund: async () => ({}), applyRefund: async () => ({}), onUpdateNotification: async () => ({}), @@ -44,21 +41,21 @@ describe("Refund CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = 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"); + 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(); @@ -67,7 +64,7 @@ describe("Refund CTA states", () => { it("should be ready after loading", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler://refund/asdasdas", { + useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { prepareRefund: async () => ({ effectivePaid: "EUR:2", @@ -91,9 +88,9 @@ describe("Refund CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -102,7 +99,7 @@ describe("Refund CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ready") expect.fail(); - if (state.hook) 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"); @@ -116,7 +113,7 @@ describe("Refund CTA states", () => { it("should be ignored after clicking the ignore button", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler://refund/asdasdas", { + useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { prepareRefund: async () => ({ effectivePaid: "EUR:2", @@ -140,9 +137,9 @@ describe("Refund CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -151,7 +148,7 @@ describe("Refund CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ready") expect.fail(); - if (state.hook) 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"); @@ -167,7 +164,7 @@ describe("Refund CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ignored") expect.fail(); - if (state.hook) expect.fail(); + if (state.error) expect.fail(); expect(state.merchantName).eq("the merchant name"); } @@ -192,7 +189,7 @@ describe("Refund CTA states", () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler://refund/asdasdas", { + useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { prepareRefund: async () => ({ awaiting: Amounts.stringify(awaiting), @@ -216,9 +213,9 @@ describe("Refund CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -227,7 +224,7 @@ describe("Refund CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "in-progress") expect.fail("1"); - if (state.hook) expect.fail(); + 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")); @@ -242,7 +239,7 @@ describe("Refund CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "in-progress") expect.fail("2"); - if (state.hook) expect.fail(); + 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")); @@ -257,7 +254,7 @@ describe("Refund CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "completed") expect.fail("3"); - if (state.hook) expect.fail(); + 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")); 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 <http://www.gnu.org/licenses/> + */ + +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 ( + <LoadingError + title={<i18n.Translate>Could not load refund status</i18n.Translate>} + error={error} + /> + ); +} + +export function IgnoredView(state: State.Ignored): VNode { + const { i18n } = useTranslationContext(); + + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash refund</i18n.Translate> + </SubTitle> + <section> + <p> + <i18n.Translate>You've ignored the tip.</i18n.Translate> + </p> + </section> + </WalletAction> + ); +} +export function InProgressView(state: State.InProgress): VNode { + const { i18n } = useTranslationContext(); + + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash refund</i18n.Translate> + </SubTitle> + <section> + <p> + <i18n.Translate>The refund is in progress.</i18n.Translate> + </p> + </section> + <section> + <Part + big + title={<i18n.Translate>Total to refund</i18n.Translate>} + text={<Amount value={state.awaitingAmount} />} + kind="negative" + /> + <Part + big + title={<i18n.Translate>Refunded</i18n.Translate>} + text={<Amount value={state.amount} />} + kind="negative" + /> + </section> + {state.products && state.products.length ? ( + <section> + <ProductList products={state.products} /> + </section> + ) : undefined} + </WalletAction> + ); +} +export function CompletedView(state: State.Completed): VNode { + const { i18n } = useTranslationContext(); + + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash refund</i18n.Translate> + </SubTitle> + <section> + <p> + <i18n.Translate>this refund is already accepted.</i18n.Translate> + </p> + </section> + <section> + <Part + big + title={<i18n.Translate>Total to refunded</i18n.Translate>} + text={<Amount value={state.granted} />} + kind="negative" + /> + </section> + </WalletAction> + ); +} +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash refund</i18n.Translate> + </SubTitle> + <section> + <p> + <i18n.Translate> + The merchant "<b>{state.merchantName}</b>" is offering you + a refund. + </i18n.Translate> + </p> + </section> + <section> + <Part + big + title={<i18n.Translate>Order amount</i18n.Translate>} + text={<Amount value={state.amount} />} + kind="neutral" + /> + {Amounts.isNonZero(state.granted) && ( + <Part + big + title={<i18n.Translate>Already refunded</i18n.Translate>} + text={<Amount value={state.granted} />} + kind="neutral" + /> + )} + <Part + big + title={<i18n.Translate>Refund offered</i18n.Translate>} + text={<Amount value={state.awaitingAmount} />} + kind="positive" + /> + </section> + {state.products && state.products.length ? ( + <section> + <ProductList products={state.products} /> + </section> + ) : undefined} + <section> + <Button variant="contained" onClick={state.accept.onClick}> + <i18n.Translate>Confirm refund</i18n.Translate> + </Button> + </section> + </WalletAction> + ); +} 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 <http://www.gnu.org/licenses/> - */ - -/** - * 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<void> => { - await api.acceptTip({ walletTipId: tip.walletTipId }); - tipInfo.retry(); - }; - - const doIgnore = async (): Promise<void> => { - 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 <Loading />; - } - return ( - <LoadingError - title={<i18n.Translate>Could not load tip status</i18n.Translate>} - error={state.hook} - /> - ); - } - - if (state.status === "ignored") { - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash tip</i18n.Translate> - </SubTitle> - <span> - <i18n.Translate>You've ignored the tip.</i18n.Translate> - </span> - </WalletAction> - ); - } - - if (state.status === "accepted") { - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash tip</i18n.Translate> - </SubTitle> - <section> - <i18n.Translate> - Tip from <code>{state.merchantBaseUrl}</code> accepted. Check your - transactions list for more details. - </i18n.Translate> - </section> - </WalletAction> - ); - } - - return ( - <WalletAction> - <LogoHeader /> - - <SubTitle> - <i18n.Translate>Digital cash tip</i18n.Translate> - </SubTitle> - - <section> - <p> - <i18n.Translate>The merchant is offering you a tip</i18n.Translate> - </p> - <Part - title={<i18n.Translate>Amount</i18n.Translate>} - text={<Amount value={state.amount} />} - kind="positive" - big - /> - <Part - title={<i18n.Translate>Merchant URL</i18n.Translate>} - text={state.merchantBaseUrl} - kind="neutral" - /> - <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={state.exchangeBaseUrl} - kind="neutral" - /> - </section> - <section> - <Button - variant="contained" - color="success" - onClick={state.accept.onClick} - > - <i18n.Translate>Accept tip</i18n.Translate> - </Button> - <Button onClick={state.ignore.onClick}> - <i18n.Translate>Ignore</i18n.Translate> - </Button> - </section> - </WalletAction> - ); -} - -export function TipPage({ talerTipUri }: Props): VNode { - const { i18n } = useTranslationContext(); - const state = useComponentState(talerTipUri, wxApi); - - if (!talerTipUri) { - return ( - <span> - <i18n.Translate>missing tip uri</i18n.Translate> - </span> - ); - } - - return <View state={state} />; -} 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 <http://www.gnu.org/licenses/> + */ + +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<State> = { + 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 <http://www.gnu.org/licenses/> + */ + + +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<void> => { + await api.acceptTip({ walletTipId: tip.walletTipId }); + tipInfo.retry(); + }; + + const doIgnore = async (): Promise<void> => { + 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 index 40a89d1bf..8c72a8812 100644 --- a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Tip/stories.tsx @@ -20,33 +20,27 @@ */ import { Amounts } from "@gnu-taler/taler-util"; -import { createExample } from "../test-utils.js"; -import { View as TestedComponent } from "./Tip.js"; +import { createExample } from "../../test-utils.js"; +import { AcceptedView, ReadyView } from "./views.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 Accepted = createExample(AcceptedView, { + status: "accepted", + error: 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: {}, - }, +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 index a77b59167..1c7d363f4 100644 --- a/packages/taler-wallet-webextension/src/cta/Tip.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Tip/test.ts @@ -19,37 +19,39 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts, PrepareTipResult } from "@gnu-taler/taler-util"; +import { + Amounts, PrepareTipResult +} from "@gnu-taler/taler-util"; import { expect } from "chai"; -import { mountHook } from "../test-utils.js"; -import { useComponentState } from "./Tip.jsx"; +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(undefined, { + useComponentState({ talerTipUri: undefined }, { prepareTip: async () => ({}), acceptTip: async () => ({}), } as any), ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = 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"); + 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(); @@ -60,7 +62,7 @@ describe("Tip CTA states", () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler://tip/asd", { + useComponentState({ talerTipUri: "taler://tip/asd" }, { prepareTip: async () => ({ accepted: tipAccepted, @@ -76,9 +78,9 @@ describe("Tip CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -87,7 +89,7 @@ describe("Tip CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ready") expect.fail(); - if (state.hook) 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"); @@ -101,7 +103,7 @@ describe("Tip CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "accepted") expect.fail(); - if (state.hook) 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"); @@ -112,7 +114,7 @@ describe("Tip CTA states", () => { it("should be ignored after clicking the ignore button", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler://tip/asd", { + useComponentState({ talerTipUri: "taler://tip/asd" }, { prepareTip: async () => ({ exchangeBaseUrl: "exchange url", @@ -125,9 +127,9 @@ describe("Tip CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -136,7 +138,7 @@ describe("Tip CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ready") expect.fail(); - if (state.hook) 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"); @@ -150,7 +152,7 @@ describe("Tip CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "ignored") expect.fail(); - if (state.hook) expect.fail(); + if (state.error) expect.fail(); } await assertNoPendingUpdate(); }); @@ -158,7 +160,7 @@ describe("Tip CTA states", () => { it("should render accepted if the tip has been used previously", async () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => - useComponentState("taler://tip/asd", { + useComponentState({ talerTipUri: "taler://tip/asd" }, { prepareTip: async () => ({ accepted: true, @@ -172,9 +174,9 @@ describe("Tip CTA states", () => { ); { - const { status, hook } = getLastResultOrThrow(); + const { status, error } = getLastResultOrThrow(); expect(status).equals("loading"); - expect(hook).undefined; + expect(error).undefined; } await waitNextUpdate(); @@ -183,7 +185,7 @@ describe("Tip CTA states", () => { const state = getLastResultOrThrow(); if (state.status !== "accepted") expect.fail(); - if (state.hook) 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"); 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 <http://www.gnu.org/licenses/> + */ + +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 ( + <LoadingError + title={<i18n.Translate>Could not load tip status</i18n.Translate>} + error={error} + /> + ); +} + +export function IgnoredView(state: State.Ignored): VNode { + const { i18n } = useTranslationContext(); + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash tip</i18n.Translate> + </SubTitle> + <span> + <i18n.Translate>You've ignored the tip.</i18n.Translate> + </span> + </WalletAction> + ); +} + +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash tip</i18n.Translate> + </SubTitle> + + <section> + <p> + <i18n.Translate>The merchant is offering you a tip</i18n.Translate> + </p> + <Part + title={<i18n.Translate>Amount</i18n.Translate>} + text={<Amount value={state.amount} />} + kind="positive" + big + /> + <Part + title={<i18n.Translate>Merchant URL</i18n.Translate>} + text={state.merchantBaseUrl} + kind="neutral" + /> + <Part + title={<i18n.Translate>Exchange</i18n.Translate>} + text={state.exchangeBaseUrl} + kind="neutral" + /> + </section> + <section> + <Button + variant="contained" + color="success" + onClick={state.accept.onClick} + > + <i18n.Translate>Accept tip</i18n.Translate> + </Button> + <Button onClick={state.ignore.onClick}> + <i18n.Translate>Ignore</i18n.Translate> + </Button> + </section> + </WalletAction> + ); +} + +export function AcceptedView(state: State.Accepted): VNode { + const { i18n } = useTranslationContext(); + return ( + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash tip</i18n.Translate> + </SubTitle> + <section> + <i18n.Translate> + Tip from <code>{state.merchantBaseUrl}</code> accepted. Check your + transactions list for more details. + </i18n.Translate> + </section> + </WalletAction> + ); +} 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 <http://www.gnu.org/licenses/> - */ - -/** - * 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<string | undefined>( - undefined, - ); - const [ageRestricted, setAgeRestricted] = useState(0); - - /** - * Ask the wallet about the withdraw URI - */ - const uriInfoHook = useAsyncAsHook(async () => { - if (!talerWithdrawUri) throw Error("ERROR_NO-URI-FOR-WITHDRAWAL"); - - const uriInfo = await api.getWithdrawalDetailsForUri({ - talerWithdrawUri, - }); - const { exchanges: knownExchanges } = await api.listExchanges(); - - return { uriInfo, knownExchanges }; - }); - - /** - * Get the amount and select one exchange - */ - const uriHookDep = - !uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response - ? undefined - : uriInfoHook.response; - - const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => { - if (!uriHookDep) - return { - amount: undefined, - thisExchange: undefined, - thisCurrencyExchanges: [], - }; - - const { uriInfo, knownExchanges } = uriHookDep; - - const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined; - const thisCurrencyExchanges = - !amount || !knownExchanges - ? [] - : knownExchanges.filter((ex) => ex.currency === amount.currency); - - const thisExchange: string | undefined = - customExchange ?? - uriInfo?.defaultExchangeBaseUrl ?? - (thisCurrencyExchanges && thisCurrencyExchanges[0] - ? thisCurrencyExchanges[0].exchangeBaseUrl - : undefined); - - return { amount, thisExchange, thisCurrencyExchanges }; - }, [uriHookDep, customExchange]); - - /** - * For the exchange selected, bring the status of the terms of service - */ - const terms = useAsyncAsHook(async () => { - if (!thisExchange) return false; - - const exchangeTos = await api.getExchangeTos(thisExchange, ["text/xml"]); - - const state = buildTermsOfServiceState(exchangeTos); - - return { state }; - }, [thisExchange]); - - /** - * With the exchange and amount, ask the wallet the information - * about the withdrawal - */ - const info = useAsyncAsHook(async () => { - if (!thisExchange || !amount) return false; - - const info = await api.getExchangeWithdrawalInfo({ - exchangeBaseUrl: thisExchange, - amount, - tosAcceptedFormat: ["text/xml"], - }); - - const withdrawalFee = Amounts.sub( - Amounts.parseOrThrow(info.withdrawalAmountRaw), - Amounts.parseOrThrow(info.withdrawalAmountEffective), - ).amount; - - return { info, withdrawalFee }; - }, [thisExchange, amount]); - - const [reviewing, setReviewing] = useState<boolean>(false); - const [reviewed, setReviewed] = useState<boolean>(false); - - const [withdrawError, setWithdrawError] = useState<TalerError | undefined>( - undefined, - ); - const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); - const [withdrawCompleted, setWithdrawCompleted] = useState<boolean>(false); - - const [showExchangeSelection, setShowExchangeSelection] = useState(false); - const [nextExchange, setNextExchange] = useState<string | undefined>(); - - if (!uriInfoHook || uriInfoHook.hasError) { - return { - status: "loading-uri", - hook: uriInfoHook, - }; - } - - if (!thisExchange || !amount) { - return { - status: "loading-exchange", - hook: { - hasError: true, - operational: false, - message: "ERROR_NO-DEFAULT-EXCHANGE", - }, - }; - } - - const selectedExchange = thisExchange; - - async function doWithdrawAndCheckError(): Promise<void> { - try { - setDoingWithdraw(true); - if (!talerWithdrawUri) return; - const res = await api.acceptWithdrawal( - talerWithdrawUri, - selectedExchange, - !ageRestricted ? undefined : ageRestricted, - ); - if (res.confirmTransferUrl) { - document.location.href = res.confirmTransferUrl; - } - setWithdrawCompleted(true); - } catch (e) { - if (e instanceof TalerError) { - setWithdrawError(e); - } - } - setDoingWithdraw(false); - } - - const exchanges = thisCurrencyExchanges.reduce( - (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), - {}, - ); - - if (!info || info.hasError) { - return { - status: "loading-info", - hook: info, - }; - } - if (!info.response) { - return { - status: "loading-info", - hook: undefined, - }; - } - if (withdrawCompleted) { - return { - status: "completed", - hook: undefined, - }; - } - - const exchangeHandler: SelectFieldHandler = { - onChange: async (e) => setNextExchange(e), - value: nextExchange ?? thisExchange, - list: exchanges, - isDirty: nextExchange !== undefined, - }; - - const editExchange: ButtonHandler = { - onClick: async () => { - setShowExchangeSelection(true); - }, - }; - const cancelEditExchange: ButtonHandler = { - onClick: async () => { - setShowExchangeSelection(false); - }, - }; - const confirmEditExchange: ButtonHandler = { - onClick: async () => { - setCustomExchange(exchangeHandler.value); - setShowExchangeSelection(false); - setNextExchange(undefined); - }, - }; - - const { withdrawalFee } = info.response; - const toBeReceived = Amounts.sub(amount, withdrawalFee).amount; - - const { state: termsState } = (!terms - ? undefined - : terms.hasError - ? undefined - : terms.response) || { state: undefined }; - - async function onAccept(accepted: boolean): Promise<void> { - if (!termsState) return; - - try { - await api.setExchangeTosAccepted( - selectedExchange, - accepted ? termsState.version : undefined, - ); - setReviewed(accepted); - } catch (e) { - if (e instanceof Error) { - //FIXME: uncomment this and display error - // setErrorAccepting(e.message); - } - } - } - - const mustAcceptFirst = - termsState !== undefined && - (termsState.status === "changed" || termsState.status === "new"); - - const ageRestrictionOptions: Record<string, string> | undefined = "6:12:18" - .split(":") - .reduce((p, c) => ({ ...p, [c]: `under ${c}` }), {}); - - if (ageRestrictionOptions) { - ageRestrictionOptions["0"] = "Not restricted"; - } - - return { - status: "success", - hook: undefined, - exchange: exchangeHandler, - editExchange, - cancelEditExchange, - confirmEditExchange, - showExchangeSelection, - toBeReceived, - withdrawalFee, - chosenAmount: amount, - ageRestriction: { - list: ageRestrictionOptions, - value: String(ageRestricted), - onChange: async (v) => setAgeRestricted(parseInt(v, 10)), - }, - doWithdrawal: { - onClick: - doingWithdraw || (mustAcceptFirst && !reviewed) - ? undefined - : doWithdrawAndCheckError, - error: withdrawError, - }, - tosProps: !termsState - ? undefined - : { - onAccept, - onReview: setReviewing, - reviewed: reviewed, - reviewing: reviewing, - terms: termsState, - }, - mustAcceptFirst, - }; -} - -export function View({ state }: { state: State }): VNode { - const { i18n } = useTranslationContext(); - if (state.status === "loading-uri") { - if (!state.hook) return <Loading />; - - return ( - <LoadingError - title={ - <i18n.Translate>Could not get the info from the URI</i18n.Translate> - } - error={state.hook} - /> - ); - } - if (state.status === "loading-exchange") { - if (!state.hook) return <Loading />; - - return ( - <LoadingError - title={<i18n.Translate>Could not get exchange</i18n.Translate>} - error={state.hook} - /> - ); - } - if (state.status === "loading-info") { - if (!state.hook) return <Loading />; - return ( - <LoadingError - title={ - <i18n.Translate>Could not get info of withdrawal</i18n.Translate> - } - error={state.hook} - /> - ); - } - - if (state.status === "completed") { - return ( - <WalletAction> - <LogoHeader /> - <SubTitle> - <i18n.Translate>Digital cash withdrawal</i18n.Translate> - </SubTitle> - <SuccessBox> - <h3> - <i18n.Translate>Withdrawal in process...</i18n.Translate> - </h3> - <p> - <i18n.Translate> - You can close the page now. Check your bank if the transaction - need a confirmation step to be completed - </i18n.Translate> - </p> - </SuccessBox> - </WalletAction> - ); - } - - return ( - <WalletAction> - <LogoHeader /> - <SubTitle> - <i18n.Translate>Digital cash withdrawal</i18n.Translate> - </SubTitle> - - {state.doWithdrawal.error && ( - <ErrorTalerOperation - title={ - <i18n.Translate> - Could not finish the withdrawal operation - </i18n.Translate> - } - error={state.doWithdrawal.error.errorDetail} - /> - )} - - <section> - <Part - title={<i18n.Translate>Total to withdraw</i18n.Translate>} - text={<Amount value={state.toBeReceived} />} - kind="positive" - /> - {Amounts.isNonZero(state.withdrawalFee) && ( - <Fragment> - <Part - title={<i18n.Translate>Chosen amount</i18n.Translate>} - text={<Amount value={state.chosenAmount} />} - kind="neutral" - /> - <Part - title={<i18n.Translate>Exchange fee</i18n.Translate>} - text={<Amount value={state.withdrawalFee} />} - kind="negative" - /> - </Fragment> - )} - <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={state.exchange.value} - kind="neutral" - big - /> - {state.showExchangeSelection ? ( - <Fragment> - <div> - <SelectList - label={<i18n.Translate>Known exchanges</i18n.Translate>} - list={state.exchange.list} - value={state.exchange.value} - name="switchingExchange" - onChange={state.exchange.onChange} - /> - </div> - <LinkSuccess - upperCased - style={{ fontSize: "small" }} - onClick={state.confirmEditExchange.onClick} - > - {state.exchange.isDirty ? ( - <i18n.Translate>Confirm exchange selection</i18n.Translate> - ) : ( - <i18n.Translate>Cancel exchange selection</i18n.Translate> - )} - </LinkSuccess> - </Fragment> - ) : ( - <LinkSuccess - style={{ fontSize: "small" }} - upperCased - onClick={state.editExchange.onClick} - > - <i18n.Translate>Edit exchange</i18n.Translate> - </LinkSuccess> - )} - </section> - <section> - <Input> - <SelectList - label={<i18n.Translate>Age restriction</i18n.Translate>} - list={state.ageRestriction.list} - name="age" - maxWidth - value={state.ageRestriction.value} - onChange={state.ageRestriction.onChange} - /> - </Input> - </section> - {state.tosProps && <TermsOfServiceSection {...state.tosProps} />} - {state.tosProps ? ( - <section> - {(state.tosProps.terms.status === "accepted" || - (state.mustAcceptFirst && state.tosProps.reviewed)) && ( - <Button - variant="contained" - color="success" - disabled={!state.doWithdrawal.onClick} - onClick={state.doWithdrawal.onClick} - > - <i18n.Translate>Confirm withdrawal</i18n.Translate> - </Button> - )} - {state.tosProps.terms.status === "notfound" && ( - <Button - variant="contained" - color="warning" - disabled={!state.doWithdrawal.onClick} - onClick={state.doWithdrawal.onClick} - > - <i18n.Translate>Withdraw anyway</i18n.Translate> - </Button> - )} - </section> - ) : ( - <section> - <i18n.Translate>Loading terms of service...</i18n.Translate> - </section> - )} - </WalletAction> - ); -} - -export function WithdrawPage({ talerWithdrawUri }: Props): VNode { - const { i18n } = useTranslationContext(); - - const state = useComponentState(talerWithdrawUri, wxApi); - - if (!talerWithdrawUri) { - return ( - <span> - <i18n.Translate>missing withdraw uri</i18n.Translate> - </span> - ); - } - - if (!state) { - return <Loading />; - } - - return <View state={state} />; -} 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<State> = { + loading: Loading, "loading-uri": LoadingUriView, "loading-exchange": LoadingExchangeView, "loading-info": LoadingInfoView, @@ -93,6 +95,4 @@ const viewMapping: StateViewMap<State> = { 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 <http://www.gnu.org/licenses/> */ -/** - * Page shown to the user to confirm creation - * of a reserve, usually requested by the bank. - * - * @author sebasjm - */ import { Amounts } from "@gnu-taler/taler-util"; import { TalerError } from "@gnu-taler/taler-wallet-core"; @@ -133,17 +127,18 @@ export function useComponentState( const [showExchangeSelection, setShowExchangeSelection] = useState(false); const [nextExchange, setNextExchange] = useState<string | undefined>(); - 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 <Loading />; return ( <LoadingError title={ <i18n.Translate>Could not get the info from the URI</i18n.Translate> } - 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 <Loading />; return ( <LoadingError title={<i18n.Translate>Could not get exchange</i18n.Translate>} - 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 <Loading />; + return ( <LoadingError title={<i18n.Translate>Could not get info of withdrawal</i18n.Translate>} - 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 { */} <Route path={Pages.ctaPay} - component={PayPage} + component={PaymentPage} goToWalletManualWithdraw={(currency?: string) => redirectTo(Pages.balanceManualWithdraw({ currency })) } |