tip and refund stories and test
This commit is contained in:
parent
e5c9f588e4
commit
939729004a
@ -24,35 +24,13 @@
|
|||||||
* Imports.
|
* 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 { Fragment, h, VNode } from "preact";
|
||||||
import { useEffect, useState } from "preact/hooks";
|
|
||||||
import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
|
|
||||||
import { Loading } from "../components/Loading.js";
|
import { Loading } from "../components/Loading.js";
|
||||||
import { LoadingError } from "../components/LoadingError.js";
|
import { LoadingError } from "../components/LoadingError.js";
|
||||||
import { LogoHeader } from "../components/LogoHeader.js";
|
import { LogoHeader } from "../components/LogoHeader.js";
|
||||||
import { Part } from "../components/Part.js";
|
import { SubTitle, WalletAction } from "../components/styled/index.js";
|
||||||
import {
|
|
||||||
ErrorBox,
|
|
||||||
SubTitle,
|
|
||||||
SuccessBox,
|
|
||||||
WalletAction,
|
|
||||||
WarningBox,
|
|
||||||
} from "../components/styled/index.js";
|
|
||||||
import { useTranslationContext } from "../context/translation.js";
|
import { useTranslationContext } from "../context/translation.js";
|
||||||
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
|
import { HookError } from "../hooks/useAsyncAsHook.js";
|
||||||
import * as wxApi from "../wxApi.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
talerDepositUri?: string;
|
talerDepositUri?: string;
|
||||||
@ -102,7 +80,7 @@ export function View({ state }: ViewProps): VNode {
|
|||||||
<LogoHeader />
|
<LogoHeader />
|
||||||
|
|
||||||
<SubTitle>
|
<SubTitle>
|
||||||
<i18n.Translate>Digital cash deposit</i18n.Translate>
|
<i18n.Translate>Digital cash refund</i18n.Translate>
|
||||||
</SubTitle>
|
</SubTitle>
|
||||||
</WalletAction>
|
</WalletAction>
|
||||||
);
|
);
|
||||||
|
@ -32,7 +32,7 @@ type Subs = {
|
|||||||
[key in NotificationType]?: VoidFunction
|
[key in NotificationType]?: VoidFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
class SubsHandler {
|
export class SubsHandler {
|
||||||
private subs: Subs = {};
|
private subs: Subs = {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -353,7 +353,7 @@ export function View({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProductList({ products }: { products: Product[] }): VNode {
|
export function ProductList({ products }: { products: Product[] }): VNode {
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
* @author Sebastian Javier Marchano (sebasjm)
|
* @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 { createExample } from "../test-utils.js";
|
||||||
import { View as TestedComponent } from "./Refund.js";
|
import { View as TestedComponent } from "./Refund.js";
|
||||||
|
|
||||||
@ -30,46 +30,70 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Complete = createExample(TestedComponent, {
|
export const Complete = createExample(TestedComponent, {
|
||||||
applyResult: {
|
state: {
|
||||||
amountEffectivePaid: "USD:10",
|
status: "completed",
|
||||||
amountRefundGone: "USD:0",
|
amount: Amounts.parseOrThrow("USD:1"),
|
||||||
amountRefundGranted: "USD:2",
|
hook: undefined,
|
||||||
contractTermsHash: "QWEASDZXC",
|
merchantName: "the merchant",
|
||||||
info: {
|
products: undefined,
|
||||||
summary: "tasty cold beer",
|
|
||||||
contractTermsHash: "QWEASDZXC",
|
|
||||||
} as Partial<OrderShortInfo> as any,
|
|
||||||
pendingAtExchange: false,
|
|
||||||
proposalId: "proposal123",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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, {
|
export const InProgress = createExample(TestedComponent, {
|
||||||
applyResult: {
|
state: {
|
||||||
amountEffectivePaid: "USD:10",
|
status: "in-progress",
|
||||||
amountRefundGone: "USD:1",
|
hook: undefined,
|
||||||
amountRefundGranted: "USD:2",
|
amount: Amounts.parseOrThrow("USD:1"),
|
||||||
contractTermsHash: "QWEASDZXC",
|
merchantName: "the merchant",
|
||||||
info: {
|
products: undefined,
|
||||||
summary: "tasty cold beer",
|
progress: 0.5,
|
||||||
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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
243
packages/taler-wallet-webextension/src/cta/Refund.test.ts
Normal file
243
packages/taler-wallet-webextension/src/cta/Refund.test.ts
Normal file
@ -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()
|
||||||
|
});
|
||||||
|
});
|
@ -21,83 +21,282 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
amountFractionalBase,
|
|
||||||
AmountJson,
|
AmountJson,
|
||||||
Amounts,
|
Amounts,
|
||||||
ApplyRefundResponse,
|
NotificationType,
|
||||||
|
Product,
|
||||||
} from "@gnu-taler/taler-util";
|
} from "@gnu-taler/taler-util";
|
||||||
import { h, VNode } from "preact";
|
import { h, VNode } from "preact";
|
||||||
import { useEffect, useState } from "preact/hooks";
|
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 { 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 * as wxApi from "../wxApi.js";
|
||||||
|
import { ProductList } from "./Pay.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
talerRefundUri?: string;
|
talerRefundUri?: string;
|
||||||
}
|
}
|
||||||
export interface ViewProps {
|
export interface ViewProps {
|
||||||
applyResult: ApplyRefundResponse;
|
state: State;
|
||||||
}
|
}
|
||||||
export function View({ applyResult }: ViewProps): VNode {
|
export function View({ state }: ViewProps): VNode {
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
return (
|
if (state.status === "loading") {
|
||||||
<section class="main">
|
if (!state.hook) {
|
||||||
<Title>GNU Taler Wallet</Title>
|
return <Loading />;
|
||||||
<article class="fade">
|
}
|
||||||
|
return (
|
||||||
|
<LoadingError
|
||||||
|
title={<i18n.Translate>Could not load refund status</i18n.Translate>}
|
||||||
|
error={state.hook}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "ignored") {
|
||||||
|
return (
|
||||||
|
<WalletAction>
|
||||||
|
<LogoHeader />
|
||||||
|
|
||||||
<SubTitle>
|
<SubTitle>
|
||||||
<i18n.Translate>Refund Status</i18n.Translate>
|
<i18n.Translate>Digital cash refund</i18n.Translate>
|
||||||
</SubTitle>
|
</SubTitle>
|
||||||
|
<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>The refund is in progress.</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>
|
||||||
|
<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>this refund is already accepted.</i18n.Translate>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</WalletAction>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WalletAction>
|
||||||
|
<LogoHeader />
|
||||||
|
|
||||||
|
<SubTitle>
|
||||||
|
<i18n.Translate>Digital cash refund</i18n.Translate>
|
||||||
|
</SubTitle>
|
||||||
|
<section>
|
||||||
<p>
|
<p>
|
||||||
<i18n.Translate>
|
<i18n.Translate>
|
||||||
The product <em>{applyResult.info.summary}</em> has received a total
|
The merchant "<b>{state.merchantName}</b>" is offering you
|
||||||
effective refund of{" "}
|
a refund.
|
||||||
</i18n.Translate>
|
</i18n.Translate>
|
||||||
<AmountView amount={applyResult.amountRefundGranted} />.
|
|
||||||
</p>
|
</p>
|
||||||
{applyResult.pendingAtExchange ? (
|
</section>
|
||||||
<p>
|
<section>
|
||||||
<i18n.Translate>
|
<Part
|
||||||
Refund processing is still in progress.
|
big
|
||||||
</i18n.Translate>
|
title={<i18n.Translate>Total to refund</i18n.Translate>}
|
||||||
</p>
|
text={<Amount value={state.amount} />}
|
||||||
) : null}
|
kind="negative"
|
||||||
{!Amounts.isZero(applyResult.amountRefundGone) ? (
|
/>
|
||||||
<p>
|
</section>
|
||||||
<i18n.Translate>
|
{state.products && state.products.length ? (
|
||||||
The refund amount of{" "}
|
<section>
|
||||||
<AmountView amount={applyResult.amountRefundGone} /> could not be
|
<ProductList products={state.products} />
|
||||||
applied.
|
</section>
|
||||||
</i18n.Translate>
|
) : undefined}
|
||||||
</p>
|
<section>
|
||||||
) : null}
|
<ButtonSuccess onClick={state.accept.onClick}>
|
||||||
</article>
|
<i18n.Translate>Confirm refund</i18n.Translate>
|
||||||
</section>
|
</ButtonSuccess>
|
||||||
|
<Button onClick={state.ignore.onClick}>
|
||||||
|
<i18n.Translate>Ignore</i18n.Translate>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</WalletAction>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export function RefundPage({ talerRefundUri }: Props): VNode {
|
|
||||||
const [applyResult, setApplyResult] = useState<
|
type State = Loading | Ready | Ignored | InProgress | Completed;
|
||||||
ApplyRefundResponse | undefined
|
|
||||||
>(undefined);
|
interface Loading {
|
||||||
const { i18n } = useTranslationContext();
|
status: "loading";
|
||||||
const [errMsg, setErrMsg] = useState<string | undefined>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!talerRefundUri) return;
|
api.onUpdateNotification([NotificationType.RefreshMelted], () => {
|
||||||
const doFetch = async (): Promise<void> => {
|
info?.retry();
|
||||||
try {
|
});
|
||||||
const result = await wxApi.applyRefund(talerRefundUri);
|
});
|
||||||
setApplyResult(result);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
setErrMsg(e.message);
|
|
||||||
console.log("err message", e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
doFetch();
|
|
||||||
}, [talerRefundUri]);
|
|
||||||
|
|
||||||
console.log("rendering");
|
if (!info || info.hasError) {
|
||||||
|
return {
|
||||||
|
status: "loading",
|
||||||
|
hook: info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { refund, uri } = info.response;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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) {
|
if (!talerRefundUri) {
|
||||||
return (
|
return (
|
||||||
@ -107,43 +306,26 @@ export function RefundPage({ talerRefundUri }: Props): VNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errMsg) {
|
return <View state={state} />;
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<i18n.Translate>Error: {errMsg}</i18n.Translate>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!applyResult) {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<i18n.Translate>Updating refund status</i18n.Translate>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View applyResult={applyResult} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAmount(amount: AmountJson | string): VNode {
|
function ProgressBar({ value }: { value: number }): 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;
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<div
|
||||||
{x} {a.currency}
|
style={{
|
||||||
</span>
|
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);
|
|
||||||
}
|
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
* @author Sebastian Javier Marchano (sebasjm)
|
* @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 { createExample } from "../test-utils.js";
|
||||||
import { View as TestedComponent } from "./Tip.js";
|
import { View as TestedComponent } from "./Tip.js";
|
||||||
|
|
||||||
@ -30,25 +30,23 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Accepted = createExample(TestedComponent, {
|
export const Accepted = createExample(TestedComponent, {
|
||||||
prepareTipResult: {
|
state: {
|
||||||
accepted: true,
|
status: "accepted",
|
||||||
merchantBaseUrl: "",
|
hook: undefined,
|
||||||
|
amount: Amounts.parseOrThrow("EUR:1"),
|
||||||
exchangeBaseUrl: "",
|
exchangeBaseUrl: "",
|
||||||
expirationTimestamp: TalerProtocolTimestamp.fromSeconds(1),
|
merchantBaseUrl: "",
|
||||||
tipAmountEffective: "USD:10",
|
|
||||||
tipAmountRaw: "USD:5",
|
|
||||||
walletTipId: "id",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NotYetAccepted = createExample(TestedComponent, {
|
export const Ready = createExample(TestedComponent, {
|
||||||
prepareTipResult: {
|
state: {
|
||||||
accepted: false,
|
status: "ready",
|
||||||
|
hook: undefined,
|
||||||
|
amount: Amounts.parseOrThrow("EUR:1"),
|
||||||
merchantBaseUrl: "http://merchant.url/",
|
merchantBaseUrl: "http://merchant.url/",
|
||||||
exchangeBaseUrl: "http://exchange.url/",
|
exchangeBaseUrl: "http://exchange.url/",
|
||||||
expirationTimestamp: TalerProtocolTimestamp.fromSeconds(1),
|
accept: {},
|
||||||
tipAmountEffective: "USD:10",
|
ignore: {},
|
||||||
tipAmountRaw: "USD:5",
|
|
||||||
walletTipId: "id",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
192
packages/taler-wallet-webextension/src/cta/Tip.test.ts
Normal file
192
packages/taler-wallet-webextension/src/cta/Tip.test.ts
Normal file
@ -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()
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
@ -20,98 +20,210 @@
|
|||||||
* @author sebasjm
|
* @author sebasjm
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AmountJson, Amounts, PrepareTipResult } from "@gnu-taler/taler-util";
|
||||||
amountFractionalBase,
|
|
||||||
AmountJson,
|
|
||||||
Amounts,
|
|
||||||
PrepareTipResult,
|
|
||||||
} from "@gnu-taler/taler-util";
|
|
||||||
import { h, VNode } from "preact";
|
import { h, VNode } from "preact";
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { Amount } from "../components/Amount.js";
|
||||||
import { Loading } from "../components/Loading.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 { 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 * as wxApi from "../wxApi.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
talerTipUri?: string;
|
talerTipUri?: string;
|
||||||
}
|
}
|
||||||
export interface ViewProps {
|
|
||||||
prepareTipResult: PrepareTipResult;
|
type State = Loading | Ready | Accepted | Ignored;
|
||||||
onAccept: () => void;
|
|
||||||
onIgnore: () => void;
|
interface Loading {
|
||||||
|
status: "loading";
|
||||||
|
hook: HookError | undefined;
|
||||||
}
|
}
|
||||||
export function View({
|
|
||||||
prepareTipResult,
|
interface Ignored {
|
||||||
onAccept,
|
status: "ignored";
|
||||||
onIgnore,
|
hook: undefined;
|
||||||
}: ViewProps): VNode {
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tip } = tipInfo.response;
|
||||||
|
|
||||||
|
const doAccept = async (): Promise<void> => {
|
||||||
|
await api.acceptTip({ walletTipId: tip.walletTipId });
|
||||||
|
tipInfo.retry();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doIgnore = async (): Promise<void> => {
|
||||||
|
setTipIgnored(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
const { i18n } = useTranslationContext();
|
||||||
|
if (state.status === "loading") {
|
||||||
|
if (!state.hook) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LoadingError
|
||||||
|
title={<i18n.Translate>Could not load tip status</i18n.Translate>}
|
||||||
|
error={state.hook}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "ignored") {
|
||||||
|
return (
|
||||||
|
<WalletAction>
|
||||||
|
<LogoHeader />
|
||||||
|
|
||||||
|
<SubTitle>
|
||||||
|
<i18n.Translate>Digital cash tip</i18n.Translate>
|
||||||
|
</SubTitle>
|
||||||
|
<span>
|
||||||
|
<i18n.Translate>You've ignored the tip.</i18n.Translate>
|
||||||
|
</span>
|
||||||
|
</WalletAction>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<section class="main">
|
<WalletAction>
|
||||||
<Title>GNU Taler Wallet</Title>
|
<LogoHeader />
|
||||||
<article class="fade">
|
|
||||||
{prepareTipResult.accepted ? (
|
<SubTitle>
|
||||||
<span>
|
<i18n.Translate>Digital cash tip</i18n.Translate>
|
||||||
<i18n.Translate>
|
</SubTitle>
|
||||||
Tip from <code>{prepareTipResult.merchantBaseUrl}</code> accepted.
|
|
||||||
Check your transactions list for more details.
|
<section>
|
||||||
</i18n.Translate>
|
<p>
|
||||||
</span>
|
<i18n.Translate>The merchant is offering you a tip</i18n.Translate>
|
||||||
) : (
|
</p>
|
||||||
<div>
|
<Part
|
||||||
<p>
|
title={<i18n.Translate>Amount</i18n.Translate>}
|
||||||
<i18n.Translate>
|
text={<Amount value={state.amount} />}
|
||||||
The merchant <code>{prepareTipResult.merchantBaseUrl}</code> is
|
kind="positive"
|
||||||
offering you a tip of{" "}
|
big
|
||||||
<strong>
|
/>
|
||||||
<AmountView amount={prepareTipResult.tipAmountEffective} />
|
<Part
|
||||||
</strong>{" "}
|
title={<i18n.Translate>Merchant URL</i18n.Translate>}
|
||||||
via the exchange <code>{prepareTipResult.exchangeBaseUrl}</code>
|
text={state.merchantBaseUrl}
|
||||||
</i18n.Translate>
|
kind="neutral"
|
||||||
</p>
|
/>
|
||||||
<button onClick={onAccept}>
|
<Part
|
||||||
<i18n.Translate>Accept tip</i18n.Translate>
|
title={<i18n.Translate>Exchange</i18n.Translate>}
|
||||||
</button>
|
text={state.exchangeBaseUrl}
|
||||||
<button onClick={onIgnore}>
|
kind="neutral"
|
||||||
<i18n.Translate>Ignore</i18n.Translate>
|
/>
|
||||||
</button>
|
</section>
|
||||||
</div>
|
<section>
|
||||||
)}
|
<ButtonSuccess onClick={state.accept.onClick}>
|
||||||
</article>
|
<i18n.Translate>Accept tip</i18n.Translate>
|
||||||
</section>
|
</ButtonSuccess>
|
||||||
|
<Button onClick={state.ignore.onClick}>
|
||||||
|
<i18n.Translate>Ignore</i18n.Translate>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</WalletAction>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TipPage({ talerTipUri }: Props): VNode {
|
export function TipPage({ talerTipUri }: Props): VNode {
|
||||||
const { i18n } = useTranslationContext();
|
const { i18n } = useTranslationContext();
|
||||||
const [updateCounter, setUpdateCounter] = useState<number>(0);
|
const state = useComponentState(talerTipUri, wxApi);
|
||||||
const [prepareTipResult, setPrepareTipResult] = useState<
|
|
||||||
PrepareTipResult | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const [tipIgnored, setTipIgnored] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!talerTipUri) return;
|
|
||||||
const doFetch = async (): Promise<void> => {
|
|
||||||
const p = await wxApi.prepareTip({ talerTipUri });
|
|
||||||
setPrepareTipResult(p);
|
|
||||||
};
|
|
||||||
doFetch();
|
|
||||||
}, [talerTipUri, updateCounter]);
|
|
||||||
|
|
||||||
const doAccept = async (): Promise<void> => {
|
|
||||||
if (!prepareTipResult) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await wxApi.acceptTip({ walletTipId: prepareTipResult?.walletTipId });
|
|
||||||
setUpdateCounter(updateCounter + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doIgnore = (): void => {
|
|
||||||
setTipIgnored(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!talerTipUri) {
|
if (!talerTipUri) {
|
||||||
return (
|
return (
|
||||||
@ -121,45 +233,5 @@ export function TipPage({ talerTipUri }: Props): VNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tipIgnored) {
|
return <View state={state} />;
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<i18n.Translate>You've ignored the tip.</i18n.Translate>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!prepareTipResult) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
prepareTipResult={prepareTipResult}
|
|
||||||
onAccept={doAccept}
|
|
||||||
onIgnore={doIgnore}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{x} {a.currency}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AmountView({ amount }: { amount: AmountJson | string }): VNode {
|
|
||||||
return renderAmount(amount);
|
|
||||||
}
|
}
|
||||||
|
@ -515,13 +515,13 @@ export function TransactionView({
|
|||||||
<Part
|
<Part
|
||||||
big
|
big
|
||||||
title={<i18n.Translate>Total tip</i18n.Translate>}
|
title={<i18n.Translate>Total tip</i18n.Translate>}
|
||||||
text={<Amount value={transaction.amountEffective} />}
|
text={<Amount value={transaction.amountRaw} />}
|
||||||
kind="positive"
|
kind="positive"
|
||||||
/>
|
/>
|
||||||
<Part
|
<Part
|
||||||
big
|
big
|
||||||
title={<i18n.Translate>Received amount</i18n.Translate>}
|
title={<i18n.Translate>Received amount</i18n.Translate>}
|
||||||
text={<Amount value={transaction.amountRaw} />}
|
text={<Amount value={transaction.amountEffective} />}
|
||||||
kind="neutral"
|
kind="neutral"
|
||||||
/>
|
/>
|
||||||
<Part
|
<Part
|
||||||
|
@ -44,6 +44,8 @@ import {
|
|||||||
KnownBankAccounts,
|
KnownBankAccounts,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
PreparePayResult,
|
PreparePayResult,
|
||||||
|
PrepareRefundRequest,
|
||||||
|
PrepareRefundResult,
|
||||||
PrepareTipRequest,
|
PrepareTipRequest,
|
||||||
PrepareTipResult,
|
PrepareTipResult,
|
||||||
RetryTransactionRequest,
|
RetryTransactionRequest,
|
||||||
@ -405,6 +407,11 @@ export function addExchange(req: AddExchangeRequest): Promise<void> {
|
|||||||
return callBackend("addExchange", req);
|
return callBackend("addExchange", req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prepareRefund(req: PrepareRefundRequest): Promise<PrepareRefundResult> {
|
||||||
|
return callBackend("prepareRefund", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> {
|
export function prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> {
|
||||||
return callBackend("prepareTip", req);
|
return callBackend("prepareTip", req);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user