diff options
author | Sebastian <sebasjm@gmail.com> | 2022-05-02 19:21:34 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2022-05-02 19:21:34 -0300 |
commit | 939729004a8f5fecde19e679a0672843c496662f (patch) | |
tree | 75eee0ad7d51dab5f759177eda3cbdc13124c61b | |
parent | e5c9f588e4618d01f6b4c91028e175147a6b5a69 (diff) | |
download | wallet-core-939729004a8f5fecde19e679a0672843c496662f.tar.xz |
tip and refund stories and test
11 files changed, 964 insertions, 268 deletions
diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.tsx b/packages/taler-wallet-webextension/src/cta/Deposit.tsx index 23c557b0c..529da11ba 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit.tsx +++ b/packages/taler-wallet-webextension/src/cta/Deposit.tsx @@ -24,35 +24,13 @@ * Imports. */ -import { - AmountJson, - Amounts, - amountToPretty, - ConfirmPayResult, - ConfirmPayResultType, - ContractTerms, - NotificationType, - PreparePayResult, - PreparePayResultType, -} 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 { 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 { - ErrorBox, - SubTitle, - SuccessBox, - WalletAction, - WarningBox, -} from "../components/styled/index.js"; +import { SubTitle, WalletAction } from "../components/styled/index.js"; import { useTranslationContext } from "../context/translation.js"; -import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import * as wxApi from "../wxApi.js"; +import { HookError } from "../hooks/useAsyncAsHook.js"; interface Props { talerDepositUri?: string; @@ -102,7 +80,7 @@ export function View({ state }: ViewProps): VNode { <LogoHeader /> <SubTitle> - <i18n.Translate>Digital cash deposit</i18n.Translate> + <i18n.Translate>Digital cash refund</i18n.Translate> </SubTitle> </WalletAction> ); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.test.ts b/packages/taler-wallet-webextension/src/cta/Pay.test.ts index 4c0fe45ca..7e9d5338f 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Pay.test.ts @@ -32,7 +32,7 @@ type Subs = { [key in NotificationType]?: VoidFunction } -class SubsHandler { +export class SubsHandler { private subs: Subs = {}; constructor() { diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx b/packages/taler-wallet-webextension/src/cta/Pay.tsx index 3e9e34fe6..0e2530149 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.tsx +++ b/packages/taler-wallet-webextension/src/cta/Pay.tsx @@ -353,7 +353,7 @@ export function View({ ); } -function ProductList({ products }: { products: Product[] }): VNode { +export function ProductList({ products }: { products: Product[] }): VNode { const { i18n } = useTranslationContext(); return ( <Fragment> diff --git a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx index c48841719..6b7cf4621 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { OrderShortInfo } from "@gnu-taler/taler-util"; +import { Amounts } from "@gnu-taler/taler-util"; import { createExample } from "../test-utils.js"; import { View as TestedComponent } from "./Refund.js"; @@ -30,46 +30,70 @@ export default { }; export const Complete = createExample(TestedComponent, { - applyResult: { - amountEffectivePaid: "USD:10", - amountRefundGone: "USD:0", - amountRefundGranted: "USD:2", - contractTermsHash: "QWEASDZXC", - info: { - summary: "tasty cold beer", - contractTermsHash: "QWEASDZXC", - } as Partial<OrderShortInfo> as any, - pendingAtExchange: false, - proposalId: "proposal123", + state: { + status: "completed", + amount: Amounts.parseOrThrow("USD:1"), + hook: undefined, + merchantName: "the merchant", + products: undefined, }, }); -export const Partial = createExample(TestedComponent, { - applyResult: { - amountEffectivePaid: "USD:10", - amountRefundGone: "USD:1", - amountRefundGranted: "USD:2", - contractTermsHash: "QWEASDZXC", - info: { - summary: "tasty cold beer", - contractTermsHash: "QWEASDZXC", - } as Partial<OrderShortInfo> as any, - pendingAtExchange: false, - proposalId: "proposal123", +export const InProgress = createExample(TestedComponent, { + state: { + status: "in-progress", + hook: undefined, + amount: Amounts.parseOrThrow("USD:1"), + merchantName: "the merchant", + products: undefined, + progress: 0.5, }, }); -export const InProgress = createExample(TestedComponent, { - applyResult: { - amountEffectivePaid: "USD:10", - amountRefundGone: "USD:1", - amountRefundGranted: "USD:2", - contractTermsHash: "QWEASDZXC", - info: { - summary: "tasty cold beer", - contractTermsHash: "QWEASDZXC", - } as Partial<OrderShortInfo> as any, - pendingAtExchange: true, - proposalId: "proposal123", +export const Ready = createExample(TestedComponent, { + state: { + status: "ready", + hook: undefined, + accept: {}, + ignore: {}, + + amount: Amounts.parseOrThrow("USD:1"), + 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"), + merchantName: "the merchant", + products: [ + { + description: "beer", + image: beer, + quantity: 2, + }, + { + description: "t-shirt", + price: "EUR:1", + quantity: 5, + }, + ], + orderId: "abcdef", + }, +}); + +export const Ignored = createExample(TestedComponent, { + state: { + status: "ignored", + hook: undefined, + merchantName: "the merchant", }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Refund.test.ts b/packages/taler-wallet-webextension/src/cta/Refund.test.ts new file mode 100644 index 000000000..e77f8e682 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Refund.test.ts @@ -0,0 +1,243 @@ +/* + This file is part of GNU Taler + (C) 2021 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, NotificationType, PrepareRefundResult } from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../test-utils.js"; +import { SubsHandler } from "./Pay.test.js"; +import { useComponentState } from "./Refund.jsx"; + +// onUpdateNotification: subscriptions.saveSubscription, + +describe("Refund CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(undefined, { + prepareRefund: async () => ({}), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading') + if (!hook) expect.fail(); + if (!hook.hasError) expect.fail(); + if (hook.operational) expect.fail(); + expect(hook.message).eq("ERROR_NO-URI-FOR-REFUND"); + } + + await assertNoPendingUpdate() + }); + + it("should be ready after loading", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState("taler://refund/asdasdas", { + prepareRefund: async () => ({ + total: 0, + applied: 0, + failed: 0, + amountEffectivePaid: 'EUR:2', + info: { + contractTermsHash: '123', + merchant: { + name: 'the merchant name' + }, + orderId: 'orderId1', + summary: 'the sumary' + } + } as PrepareRefundResult as any), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== 'ready') expect.fail(); + if (state.hook) expect.fail(); + expect(state.accept.onClick).not.undefined; + expect(state.ignore.onClick).not.undefined; + expect(state.merchantName).eq('the merchant name'); + expect(state.orderId).eq('orderId1'); + expect(state.products).undefined; + } + + await assertNoPendingUpdate() + }); + + it("should be ignored after clicking the ignore button", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState("taler://refund/asdasdas", { + prepareRefund: async () => ({ + total: 0, + applied: 0, + failed: 0, + amountEffectivePaid: 'EUR:2', + info: { + contractTermsHash: '123', + merchant: { + name: 'the merchant name' + }, + orderId: 'orderId1', + summary: 'the sumary' + } + } as PrepareRefundResult as any), + applyRefund: async () => ({}), + onUpdateNotification: async () => ({}) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== 'ready') expect.fail(); + if (state.hook) expect.fail(); + expect(state.accept.onClick).not.undefined; + expect(state.merchantName).eq('the merchant name'); + expect(state.orderId).eq('orderId1'); + expect(state.products).undefined; + + if (state.ignore.onClick === undefined) expect.fail(); + state.ignore.onClick() + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== 'ignored') expect.fail(); + if (state.hook) expect.fail(); + expect(state.merchantName).eq('the merchant name'); + } + + await assertNoPendingUpdate() + }); + + it("should be in progress when doing refresh", async () => { + let numApplied = 1; + const subscriptions = new SubsHandler(); + + function notifyMelt(): void { + numApplied++; + subscriptions.notifyEvent(NotificationType.RefreshMelted) + } + + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState("taler://refund/asdasdas", { + prepareRefund: async () => ({ + total: 3, + applied: numApplied, + failed: 0, + amountEffectivePaid: 'EUR:2', + info: { + contractTermsHash: '123', + merchant: { + name: 'the merchant name' + }, + orderId: 'orderId1', + summary: 'the sumary' + } + } as PrepareRefundResult as any), + applyRefund: async () => ({}), + onUpdateNotification: subscriptions.saveSubscription, + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== 'in-progress') expect.fail(); + if (state.hook) expect.fail(); + expect(state.merchantName).eq('the merchant name'); + expect(state.products).undefined; + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) + expect(state.progress).closeTo(1 / 3, 0.01) + + notifyMelt() + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== 'in-progress') expect.fail(); + if (state.hook) expect.fail(); + expect(state.merchantName).eq('the merchant name'); + expect(state.products).undefined; + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) + expect(state.progress).closeTo(2 / 3, 0.01) + + notifyMelt() + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== 'completed') expect.fail(); + if (state.hook) expect.fail(); + expect(state.merchantName).eq('the merchant name'); + expect(state.products).undefined; + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) + } + + await assertNoPendingUpdate() + }); +});
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/cta/Refund.tsx b/packages/taler-wallet-webextension/src/cta/Refund.tsx index 23231328a..f69fc4311 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund.tsx @@ -21,129 +21,311 @@ */ import { - amountFractionalBase, AmountJson, Amounts, - ApplyRefundResponse, + NotificationType, + Product, } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { SubTitle, Title } from "../components/styled/index.js"; +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"; +import { ProductList } from "./Pay.js"; interface Props { talerRefundUri?: string; } export interface ViewProps { - applyResult: ApplyRefundResponse; + state: State; } -export function View({ applyResult }: ViewProps): VNode { +export function View({ state }: ViewProps): VNode { const { i18n } = useTranslationContext(); - return ( - <section class="main"> - <Title>GNU Taler Wallet</Title> - <article class="fade"> + 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>Refund Status</i18n.Translate> + <i18n.Translate>Digital cash refund</i18n.Translate> </SubTitle> - <p> - <i18n.Translate> - The product <em>{applyResult.info.summary}</em> has received a total - effective refund of{" "} - </i18n.Translate> - <AmountView amount={applyResult.amountRefundGranted} />. - </p> - {applyResult.pendingAtExchange ? ( + <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> - Refund processing is still in progress. - </i18n.Translate> + <i18n.Translate>The refund is in progress.</i18n.Translate> </p> - ) : null} - {!Amounts.isZero(applyResult.amountRefundGone) ? ( + </section> + <section> + <Part + big + title={<i18n.Translate>Total to refund</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> - The refund amount of{" "} - <AmountView amount={applyResult.amountRefundGone} /> could not be - applied. - </i18n.Translate> + <i18n.Translate>this refund is already accepted.</i18n.Translate> </p> - ) : null} - </article> - </section> + </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>Total to refund</i18n.Translate>} + text={<Amount value={state.amount} />} + kind="negative" + /> + </section> + {state.products && state.products.length ? ( + <section> + <ProductList products={state.products} /> + </section> + ) : undefined} + <section> + <ButtonSuccess onClick={state.accept.onClick}> + <i18n.Translate>Confirm refund</i18n.Translate> + </ButtonSuccess> + <Button onClick={state.ignore.onClick}> + <i18n.Translate>Ignore</i18n.Translate> + </Button> + </section> + </WalletAction> ); } -export function RefundPage({ talerRefundUri }: Props): VNode { - const [applyResult, setApplyResult] = useState< - ApplyRefundResponse | undefined - >(undefined); - const { i18n } = useTranslationContext(); - const [errMsg, setErrMsg] = useState<string | undefined>(undefined); + +type State = Loading | Ready | Ignored | InProgress | Completed; + +interface Loading { + status: "loading"; + hook: HookError | undefined; +} +interface Ready { + status: "ready"; + hook: undefined; + merchantName: string; + products: Product[] | undefined; + amount: AmountJson; + 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; + progress: number; +} +interface Completed { + status: "completed"; + hook: undefined; + merchantName: string; + products: Product[] | undefined; + amount: 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(() => { - if (!talerRefundUri) return; - const doFetch = async (): Promise<void> => { - try { - const result = await wxApi.applyRefund(talerRefundUri); - setApplyResult(result); - } catch (e) { - if (e instanceof Error) { - setErrMsg(e.message); - console.log("err message", e.message); - } - } + api.onUpdateNotification([NotificationType.RefreshMelted], () => { + info?.retry(); + }); + }); + + if (!info || info.hasError) { + return { + status: "loading", + hook: info, }; - doFetch(); - }, [talerRefundUri]); + } - console.log("rendering"); + const { refund, uri } = info.response; - if (!talerRefundUri) { - return ( - <span> - <i18n.Translate>missing taler refund uri</i18n.Translate> - </span> - ); + 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, + }; } - if (errMsg) { - return ( - <span> - <i18n.Translate>Error: {errMsg}</i18n.Translate> - </span> - ); + const pending = refund.total > refund.applied + refund.failed; + const completed = refund.total > 0 && refund.applied === refund.total; + + if (pending) { + return { + status: "in-progress", + hook: undefined, + amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid), + merchantName: info.response.refund.info.merchant.name, + products: info.response.refund.info.products, + progress: (refund.applied + refund.failed) / refund.total, + }; } - if (!applyResult) { + if (completed) { + return { + status: "completed", + hook: undefined, + amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid), + merchantName: info.response.refund.info.merchant.name, + products: info.response.refund.info.products, + }; + } + + return { + status: "ready", + hook: undefined, + amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid), + 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>Updating refund status</i18n.Translate> + <i18n.Translate>missing taler refund uri</i18n.Translate> </span> ); } - return <View applyResult={applyResult} />; + return <View state={state} />; } -export function renderAmount(amount: AmountJson | string): VNode { - let a; - if (typeof amount === "string") { - a = Amounts.parse(amount); - } else { - a = amount; - } - if (!a) { - return <span>(invalid amount)</span>; - } - const x = a.value + a.fraction / amountFractionalBase; +function ProgressBar({ value }: { value: number }): VNode { return ( - <span> - {x} {a.currency} - </span> + <div + style={{ + width: 400, + height: 20, + backgroundColor: "white", + border: "solid black 1px", + }} + > + <div + style={{ + width: `${value * 100}%`, + height: "100%", + backgroundColor: "lightgreen", + }} + ></div> + </div> ); } - -function AmountView({ amount }: { amount: AmountJson | string }): VNode { - return renderAmount(amount); -} diff --git a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx b/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx index debf64aa3..0d6102d83 100644 --- a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerProtocolTimestamp } from "@gnu-taler/taler-util"; +import { Amounts } from "@gnu-taler/taler-util"; import { createExample } from "../test-utils.js"; import { View as TestedComponent } from "./Tip.js"; @@ -30,25 +30,23 @@ export default { }; export const Accepted = createExample(TestedComponent, { - prepareTipResult: { - accepted: true, - merchantBaseUrl: "", + state: { + status: "accepted", + hook: undefined, + amount: Amounts.parseOrThrow("EUR:1"), exchangeBaseUrl: "", - expirationTimestamp: TalerProtocolTimestamp.fromSeconds(1), - tipAmountEffective: "USD:10", - tipAmountRaw: "USD:5", - walletTipId: "id", + merchantBaseUrl: "", }, }); -export const NotYetAccepted = createExample(TestedComponent, { - prepareTipResult: { - accepted: false, +export const Ready = createExample(TestedComponent, { + state: { + status: "ready", + hook: undefined, + amount: Amounts.parseOrThrow("EUR:1"), merchantBaseUrl: "http://merchant.url/", exchangeBaseUrl: "http://exchange.url/", - expirationTimestamp: TalerProtocolTimestamp.fromSeconds(1), - tipAmountEffective: "USD:10", - tipAmountRaw: "USD:5", - walletTipId: "id", + accept: {}, + ignore: {}, }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Tip.test.ts b/packages/taler-wallet-webextension/src/cta/Tip.test.ts new file mode 100644 index 000000000..0eda9b5be --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Tip.test.ts @@ -0,0 +1,192 @@ +/* + This file is part of GNU Taler + (C) 2021 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, PrepareTipResult } from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../test-utils.js"; +import { useComponentState } from "./Tip.jsx"; + +describe("Tip CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(undefined, { + prepareTip: async () => ({}), + acceptTip: async () => ({}) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading') + if (!hook) expect.fail(); + if (!hook.hasError) expect.fail(); + if (hook.operational) expect.fail(); + expect(hook.message).eq("ERROR_NO-URI-FOR-TIP"); + } + + await assertNoPendingUpdate() + }); + + it("should be ready for accepting the tip", async () => { + let tipAccepted = false; + + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState("taler://tip/asd", { + prepareTip: async () => ({ + accepted: tipAccepted, + exchangeBaseUrl: "exchange url", + merchantBaseUrl: "merchant url", + tipAmountEffective: "EUR:1", + walletTipId: "tip_id", + } as PrepareTipResult as any), + acceptTip: async () => { + tipAccepted = true + } + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== "ready") expect.fail() + if (state.hook) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + if (state.accept.onClick === undefined) expect.fail(); + + state.accept.onClick(); + } + + await waitNextUpdate() + { + const state = getLastResultOrThrow() + + if (state.status !== "accepted") expect.fail() + if (state.hook) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + + } + await assertNoPendingUpdate() + }); + + it("should be ignored after clicking the ignore button", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState("taler://tip/asd", { + prepareTip: async () => ({ + exchangeBaseUrl: "exchange url", + merchantBaseUrl: "merchant url", + tipAmountEffective: "EUR:1", + walletTipId: "tip_id", + } as PrepareTipResult as any), + acceptTip: async () => ({}) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== "ready") expect.fail() + if (state.hook) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + if (state.ignore.onClick === undefined) expect.fail(); + + state.ignore.onClick(); + } + + await waitNextUpdate() + { + const state = getLastResultOrThrow() + + if (state.status !== "ignored") expect.fail() + if (state.hook) expect.fail(); + + } + await assertNoPendingUpdate() + }); + + it("should render accepted if the tip has been used previously", async () => { + + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState("taler://tip/asd", { + prepareTip: async () => ({ + accepted: true, + exchangeBaseUrl: "exchange url", + merchantBaseUrl: "merchant url", + tipAmountEffective: "EUR:1", + walletTipId: "tip_id", + } as PrepareTipResult as any), + acceptTip: async () => ({}) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const state = getLastResultOrThrow() + + if (state.status !== "accepted") expect.fail() + if (state.hook) expect.fail(); + expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); + expect(state.merchantBaseUrl).eq("merchant url"); + expect(state.exchangeBaseUrl).eq("exchange url"); + + } + await assertNoPendingUpdate() + }); + + +});
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/cta/Tip.tsx b/packages/taler-wallet-webextension/src/cta/Tip.tsx index 071243f31..dc4757b33 100644 --- a/packages/taler-wallet-webextension/src/cta/Tip.tsx +++ b/packages/taler-wallet-webextension/src/cta/Tip.tsx @@ -20,146 +20,218 @@ * @author sebasjm */ -import { - amountFractionalBase, - AmountJson, - Amounts, - PrepareTipResult, -} from "@gnu-taler/taler-util"; +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 { Title } from "../components/styled/index.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; } -export interface ViewProps { - prepareTipResult: PrepareTipResult; - onAccept: () => void; - onIgnore: () => void; -} -export function View({ - prepareTipResult, - onAccept, - onIgnore, -}: ViewProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <section class="main"> - <Title>GNU Taler Wallet</Title> - <article class="fade"> - {prepareTipResult.accepted ? ( - <span> - <i18n.Translate> - Tip from <code>{prepareTipResult.merchantBaseUrl}</code> accepted. - Check your transactions list for more details. - </i18n.Translate> - </span> - ) : ( - <div> - <p> - <i18n.Translate> - The merchant <code>{prepareTipResult.merchantBaseUrl}</code> is - offering you a tip of{" "} - <strong> - <AmountView amount={prepareTipResult.tipAmountEffective} /> - </strong>{" "} - via the exchange <code>{prepareTipResult.exchangeBaseUrl}</code> - </i18n.Translate> - </p> - <button onClick={onAccept}> - <i18n.Translate>Accept tip</i18n.Translate> - </button> - <button onClick={onIgnore}> - <i18n.Translate>Ignore</i18n.Translate> - </button> - </div> - )} - </article> - </section> - ); + +type State = Loading | Ready | Accepted | Ignored; + +interface Loading { + status: "loading"; + hook: HookError | undefined; } -export function TipPage({ talerTipUri }: Props): VNode { - const { i18n } = useTranslationContext(); - const [updateCounter, setUpdateCounter] = useState<number>(0); - const [prepareTipResult, setPrepareTipResult] = useState< - PrepareTipResult | undefined - >(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); - useEffect(() => { - if (!talerTipUri) return; - const doFetch = async (): Promise<void> => { - const p = await wxApi.prepareTip({ talerTipUri }); - setPrepareTipResult(p); + 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, }; - doFetch(); - }, [talerTipUri, updateCounter]); + } + + const { tip } = tipInfo.response; const doAccept = async (): Promise<void> => { - if (!prepareTipResult) { - return; - } - await wxApi.acceptTip({ walletTipId: prepareTipResult?.walletTipId }); - setUpdateCounter(updateCounter + 1); + await api.acceptTip({ walletTipId: tip.walletTipId }); + tipInfo.retry(); }; - const doIgnore = (): void => { + const doIgnore = async (): Promise<void> => { setTipIgnored(true); }; - if (!talerTipUri) { + 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 ( - <span> - <i18n.Translate>missing tip uri</i18n.Translate> - </span> + <LoadingError + title={<i18n.Translate>Could not load tip status</i18n.Translate>} + error={state.hook} + /> ); } - if (tipIgnored) { + if (state.status === "ignored") { return ( - <span> - <i18n.Translate>You've ignored the tip.</i18n.Translate> - </span> + <WalletAction> + <LogoHeader /> + + <SubTitle> + <i18n.Translate>Digital cash tip</i18n.Translate> + </SubTitle> + <span> + <i18n.Translate>You've ignored the tip.</i18n.Translate> + </span> + </WalletAction> ); } - if (!prepareTipResult) { - return <Loading />; + 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 ( - <View - prepareTipResult={prepareTipResult} - onAccept={doAccept} - onIgnore={doIgnore} - /> + <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> + <ButtonSuccess onClick={state.accept.onClick}> + <i18n.Translate>Accept tip</i18n.Translate> + </ButtonSuccess> + <Button onClick={state.ignore.onClick}> + <i18n.Translate>Ignore</i18n.Translate> + </Button> + </section> + </WalletAction> ); } -function renderAmount(amount: AmountJson | string): VNode { - let a; - if (typeof amount === "string") { - a = Amounts.parse(amount); - } else { - a = amount; - } - if (!a) { - return <span>(invalid amount)</span>; +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> + ); } - const x = a.value + a.fraction / amountFractionalBase; - return ( - <span> - {x} {a.currency} - </span> - ); -} -function AmountView({ amount }: { amount: AmountJson | string }): VNode { - return renderAmount(amount); + return <View state={state} />; } diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index 584fe427b..6f7c208da 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -515,13 +515,13 @@ export function TransactionView({ <Part big title={<i18n.Translate>Total tip</i18n.Translate>} - text={<Amount value={transaction.amountEffective} />} + text={<Amount value={transaction.amountRaw} />} kind="positive" /> <Part big title={<i18n.Translate>Received amount</i18n.Translate>} - text={<Amount value={transaction.amountRaw} />} + text={<Amount value={transaction.amountEffective} />} kind="neutral" /> <Part diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 3079392b6..d2e903054 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -44,6 +44,8 @@ import { KnownBankAccounts, NotificationType, PreparePayResult, + PrepareRefundRequest, + PrepareRefundResult, PrepareTipRequest, PrepareTipResult, RetryTransactionRequest, @@ -405,6 +407,11 @@ export function addExchange(req: AddExchangeRequest): Promise<void> { return callBackend("addExchange", req); } +export function prepareRefund(req: PrepareRefundRequest): Promise<PrepareRefundResult> { + return callBackend("prepareRefund", req); +} + + export function prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> { return callBackend("prepareTip", req); } |