summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/wallet/ExchangeSelection
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/ExchangeSelection')
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts115
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts242
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx563
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts23
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx931
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&apos;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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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&apos;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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>;
+}