merchant details and contract terms details factored out, to be used by other components
tests and stories updated
payment completed != confirmed (confirmed if paid by someone else)
This commit is contained in:
Sebastian 2022-08-08 14:09:28 -03:00
parent 4409d8384b
commit 7a600514c6
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
17 changed files with 1128 additions and 307 deletions

View File

@ -0,0 +1,91 @@
/*
This file is part of GNU Taler
(C) 2022 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/>
*/
import { styled } from "@linaria/react";
import { ComponentChildren, h, VNode } from "preact";
import { ButtonHandler } from "../mui/handlers.js";
import closeIcon from "../svg/close_24px.svg";
import { Link, LinkPrimary, LinkWarning } from "./styled/index.js";
interface Props {
children: ComponentChildren;
onClose: ButtonHandler;
title: string;
}
const FullSize = styled.div`
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
z-index: 10;
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
height: 5%;
vertical-align: center;
align-items: center;
`;
const Body = styled.div`
height: 95%;
`;
export function Modal({ title, children, onClose }: Props): VNode {
return (
<FullSize onClick={onClose?.onClick}>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: "white",
width: 600,
height: "80%",
margin: "auto",
borderRadius: 8,
padding: 8,
// overflow: "scroll",
}}
>
<Header>
<div>
<h2>{title}</h2>
</div>
<Link onClick={onClose?.onClick}>
<div
style={{
height: 24,
width: 24,
marginLeft: 4,
marginRight: 4,
// fill: "white",
}}
dangerouslySetInnerHTML={{ __html: closeIcon }}
/>
</Link>
</Header>
<hr />
<Body onClick={(e: any) => e.stopPropagation()}>{children}</Body>
</div>
</FullSize>
);
}

View File

