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