diff options
author | Sebastian <sebasjm@gmail.com> | 2022-08-08 14:09:28 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2022-08-08 14:09:36 -0300 |
commit | 7a600514c6d43bbaeba6b962533415e59fc46057 (patch) | |
tree | d96c02537cda29f1637787a8fb8e659a37ea8c1f /packages | |
parent | 4409d8384b77401489c2a92d3de20f79959ae34a (diff) |
fixing #6096
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)
Diffstat (limited to 'packages')
17 files changed, 1126 insertions, 305 deletions
diff --git a/packages/taler-wallet-webextension/src/components/Modal.tsx b/packages/taler-wallet-webextension/src/components/Modal.tsx new file mode 100644 index 000000000..3fea063d3 --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/Modal.tsx @@ -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> + ); +} diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx new file mode 100644 index 000000000..6f71b9d2e --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx @@ -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, {}); diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx new file mode 100644 index 000000000..b7d8376bd --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx @@ -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> + + </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> + + </Fragment> + ))} + </td> + </tr> + </ContractTermsTable> + </div> + </Modal> + ); +} diff --git a/packages/taler-wallet-webextension/src/components/index.stories.tsx b/packages/taler-wallet-webextension/src/components/index.stories.tsx index 053b27f79..901347e4f 100644 --- a/packages/taler-wallet-webextension/src/components/index.stories.tsx +++ b/packages/taler-wallet-webextension/src/components/index.stories.tsx @@ -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]; diff --git a/packages/taler-wallet-webextension/src/components/styled/index.tsx b/packages/taler-wallet-webextension/src/components/styled/index.tsx index 928562fb6..ff4a5b4d5 100644 --- a/packages/taler-wallet-webextension/src/components/styled/index.tsx +++ b/packages/taler-wallet-webextension/src/components/styled/index.tsx @@ -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; diff --git a/packages/taler-wallet-webextension/src/cta/Payment/index.ts b/packages/taler-wallet-webextension/src/cta/Payment/index.ts index 0e67a4991..5c0f6f0d6 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/index.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/index.ts @@ -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, }; diff --git a/packages/taler-wallet-webextension/src/cta/Payment/state.ts b/packages/taler-wallet-webextension/src/cta/Payment/state.ts index 3c819ec8f..f75cef06f 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/state.ts @@ -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 }; + } diff --git a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx index 603a9cb33..877c1996a 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx @@ -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, }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Payment/test.ts b/packages/taler-wallet-webextension/src/cta/Payment/test.ts index aea70b7ca..afd881a72 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/test.ts @@ -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; } diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx index a8c9a640a..4c2ddc0f2 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -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,24 +102,43 @@ 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>Purchase</i18n.Translate>} + text={contractTerms.summary} + kind="neutral" + /> <Part title={<i18n.Translate>Merchant</i18n.Translate>} - text={contractTerms.merchant.name} + 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>Purchase</i18n.Translate>} - text={contractTerms.summary} + 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 && ( @@ -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 <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 + {<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> diff --git a/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx b/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx index 89a7cace8..87cc98ea0 100644 --- a/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx @@ -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: {}, }); diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx index cdf507cb5..3275a0a07 100644 --- a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx @@ -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> diff --git a/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx b/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx index 2fe1f4ff7..d9b960748 100644 --- a/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx +++ b/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx @@ -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> diff --git a/packages/taler-wallet-webextension/src/wallet/History.tsx b/packages/taler-wallet-webextension/src/wallet/History.tsx index c192b2ba7..e40c1ac5b 100644 --- a/packages/taler-wallet-webextension/src/wallet/History.tsx +++ b/packages/taler-wallet-webextension/src/wallet/History.tsx @@ -143,7 +143,11 @@ export function HistoryView({ if (balances.length === 0 || !selectedCurrency) { return ( - <NoBalanceHelp goToWalletManualWithdraw={goToWalletManualWithdraw} /> + <NoBalanceHelp + goToWalletManualWithdraw={{ + onClick: goToWalletManualWithdraw, + }} + /> ); } return ( diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx index ae43a7b09..ba61e35f3 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx @@ -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: { @@ -429,7 +435,7 @@ export const DepositBitcoin = createExample(TestedComponent, { transaction: { ...exampleData.deposit, amountRaw: "BITCOINBTC:0.0000011", - amountEffective: "BITCOINBTC:0.00000092", + amountEffective: "BITCOINBTC:0.00000092", targetPaytoUri: "payto://bitcoin/bcrt1q6ps8qs6v8tkqrnru4xqqqa6rfwcx5ufpdfqht4?amount=BTC:0.1&subject=0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", }, diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index c42bf7066..e643fef18 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -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> ); } diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index f95066954..9700c475e 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -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. */ |