From 939729004a8f5fecde19e679a0672843c496662f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 2 May 2022 19:21:34 -0300 Subject: tip and refund stories and test --- .../taler-wallet-webextension/src/cta/Deposit.tsx | 28 +- .../taler-wallet-webextension/src/cta/Pay.test.ts | 2 +- packages/taler-wallet-webextension/src/cta/Pay.tsx | 2 +- .../src/cta/Refund.stories.tsx | 96 +++--- .../src/cta/Refund.test.ts | 243 ++++++++++++++ .../taler-wallet-webextension/src/cta/Refund.tsx | 350 ++++++++++++++++----- .../src/cta/Tip.stories.tsx | 28 +- .../taler-wallet-webextension/src/cta/Tip.test.ts | 192 +++++++++++ packages/taler-wallet-webextension/src/cta/Tip.tsx | 280 +++++++++++------ .../src/wallet/Transaction.tsx | 4 +- packages/taler-wallet-webextension/src/wxApi.ts | 7 + 11 files changed, 964 insertions(+), 268 deletions(-) create mode 100644 packages/taler-wallet-webextension/src/cta/Refund.test.ts create mode 100644 packages/taler-wallet-webextension/src/cta/Tip.test.ts 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 { - Digital cash deposit + Digital cash refund ); 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 ( 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 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 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 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 + */ + +/** + * + * @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 ( -
- GNU Taler Wallet -
+ if (state.status === "loading") { + if (!state.hook) { + return ; + } + return ( + Could not load refund status} + error={state.hook} + /> + ); + } + + if (state.status === "ignored") { + return ( + + + - Refund Status + Digital cash refund -

- - The product {applyResult.info.summary} has received a total - effective refund of{" "} - - . -

- {applyResult.pendingAtExchange ? ( +
+

+ You've ignored the tip. +

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

- - Refund processing is still in progress. - + The refund is in progress.

- ) : null} - {!Amounts.isZero(applyResult.amountRefundGone) ? ( +
+
+ Total to refund} + text={} + kind="negative" + /> +
+ {state.products && state.products.length ? ( +
+ +
+ ) : undefined} +
+ +
+
+ ); + } + + if (state.status === "completed") { + return ( + + + + + Digital cash refund + +

- - The refund amount of{" "} - could not be - applied. - + this refund is already accepted.

- ) : null} -
-
+ + + ); + } + + return ( + + + + + Digital cash refund + +
+

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

+
+
+ Total to refund} + text={} + kind="negative" + /> +
+ {state.products && state.products.length ? ( +
+ +
+ ) : undefined} +
+ + Confirm refund + + +
+
); } -export function RefundPage({ talerRefundUri }: Props): VNode { - const [applyResult, setApplyResult] = useState< - ApplyRefundResponse | undefined - >(undefined); - const { i18n } = useTranslationContext(); - const [errMsg, setErrMsg] = useState(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 => { - 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 ( - - missing taler refund uri - - ); + const doAccept = async (): Promise => { + await api.applyRefund(uri); + info.retry(); + }; + + const doIgnore = async (): Promise => { + setIgnored(true); + }; + + if (ignored) { + return { + status: "ignored", + hook: undefined, + merchantName: info.response.refund.info.merchant.name, + }; } - if (errMsg) { - return ( - - Error: {errMsg} - - ); + 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 ( - Updating refund status + missing taler refund uri ); } - return ; + return ; } -export function renderAmount(amount: AmountJson | string): VNode { - let a; - if (typeof amount === "string") { - a = Amounts.parse(amount); - } else { - a = amount; - } - if (!a) { - return (invalid amount); - } - const x = a.value + a.fraction / amountFractionalBase; +function ProgressBar({ value }: { value: number }): VNode { return ( - - {x} {a.currency} - +
+
+
); } - -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 + */ + +/** + * + * @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 ( -
- GNU Taler Wallet -
- {prepareTipResult.accepted ? ( - - - Tip from {prepareTipResult.merchantBaseUrl} accepted. - Check your transactions list for more details. - - - ) : ( -
-

- - The merchant {prepareTipResult.merchantBaseUrl} is - offering you a tip of{" "} - - - {" "} - via the exchange {prepareTipResult.exchangeBaseUrl} - -

- - -
- )} -
-
- ); + +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(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 => { - 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 => { - 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 => { 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 ; + } return ( - - missing tip uri - + Could not load tip status} + error={state.hook} + /> ); } - if (tipIgnored) { + if (state.status === "ignored") { return ( - - You've ignored the tip. - + + + + + Digital cash tip + + + You've ignored the tip. + + ); } - if (!prepareTipResult) { - return ; + if (state.status === "accepted") { + return ( + + + + + Digital cash tip + +
+ + Tip from {state.merchantBaseUrl} accepted. Check your + transactions list for more details. + +
+
+ ); } return ( - + + + + + Digital cash tip + + +
+

+ The merchant is offering you a tip +

+ Amount} + text={} + kind="positive" + big + /> + Merchant URL} + text={state.merchantBaseUrl} + kind="neutral" + /> + Exchange} + text={state.exchangeBaseUrl} + kind="neutral" + /> +
+
+ + Accept tip + + +
+
); } -function renderAmount(amount: AmountJson | string): VNode { - let a; - if (typeof amount === "string") { - a = Amounts.parse(amount); - } else { - a = amount; - } - if (!a) { - return (invalid amount); +export function TipPage({ talerTipUri }: Props): VNode { + const { i18n } = useTranslationContext(); + const state = useComponentState(talerTipUri, wxApi); + + if (!talerTipUri) { + return ( + + missing tip uri + + ); } - const x = a.value + a.fraction / amountFractionalBase; - return ( - - {x} {a.currency} - - ); -} -function AmountView({ amount }: { amount: AmountJson | string }): VNode { - return renderAmount(amount); + return ; } 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({ Total tip} - text={} + text={} kind="positive" /> Received amount} - text={} + text={} kind="neutral" /> { return callBackend("addExchange", req); } +export function prepareRefund(req: PrepareRefundRequest): Promise { + return callBackend("prepareRefund", req); +} + + export function prepareTip(req: PrepareTipRequest): Promise { return callBackend("prepareTip", req); } -- cgit v1.2.3