aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2022-05-02 19:21:34 -0300
committerSebastian <sebasjm@gmail.com>2022-05-02 19:21:34 -0300
commit939729004a8f5fecde19e679a0672843c496662f (patch)
tree75eee0ad7d51dab5f759177eda3cbdc13124c61b /packages/taler-wallet-webextension/src
parente5c9f588e4618d01f6b4c91028e175147a6b5a69 (diff)
tip and refund stories and test
Diffstat (limited to 'packages/taler-wallet-webextension/src')
-rw-r--r--packages/taler-wallet-webextension/src/cta/Deposit.tsx28
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.test.ts2
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.tsx2
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund.stories.tsx96
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund.test.ts243
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund.tsx350
-rw-r--r--packages/taler-wallet-webextension/src/cta/Tip.stories.tsx28
-rw-r--r--packages/taler-wallet-webextension/src/cta/Tip.test.ts192
-rw-r--r--packages/taler-wallet-webextension/src/cta/Tip.tsx280
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Transaction.tsx4
-rw-r--r--packages/taler-wallet-webextension/src/wxApi.ts7
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&apos;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 &quot;<b>{state.merchantName}</b>&quot; 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}&nbsp;{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&apos;ve ignored the tip.</i18n.Translate>
- </span>
+ <WalletAction>
+ <LogoHeader />
+
+ <SubTitle>
+ <i18n.Translate>Digital cash tip</i18n.Translate>
+ </SubTitle>
+ <span>
+ <i18n.Translate>You&apos;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}&nbsp;{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);
}