withdraw call to action

This commit is contained in:
Sebastian 2022-08-10 11:50:46 -03:00
parent 7a600514c6
commit dce055d0d3
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
27 changed files with 344 additions and 418 deletions

View File

@ -837,7 +837,11 @@ export const NavigationHeader = styled.div`
} }
`; `;
export const SvgIcon = styled.div<{ color: string }>` export const SvgIcon = styled.div<{
title: string;
color: string;
onClick?: any;
}>`
& > svg { & > svg {
fill: ${({ color }) => color}; fill: ${({ color }) => color};
} }
@ -846,6 +850,7 @@ export const SvgIcon = styled.div<{ color: string }>`
margin-left: auto; margin-left: auto;
margin-right: 8px; margin-right: 8px;
padding: 4px; padding: 4px;
cursor: ${({ onClick }) => (onClick ? "pointer" : "inherit")};
`; `;
export const Icon = styled.div` export const Icon = styled.div`

View File

@ -28,6 +28,7 @@ import { CompletedView, LoadingUriView, ReadyView } from "./views.js";
export interface Props { export interface Props {
talerDepositUri: string | undefined, talerDepositUri: string | undefined,
amountStr: AmountString | undefined, amountStr: AmountString | undefined,
cancel: () => Promise<void>;
} }
export type State = export type State =
@ -53,6 +54,7 @@ export namespace State {
cost: AmountJson; cost: AmountJson;
effective: AmountJson; effective: AmountJson;
confirm: ButtonHandler; confirm: ButtonHandler;
cancel: () => Promise<void>;
} }
export interface Completed { export interface Completed {
status: "completed"; status: "completed";

View File

@ -22,7 +22,7 @@ import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ talerDepositUri, amountStr }: Props, { talerDepositUri, amountStr, cancel }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const [result, setResult] = useState<CreateDepositGroupResponse | undefined>( const [result, setResult] = useState<CreateDepositGroupResponse | undefined>(
@ -72,5 +72,6 @@ export function useComponentState(
.amount, .amount,
cost: deposit.totalDepositCost, cost: deposit.totalDepositCost,
effective: deposit.effectiveDepositAmount, effective: deposit.effectiveDepositAmount,
cancel,
}; };
} }

View File

