diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/DestinationSelection')
5 files changed, 872 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts new file mode 100644 index 000000000..492da193b --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts @@ -0,0 +1,93 @@ +/* + 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 { Loading } from "../../components/Loading.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { AmountFieldHandler, ButtonHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import { wxApi } from "../../wxApi.js"; +import { useComponentState } from "./state.js"; +import { LoadingUriView, ReadyView, SelectCurrencyView } from "./views.js"; + +export type Props = PropsGet | PropsSend; + +interface PropsGet { + type: "get"; + amount?: string; + goToWalletManualWithdraw: (amount: string) => void; + goToWalletWalletInvoice: (amount: string) => void; +} +interface PropsSend { + type: "send"; + amount?: string; + goToWalletBankDeposit: (amount: string) => void; + goToWalletWalletSend: (amount: string) => void; +} + +export type State = + | State.Loading + | State.LoadingUriError + | State.Ready + | State.SelectCurrency; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "loading-error"; + error: HookError; + } + + export interface SelectCurrency { + status: "select-currency"; + error: undefined; + currencies: Record<string, string>; + onCurrencySelected: (currency: string) => void; + } + + export interface Ready { + status: "ready"; + error: undefined; + type: Props["type"]; + selectCurrency: ButtonHandler; + previous: Contact[]; + goToBank: ButtonHandler; + goToWallet: ButtonHandler; + amountHandler: AmountFieldHandler; + } +} + +export type Contact = { + icon: string; + name: string; + description: string; +}; + +const viewMapping: StateViewMap<State> = { + loading: Loading, + "loading-error": LoadingUriView, + "select-currency": SelectCurrencyView, + ready: ReadyView, +}; + +export const DestinationSelectionPage = compose( + "DestinationSelectionPage", + (p: Props) => useComponentState(p, wxApi), + viewMapping, +); diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts new file mode 100644 index 000000000..fe02151de --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts @@ -0,0 +1,167 @@ +/* + 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 { Amounts } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { assertUnreachable, RecursiveState } from "../../utils/index.js"; +import { wxApi } from "../../wxApi.js"; +import { Contact, Props, State } from "./index.js"; +import bankIcon from "../../svg/ri-bank-line.svg"; + +export function useComponentState( + props: Props, + api: typeof wxApi, +): RecursiveState<State> { + const parsedInitialAmount = !props.amount + ? undefined + : Amounts.parse(props.amount); + + // const initialCurrency = parsedInitialAmount?.currency; + + const [amount, setAmount] = useState( + !parsedInitialAmount ? undefined : parsedInitialAmount, + ); + + //FIXME: get this information from wallet + // eslint-disable-next-line no-constant-condition + const previous: Contact[] = true + ? [] + : [ + { + name: "International Bank", + icon: bankIcon, //FIXME: should be decided in the view + description: "account ending with 3454", + }, + { + name: "Max", + icon: bankIcon, + description: "account ending with 3454", + }, + { + name: "Alex", + icon: bankIcon, + description: "account ending with 3454", + }, + ]; + + if (!amount) { + return () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const hook = useAsyncAsHook(() => + api.wallet.call(WalletApiOperation.ListExchanges, {}), + ); + + if (!hook) { + return { + status: "loading", + error: undefined, + }; + } + if (hook.hasError) { + return { + status: "loading-error", + error: hook, + }; + } + const currencies: Record<string, string> = {}; + hook.response.exchanges.forEach((e) => { + if (e.currency) { + currencies[e.currency] = e.currency; + } + }); + currencies[""] = "Select a currency"; + + return { + status: "select-currency", + error: undefined, + onCurrencySelected: (c: string) => { + setAmount(Amounts.zeroOfCurrency(c)); + }, + currencies, + }; + }; + } + + const currencyAndAmount = Amounts.stringify(amount); + const invalid = Amounts.isZero(amount); + + switch (props.type) { + case "send": + return { + status: "ready", + error: undefined, + previous, + selectCurrency: { + onClick: async () => { + setAmount(undefined); + }, + }, + goToBank: { + onClick: invalid + ? undefined + : async () => { + props.goToWalletBankDeposit(currencyAndAmount); + }, + }, + goToWallet: { + onClick: invalid + ? undefined + : async () => { + props.goToWalletWalletSend(currencyAndAmount); + }, + }, + amountHandler: { + onInput: async (s) => setAmount(s), + value: amount, + }, + type: props.type, + }; + case "get": + return { + status: "ready", + error: undefined, + previous, + selectCurrency: { + onClick: async () => { + setAmount(undefined); + }, + }, + goToBank: { + onClick: invalid + ? undefined + : async () => { + props.goToWalletManualWithdraw(currencyAndAmount); + }, + }, + goToWallet: { + onClick: invalid + ? undefined + : async () => { + props.goToWalletWalletInvoice(currencyAndAmount); + }, + }, + amountHandler: { + onInput: async (s) => setAmount(s), + value: amount, + }, + type: props.type, + }; + default: + assertUnreachable(props); + } +} diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx new file mode 100644 index 000000000..b8d868683 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx @@ -0,0 +1,63 @@ +/* + 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 { createExample } from "../../test-utils.js"; +import { ReadyView, SelectCurrencyView } from "./views.js"; + +export default { + title: "wallet/destination", +}; + +export const GetCash = createExample(ReadyView, { + amountHandler: { + value: { + currency: "EUR", + fraction: 0, + value: 2, + }, + }, + goToBank: {}, + goToWallet: {}, + previous: [], + selectCurrency: {}, + type: "get", +}); +export const SendCash = createExample(ReadyView, { + amountHandler: { + value: { + currency: "EUR", + fraction: 0, + value: 1, + }, + }, + goToBank: {}, + goToWallet: {}, + previous: [], + selectCurrency: {}, + type: "send", +}); + +export const SelectCurrency = createExample(SelectCurrencyView, { + currencies: { + "": "Select a currency", + USD: "USD", + }, +}); diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts new file mode 100644 index 000000000..c2aa04849 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts @@ -0,0 +1,136 @@ +/* + 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 { + Amounts, + ExchangeEntryStatus, + ExchangeListItem, + ExchangeTosStatus, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { expect } from "chai"; +import { createWalletApiMock, mountHook } from "../../test-utils.js"; +import { useComponentState } from "./state.js"; + +const exchangeArs: ExchangeListItem = { + currency: "ARS", + exchangeBaseUrl: "http://", + tosStatus: ExchangeTosStatus.Accepted, + exchangeStatus: ExchangeEntryStatus.Ok, + paytoUris: [], + permanent: true, + ageRestrictionOptions: [], +}; + +describe("Destination selection states", () => { + it("should select currency if no amount specified", async () => { + const { handler, mock } = createWalletApiMock(); + + handler.addWalletCallResponse( + WalletApiOperation.ListExchanges, + {}, + { + exchanges: [exchangeArs], + }, + ); + + const props = { + type: "get" as const, + goToWalletManualWithdraw: () => { + return null; + }, + goToWalletWalletInvoice: () => { + null; + }, + }; + const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } = + mountHook(() => useComponentState(props, mock)); + + { + const state = pullLastResultOrThrow(); + + if (state.status !== "loading") expect.fail(); + if (state.error) expect.fail(); + } + + expect(await waitForStateUpdate()).true; + + { + const state = pullLastResultOrThrow(); + + if (state.status !== "select-currency") expect.fail(); + if (state.error) expect.fail(); + expect(state.currencies).deep.eq({ + ARS: "ARS", + "": "Select a currency", + }); + + state.onCurrencySelected(exchangeArs.currency!); + } + + expect(await waitForStateUpdate()).true; + + { + const state = pullLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.goToBank.onClick).eq(undefined); + expect(state.goToWallet.onClick).eq(undefined); + + expect(state.amountHandler.value).deep.eq(Amounts.parseOrThrow("ARS:0")); + } + + await assertNoPendingUpdate(); + expect(handler.getCallingQueueState()).eq("empty"); + }); + + it("should be possible to start with an amount specified in request params", async () => { + const { handler, mock } = createWalletApiMock(); + + const props = { + type: "get" as const, + goToWalletManualWithdraw: () => { + return null; + }, + goToWalletWalletInvoice: () => { + null; + }, + amount: "ARS:2", + }; + const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } = + mountHook(() => useComponentState(props, mock)); + + { + const state = pullLastResultOrThrow(); + + if (state.status !== "ready") expect.fail(); + if (state.error) expect.fail(); + expect(state.goToBank.onClick).not.eq(undefined); + expect(state.goToWallet.onClick).not.eq(undefined); + + expect(state.amountHandler.value).deep.eq(Amounts.parseOrThrow("ARS:2")); + } + + await assertNoPendingUpdate(); + expect(handler.getCallingQueueState()).eq("empty"); + }); +}); diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx new file mode 100644 index 000000000..ba8d65ffa --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx @@ -0,0 +1,413 @@ +/* + 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 { Fragment, h, VNode } from "preact"; +import { LoadingError } from "../../components/LoadingError.js"; +import { SelectList } from "../../components/SelectList.js"; +import { + Input, + LightText, + LinkPrimary, + SvgIcon, +} from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Pages } from "../../NavigationBar.js"; +import { Contact, State } from "./index.js"; +import arrowIcon from "../../svg/chevron-down.svg"; +import { AmountField } from "../../components/AmountField.js"; +import { Grid } from "../../mui/Grid.js"; +import { Paper } from "../../mui/Paper.js"; +import { Button } from "../../mui/Button.js"; +import { assertUnreachable } from "../../utils/index.js"; + +export function LoadingUriView({ error }: State.LoadingUriError): VNode { + const { i18n } = useTranslationContext(); + return ( + <LoadingError + title={<i18n.Translate>Could not load</i18n.Translate>} + error={error} + /> + ); +} + +export function SelectCurrencyView({ + currencies, + onCurrencySelected, +}: State.SelectCurrency): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <h2> + <i18n.Translate> + Choose a currency to proceed or add another exchange + </i18n.Translate> + </h2> + + <p> + <Input> + <SelectList + label={<i18n.Translate>Known currencies</i18n.Translate>} + list={currencies} + name="lang" + value={""} + onChange={(v) => onCurrencySelected(v)} + /> + </Input> + </p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div /> + <LinkPrimary href={Pages.settingsExchangeAdd({})}> + <i18n.Translate>Add an exchange</i18n.Translate> + </LinkPrimary> + </div> + </Fragment> + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + & > * { + margin: 8px; + } +`; + +const ContactTable = styled.table` + width: 100%; + & > tr > td { + padding: 8px; + & > div:not([data-disabled]):hover { + background-color: lightblue; + } + color: black; + div[data-disabled] > * { + color: gray; + } + } + + & > tr:nth-child(2n) { + background: #ebebeb; + } +`; + +const MediaExample = styled.div` + text-size-adjust: 100%; + color: inherit; + font-family: inherit; + font-size: inherit; + line-height: inherit; + text-transform: none; + text-align: left; + box-sizing: border-box; + align-items: center; + display: flex; + padding: 8px 8px; + + &[data-disabled]:hover { + cursor: inherit; + } + cursor: pointer; +`; + +const MediaLeft = styled.div` + text-size-adjust: 100%; + + color: inherit; + font-family: inherit; + font-size: inherit; + line-height: inherit; + text-transform: none; + text-align: left; + box-sizing: border-box; + padding-right: 8px; + display: block; +`; + +const MediaBody = styled.div` + text-size-adjust: 100%; + + font-family: inherit; + text-transform: none; + text-align: left; + box-sizing: border-box; + flex: 1 1; + font-size: 14px; + font-weight: 500; + line-height: 1.42857; +`; +const MediaRight = styled.div` + text-size-adjust: 100%; + + color: inherit; + font-family: inherit; + font-size: inherit; + line-height: inherit; + text-transform: none; + text-align: left; + box-sizing: border-box; + padding-left: 8px; +`; + +const CircleDiv = styled.div` + box-sizing: border-box; + align-items: center; + background-position: 50%; + background-repeat: no-repeat; + background-size: cover; + border-radius: 50%; + display: flex; + justify-content: center; + margin-left: auto; + margin-right: auto; + overflow: hidden; + text-align: center; + text-decoration: none; + text-transform: uppercase; + transition: background-color 0.15s ease, border-color 0.15s ease, + color 0.15s ease; + font-size: 16px; + background-color: #86a7bd1a; + height: 40px; + line-height: 40px; + width: 40px; + border: none; +`; + +export function ReadyView(props: State.Ready): VNode { + switch (props.type) { + case "get": + return ReadyGetView(props); + case "send": + return ReadySendView(props); + default: + assertUnreachable(props.type); + } +} +export function ReadyGetView({ + amountHandler, + goToBank, + goToWallet, + selectCurrency, + previous, +}: State.Ready): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Container> + <h1> + <i18n.Translate>Specify the amount and the origin</i18n.Translate> + </h1> + <Grid container columns={2} justifyContent="space-between"> + <AmountField + label={<i18n.Translate>Amount</i18n.Translate>} + required + handler={amountHandler} + /> + <Button onClick={selectCurrency.onClick}> + <i18n.Translate>Change currency</i18n.Translate> + </Button> + </Grid> + + <Grid container spacing={1} columns={1}> + {previous.length > 0 ? ( + <Fragment> + <p> + <i18n.Translate>Use previous origins:</i18n.Translate> + </p> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <ContactTable> + {previous.map((info, i) => ( + <tr key={i}> + <td> + <RowExample + info={info} + disabled={!amountHandler.onInput} + /> + </td> + </tr> + ))} + </ContactTable> + </Paper> + </Grid> + </Fragment> + ) : undefined} + {previous.length > 0 ? ( + <Grid item> + <p> + <i18n.Translate> + Or specify the origin of the money + </i18n.Translate> + </p> + </Grid> + ) : ( + <Grid item> + <p> + <i18n.Translate>Specify the origin of the money</i18n.Translate> + </p> + </Grid> + )} + <Grid item container columns={2} spacing={1}> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <p> + <i18n.Translate>From my bank account</i18n.Translate> + </p> + <Button onClick={goToBank.onClick}> + <i18n.Translate>Withdraw</i18n.Translate> + </Button> + </Paper> + </Grid> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <p> + <i18n.Translate>From another wallet</i18n.Translate> + </p> + <Button onClick={goToWallet.onClick}> + <i18n.Translate>Invoice</i18n.Translate> + </Button> + </Paper> + </Grid> + </Grid> + </Grid> + </Container> + ); +} +export function ReadySendView({ + amountHandler, + goToBank, + goToWallet, + previous, +}: State.Ready): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Container> + <h1> + <i18n.Translate>Specify the amount and the destination</i18n.Translate> + </h1> + + <div> + <AmountField + label={<i18n.Translate>Amount</i18n.Translate>} + required + handler={amountHandler} + /> + </div> + + <Grid container spacing={1} columns={1}> + {previous.length > 0 ? ( + <Fragment> + <p> + <i18n.Translate>Use previous destinations:</i18n.Translate> + </p> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <ContactTable> + {previous.map((info, i) => ( + <tr key={i}> + <td> + <RowExample + info={info} + disabled={!amountHandler.onInput} + /> + </td> + </tr> + ))} + </ContactTable> + </Paper> + </Grid> + </Fragment> + ) : undefined} + {previous.length > 0 ? ( + <Grid item> + <p> + <i18n.Translate> + Or specify the destination of the money + </i18n.Translate> + </p> + </Grid> + ) : ( + <Grid item> + <p> + <i18n.Translate> + Specify the destination of the money + </i18n.Translate> + </p> + </Grid> + )} + <Grid item container columns={2} spacing={1}> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <p> + <i18n.Translate>To my bank account</i18n.Translate> + </p> + <Button onClick={goToBank.onClick}> + <i18n.Translate>Deposit</i18n.Translate> + </Button> + </Paper> + </Grid> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <p> + <i18n.Translate>To another wallet</i18n.Translate> + </p> + <Button onClick={goToWallet.onClick}> + <i18n.Translate>Send</i18n.Translate> + </Button> + </Paper> + </Grid> + </Grid> + </Grid> + </Container> + ); +} + +function RowExample({ + info, + disabled, +}: { + info: Contact; + disabled?: boolean; +}): VNode { + return ( + <MediaExample data-disabled={disabled}> + <MediaLeft> + <CircleDiv> + <SvgIcon + title={info.name} + dangerouslySetInnerHTML={{ __html: info.icon }} + color="currentColor" + /> + </CircleDiv> + </MediaLeft> + <MediaBody> + <span>{info.name}</span> + <LightText>{info.description}</LightText> + </MediaBody> + <MediaRight> + <SvgIcon + title="Select this contact" + dangerouslySetInnerHTML={{ __html: arrowIcon }} + color="currentColor" + transform="rotate(-90deg)" + /> + </MediaRight> + </MediaExample> + ); +} |