gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] 02/02: tip and refund stories and test


From: gnunet
Subject: [taler-wallet-core] 02/02: tip and refund stories and test
Date: Tue, 03 May 2022 00:21:46 +0200

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository wallet-core.

commit 939729004a8f5fecde19e679a0672843c496662f
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Mon May 2 19:21:34 2022 -0300

    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(-)

diff --git a/packages/taler-wallet-webextension/src/cta/Deposit.tsx 
b/packages/taler-wallet-webextension/src/cta/Deposit.tsx
index 23c557b0..529da11b 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 4c0fe45c..7e9d5338 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 3e9e34fe..0e253014 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 c4884171..6b7cf462 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 00000000..e77f8e68
--- /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 23231328..f69fc431 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 debf64aa..0d6102d8 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 00000000..0eda9b5b
--- /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 071243f3..dc4757b3 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 584fe427..6f7c208d 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 3079392b..d2e90305 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);
 }

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]