@ -30,7 +30,7 @@ describe("Deposit CTA states", () => {
it("should tell the user that the URI is missing", async () => { it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerDepositUri: undefined, amountStr: undefined }, { useComponentState({ talerDepositUri: undefined, amountStr: undefined, cancel: async () => { null } }, {
prepareRefund: async () => ({}), prepareRefund: async () => ({}),
applyRefund: async () => ({}), applyRefund: async () => ({}),
onUpdateNotification: async () => ({}), onUpdateNotification: async () => ({}),
@ -61,7 +61,7 @@ describe("Deposit CTA states", () => {
it("should be ready after loading", async () => { it("should be ready after loading", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerDepositUri: "payto://refund/asdasdas", amountStr: "EUR:1" }, { useComponentState({ talerDepositUri: "payto://refund/asdasdas", amountStr: "EUR:1", cancel: async () => { null } }, {
prepareDeposit: async () => prepareDeposit: async () =>
({ ({
effectiveDepositAmount: Amounts.parseOrThrow("EUR:1"), effectiveDepositAmount: Amounts.parseOrThrow("EUR:1"),

View File

@ -100,7 +100,7 @@ export function ReadyView(state: State.Ready): VNode {
onClick={state.confirm.onClick} onClick={state.confirm.onClick}
> >
<i18n.Translate> <i18n.Translate>
Deposit {<Amount value={state.effective} />} Send &nbsp; {<Amount value={state.cost} />}
</i18n.Translate> </i18n.Translate>
</Button> </Button>
</section> </section>

View File

@ -28,7 +28,7 @@ import { LoadingUriView, BaseView } from "./views.js";
export interface Props { export interface Props {
talerPayUri?: string; talerPayUri?: string;
goToWalletManualWithdraw: (currency?: string) => Promise<void>; goToWalletManualWithdraw: (currency?: string) => Promise<void>;
goBack: () => Promise<void>; cancel: () => Promise<void>;
} }
export type State = export type State =
@ -56,7 +56,7 @@ export namespace State {
uri: string; uri: string;
error: undefined; error: undefined;
goToWalletManualWithdraw: (currency?: string) => Promise<void>; goToWalletManualWithdraw: (currency?: string) => Promise<void>;
goBack: () => Promise<void>; cancel: () => Promise<void>;
} }
export interface NoBalanceForCurrency extends BaseInfo { export interface NoBalanceForCurrency extends BaseInfo {
status: "no-balance-for-currency" status: "no-balance-for-currency"

View File

@ -24,7 +24,7 @@ import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ talerPayUri, goBack, goToWalletManualWithdraw }: Props, { talerPayUri, cancel, goToWalletManualWithdraw }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>( const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(
@ -82,7 +82,7 @@ export function useComponentState(
uri: hook.response.uri, uri: hook.response.uri,
amount, amount,
error: undefined, error: undefined,
goBack, goToWalletManualWithdraw cancel, goToWalletManualWithdraw
} }
if (!foundBalance) { if (!foundBalance) {

View File

@ -70,7 +70,7 @@ describe("Payment CTA states", () => {
it("should tell the user that the URI is missing", async () => { it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: undefined, goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: undefined, cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
} as Partial<typeof wxApi> as any), } as Partial<typeof wxApi> as any),
); );
@ -98,7 +98,7 @@ describe("Payment CTA states", () => {
it("should response with no balance", async () => { it("should response with no balance", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
preparePay: async () => preparePay: async () =>
({ ({
@ -133,7 +133,7 @@ describe("Payment CTA states", () => {
it("should not be able to pay if there is no enough balance", async () => { it("should not be able to pay if there is no enough balance", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
preparePay: async () => preparePay: async () =>
({ ({
@ -172,7 +172,7 @@ describe("Payment CTA states", () => {
it("should be able to pay (without fee)", async () => { it("should be able to pay (without fee)", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
preparePay: async () => preparePay: async () =>
({ ({
@ -214,7 +214,7 @@ describe("Payment CTA states", () => {
it("should be able to pay (with fee)", async () => { it("should be able to pay (with fee)", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
preparePay: async () => preparePay: async () =>
({ ({
@ -256,7 +256,7 @@ describe("Payment CTA states", () => {
it("should get confirmation done after pay successfully", async () => { it("should get confirmation done after pay successfully", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
preparePay: async () => preparePay: async () =>
({ ({
@ -317,7 +317,7 @@ describe("Payment CTA states", () => {
it("should not stay in ready state after pay with error", async () => { it("should not stay in ready state after pay with error", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: nullFunction, onUpdateNotification: nullFunction,
preparePay: async () => preparePay: async () =>
({ ({
@ -393,7 +393,7 @@ describe("Payment CTA states", () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, { useComponentState({ talerPayUri: "taller://pay", cancel: nullFunction, goToWalletManualWithdraw: nullFunction }, {
onUpdateNotification: subscriptions.saveSubscription, onUpdateNotification: subscriptions.saveSubscription,
preparePay: async () => preparePay: async () =>
({ ({

View File

@ -74,7 +74,7 @@ export function BaseView(state: SupportedStates): VNode {
? Amounts.parseOrThrow(state.payStatus.amountEffective) ? Amounts.parseOrThrow(state.payStatus.amountEffective)
: state.amount, : state.amount,
}; };
const totalFees = Amounts.sub(price.effective, price.raw).amount; // const totalFees = Amounts.sub(price.effective, price.raw).amount;
return ( return (
<WalletAction> <WalletAction>
@ -168,7 +168,7 @@ export function BaseView(state: SupportedStates): VNode {
goToWalletManualWithdraw={state.goToWalletManualWithdraw} goToWalletManualWithdraw={state.goToWalletManualWithdraw}
/> />
<section> <section>
<Link upperCased onClick={state.goBack}> <Link upperCased onClick={state.cancel}>
<i18n.Translate>Cancel</i18n.Translate> <i18n.Translate>Cancel</i18n.Translate>
</Link> </Link>
</section> </section>
@ -358,7 +358,7 @@ function ButtonsSection({
onClick={state.payHandler.onClick} onClick={state.payHandler.onClick}
> >
<i18n.Translate> <i18n.Translate>
Pay &nbsp; Send &nbsp;
{<Amount value={state.payStatus.amountEffective} />} {<Amount value={state.payStatus.amountEffective} />}
</i18n.Translate> </i18n.Translate>
</Button> </Button>

View File

@ -27,6 +27,7 @@ import { CompletedView, IgnoredView, InProgressView, LoadingUriView, ReadyView }
export interface Props { export interface Props {
talerRefundUri?: string; talerRefundUri?: string;
cancel: () => Promise<void>;
} }
export type State = export type State =
@ -64,6 +65,7 @@ export namespace State {
accept: ButtonHandler; accept: ButtonHandler;
ignore: ButtonHandler; ignore: ButtonHandler;
orderId: string; orderId: string;
cancel: () => Promise<void>;
} }
export interface Ignored extends BaseInfo { export interface Ignored extends BaseInfo {

View File

@ -22,7 +22,7 @@ import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ talerRefundUri }: Props, { talerRefundUri, cancel }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const [ignored, setIgnored] = useState(false); const [ignored, setIgnored] = useState(false);
@ -100,5 +100,6 @@ export function useComponentState(
ignore: { ignore: {
onClick: doIgnore, onClick: doIgnore,
}, },
cancel,
}; };
} }

View File

@ -33,7 +33,7 @@ describe("Refund CTA states", () => {
it("should tell the user that the URI is missing", async () => { it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerRefundUri: undefined }, { useComponentState({ talerRefundUri: undefined, cancel: async () => { null } }, {
prepareRefund: async () => ({}), prepareRefund: async () => ({}),
applyRefund: async () => ({}), applyRefund: async () => ({}),
onUpdateNotification: async () => ({}), onUpdateNotification: async () => ({}),
@ -64,7 +64,7 @@ describe("Refund CTA states", () => {
it("should be ready after loading", async () => { it("should be ready after loading", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { useComponentState({ talerRefundUri: "taler://refund/asdasdas", cancel: async () => { null } }, {
prepareRefund: async () => prepareRefund: async () =>
({ ({
effectivePaid: "EUR:2", effectivePaid: "EUR:2",
@ -113,7 +113,7 @@ describe("Refund CTA states", () => {
it("should be ignored after clicking the ignore button", async () => { it("should be ignored after clicking the ignore button", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { useComponentState({ talerRefundUri: "taler://refund/asdasdas", cancel: async () => { null } }, {
prepareRefund: async () => prepareRefund: async () =>
({ ({
effectivePaid: "EUR:2", effectivePaid: "EUR:2",
@ -189,7 +189,7 @@ describe("Refund CTA states", () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerRefundUri: "taler://refund/asdasdas" }, { useComponentState({ talerRefundUri: "taler://refund/asdasdas", cancel: async () => { null } }, {
prepareRefund: async () => prepareRefund: async () =>
({ ({
awaiting: Amounts.stringify(awaiting), awaiting: Amounts.stringify(awaiting),

View File

@ -20,7 +20,7 @@ import { Amount } from "../../components/Amount.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 { Part } from "../../components/Part.js";
import { SubTitle, WalletAction } from "../../components/styled/index.js"; import { Link, SubTitle, WalletAction } from "../../components/styled/index.js";
import { useTranslationContext } from "../../context/translation.js"; import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js"; import { Button } from "../../mui/Button.js";
import { ProductList } from "../Payment/views.js"; import { ProductList } from "../Payment/views.js";
@ -163,10 +163,21 @@ export function ReadyView(state: State.Ready): VNode {
</section> </section>
) : undefined} ) : undefined}
<section> <section>
<Button variant="contained" onClick={state.accept.onClick}> <Button
<i18n.Translate>Confirm refund</i18n.Translate> variant="contained"
color="success"
onClick={state.accept.onClick}
>
<i18n.Translate>
Receive &nbsp; <Amount value={state.amount} />
</i18n.Translate>
</Button> </Button>
</section> </section>
<section>
<Link upperCased onClick={state.cancel}>
<i18n.Translate>Cancel</i18n.Translate>
</Link>
</section>
</WalletAction> </WalletAction>
); );
} }

View File

@ -30,6 +30,7 @@ import { AcceptedView, IgnoredView, LoadingUriView, ReadyView } from "./views.js
export interface Props { export interface Props {
talerTipUri?: string; talerTipUri?: string;
cancel: () => Promise<void>;
} }
export type State = export type State =
@ -69,7 +70,7 @@ export namespace State {
export interface Ready extends BaseInfo { export interface Ready extends BaseInfo {
status: "ready"; status: "ready";
accept: ButtonHandler; accept: ButtonHandler;
ignore: ButtonHandler; cancel: () => Promise<void>;
} }
} }

View File

@ -22,7 +22,7 @@ import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ talerTipUri }: Props, { talerTipUri, cancel }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const [tipIgnored, setTipIgnored] = useState(false); const [tipIgnored, setTipIgnored] = useState(false);
@ -53,10 +53,6 @@ export function useComponentState(
tipInfo.retry(); tipInfo.retry();
}; };
const doIgnore = async (): Promise<void> => {
setTipIgnored(true);
};
const baseInfo = { const baseInfo = {
merchantBaseUrl: tip.merchantBaseUrl, merchantBaseUrl: tip.merchantBaseUrl,
exchangeBaseUrl: tip.exchangeBaseUrl, exchangeBaseUrl: tip.exchangeBaseUrl,
@ -84,9 +80,7 @@ export function useComponentState(
accept: { accept: {
onClick: doAccept, onClick: doAccept,
}, },
ignore: { cancel,
onClick: doIgnore,
},
}; };
} }

View File

@ -42,5 +42,4 @@ export const Ready = createExample(ReadyView, {
merchantBaseUrl: "http://merchant.url/", merchantBaseUrl: "http://merchant.url/",
exchangeBaseUrl: "http://exchange.url/", exchangeBaseUrl: "http://exchange.url/",
accept: {}, accept: {},
ignore: {},
}); });

View File

@ -30,7 +30,7 @@ describe("Tip CTA states", () => {
it("should tell the user that the URI is missing", async () => { it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerTipUri: undefined }, { useComponentState({ talerTipUri: undefined, cancel: async () => { null } }, {
prepareTip: async () => ({}), prepareTip: async () => ({}),
acceptTip: async () => ({}), acceptTip: async () => ({}),
} as any), } as any),
@ -62,7 +62,7 @@ describe("Tip CTA states", () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerTipUri: "taler://tip/asd" }, { useComponentState({ talerTipUri: "taler://tip/asd", cancel: async () => { null } }, {
prepareTip: async () => prepareTip: async () =>
({ ({
accepted: tipAccepted, accepted: tipAccepted,
@ -114,7 +114,7 @@ describe("Tip CTA states", () => {
it("should be ignored after clicking the ignore button", async () => { it("should be ignored after clicking the ignore button", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerTipUri: "taler://tip/asd" }, { useComponentState({ talerTipUri: "taler://tip/asd", cancel: async () => { null } }, {
prepareTip: async () => prepareTip: async () =>
({ ({
exchangeBaseUrl: "exchange url", exchangeBaseUrl: "exchange url",
@ -142,25 +142,25 @@ describe("Tip CTA states", () => {
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1"));
expect(state.merchantBaseUrl).eq("merchant url"); expect(state.merchantBaseUrl).eq("merchant url");
expect(state.exchangeBaseUrl).eq("exchange url"); expect(state.exchangeBaseUrl).eq("exchange url");
if (state.ignore.onClick === undefined) expect.fail(); // if (state.ignore.onClick === undefined) expect.fail();
state.ignore.onClick(); // state.ignore.onClick();
} }
await waitNextUpdate(); // await waitNextUpdate();
{ // {
const state = getLastResultOrThrow(); // const state = getLastResultOrThrow();
if (state.status !== "ignored") expect.fail(); // if (state.status !== "ignored") expect.fail();
if (state.error) expect.fail(); // if (state.error) expect.fail();
} // }
await assertNoPendingUpdate(); await assertNoPendingUpdate();
}); });
it("should render accepted if the tip has been used previously", async () => { it("should render accepted if the tip has been used previously", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerTipUri: "taler://tip/asd" }, { useComponentState({ talerTipUri: "taler://tip/asd", cancel: async () => { null } }, {
prepareTip: async () => prepareTip: async () =>
({ ({
accepted: true, accepted: true,

View File

@ -19,7 +19,7 @@ import { Amount } from "../../components/Amount.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 { Part } from "../../components/Part.js";
import { SubTitle, WalletAction } from "../../components/styled/index.js"; import { Link, SubTitle, WalletAction } from "../../components/styled/index.js";
import { useTranslationContext } from "../../context/translation.js"; import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js"; import { Button } from "../../mui/Button.js";
import { State } from "./index.js"; import { State } from "./index.js";
@ -69,7 +69,6 @@ export function ReadyView(state: State.Ready): VNode {
title={<i18n.Translate>Amount</i18n.Translate>} title={<i18n.Translate>Amount</i18n.Translate>}
text={<Amount value={state.amount} />} text={<Amount value={state.amount} />}
kind="positive" kind="positive"
big
/> />
<Part <Part
title={<i18n.Translate>Merchant URL</i18n.Translate>} title={<i18n.Translate>Merchant URL</i18n.Translate>}
@ -88,12 +87,16 @@ export function ReadyView(state: State.Ready): VNode {
color="success" color="success"
onClick={state.accept.onClick} onClick={state.accept.onClick}
> >
<i18n.Translate>Accept tip</i18n.Translate> <i18n.Translate>
</Button> Receive &nbsp; {<Amount value={state.amount} />}
<Button onClick={state.ignore.onClick}> </i18n.Translate>
<i18n.Translate>Ignore</i18n.Translate>
</Button> </Button>
</section> </section>
<section>
<Link upperCased onClick={state.cancel}>
<i18n.Translate>Cancel</i18n.Translate>
</Link>
</section>
</WalletAction> </WalletAction>
); );
} }

View File

@ -29,6 +29,7 @@ import { CompletedView, LoadingExchangeView, LoadingInfoView, LoadingUriView, Su
export interface Props { export interface Props {
talerWithdrawUri: string | undefined; talerWithdrawUri: string | undefined;
cancel: () => Promise<void>;
} }
export type State = export type State =
@ -67,13 +68,8 @@ export namespace State {
status: "success"; status: "success";
error: undefined; error: undefined;
exchange: SelectFieldHandler; exchangeUrl: string;
editExchange: ButtonHandler;
cancelEditExchange: ButtonHandler;
confirmEditExchange: ButtonHandler;
showExchangeSelection: boolean;
chosenAmount: AmountJson; chosenAmount: AmountJson;
withdrawalFee: AmountJson; withdrawalFee: AmountJson;
toBeReceived: AmountJson; toBeReceived: AmountJson;
@ -82,7 +78,9 @@ export namespace State {
tosProps?: TermsOfServiceSectionProps; tosProps?: TermsOfServiceSectionProps;
mustAcceptFirst: boolean; mustAcceptFirst: boolean;
ageRestriction: SelectFieldHandler; ageRestriction?: SelectFieldHandler;
cancel: () => Promise<void>;
}; };
} }

View File

@ -17,20 +17,16 @@
import { Amounts } from "@gnu-taler/taler-util"; import { Amounts } from "@gnu-taler/taler-util";
import { TalerError } from "@gnu-taler/taler-wallet-core"; import { TalerError } from "@gnu-taler/taler-wallet-core";
import { useMemo, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js";
import { buildTermsOfServiceState } from "../../utils/index.js"; import { buildTermsOfServiceState } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js"; import * as wxApi from "../../wxApi.js";
import { State, Props } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ talerWithdrawUri }: Props, { talerWithdrawUri, cancel }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const [customExchange, setCustomExchange] = useState<string | undefined>(
undefined,
);
const [ageRestricted, setAgeRestricted] = useState(0); const [ageRestricted, setAgeRestricted] = useState(0);
/** /**
@ -42,9 +38,8 @@ export function useComponentState(
const uriInfo = await api.getWithdrawalDetailsForUri({ const uriInfo = await api.getWithdrawalDetailsForUri({
talerWithdrawUri, talerWithdrawUri,
}); });
const { exchanges: knownExchanges } = await api.listExchanges(); const { amount, defaultExchangeBaseUrl } = uriInfo
return { amount, thisExchange: defaultExchangeBaseUrl };
return { uriInfo, knownExchanges };
}); });
/** /**
@ -55,65 +50,55 @@ export function useComponentState(
? undefined ? undefined
: uriInfoHook.response; : uriInfoHook.response;
const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => { // const { amount, thisExchange } = useMemo(() => {
if (!uriHookDep) // if (!uriHookDep)
return { // return {
amount: undefined, // amount: undefined,
thisExchange: undefined, // thisExchange: undefined,
thisCurrencyExchanges: [], // thisCurrencyExchanges: [],
}; // };
const { uriInfo, knownExchanges } = uriHookDep; // const { uriInfo } = uriHookDep;
const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined; // const amount = uriHookDep ? Amounts.parseOrThrow(uriHookDep.amount) : undefined;
const thisCurrencyExchanges = // const thisExchange = uriHookDep?.thisExchange;
!amount || !knownExchanges
? []
: knownExchanges.filter((ex) => ex.currency === amount.currency);
const thisExchange: string | undefined = // return { amount, thisExchange };
customExchange ?? // }, [uriHookDep]);
uriInfo?.defaultExchangeBaseUrl ??
(thisCurrencyExchanges && thisCurrencyExchanges[0]
? thisCurrencyExchanges[0].exchangeBaseUrl
: undefined);
return { amount, thisExchange, thisCurrencyExchanges };
}, [uriHookDep, customExchange]);
/** /**
* For the exchange selected, bring the status of the terms of service * For the exchange selected, bring the status of the terms of service
*/ */
const terms = useAsyncAsHook(async () => { const terms = useAsyncAsHook(async () => {
if (!thisExchange) return false; if (!uriHookDep?.thisExchange) return false;
const exchangeTos = await api.getExchangeTos(thisExchange, ["text/xml"]); const exchangeTos = await api.getExchangeTos(uriHookDep.thisExchange, ["text/xml"]);
const state = buildTermsOfServiceState(exchangeTos); const state = buildTermsOfServiceState(exchangeTos);
return { state }; return { state };
}, [thisExchange]); }, [uriHookDep]);
/** /**
* With the exchange and amount, ask the wallet the information * With the exchange and amount, ask the wallet the information
* about the withdrawal * about the withdrawal
*/ */
const info = useAsyncAsHook(async () => { const amountHook = useAsyncAsHook(async () => {
if (!thisExchange || !amount) return false; if (!uriHookDep?.thisExchange) return false;
const info = await api.getExchangeWithdrawalInfo({ const info = await api.getExchangeWithdrawalInfo({
exchangeBaseUrl: thisExchange, exchangeBaseUrl: uriHookDep?.thisExchange,
amount, amount: Amounts.parseOrThrow(uriHookDep.amount),
tosAcceptedFormat: ["text/xml"], tosAcceptedFormat: ["text/xml"],
}); });
const withdrawalFee = Amounts.sub( const withdrawAmount = {
Amounts.parseOrThrow(info.withdrawalAmountRaw), raw: Amounts.parseOrThrow(info.withdrawalAmountRaw),
Amounts.parseOrThrow(info.withdrawalAmountEffective), effective: Amounts.parseOrThrow(info.withdrawalAmountEffective),
).amount; }
return { info, withdrawalFee }; return { amount: withdrawAmount };
}, [thisExchange, amount]); }, [uriHookDep]);
const [reviewing, setReviewing] = useState<boolean>(false); const [reviewing, setReviewing] = useState<boolean>(false);
const [reviewed, setReviewed] = useState<boolean>(false); const [reviewed, setReviewed] = useState<boolean>(false);
@ -124,9 +109,6 @@ export function useComponentState(
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
const [withdrawCompleted, setWithdrawCompleted] = useState<boolean>(false); const [withdrawCompleted, setWithdrawCompleted] = useState<boolean>(false);
const [showExchangeSelection, setShowExchangeSelection] = useState(false);
const [nextExchange, setNextExchange] = useState<string | undefined>();
if (!uriInfoHook) return { status: "loading", error: undefined } if (!uriInfoHook) return { status: "loading", error: undefined }
if (uriInfoHook.hasError) { if (uriInfoHook.hasError) {
return { return {
@ -135,7 +117,10 @@ export function useComponentState(
}; };
} }
if (!thisExchange || !amount) { const { amount, thisExchange } = uriInfoHook.response
const chosenAmount = Amounts.parseOrThrow(amount);
if (!thisExchange) {
return { return {
status: "loading-exchange", status: "loading-exchange",
error: { error: {
@ -146,15 +131,17 @@ export function useComponentState(
}; };
} }
const selectedExchange = thisExchange; // const selectedExchange = thisExchange;
async function doWithdrawAndCheckError(): Promise<void> { async function doWithdrawAndCheckError(): Promise<void> {
if (!thisExchange) return;
try { try {
setDoingWithdraw(true); setDoingWithdraw(true);
if (!talerWithdrawUri) return; if (!talerWithdrawUri) return;
const res = await api.acceptWithdrawal( const res = await api.acceptWithdrawal(
talerWithdrawUri, talerWithdrawUri,
selectedExchange, thisExchange,
!ageRestricted ? undefined : ageRestricted, !ageRestricted ? undefined : ageRestricted,
); );
if (res.confirmTransferUrl) { if (res.confirmTransferUrl) {
@ -169,54 +156,27 @@ export function useComponentState(
setDoingWithdraw(false); setDoingWithdraw(false);
} }
const exchanges = thisCurrencyExchanges.reduce( if (!amountHook) {
(prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }),
{},
);
if (!info) {
return { status: "loading", error: undefined } return { status: "loading", error: undefined }
} }
if (info.hasError) { if (amountHook.hasError) {
return { return {
status: "loading-info", status: "loading-info",
error: info, error: amountHook,
}; };
} }
if (!info.response) { if (!amountHook.response) {
return { status: "loading", error: undefined }; return { status: "loading", error: undefined };
} }
if (withdrawCompleted) { if (withdrawCompleted) {
return { status: "completed", error: undefined }; return { status: "completed", error: undefined };
} }
const exchangeHandler: SelectFieldHandler = { const withdrawalFee = Amounts.sub(
onChange: async (e) => setNextExchange(e), amountHook.response.amount.raw,
value: nextExchange ?? thisExchange, amountHook.response.amount.effective,
list: exchanges, ).amount;
isDirty: nextExchange !== undefined, const toBeReceived = amountHook.response.amount.effective;
};
const editExchange: ButtonHandler = {
onClick: async () => {
setShowExchangeSelection(true);
},
};
const cancelEditExchange: ButtonHandler = {
onClick: async () => {
setShowExchangeSelection(false);
},
};
const confirmEditExchange: ButtonHandler = {
onClick: async () => {
setCustomExchange(exchangeHandler.value);
setShowExchangeSelection(false);
setNextExchange(undefined);
},
};
const { withdrawalFee } = info.response;
const toBeReceived = Amounts.sub(amount, withdrawalFee).amount;
const { state: termsState } = (!terms const { state: termsState } = (!terms
? undefined ? undefined
@ -225,11 +185,11 @@ export function useComponentState(
: terms.response) || { state: undefined }; : terms.response) || { state: undefined };
async function onAccept(accepted: boolean): Promise<void> { async function onAccept(accepted: boolean): Promise<void> {
if (!termsState) return; if (!termsState || !thisExchange) return;
try { try {
await api.setExchangeTosAccepted( await api.setExchangeTosAccepted(
selectedExchange, thisExchange,
accepted ? termsState.version : undefined, accepted ? termsState.version : undefined,
); );
setReviewed(accepted); setReviewed(accepted);
@ -253,22 +213,22 @@ export function useComponentState(
ageRestrictionOptions["0"] = "Not restricted"; ageRestrictionOptions["0"] = "Not restricted";
} }
//TODO: calculate based on exchange info
const ageRestrictionEnabled = false;
const ageRestriction = ageRestrictionEnabled ? {
list: ageRestrictionOptions,
value: String(ageRestricted),
onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)),
} : undefined;
return { return {
status: "success", status: "success",
error: undefined, error: undefined,
exchange: exchangeHandler, exchangeUrl: thisExchange,
editExchange,
cancelEditExchange,
confirmEditExchange,
showExchangeSelection,
toBeReceived, toBeReceived,
withdrawalFee, withdrawalFee,
chosenAmount: amount, chosenAmount,
ageRestriction: { ageRestriction,
list: ageRestrictionOptions,
value: String(ageRestricted),
onChange: async (v) => setAgeRestricted(parseInt(v, 10)),
},
doWithdrawal: { doWithdrawal: {
onClick: onClick:
doingWithdraw || (mustAcceptFirst && !reviewed) doingWithdraw || (mustAcceptFirst && !reviewed)
@ -286,6 +246,7 @@ export function useComponentState(
terms: termsState, terms: termsState,
}, },
mustAcceptFirst, mustAcceptFirst,
cancel,
}; };
} }

View File

@ -63,24 +63,13 @@ const ageRestrictionSelectField = {
export const TermsOfServiceNotYetLoaded = createExample(SuccessView, { export const TermsOfServiceNotYetLoaded = createExample(SuccessView, {
error: undefined, error: undefined,
status: "success", status: "success",
cancelEditExchange: nullHandler,
confirmEditExchange: nullHandler,
ageRestriction: ageRestrictionSelectField,
chosenAmount: { chosenAmount: {
currency: "USD", currency: "USD",
value: 2, value: 2,
fraction: 10000000, fraction: 10000000,
}, },
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
editExchange: nullHandler, exchangeUrl: "https://exchange.demo.taler.net",
exchange: {
list: exchangeList,
value: "exchange.demo.taler.net",
onChange: async () => {
null;
},
},
showExchangeSelection: false,
mustAcceptFirst: false, mustAcceptFirst: false,
withdrawalFee: { withdrawalFee: {
currency: "USD", currency: "USD",
@ -97,24 +86,13 @@ export const TermsOfServiceNotYetLoaded = createExample(SuccessView, {
export const WithSomeFee = createExample(SuccessView, { export const WithSomeFee = createExample(SuccessView, {
error: undefined, error: undefined,
status: "success", status: "success",
cancelEditExchange: nullHandler,
confirmEditExchange: nullHandler,
ageRestriction: ageRestrictionSelectField,
chosenAmount: { chosenAmount: {
currency: "USD", currency: "USD",
value: 2, value: 2,
fraction: 10000000, fraction: 10000000,
}, },
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
editExchange: nullHandler, exchangeUrl: "https://exchange.demo.taler.net",
exchange: {
list: exchangeList,
value: "exchange.demo.taler.net",
onChange: async () => {
null;
},
},
showExchangeSelection: false,
mustAcceptFirst: false, mustAcceptFirst: false,
withdrawalFee: { withdrawalFee: {
currency: "USD", currency: "USD",
@ -132,24 +110,13 @@ export const WithSomeFee = createExample(SuccessView, {
export const WithoutFee = createExample(SuccessView, { export const WithoutFee = createExample(SuccessView, {
error: undefined, error: undefined,
status: "success", status: "success",
cancelEditExchange: nullHandler,
confirmEditExchange: nullHandler,
ageRestriction: ageRestrictionSelectField,
chosenAmount: { chosenAmount: {
currency: "USD", currency: "USD",
value: 2, value: 2,
fraction: 10000000, fraction: 0,
}, },
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
editExchange: nullHandler, exchangeUrl: "https://exchange.demo.taler.net",
exchange: {
list: exchangeList,
value: "exchange.demo.taler.net",
onChange: async () => {
null;
},
},
showExchangeSelection: false,
mustAcceptFirst: false, mustAcceptFirst: false,
withdrawalFee: { withdrawalFee: {
currency: "USD", currency: "USD",
@ -167,24 +134,13 @@ export const WithoutFee = createExample(SuccessView, {
export const EditExchangeUntouched = createExample(SuccessView, { export const EditExchangeUntouched = createExample(SuccessView, {
error: undefined, error: undefined,
status: "success", status: "success",
cancelEditExchange: nullHandler,
confirmEditExchange: nullHandler,
ageRestriction: ageRestrictionSelectField,
chosenAmount: { chosenAmount: {
currency: "USD", currency: "USD",
value: 2, value: 2,
fraction: 10000000, fraction: 10000000,
}, },
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
editExchange: nullHandler, exchangeUrl: "https://exchange.demo.taler.net",
exchange: {
list: exchangeList,
value: "exchange.demo.taler.net",
onChange: async () => {
null;
},
},
showExchangeSelection: true,
mustAcceptFirst: false, mustAcceptFirst: false,
withdrawalFee: { withdrawalFee: {
currency: "USD", currency: "USD",
@ -202,25 +158,13 @@ export const EditExchangeUntouched = createExample(SuccessView, {
export const EditExchangeModified = createExample(SuccessView, { export const EditExchangeModified = createExample(SuccessView, {
error: undefined, error: undefined,
status: "success", status: "success",
cancelEditExchange: nullHandler,
confirmEditExchange: nullHandler,
ageRestriction: ageRestrictionSelectField,
chosenAmount: { chosenAmount: {
currency: "USD", currency: "USD",
value: 2, value: 2,
fraction: 10000000, fraction: 10000000,
}, },
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
editExchange: nullHandler, exchangeUrl: "https://exchange.demo.taler.net",
exchange: {
list: exchangeList,
isDirty: true,
value: "exchange.test.taler.net",
onChange: async () => {
null;
},
},
showExchangeSelection: true,
mustAcceptFirst: false, mustAcceptFirst: false,
withdrawalFee: { withdrawalFee: {
currency: "USD", currency: "USD",
@ -240,11 +184,9 @@ export const CompletedWithoutBankURL = createExample(CompletedView, {
error: undefined, error: undefined,
}); });
export const WithAgeRestrictionSelected = createExample(SuccessView, { export const WithAgeRestriction = createExample(SuccessView, {
error: undefined, error: undefined,
status: "success", status: "success",
cancelEditExchange: nullHandler,
confirmEditExchange: nullHandler,
ageRestriction: ageRestrictionSelectField, ageRestriction: ageRestrictionSelectField,
chosenAmount: { chosenAmount: {
currency: "USD", currency: "USD",
@ -252,15 +194,7 @@ export const WithAgeRestrictionSelected = createExample(SuccessView, {
fraction: 10000000, fraction: 10000000,
}, },
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
editExchange: nullHandler, exchangeUrl: "https://exchange.demo.taler.net",
exchange: {
list: exchangeList,
value: "exchange.demo.taler.net",
onChange: async () => {
null;
},
},
showExchangeSelection: false,
mustAcceptFirst: false, mustAcceptFirst: false,
withdrawalFee: { withdrawalFee: {
currency: "USD", currency: "USD",

View File

@ -44,7 +44,7 @@ describe("Withdraw CTA states", () => {
it("should tell the user that the URI is missing", async () => { it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerWithdrawUri: undefined }, { useComponentState({ talerWithdrawUri: undefined, cancel: async () => { null } }, {
listExchanges: async () => ({ exchanges }), listExchanges: async () => ({ exchanges }),
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({
amount: "ARS:2", amount: "ARS:2",
@ -76,7 +76,7 @@ describe("Withdraw CTA states", () => {
it("should tell the user that there is not known exchange", async () => { it("should tell the user that there is not known exchange", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { useComponentState({ talerWithdrawUri: "taler-withdraw://", cancel: async () => { null } }, {
listExchanges: async () => ({ exchanges }), listExchanges: async () => ({ exchanges }),
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({
amount: "EUR:2", amount: "EUR:2",
@ -110,17 +110,18 @@ describe("Withdraw CTA states", () => {
it("should be able to withdraw if tos are ok", async () => { it("should be able to withdraw if tos are ok", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { useComponentState({ talerWithdrawUri: "taler-withdraw://", cancel: async () => { null } }, {
listExchanges: async () => ({ exchanges }), listExchanges: async () => ({ exchanges }),
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({
amount: "ARS:2", amount: "ARS:2",
possibleExchanges: exchanges, possibleExchanges: exchanges,
defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl
}), }),
getExchangeWithdrawalInfo: getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> => async (): Promise<ExchangeWithdrawDetails> =>
({ ({
withdrawalAmountRaw: "ARS:5", withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:5", withdrawalAmountEffective: "ARS:2",
} as any), } as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text", contentType: "text",
@ -154,12 +155,12 @@ describe("Withdraw CTA states", () => {
expect(state.status).equals("success"); expect(state.status).equals("success");
if (state.status !== "success") return; if (state.status !== "success") return;
expect(state.exchange.isDirty).false; // expect(state.exchange.isDirty).false;
expect(state.exchange.value).equal("http://exchange.demo.taler.net"); // expect(state.exchange.value).equal("http://exchange.demo.taler.net");
expect(state.exchange.list).deep.equal({ // expect(state.exchange.list).deep.equal({
"http://exchange.demo.taler.net": "http://exchange.demo.taler.net", // "http://exchange.demo.taler.net": "http://exchange.demo.taler.net",
}); // });
expect(state.showExchangeSelection).false; // expect(state.showExchangeSelection).false;
expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
@ -175,17 +176,18 @@ describe("Withdraw CTA states", () => {
it("should be accept the tos before withdraw", async () => { it("should be accept the tos before withdraw", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() => mountHook(() =>
useComponentState({ talerWithdrawUri: "taler-withdraw://" }, { useComponentState({ talerWithdrawUri: "taler-withdraw://", cancel: async () => { null } }, {
listExchanges: async () => ({ exchanges }), listExchanges: async () => ({ exchanges }),
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({
amount: "ARS:2", amount: "ARS:2",
possibleExchanges: exchanges, possibleExchanges: exchanges,
defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl
}), }),
getExchangeWithdrawalInfo: getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> => async (): Promise<ExchangeWithdrawDetails> =>
({ ({
withdrawalAmountRaw: "ARS:5", withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:5", withdrawalAmountEffective: "ARS:2",
} as any), } as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text", contentType: "text",
@ -220,12 +222,12 @@ describe("Withdraw CTA states", () => {
expect(state.status).equals("success"); expect(state.status).equals("success");
if (state.status !== "success") return; if (state.status !== "success") return;
expect(state.exchange.isDirty).false; // expect(state.exchange.isDirty).false;
expect(state.exchange.value).equal("http://exchange.demo.taler.net"); // expect(state.exchange.value).equal("http://exchange.demo.taler.net");
expect(state.exchange.list).deep.equal({ // expect(state.exchange.list).deep.equal({
"http://exchange.demo.taler.net": "http://exchange.demo.taler.net", // "http://exchange.demo.taler.net": "http://exchange.demo.taler.net",
}); // });
expect(state.showExchangeSelection).false; // expect(state.showExchangeSelection).false;
expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
@ -245,12 +247,12 @@ describe("Withdraw CTA states", () => {
expect(state.status).equals("success"); expect(state.status).equals("success");
if (state.status !== "success") return; if (state.status !== "success") return;
expect(state.exchange.isDirty).false; // expect(state.exchange.isDirty).false;
expect(state.exchange.value).equal("http://exchange.demo.taler.net"); // expect(state.exchange.value).equal("http://exchange.demo.taler.net");
expect(state.exchange.list).deep.equal({ // expect(state.exchange.list).deep.equal({
"http://exchange.demo.taler.net": "http://exchange.demo.taler.net", // "http://exchange.demo.taler.net": "http://exchange.demo.taler.net",
}); // });
expect(state.showExchangeSelection).false; // expect(state.showExchangeSelection).false;
expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));

View File

@ -15,25 +15,26 @@
*/ */
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { State } from "./index.js";
import { useTranslationContext } from "../../context/translation.js";
import { Amount } from "../../components/Amount.js";
import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.js"; import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.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 { Part } from "../../components/Part.js";
import { SelectList } from "../../components/SelectList.js"; import { SelectList } from "../../components/SelectList.js";
import { import {
Input, Input,
LinkSuccess, Link,
SubTitle, SubTitle,
SuccessBox, SuccessBox,
SvgIcon,
WalletAction, WalletAction,
} from "../../components/styled/index.js"; } from "../../components/styled/index.js";
import { Amounts } from "@gnu-taler/taler-util"; import { useTranslationContext } from "../../context/translation.js";
import { TermsOfServiceSection } from "../TermsOfServiceSection.js";
import { Button } from "../../mui/Button.js"; import { Button } from "../../mui/Button.js";
import { ExchangeDetails, WithdrawDetails } from "../../wallet/Transaction.js";
import { TermsOfServiceSection } from "../TermsOfServiceSection.js";
import { State } from "./index.js";
import editIcon from "../../svg/edit_24px.svg";
import { Amount } from "../../components/Amount.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode { export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -115,77 +116,50 @@ export function SuccessView(state: State.Success): VNode {
/> />
)} )}
<section> <section style={{ textAlign: "left" }}>
<Part <Part
title={<i18n.Translate>Total to withdraw</i18n.Translate>} title={
text={<Amount value={state.toBeReceived} />} <div
kind="positive" style={{
/> display: "flex",
{Amounts.isNonZero(state.withdrawalFee) && ( }}
<Fragment> >
<Part <i18n.Translate>Exchange</i18n.Translate>
title={<i18n.Translate>Chosen amount</i18n.Translate>} <SvgIcon
text={<Amount value={state.chosenAmount} />} title="Edit"
kind="neutral" dangerouslySetInnerHTML={{ __html: editIcon }}
/> color="black"
<Part onClick={() => console.log("ok")}
title={<i18n.Translate>Exchange fee</i18n.Translate>} />
text={<Amount value={state.withdrawalFee} />} </div>
kind="negative" }
/> text={<ExchangeDetails exchange={state.exchangeUrl} />}
</Fragment>
)}
<Part
title={<i18n.Translate>Exchange</i18n.Translate>}
text={state.exchange.value}
kind="neutral" kind="neutral"
big big
/> />
{state.showExchangeSelection ? ( <Part
<Fragment> title={<i18n.Translate>Details</i18n.Translate>}
<div> text={
<SelectList <WithdrawDetails
label={<i18n.Translate>Known exchanges</i18n.Translate>} amount={{
list={state.exchange.list} effective: state.toBeReceived,
value={state.exchange.value} raw: state.chosenAmount,
name="switchingExchange" }}
onChange={state.exchange.onChange} />
/> }
</div> />
<LinkSuccess {state.ageRestriction && (
upperCased <Input>
style={{ fontSize: "small" }} <SelectList
onClick={state.confirmEditExchange.onClick} label={<i18n.Translate>Age restriction</i18n.Translate>}
> list={state.ageRestriction.list}
{state.exchange.isDirty ? ( name="age"
<i18n.Translate>Confirm exchange selection</i18n.Translate> value={state.ageRestriction.value}
) : ( onChange={state.ageRestriction.onChange}
<i18n.Translate>Cancel exchange selection</i18n.Translate> />
)} </Input>
</LinkSuccess>
</Fragment>
) : (
<LinkSuccess
style={{ fontSize: "small" }}
upperCased
onClick={state.editExchange.onClick}
>
<i18n.Translate>Edit exchange</i18n.Translate>
</LinkSuccess>
)} )}
</section> </section>
<section>
<Input>
<SelectList
label={<i18n.Translate>Age restriction</i18n.Translate>}
list={state.ageRestriction.list}
name="age"
maxWidth
value={state.ageRestriction.value}
onChange={state.ageRestriction.onChange}
/>
</Input>
</section>
{state.tosProps && <TermsOfServiceSection {...state.tosProps} />} {state.tosProps && <TermsOfServiceSection {...state.tosProps} />}
{state.tosProps ? ( {state.tosProps ? (
<section> <section>
@ -197,7 +171,9 @@ export function SuccessView(state: State.Success): VNode {
disabled={!state.doWithdrawal.onClick} disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick} onClick={state.doWithdrawal.onClick}
> >
<i18n.Translate>Confirm withdrawal</i18n.Translate> <i18n.Translate>
Receive &nbsp; <Amount value={state.toBeReceived} />
</i18n.Translate>
</Button> </Button>
)} )}
{state.tosProps.terms.status === "notfound" && ( {state.tosProps.terms.status === "notfound" && (
@ -216,6 +192,11 @@ export function SuccessView(state: State.Success): VNode {
<i18n.Translate>Loading terms of service...</i18n.Translate> <i18n.Translate>Loading terms of service...</i18n.Translate>
</section> </section>
)} )}
<section>
<Link upperCased onClick={state.cancel}>
<i18n.Translate>Cancel</i18n.Translate>
</Link>
</section>
</WalletAction> </WalletAction>
); );
} }

View File

@ -206,7 +206,7 @@ function ExampleList({
{list.map((k) => ( {list.map((k) => (
<li key={k.name}> <li key={k.name}>
<dl> <dl>
<dt>{k.name}</dt> <dt>{k.name.substring(k.name.indexOf("/") + 1)}</dt>
{k.examples.map((r) => { {k.examples.map((r) => {
const e = encodeURIComponent; const e = encodeURIComponent;
const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -76,33 +76,34 @@ export function Application(): VNode {
<IoCProviderForRuntime> <IoCProviderForRuntime>
{/* <Match/> won't work in the first render if <Router /> is not called first */} {/* <Match/> won't work in the first render if <Router /> is not called first */}
{/* https://github.com/preactjs/preact-router/issues/415 */} {/* https://github.com/preactjs/preact-router/issues/415 */}
<Router history={hash_history} /> <Router history={hash_history}>
<Match> <Match default>
{({ path }: { path: string }) => { {({ path }: { path: string }) => {
if (path && path.startsWith("/cta")) return; if (path && path.startsWith("/cta")) return;
return ( return (
<Fragment> <Fragment>
<LogoHeader /> <LogoHeader />
<WalletNavBar path={path} /> <WalletNavBar path={path} />
{shouldShowPendingOperations(path) && ( {shouldShowPendingOperations(path) && (
<div <div
style={{ style={{
backgroundColor: "lightcyan", backgroundColor: "lightcyan",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
}} }}
> >
<PendingTransactions <PendingTransactions
goToTransaction={(tid: string) => goToTransaction={(tid: string) =>
redirectTo(Pages.balanceTransaction({ tid })) redirectTo(Pages.balanceTransaction({ tid }))
} }
/> />
</div> </div>
)} )}
</Fragment> </Fragment>
); );
}} }}
</Match> </Match>
</Router>
<WalletBox> <WalletBox>
{globalNotification && ( {globalNotification && (
<SuccessBox onClick={clearNotification}> <SuccessBox onClick={clearNotification}>
@ -206,12 +207,28 @@ export function Application(): VNode {
goToWalletManualWithdraw={(currency?: string) => goToWalletManualWithdraw={(currency?: string) =>
redirectTo(Pages.balanceManualWithdraw({ currency })) redirectTo(Pages.balanceManualWithdraw({ currency }))
} }
goBack={() => redirectTo(Pages.balance)} cancel={() => redirectTo(Pages.balance)}
/>
<Route
path={Pages.ctaRefund}
component={RefundPage}
cancel={() => redirectTo(Pages.balance)}
/>
<Route
path={Pages.ctaTips}
component={TipPage}
cancel={() => redirectTo(Pages.balance)}
/>
<Route
path={Pages.ctaWithdraw}
component={WithdrawPage}
cancel={() => redirectTo(Pages.balance)}
/>
<Route
path={Pages.ctaDeposit}
component={DepositPageCTA}
cancel={() => redirectTo(Pages.balance)}
/> />
<Route path={Pages.ctaRefund} component={RefundPage} />
<Route path={Pages.ctaTips} component={TipPage} />
<Route path={Pages.ctaWithdraw} component={WithdrawPage} />
<Route path={Pages.ctaDeposit} component={DepositPageCTA} />
{/** {/**
* NOT FOUND * NOT FOUND

View File

@ -32,7 +32,6 @@ import {
TransactionRefund, TransactionRefund,
TransactionTip, TransactionTip,
TransactionType, TransactionType,
TransactionWithdrawal,
WithdrawalType, WithdrawalType,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { styled } from "@linaria/react"; import { styled } from "@linaria/react";
@ -308,7 +307,14 @@ export function TransactionView({
)} )}
<Part <Part
title={<i18n.Translate>Details</i18n.Translate>} title={<i18n.Translate>Details</i18n.Translate>}
text={<WithdrawDetails transaction={transaction} />} text={
<WithdrawDetails
amount={{
effective: Amounts.parseOrThrow(transaction.amountEffective),
raw: Amounts.parseOrThrow(transaction.amountRaw),
}}
/>
}
/> />
</TransactionTemplate> </TransactionTemplate>
); );
@ -713,10 +719,64 @@ function DeliveryDetails({
); );
} }
export function ExchangeDetails({ exchange }: { exchange: string }): VNode {
return (
<div>
<p style={{ marginTop: 0 }}>
<a rel="noreferrer" target="_blank" href={exchange}>
{exchange}
</a>
</p>
</div>
);
}
export interface AmountWithFee { export interface AmountWithFee {
effective: AmountJson; effective: AmountJson;
raw: AmountJson; raw: AmountJson;
} }
export function WithdrawDetails({ amount }: { amount: AmountWithFee }): VNode {
const { i18n } = useTranslationContext();
const fee = Amounts.sub(amount.raw, amount.effective).amount;
const maxFrac = [amount.raw, amount.effective, fee]
.map((a) => Amounts.maxFractionalDigits(a))
.reduce((c, p) => Math.max(c, p), 0);
return (
<PurchaseDetailsTable>
<tr>
<td>Withdraw</td>
<td>
<Amount value={amount.raw} maxFracSize={maxFrac} />
</td>
</tr>
{Amounts.isNonZero(fee) && (
<tr>
<td>Transaction fees</td>
<td>
<Amount value={fee} negative maxFracSize={maxFrac} />
</td>
</tr>
)}
<tr>
<td colSpan={2}>
<hr />
</td>
</tr>
<tr>
<td>Total</td>
<td>
<Amount value={amount.effective} maxFracSize={maxFrac} />
</td>
</tr>
</PurchaseDetailsTable>
);
}
export function PurchaseDetails({ export function PurchaseDetails({
price, price,
refund, refund,
@ -1020,53 +1080,6 @@ function TipDetails({ transaction }: { transaction: TransactionTip }): VNode {
); );
} }
function WithdrawDetails({
transaction,
}: {
transaction: TransactionWithdrawal;
}): VNode {
const { i18n } = useTranslationContext();
const r = Amounts.parseOrThrow(transaction.amountRaw);
const e = Amounts.parseOrThrow(transaction.amountEffective);
const fee = Amounts.sub(r, e).amount;
const maxFrac = [r, e, fee]
.map((a) => Amounts.maxFractionalDigits(a))
.reduce((c, p) => Math.max(c, p), 0);
return (
<PurchaseDetailsTable>
<tr>
<td>Withdraw</td>
<td>
<Amount value={transaction.amountRaw} maxFracSize={maxFrac} />
</td>
</tr>
{Amounts.isNonZero(fee) && (
<tr>
<td>Transaction fees</td>
<td>
<Amount value={fee} negative maxFracSize={maxFrac} />
</td>
</tr>
)}
<tr>
<td colSpan={2}>
<hr />
</td>
</tr>
<tr>
<td>Total</td>
<td>
<Amount value={transaction.amountEffective} maxFracSize={maxFrac} />
</td>
</tr>
</PurchaseDetailsTable>
);
}
function Header({ function Header({
timestamp, timestamp,
total, total,