@ -0,0 +1,116 @@
/*
This file is part of GNU Taler
(C) 2022 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 { WalletContractData } from "@gnu-taler/taler-wallet-core";
import { createExample } from "../test-utils.js";
import {
ErrorView,
HiddenView,
LoadingView,
ShowView,
} from "./ShowFullContractTermPopup.js";
export default {
title: "component/ShowFullContractTermPopup",
};
const cd: WalletContractData = {
amount: {
currency: "ARS",
fraction: 0,
value: 2,
},
contractTermsHash:
"92X0KSJPZ8XS2XECCGFWTCGW8XMFCXTT2S6WHZDP6H9Y3TSKMTHY94WXEWDERTNN5XWCYGW4VN5CF2D4846HXTW7P06J4CZMHCWKC9G",
fulfillmentUrl: "",
merchantBaseUrl: "https://merchant-backend.taler.ar/",
merchantPub: "JZYHJ13M91GMSQMT75J8Q6ZN0QP8XF8CRHR7K5MMWYE8JQB6AAPG",
merchantSig:
"0YA1WETV15R6K8QKS79QA3QMT16010F42Q49VSKYQ71HVQKAG0A4ZJCA4YTKHE9EA5SP156TJSKZEJJJ87305N6PS80PC48RNKYZE08",
orderId: "2022.220-0281XKKB8W7YE",
summary: "w",
maxWireFee: {
currency: "ARS",
fraction: 0,
value: 1,
},
payDeadline: {
t_s: 1660002673,
},
refundDeadline: {
t_s: 1660002673,
},
wireFeeAmortization: 1,
allowedAuditors: [
{
auditorBaseUrl: "https://auditor.taler.ar/",
auditorPub: "0000000000000000000000000000000000000000000000000000",
},
],
allowedExchanges: [
{
exchangeBaseUrl: "https://exchange.taler.ar/",
exchangePub: "1C2EYE90PYDNVRTQ25A3PA0KW5W4WPAJNNQHVHV49PT6W5CERFV0",
},
],
timestamp: {
t_s: 1659972710,
},
wireMethod: "x-taler-bank",
wireInfoHash:
"QDT28374ZHYJ59WQFZ3TW1D5WKJVDYHQT86VHED3TNMB15ANJSKXDYPPNX01348KDYCX6T4WXA5A8FJJ8YWNEB1JW726C1JPKHM89DR",
maxDepositFee: {
currency: "ARS",
fraction: 0,
value: 1,
},
merchant: {
name: "Default",
address: {
country: "ar",
},
jurisdiction: {
country: "ar",
},
},
products: [],
autoRefund: undefined,
summaryI18n: undefined,
deliveryDate: undefined,
deliveryLocation: undefined,
};
export const ShowingSimpleOrder = createExample(ShowView, {
contractTerms: cd,
});
export const Error = createExample(ErrorView, {
proposalId: "asd",
error: {
hasError: true,
message: "message",
operational: false,
// details: {
// code: 123,
// },
},
});
export const Loading = createExample(LoadingView, {});
export const Hidden = createExample(HiddenView, {});

View File

@ -0,0 +1,385 @@
/*
This file is part of GNU Taler
(C) 2022 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/>
*/
import { AbsoluteTime, Duration, Location } from "@gnu-taler/taler-util";
import { WalletContractData } from "@gnu-taler/taler-wallet-core";
import { styled } from "@linaria/react";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { Modal } from "../components/Modal.js";
import { Time } from "../components/Time.js";
import { useTranslationContext } from "../context/translation.js";
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { ButtonHandler } from "../mui/handlers.js";
import { compose, StateViewMap } from "../utils/index.js";
import * as wxApi from "../wxApi.js";
import { Amount } from "./Amount.js";
import { Link, LinkPrimary } from "./styled/index.js";
const ContractTermsTable = styled.table`
width: 100%;
border-spacing: 0px;
& > tr > td {
padding: 5px;
}
& > tr > td:nth-child(2n) {
text-align: right;
}
& > tr:nth-child(2n) {
background: #ebebeb;
}
`;
function locationAsText(l: Location | undefined): VNode {
if (!l) return <span />;
const lines = [
...(l.address_lines || []).map((e) => [e]),
[l.town_location, l.town, l.street],
[l.building_name, l.building_number],
[l.country, l.country_subdivision],
[l.district, l.post_code],
];
//remove all missing value
//then remove all empty lines
const curated = lines
.map((l) => l.filter((v) => !!v))
.filter((l) => l.length > 0);
return (
<span>
{curated.map((c, i) => (
<div key={i}>{c.join(",")}</div>
))}
</span>
);
}
type State = States.Loading | States.Error | States.Hidden | States.Show;
namespace States {
export interface Loading {
status: "loading";
hideHandler: ButtonHandler;
}
export interface Error {
status: "error";
proposalId: string;
error: HookError;
hideHandler: ButtonHandler;
}
export interface Hidden {
status: "hidden";
showHandler: ButtonHandler;
}
export interface Show {
status: "show";
hideHandler: ButtonHandler;
contractTerms: WalletContractData;
}
}
interface Props {
proposalId: string;
}
function useComponentState({ proposalId }: Props, api: typeof wxApi): State {
const [show, setShow] = useState(false);
const hook = useAsyncAsHook(async () => {
if (!show) return undefined;
return await api.getContractTermsDetails(proposalId);
}, [show]);
const hideHandler = {
onClick: async () => setShow(false),
};
const showHandler = {
onClick: async () => setShow(true),
};
if (!show) {
return {
status: "hidden",
showHandler,
};
}
if (!hook) return { status: "loading", hideHandler };
if (hook.hasError)
return { status: "error", proposalId, error: hook, hideHandler };
if (!hook.response) return { status: "loading", hideHandler };
return {
status: "show",
contractTerms: hook.response,
hideHandler,
};
}
const viewMapping: StateViewMap<State> = {
loading: LoadingView,
error: ErrorView,
show: ShowView,
hidden: HiddenView,
};
export const ShowFullContractTermPopup = compose(
"ShowFullContractTermPopup",
(p: Props) => useComponentState(p, wxApi),
viewMapping,
);
export function LoadingView({ hideHandler }: States.Loading): VNode {
return (
<Modal title="Full detail" onClose={hideHandler}>
<Loading />
</Modal>
);
}
export function ErrorView({
hideHandler,
error,
proposalId,
}: States.Error): VNode {
const { i18n } = useTranslationContext();
return (
<Modal title="Full detail" onClose={hideHandler}>
<LoadingError
title={
<i18n.Translate>
Could not load purchase proposal details
</i18n.Translate>
}
error={error}
/>
</Modal>
);
}
export function HiddenView({ showHandler }: States.Hidden): VNode {
return <Link onClick={showHandler?.onClick}>Show full details</Link>;
}
export function ShowView({ contractTerms, hideHandler }: States.Show): VNode {
const createdAt = AbsoluteTime.fromTimestamp(contractTerms.timestamp);
return (
<Modal title="Full detail" onClose={hideHandler}>
<div style={{ overflowY: "auto", height: "95%", padding: 5 }}>
<ContractTermsTable>
<tr>
<td>Order Id</td>
<td>{contractTerms.orderId}</td>
</tr>
<tr>
<td>Summary</td>
<td>{contractTerms.summary}</td>
</tr>
<tr>
<td>Amount</td>
<td>
<Amount value={contractTerms.amount} />
</td>
</tr>
<tr>
<td>Merchant name</td>
<td>{contractTerms.merchant.name}</td>
</tr>
<tr>
<td>Merchant jurisdiction</td>
<td>{locationAsText(contractTerms.merchant.jurisdiction)}</td>
</tr>
<tr>
<td>Merchant address</td>
<td>{locationAsText(contractTerms.merchant.address)}</td>
</tr>
<tr>
<td>Merchant logo</td>
<td>
<div>
<img
src={contractTerms.merchant.logo}
style={{ width: 64, height: 64, margin: 4 }}
/>
</div>
</td>
</tr>
<tr>
<td>Merchant website</td>
<td>{contractTerms.merchant.website}</td>
</tr>
<tr>
<td>Merchant email</td>
<td>{contractTerms.merchant.email}</td>
</tr>
<tr>
<td>Merchant public key</td>
<td>
<span title={contractTerms.merchantPub}>
{contractTerms.merchantPub.substring(0, 6)}...
</span>
</td>
</tr>
<tr>
<td>Delivery date</td>
<td>
{contractTerms.deliveryDate && (
<Time
timestamp={AbsoluteTime.fromTimestamp(
contractTerms.deliveryDate,
)}
format="dd MMMM yyyy, HH:mm"
/>
)}
</td>
</tr>
<tr>
<td>Delivery location</td>
<td>{locationAsText(contractTerms.deliveryLocation)}</td>
</tr>
<tr>
<td>Products</td>
<td>
{!contractTerms.products || contractTerms.products.length === 0
? "none"
: contractTerms.products
.map((p) => `${p.description} x ${p.quantity}`)
.join(", ")}
</td>
</tr>
<tr>
<td>Created at</td>
<td>
{contractTerms.timestamp && (
<Time
timestamp={AbsoluteTime.fromTimestamp(
contractTerms.timestamp,
)}
format="dd MMMM yyyy, HH:mm"
/>
)}
</td>
</tr>
<tr>
<td>Refund deadline</td>
<td>
{
<Time
timestamp={AbsoluteTime.fromTimestamp(
contractTerms.refundDeadline,
)}
format="dd MMMM yyyy, HH:mm"
/>
}
</td>
</tr>
<tr>
<td>Auto refund</td>
<td>
{
<Time
timestamp={AbsoluteTime.addDuration(
createdAt,
!contractTerms.autoRefund
? Duration.getZero()
: Duration.fromTalerProtocolDuration(
contractTerms.autoRefund,
),
)}
format="dd MMMM yyyy, HH:mm"
/>
}
</td>
</tr>
<tr>
<td>Pay deadline</td>
<td>
{
<Time
timestamp={AbsoluteTime.fromTimestamp(
contractTerms.payDeadline,
)}
format="dd MMMM yyyy, HH:mm"
/>
}
</td>
</tr>
<tr>
<td>Fulfillment URL</td>
<td>{contractTerms.fulfillmentUrl}</td>
</tr>
<tr>
<td>Fulfillment message</td>
<td>{contractTerms.fulfillmentMessage}</td>
</tr>
{/* <tr>
<td>Public reorder URL</td>
<td>{contractTerms.public_reorder_url}</td>
</tr> */}
<tr>
<td>Max deposit fee</td>
<td>
<Amount value={contractTerms.maxDepositFee} />
</td>
</tr>
<tr>
<td>Max fee</td>
<td>
<Amount value={contractTerms.maxWireFee} />
</td>
</tr>
<tr>
<td>Minimum age</td>
<td>{contractTerms.minimumAge}</td>
</tr>
{/* <tr>
<td>Extra</td>
<td>
<pre>{contractTerms.}</pre>
</td>
</tr> */}
<tr>
<td>Wire fee amortization</td>
<td>{contractTerms.wireFeeAmortization}</td>
</tr>
<tr>
<td>Auditors</td>
<td>
{(contractTerms.allowedAuditors || []).map((e) => (
<Fragment key={e.auditorPub}>
<a href={e.auditorBaseUrl} title={e.auditorPub}>
{e.auditorPub.substring(0, 6)}...
</a>
&nbsp;
</Fragment>
))}
</td>
</tr>
<tr>
<td>Exchanges</td>
<td>
{(contractTerms.allowedExchanges || []).map((e) => (
<Fragment key={e.exchangePub}>
<a href={e.exchangeBaseUrl} title={e.exchangePub}>
{e.exchangePub.substring(0, 6)}...
</a>
&nbsp;
</Fragment>
))}
</td>
</tr>
</ContractTermsTable>
</div>
</Modal>
);
}

