diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/ExchangeSelection')
5 files changed, 1874 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts new file mode 100644 index 000000000..d711f1ecc --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts @@ -0,0 +1,115 @@ +/* + 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 { + DenomOperationMap, + ExchangeFullDetails, + ExchangeListItem, + FeeDescriptionPair, +} from "@gnu-taler/taler-util"; +import { ErrorAlertView } from "../../components/CurrentAlerts.js"; +import { Loading } from "../../components/Loading.js"; +import { ErrorAlert } from "../../context/alert.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; +import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import { useComponentState } from "./state.js"; +import { + ComparingView, + NoExchangesView, + PrivacyContentView, + ReadyView, + TosContentView, +} from "./views.js"; + +export interface Props { + list: ExchangeListItem[]; + initialValue: string; + onCancel: () => Promise<void>; + onSelection: (exchange: string) => Promise<void>; +} + +export type State = + | State.Loading + | State.LoadingUriError + | State.Ready + | State.Comparing + | State.ShowingTos + | State.ShowingPrivacy + | SelectExchangeState.NoExchangeFound; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "error"; + error: ErrorAlert; + } + + export interface BaseInfo { + exchanges: SelectFieldHandler; + selected: ExchangeFullDetails; + error: undefined; + onShowTerms: ButtonHandler; + onShowPrivacy: ButtonHandler; + } + + export interface Ready extends BaseInfo { + status: "ready"; + onClose: ButtonHandler; + } + + export interface Comparing extends BaseInfo { + status: "comparing"; + coinOperationTimeline: DenomOperationMap<FeeDescriptionPair[]>; + wireFeeTimeline: Record<string, FeeDescriptionPair[]>; + globalFeeTimeline: FeeDescriptionPair[]; + missingWireTYpe: string[]; + newWireType: string[]; + onReset: ButtonHandler; + onSelect: ButtonHandler; + } + export interface ShowingTos { + status: "showing-tos"; + exchangeUrl: string; + onClose: ButtonHandler; + } + export interface ShowingPrivacy { + status: "showing-privacy"; + exchangeUrl: string; + onClose: ButtonHandler; + } +} + +const viewMapping: StateViewMap<State> = { + loading: Loading, + error: ErrorAlertView, + comparing: ComparingView, + "no-exchange-found": NoExchangesView, + "showing-tos": TosContentView, + "showing-privacy": PrivacyContentView, + ready: ReadyView, +}; + +export const ExchangeSelectionPage = compose( + "ExchangeSelectionPage", + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts new file mode 100644 index 000000000..d70b62de0 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts @@ -0,0 +1,242 @@ +/* + 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 { DenomOperationMap, FeeDescription } from "@gnu-taler/taler-util"; +import { + WalletApiOperation, + createPairTimeline, +} from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useState } from "preact/hooks"; +import { alertFromError, useAlertContext } from "../../context/alert.js"; +import { useBackendContext } from "../../context/backend.js"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ + onCancel, + onSelection, + list: exchanges, + initialValue, +}: Props): State { + const api = useBackendContext(); + const { pushAlertOnError } = useAlertContext(); + const { i18n } = useTranslationContext(); + const initialValueIdx = exchanges.findIndex( + (e) => e.exchangeBaseUrl === initialValue, + ); + if (initialValueIdx === -1) { + throw Error( + `wrong usage of ExchangeSelection component, currentExchange '${initialValue}' is not in the list of exchanges`, + ); + } + const [value, setValue] = useState(String(initialValueIdx)); + + const selectedIdx = parseInt(value, 10); + const selectedExchange = exchanges[selectedIdx]; + + const comparingExchanges = selectedIdx !== initialValueIdx; + + const initialExchange = comparingExchanges + ? exchanges[initialValueIdx] + : undefined; + + const hook = useAsyncAsHook(async () => { + const selected = await api.wallet.call( + WalletApiOperation.GetExchangeDetailedInfo, + { + exchangeBaseUrl: selectedExchange.exchangeBaseUrl, + }, + ); + + const original = !initialExchange + ? undefined + : await api.wallet.call(WalletApiOperation.GetExchangeDetailedInfo, { + exchangeBaseUrl: initialExchange.exchangeBaseUrl, + }); + + return { + exchanges, + selected: selected.exchange, + original: original?.exchange, + }; + }, [selectedExchange, initialExchange]); + + const [showingTos, setShowingTos] = useState<string | undefined>(undefined); + const [showingPrivacy, setShowingPrivacy] = useState<string | undefined>( + undefined, + ); + + if (!hook) { + return { + status: "loading", + error: undefined, + }; + } + if (hook.hasError) { + return { + status: "error", + error: alertFromError( + i18n, + i18n.str`Could not load exchange details info`, + hook, + ), + }; + } + + const { selected, original } = hook.response; + + const exchangeMap = exchanges.reduce( + (prev, cur, idx) => ({ ...prev, [String(idx)]: cur.exchangeBaseUrl }), + {} as Record<string, string>, + ); + + if (showingPrivacy) { + return { + status: "showing-privacy", + onClose: { + onClick: pushAlertOnError(async () => setShowingPrivacy(undefined)), + }, + exchangeUrl: showingPrivacy, + }; + } + if (showingTos) { + return { + status: "showing-tos", + onClose: { + onClick: pushAlertOnError(async () => setShowingTos(undefined)), + }, + exchangeUrl: showingTos, + }; + } + + if (!comparingExchanges || !original) { + // !original <=> selected == original + return { + status: "ready", + exchanges: { + list: exchangeMap, + value: value, + onChange: pushAlertOnError(async (v) => { + setValue(v); + }), + }, + error: undefined, + onClose: { + onClick: pushAlertOnError(onCancel), + }, + selected, + onShowPrivacy: { + onClick: pushAlertOnError(async () => { + setShowingPrivacy(selected.exchangeBaseUrl); + }), + }, + onShowTerms: { + onClick: pushAlertOnError(async () => { + setShowingTos(selected.exchangeBaseUrl); + }), + }, + }; + } + + // this may be expensive, useMemo + const coinOperationTimeline: DenomOperationMap<FeeDescription[]> = { + deposit: createPairTimeline( + selected.denomFees.deposit, + original.denomFees.deposit, + ), + refresh: createPairTimeline( + selected.denomFees.refresh, + original.denomFees.refresh, + ), + refund: createPairTimeline( + selected.denomFees.refund, + original.denomFees.refund, + ), + withdraw: createPairTimeline( + selected.denomFees.withdraw, + original.denomFees.withdraw, + ), + }; + + const globalFeeTimeline = createPairTimeline( + selected.globalFees, + original.globalFees, + ); + + const allWireType = Object.keys(selected.transferFees).concat( + Object.keys(original.transferFees), + ); + + const wireFeeTimeline: Record<string, FeeDescription[]> = {}; + + const missingWireTYpe: string[] = []; + const newWireType: string[] = []; + + for (const wire of allWireType) { + const selectedWire = selected.transferFees[wire]; + const originalWire = original.transferFees[wire]; + + if (!selectedWire) { + newWireType.push(wire); + continue; + } + if (!originalWire) { + missingWireTYpe.push(wire); + continue; + } + + wireFeeTimeline[wire] = createPairTimeline(selectedWire, originalWire); + } + + return { + status: "comparing", + exchanges: { + list: exchangeMap, + value: value, + onChange: pushAlertOnError(async (v) => { + setValue(v); + }), + }, + error: undefined, + onReset: { + onClick: pushAlertOnError(async () => { + setValue(String(initialValue)); + }), + }, + onSelect: { + onClick: pushAlertOnError(async () => { + onSelection(selected.exchangeBaseUrl); + }), + }, + onShowPrivacy: { + onClick: pushAlertOnError(async () => { + setShowingPrivacy(selected.exchangeBaseUrl); + }), + }, + onShowTerms: { + onClick: pushAlertOnError(async () => { + setShowingTos(selected.exchangeBaseUrl); + }), + }, + selected, + coinOperationTimeline, + wireFeeTimeline, + globalFeeTimeline, + missingWireTYpe, + newWireType, + }; +} diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx new file mode 100644 index 000000000..990e2790f --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx @@ -0,0 +1,563 @@ +/* + 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 * as tests from "@gnu-taler/web-util/testing"; +import { ComparingView, ReadyView, NoExchangesView } from "./views.js"; + +export default { + title: "select exchange", +}; + +export const NoExchangeFound = tests.createExample(NoExchangesView, { + currency: "USD", + defaultExchange: "https://exchange.taler.ar", +}); + +export const Bitcoin1 = tests.createExample(ReadyView, { + exchanges: { + list: { "0": "https://exchange.taler.ar" }, + value: "0", + }, + selected: { + currency: "BITCOINBTC", + auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], + } as any, + onShowPrivacy: {}, + onShowTerms: {}, + onClose: {}, +}); +export const Bitcoin2 = tests.createExample(ReadyView, { + exchanges: { + list: { + "https://exchange.taler.ar": "https://exchange.taler.ar", + "https://exchange-btc.taler.ar": "https://exchange-btc.taler.ar", + }, + value: "https://exchange.taler.ar", + }, + selected: { + currency: "BITCOINBTC", + auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], + } as any, + onShowPrivacy: {}, + onShowTerms: {}, + onClose: {}, +}); + +export const Kudos1 = tests.createExample(ReadyView, { + exchanges: { + list: { + "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar", + }, + value: "https://exchange-kudos.taler.ar", + }, + selected: { + currency: "BITCOINBTC", + auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], + } as any, + onShowPrivacy: {}, + onShowTerms: {}, + onClose: {}, +}); +export const Kudos2 = tests.createExample(ReadyView, { + exchanges: { + list: { + "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar", + "https://exchange-kudos2.taler.ar": "https://exchange-kudos2.taler.ar", + }, + value: "https://exchange-kudos.taler.ar", + }, + selected: { + currency: "BITCOINBTC", + auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + denomFees: timelineExample(), + transferFees: {}, + globalFees: [], + } as any, + onShowPrivacy: {}, + onShowTerms: {}, + onClose: {}, +}); +export const ComparingBitcoin = tests.createExample(ComparingView, { + exchanges: { + list: { "http://exchange": "http://exchange" }, + value: "http://exchange", + }, + selected: { + currency: "BITCOINBTC", + auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + transferFees: {}, + globalFees: [], + } as any, + onReset: {}, + onShowPrivacy: {}, + onShowTerms: {}, + onSelect: {}, + error: undefined, + coinOperationTimeline: { + deposit: [], + refresh: [], + refund: [], + withdraw: [], + }, + globalFeeTimeline: [], + newWireType: [], + missingWireTYpe: [], + wireFeeTimeline: {}, +}); +export const ComparingKudos = tests.createExample(ComparingView, { + exchanges: { + list: { "http://exchange": "http://exchange" }, + value: "http://exchange", + }, + selected: { + currency: "KUDOS", + auditors: [], + exchangeBaseUrl: "https://exchange.taler.ar", + transferFees: {}, + globalFees: [], + } as any, + onReset: {}, + onShowPrivacy: {}, + onShowTerms: {}, + onSelect: {}, + error: undefined, + coinOperationTimeline: { + deposit: [], + refresh: [], + refund: [], + withdraw: [], + }, + globalFeeTimeline: [], + newWireType: [], + missingWireTYpe: [], + wireFeeTimeline: {}, +}); + +function timelineExample() { + return { + deposit: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1916386904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + refresh: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + refund: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + withdraw: [ + { + group: "0.1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "10", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "1000", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "2", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + { + group: "5", + from: { + t_ms: 1664098904000, + }, + until: { + t_ms: 1758706904000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + wad: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + wire: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + closing: [ + { + group: "iban", + from: { + t_ms: 1640995200000, + }, + until: { + t_ms: 1798761600000, + }, + fee: { + currency: "KUDOS", + fraction: 1000000, + value: 0, + }, + }, + ], + }; +} diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts new file mode 100644 index 000000000..3c7235851 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts @@ -0,0 +1,23 @@ +/* + 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 { AbsoluteTime, Amounts, DenominationInfo } from "@gnu-taler/taler-util"; +import { expect } from "chai"; diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx new file mode 100644 index 000000000..6f67d84b7 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx @@ -0,0 +1,931 @@ +/* + 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 { FeeDescription, FeeDescriptionPair } from "@gnu-taler/taler-util"; +import { styled } from "@linaria/react"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Amount } from "../../components/Amount.js"; +import { AlertView } from "../../components/CurrentAlerts.js"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { SelectList } from "../../components/SelectList.js"; +import { Input, SvgIcon } from "../../components/styled/index.js"; +import { TermsOfService } from "../../components/TermsOfService/index.js"; +import { Time } from "../../components/Time.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; +import { Button } from "../../mui/Button.js"; +import arrowDown from "../../svg/chevron-down.inline.svg"; +import { State } from "./index.js"; + +const ButtonGroup = styled.div` + & > button { + margin-left: 8px; + margin-right: 8px; + } +`; +const ButtonGroupFooter = styled.div` + & { + display: flex; + justify-content: space-between; + } + & > button { + margin-left: 8px; + margin-right: 8px; + } +`; + +const FeeDescriptionTable = styled.table` + & { + margin-bottom: 20px; + width: 100%; + border-collapse: collapse; + } + td { + padding: 8px; + } + td.fee { + text-align: center; + } + th.fee { + text-align: center; + } + td.value { + text-align: right; + width: 15%; + white-space: nowrap; + } + td.icon { + width: 24px; + } + td.icon > div { + width: 24px; + height: 24px; + margin: 0px; + } + td.expiration { + text-align: center; + } + + tr[data-main="true"] { + background-color: #add8e662; + } + tr[data-main="true"] > td.value, + tr[data-main="true"] > td.expiration, + tr[data-main="true"] > td.fee { + border-bottom: lightgray solid 1px; + } + tr[data-hidden="true"] { + display: none; + } + tbody > tr.value[data-hasMore="true"], + tbody > tr.value[data-hasMore="true"] > td { + cursor: pointer; + } + th { + position: sticky; + top: 0; + background-color: white; + } +`; + +const Container = styled.div` + display: flex; + flex-direction: column; + & > * { + margin-bottom: 20px; + } +`; + +export function PrivacyContentView({ + exchangeUrl, + onClose, +}: State.ShowingPrivacy): VNode { + const { i18n } = useTranslationContext(); + return ( + <div> + <Button variant="outlined" onClick={onClose.onClick}> + <i18n.Translate>Close</i18n.Translate> + </Button> + <div>show privacy terms for {exchangeUrl}</div> + </div> + ); +} + +export function TosContentView({ + exchangeUrl, + onClose, +}: State.ShowingTos): VNode { + const { i18n } = useTranslationContext(); + return ( + <div> + <Button variant="outlined" onClick={onClose.onClick}> + <i18n.Translate>Close</i18n.Translate> + </Button> + <TermsOfService exchangeUrl={exchangeUrl} readOnly > + s + </TermsOfService> + </div> + ); +} + +export function NoExchangesView({ + defaultExchange, + currency, +}: SelectExchangeState.NoExchangeFound): VNode { + const { i18n } = useTranslationContext(); + return ( + <Fragment> + <p> + <AlertView + alert={{ + type: "error", + message: i18n.str`There is no exchange available for currency ${currency}`, + description: i18n.str`You can add more exchanges from the settings.`, + cause: undefined, + context: undefined, + }} + /> + </p> + {defaultExchange && ( + <AlertView + alert={{ + type: "warning", + message: i18n.str`Exchange ${defaultExchange} is not available`, + description: i18n.str`Exchange status can view accessed from the settings.`, + }} + /> + )} + </Fragment> + ); +} + +export function ComparingView({ + exchanges, + selected, + onReset, + onSelect, + coinOperationTimeline, + globalFeeTimeline, + wireFeeTimeline, + missingWireTYpe, + newWireType, + onShowPrivacy, + onShowTerms, +}: State.Comparing): VNode { + const { i18n } = useTranslationContext(); + return ( + <Container> + <h2> + <i18n.Translate>Service fee description</i18n.Translate> + </h2> + + <section> + <div + style={{ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "space-between", + }} + > + <p> + <Input> + <SelectList + label={ + <i18n.Translate> + Select {selected.currency} exchange + </i18n.Translate> + } + list={exchanges.list} + name="lang" + value={exchanges.value} + onChange={exchanges.onChange} + /> + </Input> + </p> + <ButtonGroup> + <Button variant="outlined" onClick={onReset.onClick}> + <i18n.Translate>Reset</i18n.Translate> + </Button> + <Button variant="contained" onClick={onSelect.onClick}> + <i18n.Translate>Use this exchange</i18n.Translate> + </Button> + </ButtonGroup> + </div> + </section> + <section> + <dl> + <dt> + <i18n.Translate>Auditors</i18n.Translate> + </dt> + {selected.auditors.length === 0 ? ( + <dd style={{ color: "red" }}> + <i18n.Translate>Doesn't have auditors</i18n.Translate> + </dd> + ) : ( + selected.auditors.map((a) => { + <dd>{a.auditor_url}</dd>; + }) + )} + </dl> + <table> + <tr> + <td> + <i18n.Translate>currency</i18n.Translate> + </td> + <td>{selected.currency}</td> + </tr> + </table> + </section> + <section> + <h2> + <i18n.Translate>Coin operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by denomination + value and is valid for a period of time. The exchange will charge + the indicated amount every time a coin is used in such operation. + </i18n.Translate> + </p> + <p> + <i18n.Translate>Deposits</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Current</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Selected</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeePairByValue + list={coinOperationTimeline.deposit} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable> + <p> + <i18n.Translate>Withdrawals</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Current</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Selected</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeePairByValue + list={coinOperationTimeline.withdraw} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable> + <p> + <i18n.Translate>Refunds</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Current</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Selected</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeePairByValue + list={coinOperationTimeline.refund} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable>{" "} + <p> + <i18n.Translate>Refresh</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Current</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Selected</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeePairByValue + list={coinOperationTimeline.refresh} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable>{" "} + </section> + <section> + <h2> + <i18n.Translate>Transfer operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + {missingWireTYpe.map((type) => { + return ( + <p key={type}> + Wire <b>{type}</b> is not supported for this exchange. + </p> + ); + })} + {newWireType.map((type) => { + return ( + <Fragment key={type}> + <p> + Wire <b>{type}</b> is not supported for the previous exchange. + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Operation</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue + list={selected.transferFees[type]} + /> + </tbody> + </FeeDescriptionTable> + </Fragment> + ); + })} + {Object.entries(wireFeeTimeline).map(([type, fees], idx) => { + return ( + <Fragment key={idx}> + <p>{type}</p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Operation</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Current</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Selected</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeePairByValue + list={fees} + sorting={(a, b) => a.localeCompare(b)} + /> + </tbody> + </FeeDescriptionTable> + </Fragment> + ); + })} + </section> + <section> + <h2> + <i18n.Translate>Wallet operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Feature</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Current</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Selected</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeePairByValue + list={globalFeeTimeline} + sorting={(a, b) => a.localeCompare(b)} + /> + </tbody> + </FeeDescriptionTable> + </section> + <section> + <ButtonGroupFooter> + <Button onClick={onShowPrivacy.onClick} variant="outlined"> + Privacy policy + </Button> + <Button onClick={onShowTerms.onClick} variant="outlined"> + Terms of service + </Button> + </ButtonGroupFooter> + </section> + <section> + <ButtonGroupFooter> + <Button onClick={onShowPrivacy.onClick} variant="outlined"> + Privacy policy + </Button> + <Button onClick={onShowTerms.onClick} variant="outlined"> + Terms of service + </Button> + </ButtonGroupFooter> + </section> + </Container> + ); +} + +export function ReadyView({ + exchanges, + selected, + onClose, + onShowPrivacy, + onShowTerms, +}: State.Ready): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Container> + <h2> + <i18n.Translate>Service fee description</i18n.Translate> + </h2> + <p> + All fee indicated below are in the same and only currency the exchange + works. + </p> + <section> + <div + style={{ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "space-between", + }} + > + {Object.keys(exchanges.list).length === 1 ? ( + <Fragment> + <p>Exchange: {selected.exchangeBaseUrl}</p> + </Fragment> + ) : ( + <p> + <Input> + <SelectList + label={ + <i18n.Translate> + Select {selected.currency} exchange + </i18n.Translate> + } + list={exchanges.list} + name="lang" + value={exchanges.value} + onChange={exchanges.onChange} + /> + </Input> + </p> + )} + <Button variant="outlined" onClick={onClose.onClick}> + <i18n.Translate>Close</i18n.Translate> + </Button> + </div> + </section> + <section> + <dl> + <dt>Auditors</dt> + {selected.auditors.length === 0 ? ( + <dd style={{ color: "red" }}> + <i18n.Translate>Doesn't have auditors</i18n.Translate> + </dd> + ) : ( + selected.auditors.map((a) => { + <dd>{a.auditor_url}</dd>; + }) + )} + </dl> + <table> + <tr> + <td> + <i18n.Translate>Currency</i18n.Translate> + </td> + <td> + <b>{selected.currency}</b> + </td> + </tr> + </table> + </section> + <section> + <h2> + <i18n.Translate>Coin operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by denomination + value and is valid for a period of time. The exchange will charge + the indicated amount every time a coin is used in such operation. + </i18n.Translate> + </p> + <p> + <i18n.Translate>Deposits</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue + list={selected.denomFees.deposit} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable> + <p> + <i18n.Translate>Withdrawals</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue + list={selected.denomFees.withdraw} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable> + <p> + <i18n.Translate>Refunds</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue + list={selected.denomFees.refund} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable>{" "} + <p> + <i18n.Translate>Refresh</i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Denomination</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue + list={selected.denomFees.refresh} + sorting={(a, b) => Number(a) - Number(b)} + /> + </tbody> + </FeeDescriptionTable> + </section> + <section> + <h2> + <i18n.Translate>Transfer operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + {Object.entries(selected.transferFees).map(([type, fees], idx) => { + return ( + <Fragment key={idx}> + <p>{type}</p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Operation</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue list={fees} /> + </tbody> + </FeeDescriptionTable> + </Fragment> + ); + })} + </section> + <section> + <h2> + <i18n.Translate>Wallet operations</i18n.Translate> + </h2> + <p> + <i18n.Translate> + Every operation in this section may be different by transfer type + and is valid for a period of time. The exchange will charge the + indicated amount every time a transfer is made. + </i18n.Translate> + </p> + <FeeDescriptionTable> + <thead> + <tr> + <th> </th> + <th> + <i18n.Translate>Feature</i18n.Translate> + </th> + <th class="fee"> + <i18n.Translate>Fee</i18n.Translate> + </th> + <th> + <i18n.Translate>Until</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + <RenderFeeDescriptionByValue list={selected.globalFees} /> + </tbody> + </FeeDescriptionTable> + </section> + <section> + <ButtonGroupFooter> + <Button onClick={onShowPrivacy.onClick} variant="outlined"> + Privacy policy + </Button> + <Button onClick={onShowTerms.onClick} variant="outlined"> + Terms of service + </Button> + </ButtonGroupFooter> + </section> + </Container> + ); +} + +function FeeDescriptionRowsGroup({ + infos, +}: { + infos: FeeDescription[]; +}): VNode { + const [expanded, setExpand] = useState(false); + const hasMoreInfo = infos.length > 1; + return ( + <Fragment> + {infos.map((info, idx) => { + const main = idx === 0; + return ( + <tr + key={idx} + class="value" + data-hasMore={hasMoreInfo} + data-main={main} + data-hidden={!main && !expanded} + onClick={() => setExpand((p) => !p)} + > + <td class="icon"> + {hasMoreInfo && main ? ( + <SvgIcon + title="Select this contact" + dangerouslySetInnerHTML={{ __html: arrowDown }} + color="currentColor" + transform={expanded ? "" : "rotate(-90deg)"} + /> + ) : undefined} + </td> + <td class="value">{main ? info.group : ""}</td> + {info.fee ? ( + <td class="fee">{<Amount value={info.fee} hideCurrency />}</td> + ) : undefined} + <td class="expiration"> + <Time timestamp={info.until} format="dd-MMM-yyyy" /> + </td> + </tr> + ); + })} + </Fragment> + ); +} + +function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode { + const [expanded, setExpand] = useState(false); + const hasMoreInfo = infos.length > 1; + return ( + <Fragment> + {infos.map((info, idx) => { + const main = idx === 0; + return ( + <tr + key={idx} + class="value" + data-hasMore={hasMoreInfo} + data-main={main} + data-hidden={!main && !expanded} + onClick={() => setExpand((p) => !p)} + > + <td class="icon"> + {hasMoreInfo && main ? ( + <SvgIcon + title="Expand" + dangerouslySetInnerHTML={{ __html: arrowDown }} + color="currentColor" + transform={expanded ? "" : "rotate(-90deg)"} + /> + ) : undefined} + </td> + <td class="value">{main ? info.group : ""}</td> + {info.left ? ( + <td class="fee">{<Amount value={info.left} hideCurrency />}</td> + ) : ( + <td class="fee"> --- </td> + )} + {info.right ? ( + <td class="fee">{<Amount value={info.right} hideCurrency />}</td> + ) : ( + <td class="fee"> --- </td> + )} + <td class="expiration"> + <Time timestamp={info.until} format="dd-MMM-yyyy HH:mm:ss" /> + </td> + </tr> + ); + })} + </Fragment> + ); +} + +/** + * Group by value and then render using FeePairRowsGroup + * @param param0 + * @returns + */ +function RenderFeePairByValue({ + list, + sorting, +}: { + list: FeeDescriptionPair[]; + sorting?: (a: string, b: string) => number; +}): VNode { + const grouped = list.reduce((prev, cur) => { + if (!prev[cur.group]) { + prev[cur.group] = []; + } + prev[cur.group].push(cur); + return prev; + }, {} as Record<string, FeeDescriptionPair[]>); + const p = Object.keys(grouped) + .sort(sorting) + .map((i, idx) => <FeePairRowsGroup key={idx} infos={grouped[i]} />); + return <Fragment>{p}</Fragment>; +} +/** + * + * Group by value and then render using FeeDescriptionRowsGroup + * @param param0 + * @returns + */ +function RenderFeeDescriptionByValue({ + list, + sorting, +}: { + list: FeeDescription[]; + sorting?: (a: string, b: string) => number; +}): VNode { + const grouped = list.reduce((prev, cur) => { + if (!prev[cur.group]) { + prev[cur.group] = []; + } + prev[cur.group].push(cur); + return prev; + }, {} as Record<string, FeeDescription[]>); + const p = Object.keys(grouped) + .sort(sorting) + .map((i, idx) => <FeeDescriptionRowsGroup key={idx} infos={grouped[i]} />); + return <Fragment>{p}</Fragment>; +} |