View File

@ -19,8 +19,9 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import * as a1 from "./Banner.stories.js";
import * as a1 from "./Banner.stories.js";
import * as a2 from "./PendingTransactions.stories.js";
import * as a3 from "./Amount.stories.js";
import * as a4 from "./ShowFullContractTermPopup.stories.js";
export default [a1, a2, a3];
export default [a1, a2, a3, a4];

View File

@ -40,8 +40,18 @@ export const WalletAction = styled.div`
& h1:first-child {
margin-top: 0;
}
& > * {
width: 600px;
}
section {
margin-bottom: 2em;
table td {
padding: 5px 5px;
}
table tr {
border-bottom: 1px solid black;
border-top: 1px solid black;
}
button {
margin-right: 8px;
margin-left: 8px;

View File

@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { AmountJson, ConfirmPayResult, PreparePayResult } from "@gnu-taler/taler-util";
import { AmountJson, ConfirmPayResult, PreparePayResult, PreparePayResultAlreadyConfirmed, PreparePayResultInsufficientBalance, PreparePayResultPaymentPossible } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import { ButtonHandler } from "../../mui/handlers.js";
@ -37,6 +37,7 @@ export type State =
| State.Ready
| State.NoEnoughBalance
| State.NoBalanceForCurrency
| State.Completed
| State.Confirmed;
export namespace State {
@ -52,8 +53,6 @@ export namespace State {
interface BaseInfo {
amount: AmountJson;
totalFees: AmountJson;
payStatus: PreparePayResult;
uri: string;
error: undefined;
goToWalletManualWithdraw: (currency?: string) => Promise<void>;
@ -61,20 +60,30 @@ export namespace State {
}
export interface NoBalanceForCurrency extends BaseInfo {
status: "no-balance-for-currency"
payStatus: PreparePayResult;
balance: undefined;
}
export interface NoEnoughBalance extends BaseInfo {
status: "no-enough-balance"
payStatus: PreparePayResult;
balance: AmountJson;
}
export interface Ready extends BaseInfo {
status: "ready";
payStatus: PreparePayResultPaymentPossible;
payHandler: ButtonHandler;
balance: AmountJson;
}
export interface Confirmed extends BaseInfo {
status: "confirmed";
payStatus: PreparePayResultAlreadyConfirmed;
balance: AmountJson;
}
export interface Completed extends BaseInfo {
status: "completed";
payStatus: PreparePayResult;
payResult: ConfirmPayResult;
payHandler: ButtonHandler;
balance: AmountJson;
@ -87,6 +96,7 @@ const viewMapping: StateViewMap<State> = {
"no-balance-for-currency": BaseView,
"no-enough-balance": BaseView,
confirmed: BaseView,
completed: BaseView,
ready: BaseView,
};

View File

@ -78,20 +78,9 @@ export function useComponentState(
(b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
);
let totalFees = Amounts.getZero(amount.currency);
if (payStatus.status === PreparePayResultType.PaymentPossible) {
const amountEffective: AmountJson = Amounts.parseOrThrow(
payStatus.amountEffective,
);
totalFees = Amounts.sub(amountEffective, amount).amount;
}
const baseResult = {
uri: hook.response.uri,
amount,
totalFees,
payStatus,
error: undefined,
goBack, goToWalletManualWithdraw
}
@ -100,12 +89,45 @@ export function useComponentState(
return {
status: "no-balance-for-currency",
balance: undefined,
payStatus,
...baseResult,
}
}
const foundAmount = Amounts.parseOrThrow(foundBalance.available);
if (payResult) {
return {
status: "completed",
balance: foundAmount,
payStatus,
payHandler: {
error: payErrMsg,
},
payResult,
...baseResult,
};
}
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
return {
status: 'no-enough-balance',
balance: foundAmount,
payStatus,
...baseResult,
}
}
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
return {
status: "confirmed",
balance: foundAmount,
payStatus,
...baseResult,
};
}
async function doPayment(): Promise<void> {
try {
if (payStatus.status !== "payment-possible") {
@ -138,34 +160,19 @@ export function useComponentState(
}
}
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
return {
status: 'no-enough-balance',
balance: foundAmount,
...baseResult,
}
}
const payHandler: ButtonHandler = {
onClick: payErrMsg ? undefined : doPayment,
error: payErrMsg,
};
if (!payResult) {
return {
status: "ready",
payHandler,
...baseResult,
balance: foundAmount
};
}
// (payStatus.status === PreparePayResultType.PaymentPossible)
return {
status: "confirmed",
balance: foundAmount,
payResult,
payHandler: {},
status: "ready",
payHandler,
payStatus,
...baseResult,
balance: foundAmount
};
}

View File

@ -21,11 +21,14 @@
import {
Amounts,
ConfirmPayResultType,
ContractTerms,
PreparePayResultType,
} from "@gnu-taler/taler-util";
import merchantIcon from "../../../static-dev/merchant-icon.jpeg";
import { createExample } from "../../test-utils.js";
import { BaseView } from "./views.js";
import beer from "../../../static-dev/beer.png";
export default {
title: "cta/payment",
@ -34,25 +37,22 @@ export default {
};
export const NoBalance = createExample(BaseView, {
status: "ready",
status: "no-balance-for-currency",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: undefined,
payHandler: {
onClick: async () => {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0"),
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
contractTerms: {
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
summary: "some beers",
amount: "USD:10",
@ -62,7 +62,7 @@ export const NoBalance = createExample(BaseView, {
});
export const NoEnoughBalance = createExample(BaseView, {
status: "ready",
status: "no-enough-balance",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
@ -70,21 +70,18 @@ export const NoEnoughBalance = createExample(BaseView, {
fraction: 40000000,
value: 9,
},
payHandler: {
onClick: async () => {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0"),
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
contractTerms: {
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
summary: "some beers",
amount: "USD:10",
@ -94,7 +91,7 @@ export const NoEnoughBalance = createExample(BaseView, {
});
export const EnoughBalanceButRestricted = createExample(BaseView, {
status: "ready",
status: "no-enough-balance",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
@ -102,21 +99,18 @@ export const EnoughBalanceButRestricted = createExample(BaseView, {
fraction: 40000000,
value: 19,
},
payHandler: {
onClick: async () => {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0"),
uri: "",
payStatus: {
status: PreparePayResultType.InsufficientBalance,
noncePriv: "",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
contractTerms: {
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
summary: "some beers",
amount: "USD:10",
@ -139,7 +133,6 @@ export const PaymentPossible = createExample(BaseView, {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0"),
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
@ -150,13 +143,19 @@ export const PaymentPossible = createExample(BaseView, {
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
pay_deadline: {
t_s: new Date().getTime() / 1000 + 60 * 60 * 3,
},
amount: "USD:10",
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
},
});
@ -174,7 +173,6 @@ export const PaymentPossibleWithFee = createExample(BaseView, {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
@ -185,18 +183,19 @@ export const PaymentPossibleWithFee = createExample(BaseView, {
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
amount: "USD:10",
summary: "some beers",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
},
});
import beer from "../../../static-dev/beer.png";
export const TicketWithAProductList = createExample(BaseView, {
status: "ready",
error: undefined,
@ -211,7 +210,6 @@ export const TicketWithAProductList = createExample(BaseView, {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
@ -222,7 +220,10 @@ export const TicketWithAProductList = createExample(BaseView, {
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
amount: "USD:10",
summary: "some beers",
@ -247,11 +248,11 @@ export const TicketWithAProductList = createExample(BaseView, {
],
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
},
});
export const AlreadyConfirmedByOther = createExample(BaseView, {
export const TicketWithShipping = createExample(BaseView, {
status: "ready",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
@ -265,7 +266,52 @@ export const AlreadyConfirmedByOther = createExample(BaseView, {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
status: PreparePayResultType.PaymentPossible,
amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
amount: "USD:10",
summary: "banana pi set",
products: [
{
description: "banana pi",
price: "USD:2",
quantity: 1,
},
],
delivery_date: {
t_s: new Date().getTime() / 1000 + 30 * 24 * 60 * 60,
},
delivery_location: {
town: "Liverpool",
street: "Down st 1234",
},
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
},
});
export const AlreadyConfirmedByOther = createExample(BaseView, {
status: "confirmed",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
currency: "USD",
fraction: 40000000,
value: 11,
},
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payStatus: {
@ -274,19 +320,22 @@ export const AlreadyConfirmedByOther = createExample(BaseView, {
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
paid: false,
},
});
export const AlreadyPaidWithoutFulfillment = createExample(BaseView, {
status: "ready",
status: "completed",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
@ -294,33 +343,34 @@ export const AlreadyPaidWithoutFulfillment = createExample(BaseView, {
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payResult: {
type: ConfirmPayResultType.Done,
contractTerms: {} as any,
},
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
paid: true,
},
});
export const AlreadyPaidWithFulfillment = createExample(BaseView, {
status: "ready",
status: "completed",
error: undefined,
amount: Amounts.parseOrThrow("USD:10"),
balance: {
@ -328,29 +378,34 @@ export const AlreadyPaidWithFulfillment = createExample(BaseView, {
fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
},
},
totalFees: Amounts.parseOrThrow("USD:0.20"),
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
payResult: {
type: ConfirmPayResultType.Done,
contractTerms: {
fulfillment_message: "thanks for buying!",
fulfillment_url: "https://demo.taler.net",
} as Partial<ContractTerms> as any,
},
payStatus: {
status: PreparePayResultType.AlreadyConfirmed,
amountEffective: "USD:10",
amountRaw: "USD:10",
contractTerms: {
merchant: {
name: "someone",
name: "the merchant",
logo: merchantIcon,
website: "https://www.themerchant.taler",
email: "contact@merchant.taler",
},
fulfillment_url: "https://demo.taler.net",
fulfillment_message:
"congratulations! you are looking at the fulfillment message! ",
summary: "some beers",
amount: "USD:10",
} as Partial<ContractTerms> as any,
contractTermsHash: "123456",
proposalId: "proposal1234",
proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
paid: true,
},
});

View File

@ -204,7 +204,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:0"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:0"));
expect(r.payHandler.onClick).not.undefined;
}
@ -246,7 +246,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
expect(r.payHandler.onClick).not.undefined;
}
@ -293,7 +293,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
if (r.payHandler.onClick === undefined) expect.fail();
r.payHandler.onClick();
}
@ -302,13 +302,13 @@ describe("Payment CTA states", () => {
{
const r = getLastResultOrThrow();
if (r.status !== "confirmed") expect.fail();
if (r.status !== "completed") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
if (r.payResult.type !== ConfirmPayResultType.Done) expect.fail();
expect(r.payResult.contractTerms).not.undefined;
expect(r.payHandler.onClick).undefined;
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// if (r.payResult.type !== ConfirmPayResultType.Done) expect.fail();
// expect(r.payResult.contractTerms).not.undefined;
// expect(r.payHandler.onClick).undefined;
}
await assertNoPendingUpdate();
@ -354,7 +354,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
if (r.payHandler.onClick === undefined) expect.fail();
r.payHandler.onClick();
}
@ -366,7 +366,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
expect(r.payHandler.onClick).undefined;
if (r.payHandler.error === undefined) expect.fail();
//FIXME: error message here is bad
@ -425,7 +425,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:10"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
expect(r.payHandler.onClick).not.undefined;
notifyCoinWithdrawn(Amounts.parseOrThrow("USD:5"));
@ -438,7 +438,7 @@ describe("Payment CTA states", () => {
if (r.status !== "ready") expect.fail();
expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
// expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
expect(r.payHandler.onClick).not.undefined;
}

View File

@ -15,6 +15,7 @@
*/
import {
AbsoluteTime,
Amounts,
ConfirmPayResultType,
ContractTerms,
@ -38,8 +39,10 @@ import {
WalletAction,
WarningBox,
} from "../../components/styled/index.js";
import { Time } from "../../components/Time.js";
import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js";
import { MerchantDetails, PurchaseDetails } from "../../wallet/Transaction.js";
import { State } from "./index.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
@ -56,6 +59,7 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode {
type SupportedStates =
| State.Ready
| State.Confirmed
| State.Completed
| State.NoBalanceForCurrency
| State.NoEnoughBalance;
@ -63,6 +67,15 @@ export function BaseView(state: SupportedStates): VNode {
const { i18n } = useTranslationContext();
const contractTerms: ContractTerms = state.payStatus.contractTerms;
const price = {
raw: state.amount,
effective:
"amountEffective" in state.payStatus
? Amounts.parseOrThrow(state.payStatus.amountEffective)
: state.amount,
};
const totalFees = Amounts.sub(price.effective, price.raw).amount;
return (
<WalletAction>
<LogoHeader />
@ -73,9 +86,9 @@ export function BaseView(state: SupportedStates): VNode {
<ShowImportantMessage state={state} />
<section>
{state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
Amounts.isNonZero(state.totalFees) && (
<section style={{ textAlign: "left" }}>
{/* {state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
Amounts.isNonZero(totalFees) && (
<Part
big
title={<i18n.Translate>Total to pay</i18n.Translate>}
@ -89,26 +102,45 @@ export function BaseView(state: SupportedStates): VNode {
text={<Amount value={state.payStatus.amountRaw} />}
kind="neutral"
/>
{Amounts.isNonZero(state.totalFees) && (
{Amounts.isNonZero(totalFees) && (
<Fragment>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={state.totalFees} />}
text={<Amount value={totalFees} />}
kind="negative"
/>
</Fragment>
)}
<Part
title={<i18n.Translate>Merchant</i18n.Translate>}
text={contractTerms.merchant.name}
kind="neutral"
/>
)} */}
<Part
title={<i18n.Translate>Purchase</i18n.Translate>}
text={contractTerms.summary}
kind="neutral"
/>
<Part
title={<i18n.Translate>Merchant</i18n.Translate>}
text={<MerchantDetails merchant={contractTerms.merchant} />}
kind="neutral"
/>
{/* <pre>{JSON.stringify(price)}</pre>
<hr />
<pre>{JSON.stringify(state.payStatus, undefined, 2)}</pre> */}
<Part
title={<i18n.Translate>Details</i18n.Translate>}
text={
<PurchaseDetails
price={price}
info={{
...contractTerms,
orderId: contractTerms.order_id,
contractTermsHash: "",
products: contractTerms.products!,
}}
proposalId={state.payStatus.proposalId}
/>
}
kind="neutral"
/>
{contractTerms.order_id && (
<Part
title={<i18n.Translate>Receipt</i18n.Translate>}
@ -116,8 +148,19 @@ export function BaseView(state: SupportedStates): VNode {
kind="neutral"
/>
)}
{contractTerms.products && contractTerms.products.length > 0 && (
<ProductList products={contractTerms.products} />
{contractTerms.pay_deadline && (
<Part
title={<i18n.Translate>Valid until</i18n.Translate>}
text={
<Time
timestamp={AbsoluteTime.fromTimestamp(
contractTerms.pay_deadline,
)}
format="dd MMMM yyyy, HH:mm"
/>
}
kind="neutral"
/>
)}
</section>
<ButtonsSection
@ -232,7 +275,7 @@ function ShowImportantMessage({ state }: { state: SupportedStates }): VNode {
);
}
if (state.status == "confirmed") {
if (state.status == "completed") {
const { payResult, payHandler } = state;
if (payHandler.error) {
return <ErrorTalerOperation error={payHandler.error.errorDetail} />;
@ -264,7 +307,7 @@ function ShowImportantMessage({ state }: { state: SupportedStates }): VNode {
return <Fragment />;
}
function PayWithMobile({ state }: { state: State.Ready }): VNode {
function PayWithMobile({ state }: { state: SupportedStates }): VNode {
const { i18n } = useTranslationContext();
const [showQR, setShowQR] = useState<boolean>(false);
@ -286,7 +329,7 @@ function PayWithMobile({ state }: { state: State.Ready }): VNode {
<div>
<QR text={privateUri} />
<i18n.Translate>
Scan the QR code or
Scan the QR code or &nbsp;
<a href={privateUri}>
<i18n.Translate>click here</i18n.Translate>
</a>
@ -306,61 +349,66 @@ function ButtonsSection({
}): VNode {
const { i18n } = useTranslationContext();
if (state.status === "ready") {
const { payStatus } = state;
if (payStatus.status === PreparePayResultType.PaymentPossible) {
return (
<Fragment>
<section>
<Button
variant="contained"
color="success"
onClick={state.payHandler.onClick}
>
<i18n.Translate>
Pay {<Amount value={payStatus.amountEffective} />}
</i18n.Translate>
</Button>
</section>
<PayWithMobile state={state} />
</Fragment>
);
}
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
let BalanceMessage = "";
if (!state.balance) {
BalanceMessage = i18n.str`You have no balance for this currency. Withdraw digital cash first.`;
return (
<Fragment>
<section>
<Button
variant="contained"
color="success"
onClick={state.payHandler.onClick}
>
<i18n.Translate>
Pay &nbsp;
{<Amount value={state.payStatus.amountEffective} />}
</i18n.Translate>
</Button>
</section>
<PayWithMobile state={state} />
</Fragment>
);
}
if (
state.status === "no-enough-balance" ||
state.status === "no-balance-for-currency"
) {
// if (state.payStatus.status === PreparePayResultType.InsufficientBalance) {
let BalanceMessage = "";
if (!state.balance) {
BalanceMessage = i18n.str`You have no balance for this currency. Withdraw digital cash first.`;
} else {
const balanceShouldBeEnough =
Amounts.cmp(state.balance, state.amount) !== -1;
if (balanceShouldBeEnough) {
BalanceMessage = i18n.str`Could not find enough coins to pay this order. Even if you have enough ${state.balance.currency} some restriction may apply.`;
} else {
const balanceShouldBeEnough =
Amounts.cmp(state.balance, state.amount) !== -1;
if (balanceShouldBeEnough) {
BalanceMessage = i18n.str`Could not find enough coins to pay this order. Even if you have enough ${state.balance.currency} some restriction may apply.`;
} else {
BalanceMessage = i18n.str`Your current balance is not enough for this order.`;
}
BalanceMessage = i18n.str`Your current balance is not enough for this order.`;
}
return (
<Fragment>
<section>
<WarningBox>{BalanceMessage}</WarningBox>
</section>
<section>
<Button
variant="contained"
color="success"
onClick={() => goToWalletManualWithdraw(state.amount.currency)}
>
<i18n.Translate>Withdraw digital cash</i18n.Translate>
</Button>
</section>
<PayWithMobile state={state} />
</Fragment>
);
}
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
return (
<Fragment>
<section>
<WarningBox>{BalanceMessage}</WarningBox>
</section>
<section>
<Button
variant="contained"
color="success"
onClick={() => goToWalletManualWithdraw(state.amount.currency)}
>
<i18n.Translate>Withdraw digital cash</i18n.Translate>
</Button>
</section>
<PayWithMobile state={state} />
</Fragment>
);
// }
}
if (state.status === "confirmed") {
if (state.payStatus.status === PreparePayResultType.AlreadyConfirmed) {
return (
<Fragment>
<section>
{payStatus.paid &&
{state.payStatus.paid &&
state.payStatus.contractTerms.fulfillment_message && (
<Part
title={<i18n.Translate>Merchant message</i18n.Translate>}
@ -369,13 +417,13 @@ function ButtonsSection({
/>
)}
</section>
{!payStatus.paid && <PayWithMobile state={state} />}
{!state.payStatus.paid && <PayWithMobile state={state} />}
</Fragment>
);
}
}
if (state.status === "confirmed") {
if (state.status === "completed") {
if (state.payResult.type === ConfirmPayResultType.Pending) {
return (
<section>

View File

@ -30,6 +30,7 @@ export default {
export const EmptyBalance = createExample(TestedComponent, {
balances: [],
goToWalletManualWithdraw: {},
});
export const SomeCoins = createExample(TestedComponent, {
@ -42,6 +43,8 @@ export const SomeCoins = createExample(TestedComponent, {
requiresUserInput: false,
},
],
addAction: {},
goToWalletManualWithdraw: {},
});
export const SomeCoinsInTreeCurrencies = createExample(TestedComponent, {
@ -68,6 +71,8 @@ export const SomeCoinsInTreeCurrencies = createExample(TestedComponent, {
requiresUserInput: false,
},
],
goToWalletManualWithdraw: {},
addAction: {},
});
export const NoCoinsInTreeCurrencies = createExample(TestedComponent, {
@ -94,6 +99,8 @@ export const NoCoinsInTreeCurrencies = createExample(TestedComponent, {
requiresUserInput: false,
},
],
goToWalletManualWithdraw: {},
addAction: {},
});
export const SomeCoinsInFiveCurrencies = createExample(TestedComponent, {
@ -148,4 +155,6 @@ export const SomeCoinsInFiveCurrencies = createExample(TestedComponent, {
requiresUserInput: false,
},
],
goToWalletManualWithdraw: {},
addAction: {},
});

View File

@ -23,8 +23,10 @@ import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { MultiActionButton } from "../components/MultiActionButton.js";
import { useTranslationContext } from "../context/translation.js";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { Button } from "../mui/Button.js";
import { ButtonHandler } from "../mui/handlers.js";
import { compose, StateViewMap } from "../utils/index.js";
import { AddNewActionView } from "../wallet/AddNewActionView.js";
import * as wxApi from "../wxApi.js";
import { NoBalanceHelp } from "./NoBalanceHelp.js";
@ -34,17 +36,46 @@ export interface Props {
goToWalletHistory: (currency: string) => Promise<void>;
goToWalletManualWithdraw: () => Promise<void>;
}
export function BalancePage({
goToWalletManualWithdraw,
goToWalletDeposit,
goToWalletHistory,
}: Props): VNode {
const { i18n } = useTranslationContext();
export type State = State.Loading | State.Error | State.Action | State.Balances;
export namespace State {
export interface Loading {
status: "loading";
error: undefined;
}
export interface Error {
status: "error";
error: HookError;
}
export interface Action {
status: "action";
error: undefined;
cancel: ButtonHandler;
}
export interface Balances {
status: "balance";
error: undefined;
balances: Balance[];
addAction: ButtonHandler;
goToWalletDeposit: (currency: string) => Promise<void>;
goToWalletHistory: (currency: string) => Promise<void>;
goToWalletManualWithdraw: ButtonHandler;
}
}
function useComponentState(
{ goToWalletDeposit, goToWalletHistory, goToWalletManualWithdraw }: Props,
api: typeof wxApi,
): State {
const [addingAction, setAddingAction] = useState(false);
const state = useAsyncAsHook(wxApi.getBalance);
const state = useAsyncAsHook(api.getBalance);
useEffect(() => {
return wxApi.onUpdateNotification(
return api.onUpdateNotification(
[NotificationType.WithdrawGroupFinished],
() => {
state?.retry();
@ -52,58 +83,80 @@ export function BalancePage({
);
});
const balances = !state || state.hasError ? [] : state.response.balances;
if (!state) {
return <Loading />;
return {
status: "loading",
error: undefined,
};
}
if (state.hasError) {
return (
<LoadingError
title={<i18n.Translate>Could not load balance page</i18n.Translate>}
error={state}
/>
);
return {
status: "error",
error: state,
};
}
if (addingAction) {
return <AddNewActionView onCancel={async () => setAddingAction(false)} />;
return {
status: "action",
error: undefined,
cancel: {
onClick: async () => setAddingAction(false),
},
};
}
return {
status: "balance",
error: undefined,
balances: state.response.balances,
addAction: {
onClick: async () => setAddingAction(true),
},
goToWalletManualWithdraw: {
onClick: goToWalletManualWithdraw,
},
goToWalletDeposit,
goToWalletHistory,
};
}
const viewMapping: StateViewMap<State> = {
loading: Loading,
error: ErrorView,
action: ActionView,
balance: BalanceView,
};
export const BalancePage = compose(
"BalancePage",
(p: Props) => useComponentState(p, wxApi),
viewMapping,
);
function ErrorView({ error }: State.Error): VNode {
const { i18n } = useTranslationContext();
return (
<BalanceView
balances={balances}
goToWalletManualWithdraw={goToWalletManualWithdraw}
goToWalletDeposit={goToWalletDeposit}
goToWalletHistory={goToWalletHistory}
goToAddAction={async () => setAddingAction(true)}
<LoadingError
title={<i18n.Translate>Could not load balance page</i18n.Translate>}
error={error}
/>
);
}
export interface BalanceViewProps {
balances: Balance[];
goToWalletManualWithdraw: () => Promise<void>;
goToAddAction: () => Promise<void>;
goToWalletDeposit: (currency: string) => Promise<void>;
goToWalletHistory: (currency: string) => Promise<void>;
function ActionView({ cancel }: State.Action): VNode {
return <AddNewActionView onCancel={cancel.onClick!} />;
}
export function BalanceView({
balances,
goToWalletManualWithdraw,
goToWalletDeposit,
goToWalletHistory,
goToAddAction,
}: BalanceViewProps): VNode {
export function BalanceView(state: State.Balances): VNode {
const { i18n } = useTranslationContext();
const currencyWithNonZeroAmount = balances
const currencyWithNonZeroAmount = state.balances
.filter((b) => !Amounts.isZero(b.available))
.map((b) => b.available.split(":")[0]);
if (balances.length === 0) {
if (state.balances.length === 0) {
return (
<NoBalanceHelp goToWalletManualWithdraw={goToWalletManualWithdraw} />
<NoBalanceHelp
goToWalletManualWithdraw={state.goToWalletManualWithdraw}
/>
);
}
@ -111,23 +164,26 @@ export function BalanceView({
<Fragment>
<section>
<BalanceTable
balances={balances}
goToWalletHistory={goToWalletHistory}
balances={state.balances}
goToWalletHistory={state.goToWalletHistory}
/>
</section>
<footer style={{ justifyContent: "space-between" }}>
<Button variant="contained" onClick={goToWalletManualWithdraw}>
<Button
variant="contained"
onClick={state.goToWalletManualWithdraw.onClick}
>
<i18n.Translate>Withdraw</i18n.Translate>
</Button>
{currencyWithNonZeroAmount.length > 0 && (
<MultiActionButton
label={(s) => <i18n.Translate>Deposit {s}</i18n.Translate>}
actions={currencyWithNonZeroAmount}
onClick={(c) => goToWalletDeposit(c)}
onClick={(c) => state.goToWalletDeposit(c)}
/>
)}
<JustInDevMode>
<Button onClick={goToAddAction}>
<Button onClick={state.addAction.onClick}>
<i18n.Translate>Enter URI</i18n.Translate>
</Button>
</JustInDevMode>

View File

@ -17,13 +17,14 @@ import { css } from "@linaria/core";
import { Fragment, h, VNode } from "preact";
import { Alert } from "../mui/Alert.js";
import { Button } from "../mui/Button.js";
import { ButtonHandler } from "../mui/handlers.js";
import { Paper } from "../mui/Paper.js";
import { Typography } from "../mui/Typography.js";
export function NoBalanceHelp({
goToWalletManualWithdraw,
}: {
goToWalletManualWithdraw: () => Promise<void>;
goToWalletManualWithdraw: ButtonHandler;
}): VNode {
return (
<Paper
@ -37,7 +38,7 @@ export function NoBalanceHelp({
fullWidth
color="warning"
variant="outlined"
onClick={goToWalletManualWithdraw}
onClick={goToWalletManualWithdraw.onClick}
>
<Typography>Withdraw</Typography>
</Button>

View File

@ -143,7 +143,11 @@ export function HistoryView({
if (balances.length === 0 || !selectedCurrency) {
return (
<NoBalanceHelp goToWalletManualWithdraw={goToWalletManualWithdraw} />
<NoBalanceHelp
goToWalletManualWithdraw={{
onClick: goToWalletManualWithdraw,
}}
/>
);
}
return (

View File

@ -114,6 +114,12 @@ const exampleData = {
tip: {
...commonTransaction,
type: TransactionType.Tip,
// merchant: {
// name: "the merchant",
// logo: merchantIcon,
// website: "https://www.themerchant.taler",
// email: "contact@merchant.taler",
// },
merchantBaseUrl: "http://merchant.taler",
} as TransactionTip,
refund: {

View File

@ -16,18 +16,18 @@
import {
AbsoluteTime,
amountFractionalLength,
AmountJson,
Amounts,
Location,
MerchantInfo,
NotificationType,
OrderShortInfo,
parsePaytoUri,
PaytoUri,
stringifyPaytoUri,
TalerProtocolTimestamp,
Transaction,
TransactionDeposit,
TransactionPayment,
TransactionRefresh,
TransactionRefund,
TransactionTip,
@ -46,6 +46,7 @@ import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { Kind, Part, PartCollapsible, PartPayto } from "../components/Part.js";
import { ShowFullContractTermPopup } from "../components/ShowFullContractTermPopup.js";
import {
CenteredDialog,
InfoBox,
@ -319,10 +320,15 @@ export function TransactionView({
? undefined
: Amounts.parseOrThrow(transaction.refundPending);
const total = Amounts.sub(
Amounts.parseOrThrow(transaction.amountEffective),
Amounts.parseOrThrow(transaction.totalRefundEffective),
).amount;
const price = {
raw: Amounts.parseOrThrow(transaction.amountRaw),
effective: Amounts.parseOrThrow(transaction.amountEffective),
};
const refund = {
raw: Amounts.parseOrThrow(transaction.totalRefundRaw),
effective: Amounts.parseOrThrow(transaction.totalRefundEffective),
};
const total = Amounts.sub(price.effective, refund.effective).amount;
return (
<TransactionTemplate>
@ -404,45 +410,7 @@ export function TransactionView({
)}
<Part
title={<i18n.Translate>Merchant</i18n.Translate>}
text={
<Fragment>
<div style={{ display: "flex", flexDirection: "row" }}>
{transaction.info.merchant.logo && (
<div>
<img
src={transaction.info.merchant.logo}
style={{ width: 64, height: 64, margin: 4 }}
/>
</div>
)}
<div>
<p>{transaction.info.merchant.name}</p>
{transaction.info.merchant.website && (
<a
href={transaction.info.merchant.website}
target="_blank"
style={{ textDecorationColor: "gray" }}
rel="noreferrer"
>
<SmallLightText>
{transaction.info.merchant.website}
</SmallLightText>
</a>
)}
{transaction.info.merchant.email && (
<a
href={`mailto:${transaction.info.merchant.email}`}
style={{ textDecorationColor: "gray" }}
>
<SmallLightText>
{transaction.info.merchant.email}
</SmallLightText>
</a>
)}
</div>
</div>
</Fragment>
}
text={<MerchantDetails merchant={transaction.info.merchant} />}
kind="neutral"
/>
<Part
@ -452,7 +420,14 @@ export function TransactionView({
/>
<Part
title={<i18n.Translate>Details</i18n.Translate>}
text={<PurchaseDetails transaction={transaction} />}
text={
<PurchaseDetails
price={price}
refund={refund}
info={transaction.info}
proposalId={transaction.proposalId}
/>
}
kind="neutral"
/>
</TransactionTemplate>
@ -521,12 +496,7 @@ export function TransactionView({
</Header>
{/* <Part
title={<i18n.Translate>Merchant</i18n.Translate>}
text={transaction.info.merchant.name}
kind="neutral"
/>
<Part
title={<i18n.Translate>Invoice ID</i18n.Translate>}
text={transaction.info.orderId}
text={<MerchantDetails merchant={transaction.merchant} />}
kind="neutral"
/> */}
<Part
@ -584,6 +554,46 @@ export function TransactionView({
return <div />;
}
export function MerchantDetails({
merchant,
}: {
merchant: MerchantInfo;
}): VNode {
return (
<div style={{ display: "flex", flexDirection: "row" }}>
{merchant.logo && (
<div>
<img
src={merchant.logo}
style={{ width: 64, height: 64, margin: 4 }}
/>
</div>
)}
<div>
<p style={{ marginTop: 0 }}>{merchant.name}</p>
{merchant.website && (
<a
href={merchant.website}
target="_blank"
style={{ textDecorationColor: "gray" }}
rel="noreferrer"
>
<SmallLightText>{merchant.website}</SmallLightText>
</a>
)}
{merchant.email && (
<a
href={`mailto:${merchant.email}`}
style={{ textDecorationColor: "gray" }}
>
<SmallLightText>{merchant.email}</SmallLightText>
</a>
)}
</div>
</div>
);
}
function DeliveryDetails({
date,
location,
@ -703,57 +713,58 @@ function DeliveryDetails({
);
}
function PurchaseDetails({
transaction,
export interface AmountWithFee {
effective: AmountJson;
raw: AmountJson;
}
export function PurchaseDetails({
price,
refund,
info,
proposalId,
}: {
transaction: TransactionPayment;
price: AmountWithFee;
refund?: AmountWithFee;
info: OrderShortInfo;
proposalId: string;
}): VNode {
const { i18n } = useTranslationContext();
const partialFee = Amounts.sub(
Amounts.parseOrThrow(transaction.amountEffective),
Amounts.parseOrThrow(transaction.amountRaw),
).amount;
const partialFee = Amounts.sub(price.effective, price.raw).amount;
const refundRaw = Amounts.parseOrThrow(transaction.totalRefundRaw);
const refundFee = Amounts.sub(
refundRaw,
Amounts.parseOrThrow(transaction.totalRefundEffective),
).amount;
const refundFee = !refund
? Amounts.getZero(price.effective.currency)
: Amounts.sub(refund.raw, refund.effective).amount;
const fee = Amounts.sum([partialFee, refundFee]).amount;
const hasProducts =
transaction.info.products && transaction.info.products.length > 0;
const hasProducts = info.products && info.products.length > 0;
const hasShipping =
transaction.info.delivery_date !== undefined ||
transaction.info.delivery_location !== undefined;
info.delivery_date !== undefined || info.delivery_location !== undefined;
const showLargePic = (): void => {
return;
};
const total = Amounts.sub(
Amounts.parseOrThrow(transaction.amountEffective),
Amounts.parseOrThrow(transaction.totalRefundEffective),
).amount;
const total = !refund
? price.effective
: Amounts.sub(price.effective, refund.effective).amount;
return (
<PurchaseDetailsTable>
<tr>
<td>Price</td>
<td>
<Amount value={transaction.amountRaw} />
<Amount value={price.raw} />
</td>
</tr>
{Amounts.isNonZero(refundRaw) && (
{refund && Amounts.isNonZero(refund.raw) && (
<tr>
<td>Refunded</td>
<td>
<Amount value={transaction.totalRefundRaw} negative />
<Amount value={refund.raw} negative />
</td>
</tr>
)}
@ -784,7 +795,7 @@ function PurchaseDetails({
title={<i18n.Translate>Products</i18n.Translate>}
text={
<ListOfProducts>
{transaction.info.products?.map((p, k) => (
{info.products?.map((p, k) => (
<Row key={k}>
<a href="#" onClick={showLargePic}>
<img src={p.image ? p.image : emptyImg} />
@ -813,14 +824,19 @@ function PurchaseDetails({
title={<i18n.Translate>Delivery</i18n.Translate>}
text={
<DeliveryDetails
date={transaction.info.delivery_date}
location={transaction.info.delivery_location}
date={info.delivery_date}
location={info.delivery_location}
/>
}
/>
</td>
</tr>
)}
<tr>
<td>
<ShowFullContractTermPopup proposalId={proposalId} />
</td>
</tr>
</PurchaseDetailsTable>
);
}

View File

@ -63,6 +63,7 @@ import {
PendingOperationsResponse,
RemoveBackupProviderRequest,
TalerError,
WalletContractData,
} from "@gnu-taler/taler-wallet-core";
import type { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import type { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw";
@ -190,6 +191,11 @@ export function getBalance(): Promise<BalancesResponse> {
return callBackend("getBalances", {});
}
export function getContractTermsDetails(proposalId: string): Promise<WalletContractData> {
return callBackend("getContractTermsDetails", { proposalId });
}
/**
* Retrieve the full event history for this wallet.
*/