commit 2a4dbc67e8d1e6a256431e34f0b2e0e19d204f70 parent 64dea5e94086d8f79d1af7e5b7ee045d72d02495 Author: Sebastian <sebasjm@gmail.com> Date: Fri, 8 Mar 2024 09:20:02 -0300 remove dead code Diffstat:
54 files changed, 3909 insertions(+), 4110 deletions(-)
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx @@ -27,12 +27,13 @@ import { TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; +import { useEffect } from "preact/hooks"; import { useBankCoreApiContext } from "./context/config.js"; +import { useNavigationContext } from "./context/navigation.js"; import { useSettingsContext } from "./context/settings.js"; -import { useBackendState } from "./hooks/backend.js"; +import { useSessionState } from "./hooks/session.js"; import { AccountPage } from "./pages/AccountPage/index.js"; import { BankFrame } from "./pages/BankFrame.js"; -import { DownloadStats } from "./pages/DownloadStats.js"; import { LoginForm } from "./pages/LoginForm.js"; import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; import { RegistrationPage } from "./pages/RegistrationPage.js"; @@ -44,19 +45,18 @@ import { ShowAccountDetails } from "./pages/account/ShowAccountDetails.js"; import { UpdateAccountPassword } from "./pages/account/UpdateAccountPassword.js"; import { AdminHome } from "./pages/admin/AdminHome.js"; import { CreateNewAccount } from "./pages/admin/CreateNewAccount.js"; +import { DownloadStats } from "./pages/admin/DownloadStats.js"; import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; -import { CreateCashout } from "./pages/business/CreateCashout.js"; -import { ShowCashoutDetails } from "./pages/business/ShowCashoutDetails.js"; +import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; +import { CreateCashout } from "./pages/regional/CreateCashout.js"; +import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; import { urlPattern, useCurrentLocation } from "./route.js"; -import { useNavigationContext } from "./context/navigation.js"; -import { useEffect } from "preact/hooks"; -import { ConversionConfig } from "./pages/ConversionConfig.js"; export function Routing(): VNode { - const backend = useBackendState(); + const session = useSessionState(); - if (backend.state.status === "loggedIn") { - const { isUserAdministrator, username } = backend.state; + if (session.state.status === "loggedIn") { + const { isUserAdministrator, username } = session.state; return ( <BankFrame account={username} routeAccountDetails={privatePages.myAccountDetails}> <PrivateRouting username={username} isAdmin={isUserAdministrator} /> @@ -67,7 +67,7 @@ export function Routing(): VNode { <BankFrame> <PublicRounting onLoggedUser={(username, token) => { - backend.logIn({ username, token: token }); + session.logIn({ username, token: token }); }} /> </BankFrame> diff --git a/packages/demobank-ui/src/app.tsx b/packages/demobank-ui/src/app.tsx @@ -0,0 +1,137 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + canonicalizeBaseUrl, + getGlobalLogLevel, + setGlobalLogLevelFromString, +} from "@gnu-taler/taler-util"; +import { Loading, TranslationProvider } from "@gnu-taler/web-util/browser"; +import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { SWRConfig } from "swr"; +import { Routing } from "./Routing.js"; +import { BankCoreApiProvider } from "./context/config.js"; +import { BrowserHashNavigationProvider } from "./context/navigation.js"; +import { SettingsProvider } from "./context/settings.js"; +import { TalerWalletIntegrationBrowserProvider } from "./context/wallet-integration.js"; +import { strings } from "./i18n/strings.js"; +import { BankFrame } from "./pages/BankFrame.js"; +import { BankUiSettings, fetchSettings } from "./settings.js"; +const WITH_LOCAL_STORAGE_CACHE = false; + +export function App() { + const [settings, setSettings] = useState<BankUiSettings>(); + useEffect(() => { + fetchSettings(setSettings); + }, []); + if (!settings) return <Loading />; + + const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); + return ( + <SettingsProvider value={settings}> + <TranslationProvider + source={strings} + completeness={{ + es: strings["es"].completeness, + de: strings["de"].completeness, + }} + > + <BankCoreApiProvider baseUrl={baseUrl} frameOnError={BankFrame}> + <SWRConfig + value={{ + provider: WITH_LOCAL_STORAGE_CACHE + ? localStorageProvider + : undefined, + // normally, do not revalidate + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: undefined, + focusThrottleInterval: undefined, + + // normally, do not refresh + refreshInterval: undefined, + dedupingInterval: 2000, + refreshWhenHidden: false, + refreshWhenOffline: false, + + // ignore errors + shouldRetryOnError: false, + errorRetryCount: 0, + errorRetryInterval: undefined, + + // do not go to loading again if already has data + keepPreviousData: true, + }} + > + <TalerWalletIntegrationBrowserProvider> + <BrowserHashNavigationProvider> + <Routing /> + </BrowserHashNavigationProvider> + </TalerWalletIntegrationBrowserProvider> + </SWRConfig> + </BankCoreApiProvider> + </TranslationProvider> + </SettingsProvider> + ); +}; + +// @ts-expect-error creating a new property for window object +window.setGlobalLogLevelFromString = setGlobalLogLevelFromString; +// @ts-expect-error creating a new property for window object +window.getGlobalLevel = getGlobalLogLevel; + +function localStorageProvider(): Map<unknown, unknown> { + const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); + + window.addEventListener("beforeunload", () => { + const appCache = JSON.stringify(Array.from(map.entries())); + localStorage.setItem("app-cache", appCache); + }); + return map; +} + +function getInitialBackendBaseURL( + backendFromSettings: string | undefined, +): string { + const overrideUrl = + typeof localStorage !== "undefined" + ? localStorage.getItem("corebank-api-base-url") + : undefined; + let result: string; + + if (!overrideUrl) { + // normal path + if (!backendFromSettings) { + console.error( + "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", + ); + result = window.origin; + } else { + result = backendFromSettings; + } + } else { + // testing/development path + result = overrideUrl; + } + try { + return canonicalizeBaseUrl(result); + } catch (e) { + // fall back + return canonicalizeBaseUrl(window.origin); + } +} diff --git a/packages/demobank-ui/src/components/Cashouts/state.ts b/packages/demobank-ui/src/components/Cashouts/state.ts @@ -15,7 +15,7 @@ */ import { TalerError } from "@gnu-taler/taler-util"; -import { useCashouts } from "../../hooks/circuit.js"; +import { useCashouts } from "../../hooks/regional.js"; import { Props, State } from "./index.js"; export function useComponentState({ diff --git a/packages/demobank-ui/src/components/Cashouts/test.ts b/packages/demobank-ui/src/components/Cashouts/test.ts @@ -22,7 +22,6 @@ import * as tests from "@gnu-taler/web-util/testing"; import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; import { expect } from "chai"; -import { CASHOUT_API_EXAMPLE } from "../../endpoints.js"; import { Props } from "./index.js"; import { useComponentState } from "./state.js"; import { buildNullRoutDefinition } from "../../route.js"; @@ -36,15 +35,15 @@ describe("Cashout states", () => { routeCashoutDetails: buildNullRoutDefinition(), }; - env.addRequestExpectation(CASHOUT_API_EXAMPLE.LIST_FIRST_PAGE, { - response: { - cashouts: [], - }, - }); + // env.addRequestExpectation(CASHOUT_API_EXAMPLE.LIST_FIRST_PAGE, { + // response: { + // cashouts: [], + // }, + // }); - env.addRequestExpectation(CASHOUT_API_EXAMPLE.MULTI_GET_EMPTY_FIRST_PAGE, { - response: [], - }); + // env.addRequestExpectation(CASHOUT_API_EXAMPLE.MULTI_GET_EMPTY_FIRST_PAGE, { + // response: [], + // }); const hookBehavior = await tests.hookBehaveLikeThis( useComponentState, diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -29,7 +29,7 @@ import { } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { useConversionInfo } from "../../hooks/circuit.js"; +import { useConversionInfo } from "../../hooks/regional.js"; import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js"; import { State } from "./index.js"; diff --git a/packages/demobank-ui/src/components/Transactions/state.ts b/packages/demobank-ui/src/components/Transactions/state.ts @@ -20,7 +20,7 @@ import { TalerError, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { useTransactions } from "../../hooks/access.js"; +import { useTransactions } from "../../hooks/account.js"; import { Props, State, Transaction } from "./index.js"; export function useComponentState({ account, routeCreateWireTransfer }: Props): State { diff --git a/packages/demobank-ui/src/components/Transactions/test.ts b/packages/demobank-ui/src/components/Transactions/test.ts @@ -23,7 +23,6 @@ import { TalerErrorCode } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; import { expect } from "chai"; -import { TRANSACTION_API_EXAMPLE } from "../../endpoints.js"; import { Props } from "./index.js"; import { useComponentState } from "./state.js"; @@ -36,63 +35,63 @@ describe("Transaction states", () => { routeCreateWireTransfer: undefined, }; - env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { - response: { - data: { - transactions: [ - { - creditorIban: "DE159593", - creditorBic: "SANDBOXX", - creditorName: "exchange company", - debtorIban: "DE118695", - debtorBic: "SANDBOXX", - debtorName: "Name unknown", - amount: "1", - currency: "KUDOS", - subject: - "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410", - date: "2022-12-12Z", - uid: "8PPFR9EM", - direction: "DBIT", - pmtInfId: null, - msgId: null, - }, - { - creditorIban: "DE159593", - creditorBic: "SANDBOXX", - creditorName: "exchange company", - debtorIban: "DE118695", - debtorBic: "SANDBOXX", - debtorName: "Name unknown", - amount: "5.00", - currency: "KUDOS", - subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0", - date: "2022-12-07Z", - uid: "7FZJC3RJ", - direction: "DBIT", - pmtInfId: null, - msgId: null, - }, - { - creditorIban: "DE118695", - creditorBic: "SANDBOXX", - creditorName: "Name unknown", - debtorIban: "DE579516", - debtorBic: "SANDBOXX", - debtorName: "The Bank", - amount: "100", - currency: "KUDOS", - subject: "Sign-up bonus", - date: "2022-12-07Z", - uid: "I31A06J8", - direction: "CRDT", - pmtInfId: null, - msgId: null, - }, - ], - }, - }, - }); + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { + // response: { + // data: { + // transactions: [ + // { + // creditorIban: "DE159593", + // creditorBic: "SANDBOXX", + // creditorName: "exchange company", + // debtorIban: "DE118695", + // debtorBic: "SANDBOXX", + // debtorName: "Name unknown", + // amount: "1", + // currency: "KUDOS", + // subject: + // "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410", + // date: "2022-12-12Z", + // uid: "8PPFR9EM", + // direction: "DBIT", + // pmtInfId: null, + // msgId: null, + // }, + // { + // creditorIban: "DE159593", + // creditorBic: "SANDBOXX", + // creditorName: "exchange company", + // debtorIban: "DE118695", + // debtorBic: "SANDBOXX", + // debtorName: "Name unknown", + // amount: "5.00", + // currency: "KUDOS", + // subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0", + // date: "2022-12-07Z", + // uid: "7FZJC3RJ", + // direction: "DBIT", + // pmtInfId: null, + // msgId: null, + // }, + // { + // creditorIban: "DE118695", + // creditorBic: "SANDBOXX", + // creditorName: "Name unknown", + // debtorIban: "DE579516", + // debtorBic: "SANDBOXX", + // debtorName: "The Bank", + // amount: "100", + // currency: "KUDOS", + // subject: "Sign-up bonus", + // date: "2022-12-07Z", + // uid: "I31A06J8", + // direction: "CRDT", + // pmtInfId: null, + // msgId: null, + // }, + // ], + // }, + // }, + // }); const hookBehavior = await tests.hookBehaveLikeThis( useComponentState, @@ -165,13 +164,13 @@ describe("Transaction states", () => { routeCreateWireTransfer: undefined, }; - env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, { - response: { - error: { - code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, - }, - }, - }); + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, { + // response: { + // error: { + // code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + // }, + // }, + // }); const hookBehavior = await tests.hookBehaveLikeThis( useComponentState, diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx @@ -1,142 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - canonicalizeBaseUrl, - getGlobalLogLevel, - setGlobalLogLevelFromString, -} from "@gnu-taler/taler-util"; -import { Loading, TranslationProvider } from "@gnu-taler/web-util/browser"; -import { FunctionalComponent, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { SWRConfig } from "swr"; -import { Routing } from "../Routing.js"; -import { BackendStateProvider } from "../context/backend.js"; -import { BankCoreApiProvider } from "../context/config.js"; -import { SettingsProvider } from "../context/settings.js"; -import { strings } from "../i18n/strings.js"; -import { BankFrame } from "../pages/BankFrame.js"; -import { BankUiSettings, fetchSettings } from "../settings.js"; -import { TalerWalletIntegrationBrowserProvider } from "../context/wallet-integration.js"; -import { BrowserHashNavigationProvider } from "../context/navigation.js"; -const WITH_LOCAL_STORAGE_CACHE = false; - -const App: FunctionalComponent = () => { - const [settings, setSettings] = useState<BankUiSettings>(); - useEffect(() => { - fetchSettings(setSettings); - }, []); - if (!settings) return <Loading />; - - const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); - return ( - <SettingsProvider value={settings}> - <TranslationProvider - source={strings} - completeness={{ - es: strings["es"].completeness, - de: strings["de"].completeness, - }} - > - <BackendStateProvider> - <BankCoreApiProvider baseUrl={baseUrl} frameOnError={BankFrame}> - <SWRConfig - value={{ - provider: WITH_LOCAL_STORAGE_CACHE - ? localStorageProvider - : undefined, - // normally, do not revalidate - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - revalidateOnMount: undefined, - focusThrottleInterval: undefined, - - // normally, do not refresh - refreshInterval: undefined, - dedupingInterval: 2000, - refreshWhenHidden: false, - refreshWhenOffline: false, - - // ignore errors - shouldRetryOnError: false, - errorRetryCount: 0, - errorRetryInterval: undefined, - - // do not go to loading again if already has data - keepPreviousData: true, - }} - > - <TalerWalletIntegrationBrowserProvider> - <BrowserHashNavigationProvider> - <Routing /> - </BrowserHashNavigationProvider> - </TalerWalletIntegrationBrowserProvider> - </SWRConfig> - </BankCoreApiProvider> - </BackendStateProvider> - </TranslationProvider> - </SettingsProvider> - ); -}; - -// @ts-expect-error creating a new property for window object -window.setGlobalLogLevelFromString = setGlobalLogLevelFromString; -// @ts-expect-error creating a new property for window object -window.getGlobalLevel = getGlobalLogLevel; - -function localStorageProvider(): Map<unknown, unknown> { - const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); - - window.addEventListener("beforeunload", () => { - const appCache = JSON.stringify(Array.from(map.entries())); - localStorage.setItem("app-cache", appCache); - }); - return map; -} - -export default App; - -function getInitialBackendBaseURL( - backendFromSettings: string | undefined, -): string { - const overrideUrl = - typeof localStorage !== "undefined" - ? localStorage.getItem("corebank-api-base-url") - : undefined; - let result: string; - - if (!overrideUrl) { - // normal path - if (!backendFromSettings) { - console.error( - "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", - ); - result = window.origin; - } else { - result = backendFromSettings; - } - } else { - // testing/development path - result = overrideUrl; - } - try { - return canonicalizeBaseUrl(result); - } catch (e) { - // fall back - return canonicalizeBaseUrl(window.origin); - } -} diff --git a/packages/demobank-ui/src/context/backend.ts b/packages/demobank-ui/src/context/backend.ts @@ -1,79 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { ComponentChildren, createContext, h, VNode } from "preact"; -import { useContext } from "preact/hooks"; -import { - BackendStateHandler, - defaultState, - useBackendState, -} from "../hooks/backend.js"; - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -export type Type = BackendStateHandler; - -const initial: Type = { - state: defaultState, - logOut() { - null; - }, - expired() { - null; - }, - logIn(_info) { - null; - }, -}; -const Context = createContext<Type>(initial); - -export const useBackendContext = (): Type => useContext(Context); - -export const BackendStateProvider = ({ - children, -}: { - children: ComponentChildren; -}): VNode => { - const value = useBackendState(); - - return h(Context.Provider, { - value, - children, - }); -}; - -export const BackendStateProviderTesting = ({ - children, - state, -}: { - children: ComponentChildren; - state: typeof defaultState; -}): VNode => { - const value: BackendStateHandler = { - state, - logIn: () => {}, - expired: () => {}, - logOut: () => {}, - }; - - return h(Context.Provider, { - value, - children, - }); -}; diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts @@ -46,12 +46,12 @@ import { revalidateAccountDetails, revalidatePublicAccounts, revalidateTransactions, -} from "../hooks/access.js"; +} from "../hooks/account.js"; import { revalidateBusinessAccounts, revalidateCashouts, revalidateConversionInfo, -} from "../hooks/circuit.js"; +} from "../hooks/regional.js"; /** * @@ -156,6 +156,10 @@ export const BankCoreApiProvider = ({ children, }); }; + +/** + * + */ class CacheAwareTalerBankConversionHttpClient extends TalerBankConversionHttpClient { constructor(baseUrl: string, httpClient?: HttpRequestLibrary) { super(baseUrl, httpClient); diff --git a/packages/demobank-ui/src/endpoints.ts b/packages/demobank-ui/src/endpoints.ts @@ -1,48 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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) - */ - -export const TRANSACTION_API_EXAMPLE = { - LIST_FIRST_PAGE: { - method: "get" as const, - url: '["access-api/accounts/myAccount/transactions",null,20]', - }, - LIST_ERROR: { - method: "get" as const, - url: '["access-api/accounts/myAccount/transactions",null,20]', - code: 500, - }, - LIST_NOT_FOUND: { - method: "get" as const, - url: '["access-api/accounts/myAccount/transactions",null,20]', - code: 404, - }, -}; - -export const CASHOUT_API_EXAMPLE = { - LIST_FIRST_PAGE: { - method: "get" as const, - url: '["circuit-api/cashouts","123"]', - }, - MULTI_GET_EMPTY_FIRST_PAGE: { - method: "get" as const, - url: "[[]]", - }, -}; diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts @@ -1,294 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AccessToken, - TalerCoreBankResultByMethod, - TalerHttpError, - WithdrawalOperationStatus, -} from "@gnu-taler/taler-util"; -import { useEffect, useState } from "preact/hooks"; -import { PAGE_SIZE } from "../utils.js"; -import { useBackendState } from "./backend.js"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook, mutate } from "swr"; -import { useBankCoreApiContext } from "../context/config.js"; -const useSWR = _useSWR as unknown as SWRHook; - -export interface InstanceTemplateFilter { - // FIXME: add filter to the template list - position?: string; -} - -export function revalidateAccountDetails() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getAccount", - undefined, - { revalidate: true }, - ); -} - -export function useAccountDetails(account: string) { - const { state: credentials } = useBackendState(); - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token]: [string, AccessToken]) { - return await api.getAccount({ username, token }); - } - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getAccount">, - TalerHttpError - >([account, token, "getAccount"], fetcher, {}); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateWithdrawalDetails() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", undefined, { revalidate: true }); -} - -export function useWithdrawalDetails(wid: string) { - const { api } = useBankCoreApiContext(); - const [latestStatus, setLatestStatus] = useState<WithdrawalOperationStatus>(); - - async function fetcher([wid, old_state]: [ - string, - WithdrawalOperationStatus | undefined, - ]) { - return await api.getWithdrawalById( - wid, - old_state === undefined ? undefined : { old_state, timeoutMs: 15000 }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getWithdrawalById">, - TalerHttpError - >([wid, latestStatus, "getWithdrawalById"], fetcher, { - refreshInterval: 3000, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - const currentStatus = - data !== undefined && data.type === "ok" ? data.body.status : undefined; - - useEffect(() => { - if (currentStatus !== undefined && currentStatus !== latestStatus) { - setLatestStatus(currentStatus); - } - }, [currentStatus]); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateTransactionDetails() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", undefined, { revalidate: true } - ); -} -export function useTransactionDetails(account: string, tid: number) { - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token, txid]: [ - string, - AccessToken, - number, - ]) { - return await api.getTransactionById({ username, token }, txid); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getTransactionById">, - TalerHttpError - >([account, token, tid, "getTransactionById"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidatePublicAccounts() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", undefined, { revalidate: true } - ); -} -export function usePublicAccounts( - filterAccount: string | undefined, - initial?: number, -) { - const [offset, setOffset] = useState<number | undefined>(initial); - - const { api } = useBankCoreApiContext(); - - async function fetcher([account, txid]: [ - string | undefined, - number | undefined, - ]) { - return await api.getPublicAccounts( - { account }, - { - limit: PAGE_SIZE, - offset: txid ? String(txid) : undefined, - order: "asc", - }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getPublicAccounts">, - TalerHttpError - >([filterAccount, offset, "getPublicAccounts"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - const isLastPage = - data && data.type === "ok" && data.body.public_accounts.length <= PAGE_SIZE; - const isFirstPage = !offset; - - const result = data && data.type == "ok" ? structuredClone(data.body.public_accounts) : [] - if (result.length == PAGE_SIZE+1) { - result.pop() - } - const pagination = { - result, - isLastPage, - isFirstPage, - loadNext: () => { - if (!result.length) return; - setOffset(result[result.length - 1].row_id); - }, - loadFirst: () => { - setOffset(0); - }, - }; - - // const public_accountslist = data?.type !== "ok" ? [] : data.body.public_accounts; - if (data) { - return { ok: true, data: data.body, ...pagination }; - } - if (error) { - return error; - } - return undefined; -} - -export function revalidateTransactions() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions", - undefined, - { revalidate: true }, - ); -} -export function useTransactions(account: string, initial?: number) { - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - const [offset, setOffset] = useState<number | undefined>(initial); - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token, txid]: [ - string, - AccessToken, - number | undefined, - ]) { - return await api.getTransactions( - { username, token }, - { - limit: PAGE_SIZE +1 , - offset: txid ? String(txid) : undefined, - order: "dec", - }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getTransactions">, - TalerHttpError - >([account, token, offset, "getTransactions"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - refreshWhenOffline: false, - // revalidateOnMount: false, - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - }); - - const isLastPage = - data && data.type === "ok" && data.body.transactions.length <= PAGE_SIZE; - const isFirstPage = !offset; - - const result = data && data.type == "ok" ? structuredClone(data.body.transactions) : [] - if (result.length == PAGE_SIZE+1) { - result.pop() - } - const pagination = { - result, - isLastPage, - isFirstPage, - loadNext: () => { - if (!result.length) return; - setOffset(result[result.length - 1].row_id); - }, - loadFirst: () => { - setOffset(0); - }, - }; - - if (data) { - return { ok: true, data, ...pagination }; - } - if (error) { - return error; - } - return undefined; -} diff --git a/packages/demobank-ui/src/hooks/account.ts b/packages/demobank-ui/src/hooks/account.ts @@ -0,0 +1,294 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AccessToken, + TalerCoreBankResultByMethod, + TalerHttpError, + WithdrawalOperationStatus, +} from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; +import { PAGE_SIZE } from "../utils.js"; +import { useSessionState } from "./session.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, mutate } from "swr"; +import { useBankCoreApiContext } from "../context/config.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export interface InstanceTemplateFilter { + // FIXME: add filter to the template list + position?: string; +} + +export function revalidateAccountDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getAccount", + undefined, + { revalidate: true }, + ); +} + +export function useAccountDetails(account: string) { + const { state: credentials } = useSessionState(); + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token]: [string, AccessToken]) { + return await api.getAccount({ username, token }); + } + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getAccount">, + TalerHttpError + >([account, token, "getAccount"], fetcher, {}); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateWithdrawalDetails() { + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", undefined, { revalidate: true }); +} + +export function useWithdrawalDetails(wid: string) { + const { api } = useBankCoreApiContext(); + const [latestStatus, setLatestStatus] = useState<WithdrawalOperationStatus>(); + + async function fetcher([wid, old_state]: [ + string, + WithdrawalOperationStatus | undefined, + ]) { + return await api.getWithdrawalById( + wid, + old_state === undefined ? undefined : { old_state, timeoutMs: 15000 }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getWithdrawalById">, + TalerHttpError + >([wid, latestStatus, "getWithdrawalById"], fetcher, { + refreshInterval: 3000, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + const currentStatus = + data !== undefined && data.type === "ok" ? data.body.status : undefined; + + useEffect(() => { + if (currentStatus !== undefined && currentStatus !== latestStatus) { + setLatestStatus(currentStatus); + } + }, [currentStatus]); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateTransactionDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", undefined, { revalidate: true } + ); +} +export function useTransactionDetails(account: string, tid: number) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, txid]: [ + string, + AccessToken, + number, + ]) { + return await api.getTransactionById({ username, token }, txid); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getTransactionById">, + TalerHttpError + >([account, token, tid, "getTransactionById"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidatePublicAccounts() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", undefined, { revalidate: true } + ); +} +export function usePublicAccounts( + filterAccount: string | undefined, + initial?: number, +) { + const [offset, setOffset] = useState<number | undefined>(initial); + + const { api } = useBankCoreApiContext(); + + async function fetcher([account, txid]: [ + string | undefined, + number | undefined, + ]) { + return await api.getPublicAccounts( + { account }, + { + limit: PAGE_SIZE, + offset: txid ? String(txid) : undefined, + order: "asc", + }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getPublicAccounts">, + TalerHttpError + >([filterAccount, offset, "getPublicAccounts"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + const isLastPage = + data && data.type === "ok" && data.body.public_accounts.length <= PAGE_SIZE; + const isFirstPage = !offset; + + const result = data && data.type == "ok" ? structuredClone(data.body.public_accounts) : [] + if (result.length == PAGE_SIZE+1) { + result.pop() + } + const pagination = { + result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + setOffset(result[result.length - 1].row_id); + }, + loadFirst: () => { + setOffset(0); + }, + }; + + // const public_accountslist = data?.type !== "ok" ? [] : data.body.public_accounts; + if (data) { + return { ok: true, data: data.body, ...pagination }; + } + if (error) { + return error; + } + return undefined; +} + +export function revalidateTransactions() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions", + undefined, + { revalidate: true }, + ); +} +export function useTransactions(account: string, initial?: number) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + const [offset, setOffset] = useState<number | undefined>(initial); + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, txid]: [ + string, + AccessToken, + number | undefined, + ]) { + return await api.getTransactions( + { username, token }, + { + limit: PAGE_SIZE +1 , + offset: txid ? String(txid) : undefined, + order: "dec", + }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getTransactions">, + TalerHttpError + >([account, token, offset, "getTransactions"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + refreshWhenOffline: false, + // revalidateOnMount: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); + + const isLastPage = + data && data.type === "ok" && data.body.transactions.length <= PAGE_SIZE; + const isFirstPage = !offset; + + const result = data && data.type == "ok" ? structuredClone(data.body.transactions) : [] + if (result.length == PAGE_SIZE+1) { + result.pop() + } + const pagination = { + result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + setOffset(result[result.length - 1].row_id); + }, + loadFirst: () => { + setOffset(0); + }, + }; + + if (data) { + return { ok: true, data, ...pagination }; + } + if (error) { + return error; + } + return undefined; +} diff --git a/packages/demobank-ui/src/hooks/async.ts b/packages/demobank-ui/src/hooks/async.ts @@ -1,80 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { useState } from "preact/hooks"; - -export interface Options { - slowTolerance: number; -} - -export interface AsyncOperationApi<T> { - request: (...a: Array<unknown>) => void; - cancel: () => void; - data: T | undefined; - isSlow: boolean; - isLoading: boolean; - error: string | undefined; -} - -export function useAsync<T>( - fn?: (...args: Array<unknown>) => Promise<T>, - { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }, -): AsyncOperationApi<T> { - const [data, setData] = useState<T | undefined>(undefined); - const [isLoading, setLoading] = useState<boolean>(false); - const [error, setError] = useState<string | undefined>(undefined); - const [isSlow, setSlow] = useState(false); - - const request = async (...args: Array<unknown>) => { - if (!fn) return; - setLoading(true); - const handler = setTimeout(() => { - setSlow(true); - }, tooLong); - - try { - const result = await fn(...args); - setData(result); - } catch (error) { - if (error instanceof Error) { - setError(error.message); - } else { - setError(`Unknown error: ${error}`); - } - } - setLoading(false); - setSlow(false); - clearTimeout(handler); - }; - - function cancel() { - setLoading(false); - setSlow(false); - } - - return { - request, - cancel, - data, - isSlow, - isLoading, - error, - }; -} diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts @@ -1,131 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AccessToken, - Codec, - buildCodecForObject, - buildCodecForUnion, - codecForBoolean, - codecForConstString, - codecForString, -} from "@gnu-taler/taler-util"; -import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { mutate } from "swr"; - -/** - * Has the information to reach and - * authenticate at the bank's backend. - */ -export type BackendState = LoggedIn | LoggedOut | Expired; - -interface LoggedIn { - status: "loggedIn"; - isUserAdministrator: boolean; - username: string; - token: AccessToken; -} -interface Expired { - status: "expired"; - isUserAdministrator: boolean; - username: string; -} -interface LoggedOut { - status: "loggedOut"; -} - -export const codecForBackendStateLoggedIn = (): Codec<LoggedIn> => - buildCodecForObject<LoggedIn>() - .property("status", codecForConstString("loggedIn")) - .property("username", codecForString()) - .property("token", codecForString() as Codec<AccessToken>) - .property("isUserAdministrator", codecForBoolean()) - .build("BackendState.LoggedIn"); - -export const codecForBackendStateExpired = (): Codec<Expired> => - buildCodecForObject<Expired>() - .property("status", codecForConstString("expired")) - .property("username", codecForString()) - .property("isUserAdministrator", codecForBoolean()) - .build("BackendState.Expired"); - -export const codecForBackendStateLoggedOut = (): Codec<LoggedOut> => - buildCodecForObject<LoggedOut>() - .property("status", codecForConstString("loggedOut")) - .build("BackendState.LoggedOut"); - -export const codecForBackendState = (): Codec<BackendState> => - buildCodecForUnion<BackendState>() - .discriminateOn("status") - .alternative("loggedIn", codecForBackendStateLoggedIn()) - .alternative("loggedOut", codecForBackendStateLoggedOut()) - .alternative("expired", codecForBackendStateExpired()) - .build("BackendState"); - -export const defaultState: BackendState = { - status: "loggedOut", -}; - -export interface BackendStateHandler { - state: BackendState; - logOut(): void; - expired(): void; - logIn(info: { username: string; token: AccessToken }): void; -} - -const BACKEND_STATE_KEY = buildStorageKey("bank-state", codecForBackendState()); - -/** - * Return getters and setters for - * login credentials and backend's - * base URL. - */ -export function useBackendState(): BackendStateHandler { - const { value: state, update } = useLocalStorage( - BACKEND_STATE_KEY, - defaultState, - ); - - return { - state, - logOut() { - update(defaultState); - }, - expired() { - if (state.status === "loggedOut") return; - const nextState: BackendState = { - status: "expired", - username: state.username, - isUserAdministrator: state.username === "admin", - }; - update(nextState); - }, - logIn(info) { - // admin is defined by the username - const nextState: BackendState = { - status: "loggedIn", - ...info, - isUserAdministrator: info.username === "admin", - }; - update(nextState); - cleanAllCache(); - }, - }; -} - -function cleanAllCache(): void { - mutate(() => true, undefined, { revalidate: false }); -} diff --git a/packages/demobank-ui/src/hooks/bank-state.ts b/packages/demobank-ui/src/hooks/bank-state.ts @@ -141,6 +141,8 @@ const codecForChallenge = (): Codec<ChallengeInProgess> => .alternative("update-password", codecForChallengeUpdatePassword()) .build("ChallengeInProgess"); + + interface BankState { currentWithdrawalOperationId: string | undefined; currentChallenge: ChallengeInProgess | undefined; @@ -159,6 +161,14 @@ const defaultBankState: BankState = { const BANK_STATE_KEY = buildStorageKey("bank-app-state", codecForBankState()); +/** + * Client state saved in local storage. + * + * This information is saved in the client because + * the backend server session API is not enough. + * + * @returns tuple of [state, update(), reset()] + */ export function useBankState(): [ Readonly<BankState>, <T extends keyof BankState>(key: T, value: BankState[T]) => void, @@ -175,3 +185,4 @@ export function useBankState(): [ } return [value, updateField, reset]; } + diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts @@ -1,484 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { PAGE_SIZE } from "../utils.js"; -import { useBackendState } from "./backend.js"; - -import { - AccessToken, - AmountJson, - Amounts, - HttpStatusCode, - OperationOk, - TalerBankConversionResultByMethod, - TalerCoreBankErrorsByMethod, - TalerCoreBankResultByMethod, - TalerCorebankApi, - TalerError, - TalerHttpError, - opFixedSuccess, -} from "@gnu-taler/taler-util"; -import _useSWR, { SWRHook, mutate } from "swr"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useState } from "preact/hooks"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -const useSWR = _useSWR as unknown as SWRHook; - -export type TransferCalculation = { - debit: AmountJson; - credit: AmountJson; - beforeFee: AmountJson; -} | "amount-is-too-small"; -type EstimatorFunction = ( - amount: AmountJson, - fee: AmountJson, -) => Promise<TransferCalculation>; - -type ConversionEstimators = { - estimateByCredit: EstimatorFunction; - estimateByDebit: EstimatorFunction; -}; - -export function revalidateConversionInfo() { - return mutate( - (key) => - Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI", - ); -} -export function useConversionInfo() { - const { api, config } = useBankCoreApiContext(); - - async function fetcher() { - return await api.getConversionInfoAPI().getConfig(); - } - const { data, error } = useSWR< - TalerBankConversionResultByMethod<"getConfig">, - TalerHttpError - >(!config.allow_conversion ? undefined : ["getConversionInfoAPI"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function useCashinEstimator(): ConversionEstimators { - const { api } = useBankCoreApiContext(); - return { - estimateByCredit: async (fiatAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashinRate({ - credit: fiatAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.sub(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - estimateByDebit: async (regionalAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashinRate({ - debit: regionalAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.add(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - }; -} - -export function useCashoutEstimator(): ConversionEstimators { - const { api } = useBankCoreApiContext(); - return { - estimateByCredit: async (fiatAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashoutRate({ - credit: fiatAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.sub(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - estimateByDebit: async (regionalAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashoutRate({ - debit: regionalAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.add(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - }; -} - -/** - * @deprecated use useCashoutEstimator - */ -export function useEstimator(): ConversionEstimators { - return useCashoutEstimator() -} - -export function revalidateBusinessAccounts() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); -} -export function useBusinessAccounts() { - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - const { api } = useBankCoreApiContext(); - - const [offset, setOffset] = useState<number | undefined>(); - - function fetcher([token, offset]: [AccessToken, number]) { - // FIXME: add account name filter - return api.getAccounts( - token, - {}, - { - limit: PAGE_SIZE + 1, - offset: String(offset), - order: "asc", - }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getAccounts">, - TalerHttpError - >([token, offset ?? 0, "getAccounts"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - const isLastPage = - data && data.type === "ok" && data.body.accounts.length <= PAGE_SIZE; - const isFirstPage = !offset; - - const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : [] - if (result.length == PAGE_SIZE + 1) { - result.pop() - } - const pagination = { - result, - isLastPage, - isFirstPage, - loadNext: () => { - if (!result.length) return; - setOffset(result[result.length - 1].row_id); - }, - loadFirst: () => { - setOffset(0); - }, - }; - - if (data) return { ok: true, data, ...pagination }; - if (error) return error; - return undefined; -} - -type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number }; -function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { - return c !== undefined; -} -export function revalidateOnePendingCashouts() { - return mutate( - (key) => - Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", undefined, { revalidate: true } - ); -} -export function useOnePendingCashouts(account: string) { - const { state: credentials } = useBackendState(); - const { api, config } = useBankCoreApiContext(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - async function fetcher([username, token]: [string, AccessToken]) { - const list = await api.getAccountCashouts({ username, token }); - if (list.type !== "ok") { - return list; - } - const pendingCashout = list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; - if (!pendingCashout) return opFixedSuccess(list.httpResp, undefined); - const cashoutInfo = await api.getCashoutById( - { username, token }, - pendingCashout.cashout_id, - ); - if (cashoutInfo.type !== "ok") { - return cashoutInfo; - } - return opFixedSuccess(list.httpResp, { - ...cashoutInfo.body, - id: pendingCashout.cashout_id, - }); - } - - const { data, error } = useSWR< - | OperationOk<CashoutWithId | undefined> - | TalerCoreBankErrorsByMethod<"getAccountCashouts"> - | TalerCoreBankErrorsByMethod<"getCashoutById">, - TalerHttpError - >( - !config.allow_conversion - ? undefined - : [account, token, "useOnePendingCashouts"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateCashouts() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); -} -export function useCashouts(account: string) { - const { state: credentials } = useBackendState(); - const { api, config } = useBankCoreApiContext(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - async function fetcher([username, token]: [string, AccessToken]) { - const list = await api.getAccountCashouts({ username, token }); - if (list.type !== "ok") { - return list; - } - const all: Array<CashoutWithId | undefined> = await Promise.all( - list.body.cashouts.map(async (c) => { - const r = await api.getCashoutById({ username, token }, c.cashout_id); - if (r.type === "fail") { - return undefined; - } - return { ...r.body, id: c.cashout_id }; - }), - ); - const cashouts = all.filter(notUndefined); - return { type: "ok" as const, body: { cashouts }, httpResp: list.httpResp }; - } - const { data, error } = useSWR< - | OperationOk<{ cashouts: CashoutWithId[] }> - | TalerCoreBankErrorsByMethod<"getAccountCashouts">, - TalerHttpError - >( - !config.allow_conversion ? undefined : [account, token, "useCashouts"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateCashoutDetails() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", undefined, { revalidate: true } - ); -} -export function useCashoutDetails(cashoutId: number | undefined) { - const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token, id]: [string, AccessToken, number]) { - return api.getCashoutById({ username, token }, id); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getCashoutById">, - TalerHttpError - >( - cashoutId === undefined - ? undefined - : [creds?.username, creds?.token, cashoutId, "getCashoutById"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} -export type MonitorMetrics = { - lastHour: TalerCoreBankResultByMethod<"getMonitor">; - lastDay: TalerCoreBankResultByMethod<"getMonitor">; - lastMonth: TalerCoreBankResultByMethod<"getMonitor">; -}; - -export type LastMonitor = { - current: TalerCoreBankResultByMethod<"getMonitor">; - previous: TalerCoreBankResultByMethod<"getMonitor">; -}; -export function revalidateLastMonitorInfo() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", undefined, { revalidate: true } - ); -} -export function useLastMonitorInfo( - currentMoment: number, - previousMoment: number, - timeframe: TalerCorebankApi.MonitorTimeframeParam, -) { - const { api } = useBankCoreApiContext(); - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - async function fetcher([token, timeframe]: [ - AccessToken, - TalerCorebankApi.MonitorTimeframeParam, - ]) { - const [current, previous] = await Promise.all([ - api.getMonitor(token, { timeframe, which: currentMoment }), - api.getMonitor(token, { timeframe, which: previousMoment }), - ]); - return { - current, - previous, - }; - } - - const { data, error } = useSWR<LastMonitor, TalerHttpError>( - !token ? undefined : [token, timeframe, "useLastMonitorInfo"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} diff --git a/packages/demobank-ui/src/hooks/form.ts b/packages/demobank-ui/src/hooks/form.ts @@ -0,0 +1,100 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { AmountJson, TalerBankConversionApi, TranslatedString } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; + +export type UIField = { + value: string | undefined; + onUpdate: (s: string) => void; + error: TranslatedString | undefined; +} + +type FormHandler<T> = { + [k in keyof T]?: + T[k] extends string ? UIField : + T[k] extends AmountJson ? UIField : + FormHandler<T[k]>; +} + +export type FormValues<T> = { + [k in keyof T]: + T[k] extends string ? (string | undefined) : + T[k] extends AmountJson ? (string | undefined) : + FormValues<T[k]>; +} + +export type RecursivePartial<T> = { + [k in keyof T]?: + T[k] extends string ? (string) : + T[k] extends AmountJson ? (AmountJson) : + RecursivePartial<T[k]>; +} + +export type FormErrors<T> = { + [k in keyof T]?: + T[k] extends string ? (TranslatedString) : + T[k] extends AmountJson ? (TranslatedString) : + FormErrors<T[k]>; +} + +export type FormStatus<T> = { + status: "ok", + result: T, + errors: undefined, +} | { + status: "fail", + result: RecursivePartial<T>, + errors: FormErrors<T>, +} + + +function constructFormHandler<T>(form: FormValues<T>, updateForm: (d: FormValues<T>) => void, errors: FormErrors<T> | undefined): FormHandler<T> { + const keys = (Object.keys(form) as Array<keyof T>) + + const handler = keys.reduce((prev, fieldName) => { + const currentValue: any = form[fieldName]; + const currentError: any = errors ? errors[fieldName] : undefined; + function updater(newValue: any) { + updateForm({ ...form, [fieldName]: newValue }) + } + if (typeof currentValue === "object") { + const group = constructFormHandler(currentValue, updater, currentError) + // @ts-expect-error asdasd + prev[fieldName] = group + return prev; + } + const field: UIField = { + error: currentError, + value: currentValue, + onUpdate: updater + } + // @ts-expect-error asdasd + prev[fieldName] = field + return prev + }, {} as FormHandler<T>) + + return handler; +} + +export function useFormState<T>(defaultValue: FormValues<T>, check: (f: FormValues<T>) => FormStatus<T>): [FormHandler<T>, FormStatus<T>] { + const [form, updateForm] = useState<FormValues<T>>(defaultValue) + + const status = check(form) + const handler = constructFormHandler(form, updateForm, status.errors) + + return [handler, status] +} +\ No newline at end of file diff --git a/packages/demobank-ui/src/hooks/index.ts b/packages/demobank-ui/src/hooks/index.ts @@ -1,60 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { StateUpdater } from "preact/hooks"; -import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { codecForBoolean } from "@gnu-taler/taler-util"; -export type ValueOrFunction<T> = T | ((p: T) => T); - -const calculateRootPath = () => { - const rootPath = - typeof window !== "undefined" - ? window.location.origin + window.location.pathname - : "/"; - return rootPath; -}; - -const BACKEND_URL_KEY = buildStorageKey("backend-url"); -const TRIED_LOGIN_KEY = buildStorageKey("tried-login", codecForBoolean()); - -export function useBackendURL( - url?: string, -): [string, boolean, StateUpdater<string>, () => void] { - const { value, update: setter } = useLocalStorage( - BACKEND_URL_KEY, - url || calculateRootPath(), - ); - - const { - value: triedToLog, - update: setTriedToLog, - reset: resetBackend, - } = useLocalStorage(TRIED_LOGIN_KEY); - - const checkedSetter = (v: ValueOrFunction<string>) => { - setTriedToLog(true); - const computedValue = - v instanceof Function ? v(value) : v.replace(/\/$/, ""); - return setter(computedValue); - }; - - return [value, !!triedToLog, checkedSetter, resetBackend]; -} diff --git a/packages/demobank-ui/src/hooks/preferences.ts b/packages/demobank-ui/src/hooks/preferences.ts @@ -36,36 +36,6 @@ interface Preferences { showDebugInfo: boolean; } -export function getAllBooleanPreferences(): Array<keyof Preferences> { - return [ - "fastWithdrawal", - "showDebugInfo", - "showDemoDescription", - "showInstallWallet", - "showWithdrawalSuccess", - ]; -} - -export function getLabelForPreferences( - k: keyof Preferences, - i18n: ReturnType<typeof useTranslationContext>["i18n"], -): TranslatedString { - switch (k) { - case "maxWithdrawalAmount": - return i18n.str`Max withdrawal amount`; - case "showWithdrawalSuccess": - return i18n.str`Show withdrawal confirmation`; - case "showDemoDescription": - return i18n.str`Show demo description`; - case "showInstallWallet": - return i18n.str`Show install wallet first`; - case "fastWithdrawal": - return i18n.str`Use fast withdrawal form`; - case "showDebugInfo": - return i18n.str`Show debug info`; - } -} - export const codecForPreferences = (): Codec<Preferences> => buildCodecForObject<Preferences>() .property("showWithdrawalSuccess", codecForBoolean()) @@ -89,7 +59,11 @@ const BANK_PREFERENCES_KEY = buildStorageKey( "bank-preferences", codecForPreferences(), ); - +/** + * User preferences. + * + * @returns tuple of [state, update()] + */ export function usePreferences(): [ Readonly<Preferences>, <T extends keyof Preferences>(key: T, value: Preferences[T]) => void, @@ -105,3 +79,34 @@ export function usePreferences(): [ } return [value, updateField]; } + +export function getAllBooleanPreferences(): Array<keyof Preferences> { + return [ + "fastWithdrawal", + "showDebugInfo", + "showDemoDescription", + "showInstallWallet", + "showWithdrawalSuccess", + ]; +} + +export function getLabelForPreferences( + k: keyof Preferences, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): TranslatedString { + switch (k) { + case "maxWithdrawalAmount": + return i18n.str`Max withdrawal amount`; + case "showWithdrawalSuccess": + return i18n.str`Show withdrawal confirmation`; + case "showDemoDescription": + return i18n.str`Show demo description`; + case "showInstallWallet": + return i18n.str`Show install wallet first`; + case "fastWithdrawal": + return i18n.str`Use fast withdrawal form`; + case "showDebugInfo": + return i18n.str`Show debug info`; + } +} + diff --git a/packages/demobank-ui/src/hooks/regional.ts b/packages/demobank-ui/src/hooks/regional.ts @@ -0,0 +1,484 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { PAGE_SIZE } from "../utils.js"; +import { useSessionState } from "./session.js"; + +import { + AccessToken, + AmountJson, + Amounts, + HttpStatusCode, + OperationOk, + TalerBankConversionResultByMethod, + TalerCoreBankErrorsByMethod, + TalerCoreBankResultByMethod, + TalerCorebankApi, + TalerError, + TalerHttpError, + opFixedSuccess, +} from "@gnu-taler/taler-util"; +import _useSWR, { SWRHook, mutate } from "swr"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useState } from "preact/hooks"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +const useSWR = _useSWR as unknown as SWRHook; + +export type TransferCalculation = { + debit: AmountJson; + credit: AmountJson; + beforeFee: AmountJson; +} | "amount-is-too-small"; +type EstimatorFunction = ( + amount: AmountJson, + fee: AmountJson, +) => Promise<TransferCalculation>; + +type ConversionEstimators = { + estimateByCredit: EstimatorFunction; + estimateByDebit: EstimatorFunction; +}; + +export function revalidateConversionInfo() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI", + ); +} +export function useConversionInfo() { + const { api, config } = useBankCoreApiContext(); + + async function fetcher() { + return await api.getConversionInfoAPI().getConfig(); + } + const { data, error } = useSWR< + TalerBankConversionResultByMethod<"getConfig">, + TalerHttpError + >(!config.allow_conversion ? undefined : ["getConversionInfoAPI"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function useCashinEstimator(): ConversionEstimators { + const { api } = useBankCoreApiContext(); + return { + estimateByCredit: async (fiatAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashinRate({ + credit: fiatAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.sub(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + estimateByDebit: async (regionalAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashinRate({ + debit: regionalAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.add(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + }; +} + +export function useCashoutEstimator(): ConversionEstimators { + const { api } = useBankCoreApiContext(); + return { + estimateByCredit: async (fiatAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashoutRate({ + credit: fiatAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.sub(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + estimateByDebit: async (regionalAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashoutRate({ + debit: regionalAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.add(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + }; +} + +/** + * @deprecated use useCashoutEstimator + */ +export function useEstimator(): ConversionEstimators { + return useCashoutEstimator() +} + +export function revalidateBusinessAccounts() { + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); +} +export function useBusinessAccounts() { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { api } = useBankCoreApiContext(); + + const [offset, setOffset] = useState<number | undefined>(); + + function fetcher([token, offset]: [AccessToken, number]) { + // FIXME: add account name filter + return api.getAccounts( + token, + {}, + { + limit: PAGE_SIZE + 1, + offset: String(offset), + order: "asc", + }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getAccounts">, + TalerHttpError + >([token, offset ?? 0, "getAccounts"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + const isLastPage = + data && data.type === "ok" && data.body.accounts.length <= PAGE_SIZE; + const isFirstPage = !offset; + + const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : [] + if (result.length == PAGE_SIZE + 1) { + result.pop() + } + const pagination = { + result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + setOffset(result[result.length - 1].row_id); + }, + loadFirst: () => { + setOffset(0); + }, + }; + + if (data) return { ok: true, data, ...pagination }; + if (error) return error; + return undefined; +} + +type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number }; +function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { + return c !== undefined; +} +export function revalidateOnePendingCashouts() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", undefined, { revalidate: true } + ); +} +export function useOnePendingCashouts(account: string) { + const { state: credentials } = useSessionState(); + const { api, config } = useBankCoreApiContext(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + async function fetcher([username, token]: [string, AccessToken]) { + const list = await api.getAccountCashouts({ username, token }); + if (list.type !== "ok") { + return list; + } + const pendingCashout = list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; + if (!pendingCashout) return opFixedSuccess(list.httpResp, undefined); + const cashoutInfo = await api.getCashoutById( + { username, token }, + pendingCashout.cashout_id, + ); + if (cashoutInfo.type !== "ok") { + return cashoutInfo; + } + return opFixedSuccess(list.httpResp, { + ...cashoutInfo.body, + id: pendingCashout.cashout_id, + }); + } + + const { data, error } = useSWR< + | OperationOk<CashoutWithId | undefined> + | TalerCoreBankErrorsByMethod<"getAccountCashouts"> + | TalerCoreBankErrorsByMethod<"getCashoutById">, + TalerHttpError + >( + !config.allow_conversion + ? undefined + : [account, token, "useOnePendingCashouts"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateCashouts() { + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); +} +export function useCashouts(account: string) { + const { state: credentials } = useSessionState(); + const { api, config } = useBankCoreApiContext(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + async function fetcher([username, token]: [string, AccessToken]) { + const list = await api.getAccountCashouts({ username, token }); + if (list.type !== "ok") { + return list; + } + const all: Array<CashoutWithId | undefined> = await Promise.all( + list.body.cashouts.map(async (c) => { + const r = await api.getCashoutById({ username, token }, c.cashout_id); + if (r.type === "fail") { + return undefined; + } + return { ...r.body, id: c.cashout_id }; + }), + ); + const cashouts = all.filter(notUndefined); + return { type: "ok" as const, body: { cashouts }, httpResp: list.httpResp }; + } + const { data, error } = useSWR< + | OperationOk<{ cashouts: CashoutWithId[] }> + | TalerCoreBankErrorsByMethod<"getAccountCashouts">, + TalerHttpError + >( + !config.allow_conversion ? undefined : [account, token, "useCashouts"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateCashoutDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", undefined, { revalidate: true } + ); +} +export function useCashoutDetails(cashoutId: number | undefined) { + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, id]: [string, AccessToken, number]) { + return api.getCashoutById({ username, token }, id); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getCashoutById">, + TalerHttpError + >( + cashoutId === undefined + ? undefined + : [creds?.username, creds?.token, cashoutId, "getCashoutById"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} +export type MonitorMetrics = { + lastHour: TalerCoreBankResultByMethod<"getMonitor">; + lastDay: TalerCoreBankResultByMethod<"getMonitor">; + lastMonth: TalerCoreBankResultByMethod<"getMonitor">; +}; + +export type LastMonitor = { + current: TalerCoreBankResultByMethod<"getMonitor">; + previous: TalerCoreBankResultByMethod<"getMonitor">; +}; +export function revalidateLastMonitorInfo() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", undefined, { revalidate: true } + ); +} +export function useLastMonitorInfo( + currentMoment: number, + previousMoment: number, + timeframe: TalerCorebankApi.MonitorTimeframeParam, +) { + const { api } = useBankCoreApiContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + async function fetcher([token, timeframe]: [ + AccessToken, + TalerCorebankApi.MonitorTimeframeParam, + ]) { + const [current, previous] = await Promise.all([ + api.getMonitor(token, { timeframe, which: currentMoment }), + api.getMonitor(token, { timeframe, which: previousMoment }), + ]); + return { + current, + previous, + }; + } + + const { data, error } = useSWR<LastMonitor, TalerHttpError>( + !token ? undefined : [token, timeframe, "useLastMonitorInfo"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} diff --git a/packages/demobank-ui/src/hooks/session.ts b/packages/demobank-ui/src/hooks/session.ts @@ -0,0 +1,131 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AccessToken, + Codec, + buildCodecForObject, + buildCodecForUnion, + codecForBoolean, + codecForConstString, + codecForString, +} from "@gnu-taler/taler-util"; +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { mutate } from "swr"; + +/** + * Has the information to reach and + * authenticate at the bank's backend. + */ +export type SessionState = LoggedIn | LoggedOut | Expired; + +interface LoggedIn { + status: "loggedIn"; + isUserAdministrator: boolean; + username: string; + token: AccessToken; +} +interface Expired { + status: "expired"; + isUserAdministrator: boolean; + username: string; +} +interface LoggedOut { + status: "loggedOut"; +} + +export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> => + buildCodecForObject<LoggedIn>() + .property("status", codecForConstString("loggedIn")) + .property("username", codecForString()) + .property("token", codecForString() as Codec<AccessToken>) + .property("isUserAdministrator", codecForBoolean()) + .build("SessionState.LoggedIn"); + +export const codecForSessionStateExpired = (): Codec<Expired> => + buildCodecForObject<Expired>() + .property("status", codecForConstString("expired")) + .property("username", codecForString()) + .property("isUserAdministrator", codecForBoolean()) + .build("SessionState.Expired"); + +export const codecForSessionStateLoggedOut = (): Codec<LoggedOut> => + buildCodecForObject<LoggedOut>() + .property("status", codecForConstString("loggedOut")) + .build("SessionState.LoggedOut"); + +export const codecForSessionState = (): Codec<SessionState> => + buildCodecForUnion<SessionState>() + .discriminateOn("status") + .alternative("loggedIn", codecForSessionStateLoggedIn()) + .alternative("loggedOut", codecForSessionStateLoggedOut()) + .alternative("expired", codecForSessionStateExpired()) + .build("SessionState"); + +export const defaultState: SessionState = { + status: "loggedOut", +}; + +export interface SessionStateHandler { + state: SessionState; + logOut(): void; + expired(): void; + logIn(info: { username: string; token: AccessToken }): void; +} + +const SESSION_STATE_KEY = buildStorageKey("bank-state", codecForSessionState()); + +/** + * Return getters and setters for + * login credentials and backend's + * base URL. + */ +export function useSessionState(): SessionStateHandler { + const { value: state, update } = useLocalStorage( + SESSION_STATE_KEY, + defaultState, + ); + + return { + state, + logOut() { + update(defaultState); + }, + expired() { + if (state.status === "loggedOut") return; + const nextState: SessionState = { + status: "expired", + username: state.username, + isUserAdministrator: state.username === "admin", + }; + update(nextState); + }, + logIn(info) { + // admin is defined by the username + const nextState: SessionState = { + status: "loggedIn", + ...info, + isUserAdministrator: info.username === "admin", + }; + update(nextState); + cleanAllCache(); + }, + }; +} + +function cleanAllCache(): void { + mutate(() => true, undefined, { revalidate: false }); +} diff --git a/packages/demobank-ui/src/index.tsx b/packages/demobank-ui/src/index.tsx @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import App from "./components/app.js"; +import { App } from "./app.js"; import { h, render } from "preact"; import "./scss/main.css"; diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts @@ -21,7 +21,7 @@ import { assertUnreachable, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { useAccountDetails } from "../../hooks/access.js"; +import { useAccountDetails } from "../../hooks/account.js"; import { Props, State } from "./index.js"; export function useComponentState({ diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -28,8 +28,8 @@ import { ComponentChildren, VNode, h } from "preact"; import { useEffect, useErrorBoundary } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; import { useSettingsContext } from "../context/settings.js"; -import { useAccountDetails } from "../hooks/access.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useAccountDetails } from "../hooks/account.js"; +import { useSessionState } from "../hooks/session.js"; import { useBankState } from "../hooks/bank-state.js"; import { getAllBooleanPreferences, @@ -52,7 +52,7 @@ export function BankFrame({ children: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendState(); + const session = useSessionState(); const settings = useSettingsContext(); const [preferences, updatePreferences] = usePreferences(); const [, , resetBankState] = useBankState(); @@ -86,10 +86,10 @@ export function BankFrame({ iconLinkURL={settings.iconLinkURL ?? "#"} profileURL={routeAccountDetails?.url({})} onLogout={ - backend.state.status !== "loggedIn" + session.state.status !== "loggedIn" ? undefined : () => { - backend.logOut(); + session.logOut(); resetBankState(); } } diff --git a/packages/demobank-ui/src/pages/ConversionConfig.tsx b/packages/demobank-ui/src/pages/ConversionConfig.tsx @@ -1,1018 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AmountJson, - Amounts, - HttpStatusCode, - TalerBankConversionApi, - TalerError, - TranslatedString, - assertUnreachable -} from "@gnu-taler/taler-util"; -import { - Attention, - InternationalizationAPI, - LocalNotificationBanner, - ShowInputErrorLabel, - useLocalNotification, - useTranslationContext, - utils -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; -import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../hooks/circuit.js"; -import { RouteDefinition } from "../route.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { InputAmount, RenderAmount } from "./PaytoWireTransferForm.js"; -import { ProfileNavigation } from "./ProfileNavigation.js"; - -interface Props { - routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; - routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; - routeCancel: RouteDefinition; - onUpdateSuccess: () => void; -} - -type UIField = { - value: string | undefined; - onUpdate: (s: string) => void; - error: TranslatedString | undefined; -} - -type FormHandler<T> = { - [k in keyof T]?: - T[k] extends string ? UIField : - T[k] extends AmountJson ? UIField : - FormHandler<T[k]>; -} - -type FormValues<T> = { - [k in keyof T]: - T[k] extends string ? (string | undefined) : - T[k] extends AmountJson ? (string | undefined) : - FormValues<T[k]>; -} - -type RecursivePartial<T> = { - [k in keyof T]?: - T[k] extends string ? (string) : - T[k] extends AmountJson ? (AmountJson) : - RecursivePartial<T[k]>; -} - -type FormErrors<T> = { - [k in keyof T]?: - T[k] extends string ? (TranslatedString) : - T[k] extends AmountJson ? (TranslatedString) : - FormErrors<T[k]>; -} - -type FormStatus<T> = { - status: "ok", - result: T, - errors: undefined, -} | { - status: "fail", - result: RecursivePartial<T>, - errors: FormErrors<T>, -} -type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } - -function constructFormHandler<T>(form: FormValues<T>, updateForm: (d: FormValues<T>) => void, errors: FormErrors<T> | undefined): FormHandler<T> { - const keys = (Object.keys(form) as Array<keyof T>) - - const handler = keys.reduce((prev, fieldName) => { - const currentValue: any = form[fieldName]; - const currentError: any = errors ? errors[fieldName] : undefined; - function updater(newValue: any) { - updateForm({ ...form, [fieldName]: newValue }) - } - if (typeof currentValue === "object") { - const group = constructFormHandler(currentValue, updater, currentError) - // @ts-expect-error asdasd - prev[fieldName] = group - return prev; - } - const field: UIField = { - error: currentError, - value: currentValue, - onUpdate: updater - } - // @ts-expect-error asdasd - prev[fieldName] = field - return prev - }, {} as FormHandler<T>) - - return handler; -} - -function useFormState<T>(defaultValue: FormValues<T>, check: (f: FormValues<T>) => FormStatus<T>): [FormHandler<T>, FormStatus<T>] { - const [form, updateForm] = useState<FormValues<T>>(defaultValue) - - const status = check(form) - const handler = constructFormHandler(form, updateForm, status.errors) - - return [handler, status] -} - -function useComponentState({ - onUpdateSuccess, - routeCancel, - routeConversionConfig, - routeMyAccountCashout, - routeMyAccountDelete, - routeMyAccountDetails, - routeMyAccountPassword, -}: Props): utils.RecursiveState<VNode> { - - const result = useConversionInfo() - const info = result && !(result instanceof TalerError) && result.type === "ok" ? - result.body : undefined; - - const { state: credentials } = useBackendState(); - const creds = - credentials.status !== "loggedIn" || !credentials.isUserAdministrator - ? undefined - : credentials; - - if (!info) { - return <div>waiting...</div> - } - - if (!creds) { - return <div>only admin can setup conversion</div>; - } - - return () => { - const { i18n } = useTranslationContext(); - - const { api, config } = useBankCoreApiContext(); - - const [notification, notify, handleError] = useLocalNotification(); - - const initalState: FormValues<FormType> = { - amount: "100", - conv: { - cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], - cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], - cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], - cashin_ratio: info.conversion_rate.cashin_ratio, - cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, - cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], - cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1], - cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], - cashout_ratio: info.conversion_rate.cashout_ratio, - cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, - } - } - - const [form, status] = useFormState<FormType>( - initalState, - checkConversionForm(i18n, info.regional_currency, info.fiat_currency) - ) - - const { - estimateByDebit: calculateCashoutFromDebit, - } = useCashoutEstimator(); - - const { - estimateByDebit: calculateCashinFromDebit, - } = useCashinEstimator(); - - const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>() - - useEffect(() => { - async function doAsync() { - await handleError(async () => { - if (!info) return; - if (!form.amount?.value || form.amount.error) return; - const in_amount = Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`) - const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee) - const cashin = await calculateCashinFromDebit(in_amount, in_fee); - - if (cashin === "amount-is-too-small") { - setCalc(undefined) - return; - } - // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) - const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee) - const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); - - setCalc({ cashin, cashout }); - }); - } - doAsync(); - }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]); - - const [section, setSection] = useState<"detail" | "cashout" | "cashin">("detail") - const cashinCalc = calculationResult?.cashin === "amount-is-too-small" ? undefined : calculationResult?.cashin - const cashoutCalc = calculationResult?.cashout === "amount-is-too-small" ? undefined : calculationResult?.cashout - async function doUpdate() { - if (!creds) return - await handleError(async () => { - if (status.status === "fail") return; - const resp = await api - .getConversionInfoAPI() - .updateConversionRate(creds.token, status.result.conv) - if (resp.type === "ok") { - setSection("detail") - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: { - return notify({ - type: "error", - title: i18n.str`Wrong credentials`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - case HttpStatusCode.NotImplemented: { - return notify({ - type: "error", - title: i18n.str`Conversion is disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - default: - assertUnreachable(resp); - } - } - }); - } - - const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) - const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) - - const both_high = in_ratio > 1 && out_ratio > 1; - const both_low = in_ratio < 1 && out_ratio < 1; - - - return ( - <div> - <ProfileNavigation current="conversion" - routeMyAccountCashout={routeMyAccountCashout} - routeMyAccountDelete={routeMyAccountDelete} - routeMyAccountDetails={routeMyAccountDetails} - routeMyAccountPassword={routeMyAccountPassword} - routeConversionConfig={routeConversionConfig} - /> - - <LocalNotificationBanner notification={notification} /> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Conversion</i18n.Translate> - </h2> - <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> - <label - data-enabled={section === "detail"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="Newsletter" - class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" - onChange={() => { - setSection("detail") - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Details</i18n.Translate> - </span> - </span> - </span> - </label> - - <label - data-enabled={section === "cashout"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="Existing Customers" - class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" - onChange={() => { - setSection("cashout") - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Config cashout</i18n.Translate> - </span> - </span> - </span> - </label> - <label - data-enabled={section === "cashin"} - class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" - > - <input - type="radio" - name="project-type" - value="Existing Customers" - class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" - onChange={() => { - setSection("cashin") - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Config cashin</i18n.Translate> - </span> - </span> - </span> - </label> - </div> - - </div> - - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} - > - {section == "cashin" && - <ConversionForm id="cashin" - inputCurrency={info.fiat_currency} - outputCurrency={info.regional_currency} - fee={form?.conv?.cashin_fee} - minimum={form?.conv?.cashin_min_amount} - ratio={form?.conv?.cashin_ratio} - rounding={form?.conv?.cashin_rounding_mode} - tiny={form?.conv?.cashin_tiny_amount} - />} - - - - {section == "cashout" && <Fragment> - <ConversionForm id="cashout" - inputCurrency={info.regional_currency} - outputCurrency={info.fiat_currency} - fee={form?.conv?.cashout_fee} - minimum={form?.conv?.cashout_min_amount} - ratio={form?.conv?.cashout_ratio} - rounding={form?.conv?.cashout_rounding_mode} - tiny={form?.conv?.cashout_tiny_amount} - /> - </Fragment>} - - - - - {section == "detail" && <Fragment> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin ratio</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - {info.conversion_rate.cashin_ratio} - </dd> - </div> - </div> - - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout ratio</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - {info.conversion_rate.cashout_ratio} - </dd> - </div> - </div> - - {both_low || both_high ? <div class="p-4"> - <Attention title={i18n.str`Bad ratios`} type="warning"> - <i18n.Translate> - One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1. - </i18n.Translate> - </Attention> - </div> : undefined} - - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - for="amount" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Initial amount`}</label> - <InputAmount - name="amount" - left - currency={info.fiat_currency} - value={form.amount?.value ?? ""} - onChange={form.amount?.onUpdate} - /> - <ShowInputErrorLabel - message={form.amount?.error} - isDirty={form.amount?.value !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Use it to test how the conversion will affect the amount.</i18n.Translate> - </p> - </div> - </div> - </div> - - {!cashoutCalc || !cashinCalc ? undefined : ( - <div class="px-6 pt-6"> - <div class="sm:col-span-5"> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Sending to this bank</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={cashinCalc.debit} - negative - withColor - spec={info.regional_currency_specification} - /> - </dd> - </div> - - {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( - <div class="flex items-center justify-between afu "> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Converted</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={cashinCalc.beforeFee} - spec={info.fiat_currency_specification} - /> - </dd> - </div> - )} - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> - <i18n.Translate>Cashin after fee</i18n.Translate> - </dt> - <dd class="text-lg text-gray-900 font-medium"> - <RenderAmount - value={cashinCalc.credit} - withColor - spec={info.fiat_currency_specification} - /> - </dd> - </div> - </dl> - </div> - - <div class="sm:col-span-5"> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Sending from this bank</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={cashoutCalc.debit} - negative - withColor - spec={info.fiat_currency_specification} - /> - </dd> - </div> - - {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( - <div class="flex items-center justify-between afu"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Converted</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={cashoutCalc.beforeFee} - spec={info.regional_currency_specification} - /> - </dd> - </div> - )} - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> - <i18n.Translate>Cashout after fee</i18n.Translate> - </dt> - <dd class="text-lg text-gray-900 font-medium"> - <RenderAmount - value={cashoutCalc.credit} - withColor - spec={info.regional_currency_specification} - /> - </dd> - </div> - </dl> - </div> - - {cashoutCalc && status.status === "ok" && Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ? <div class="p-4"> - <Attention title={i18n.str`Bad configuration`} type="warning"> - <i18n.Translate> - This configuration allows users to cash out more of what has been cashed in. - </i18n.Translate> - </Attention> - </div> : undefined} - </div> - )} - </Fragment>} - - - <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> - <a name="cancel" - href={routeCancel.url({})} - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - {section == "cashin" || section == "cashout" ? <Fragment> - <button - type="submit" - name="update conversion" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={async () => { - doUpdate() - }} - > - <i18n.Translate>Update</i18n.Translate> - </button> - </Fragment> : <div />} - </div> - - - </form> - </div> - </div> - ); - - } -} - -/** - * Show histories of public accounts. - */ -export const ConversionConfig = utils.recursive(useComponentState); - -function checkConversionForm(i18n: InternationalizationAPI, regional: string, fiat: string) { - return function check(state: FormValues<FormType>): FormStatus<FormType> { - - const cashin_min_amount = Amounts.parse(`${fiat}:${state.conv.cashin_min_amount}`) - const cashin_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`) - const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`) - - const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`) - const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`) - const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`) - - const am = Amounts.parse(`${fiat}:${state.amount}`) - - const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "") - const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "") - - const errors = undefinedIfEmpty<FormErrors<FormType>>({ - conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ - cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : - !cashin_min_amount ? i18n.str`invalid` : - undefined, - cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : - !cashin_tiny_amount ? i18n.str`invalid` : - undefined, - cashin_fee: !state.conv.cashin_fee ? i18n.str`required` : - !cashin_fee ? i18n.str`invalid` : - undefined, - - cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : - !cashout_min_amount ? i18n.str`invalid` : - undefined, - cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : - !cashout_tiny_amount ? i18n.str`invalid` : - undefined, - cashout_fee: !state.conv.cashin_fee ? i18n.str`required` : - !cashout_fee ? i18n.str`invalid` : - undefined, - - cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, - cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, - - cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, - cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, - }), - - amount: !state.amount ? i18n.str`required` : - !am ? i18n.str`invalid` : - undefined, - }) - - const result: RecursivePartial<FormType> = { - amount: am, - conv: { - cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, - cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, - cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, - cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? (state.conv.cashin_rounding_mode!) : undefined, - cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined, - cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, - cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, - cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, - cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? (state.conv.cashout_rounding_mode!) : undefined, - cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined, - } - - } - return errors === undefined ? - { status: "ok", result: result as FormType, errors } : - { status: "fail", result, errors } - } -} - - -function ConversionForm({ id, inputCurrency, outputCurrency, fee, minimum, ratio, rounding, tiny }: { - inputCurrency: string, - outputCurrency: string, - minimum: UIField | undefined, - tiny: UIField | undefined, - fee: UIField | undefined, - rounding: UIField | undefined, - ratio: UIField | undefined, - id: string, -}): VNode { - const { i18n } = useTranslationContext(); - return <Fragment> - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - for="cashin_min_amount" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Minimum amount`}</label> - <InputAmount - name="cashin_min_amount" - left - currency={inputCurrency} - value={minimum?.value ?? ""} - onChange={minimum?.onUpdate} - /> - <ShowInputErrorLabel - message={minimum?.error} - isDirty={minimum?.value !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate> - </p> - </div> - </div> - </div> - - <div class="px-6 pt-6"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="password" - > - {i18n.str`Ratio`} - </label> - <div class="mt-2"> - <input - type="number" - class="block rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="current" - id="cashin_ratio" - data-error={!!ratio?.error && ratio?.value !== undefined} - value={ratio?.value ?? ""} - onChange={(e) => { - ratio?.onUpdate(e.currentTarget.value); - }} - autocomplete="off" - /> - <ShowInputErrorLabel - message={ratio?.error} - isDirty={ratio?.value !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Conversion ratio between currencies - </i18n.Translate> - </p> - </div> - - <div class="px-6 pt-4"> - <Attention title={i18n.str`Example conversion`}> - <i18n.Translate>1 {inputCurrency} will be converted into {ratio?.value} {outputCurrency}</i18n.Translate> - </Attention> - </div> - - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - for="cashin_tiny_amount" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Rounding value`}</label> - <InputAmount - name="cashin_tiny_amount" - left - currency={outputCurrency} - value={tiny?.value ?? ""} - onChange={tiny?.onUpdate} - /> - <ShowInputErrorLabel - message={tiny?.error} - isDirty={tiny?.value !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Smallest difference between two amounts after the ratio is applied.</i18n.Translate> - </p> - </div> - </div> - </div> - - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="channel" - > - {i18n.str`Rounding mode`} - </label> - <div class="mt-2 max-w-xl text-sm text-gray-500"> - <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> - <label - onClick={(e) => { - e.preventDefault(); - rounding?.onUpdate("zero") - }} - data-selected={rounding?.value === "zero"} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" - > - <input - type="radio" - name="channel" - value="Newsletter" - class="sr-only" - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span - id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " - > - <i18n.Translate>Zero</i18n.Translate> - </span> - <i18n.Translate>Amount will be round below to the largest possible value smaller than the input.</i18n.Translate> - </span> - </span> - <svg - data-selected={rounding?.value === "zero"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </label> - - <label - onClick={(e) => { - e.preventDefault(); - rounding?.onUpdate("up") - }} - data-selected={rounding?.value === "up"} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" - > - <input - type="radio" - name="channel" - value="Existing Customers" - class="sr-only" - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span - id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " - > - <i18n.Translate>Up</i18n.Translate> - </span> - <i18n.Translate>Amount will be round up to the smallest possible value larger than the input.</i18n.Translate> - </span> - </span> - <svg - data-selected={rounding?.value === "up"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </label> - <label - onClick={(e) => { - e.preventDefault(); - rounding?.onUpdate("nearest") - }} - data-selected={rounding?.value === "nearest"} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" - > - <input - type="radio" - name="channel" - value="Existing Customers" - class="sr-only" - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span - id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " - > - <i18n.Translate>Nearest</i18n.Translate> - </span> - <i18n.Translate>Amount will be round to the closest possible value.</i18n.Translate> - </span> - </span> - <svg - data-selected={rounding?.value === "nearest"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </label> - </div> - </div> - </div> - </div> - </div> - - <div class="px-6 pt-4"> - <Attention title={i18n.str`Examples`}> - <section class="grid grid-cols-1 gap-y-3 text-gray-600"> - <details class="group text-sm"> - <summary class="flex cursor-pointer flex-row items-center justify-between "> - Rounding an amount of 1.24 with rounding value 0.1 - <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> - </svg> - </summary> - <p class="text-gray-900 my-4"> - Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. - </p> - <p class="text-gray-900 my-4"> - With the "zero" mode the value will be rounded to 1.2 - </p> - <p class="text-gray-900 my-4"> - With the "nearest" mode the value will be rounded to 1.2 - </p> - <p class="text-gray-900 mt-4"> - With the "up" mode the value will be rounded to 1.3 - </p> - </details> - <details class="group "> - <summary class="flex cursor-pointer flex-row items-center justify-between "> - Rounding an amount of 1.26 with rounding value 0.1 - <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> - </svg> - </summary> - <p class="text-gray-900 my-4"> - Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. - </p> - <p class="text-gray-900 my-4"> - With the "zero" mode the value will be rounded to 1.2 - </p> - <p class="text-gray-900 my-4"> - With the "nearest" mode the value will be rounded to 1.3 - </p> - <p class="text-gray-900 my-4"> - With the "up" mode the value will be rounded to 1.3 - </p> - </details> - <details class="group "> - <summary class="flex cursor-pointer flex-row items-center justify-between "> - Rounding an amount of 1.24 with rounding value 0.3 - <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> - </svg> - </summary> - <p class="text-gray-900 my-4"> - Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. - </p> - <p class="text-gray-900 my-4"> - With the "zero" mode the value will be rounded to 1.2 - </p> - <p class="text-gray-900 my-4"> - With the "nearest" mode the value will be rounded to 1.2 - </p> - <p class="text-gray-900 my-4"> - With the "up" mode the value will be rounded to 1.5 - </p> - </details> - <details class="group "> - <summary class="flex cursor-pointer flex-row items-center justify-between "> - Rounding an amount of 1.26 with rounding value 0.3 - <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> - </svg> - </summary> - <p class="text-gray-900 my-4"> - Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. - </p> - <p class="text-gray-900 my-4"> - With the "zero" mode the value will be rounded to 1.2 - </p> - <p class="text-gray-900 my-4"> - With the "nearest" mode the value will be rounded to 1.3 - </p> - <p class="text-gray-900 my-4"> - With the "up" mode the value will be rounded to 1.3 - </p> - </details> - </section> - </Attention> - </div> - - - - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - for="cashin_fee" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Fee`}</label> - <InputAmount - name="cashin_fee" - left - currency={outputCurrency} - value={fee?.value ?? ""} - onChange={fee?.onUpdate} - /> - <ShowInputErrorLabel - message={fee?.error} - isDirty={fee?.value !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Amount to be deducted before amount is credited.</i18n.Translate> - </p> - </div> - </div> - </div> - - </Fragment> -} diff --git a/packages/demobank-ui/src/pages/DownloadStats.tsx b/packages/demobank-ui/src/pages/DownloadStats.tsx @@ -1,585 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AccessToken, - AmountString, - TalerCoreBankHttpClient, - TalerCorebankApi, - TalerError, -} from "@gnu-taler/taler-util"; -import { - Attention, - LocalNotificationBanner, - useLocalNotification, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; -import { EmptyObject, RouteDefinition } from "../route.js"; -import { getTimeframesForDate } from "./admin/AdminHome.js"; - -interface Props { - routeCancel: RouteDefinition; -} - -type Options = { - dayMetric: boolean; - hourMetric: boolean; - monthMetric: boolean; - yearMetric: boolean; - compareWithPrevious: boolean; - endOnFirstFail: boolean; - includeHeader: boolean; -}; - -/** - * Show histories of public accounts. - */ -export function DownloadStats({ routeCancel }: Props): VNode { - const { i18n } = useTranslationContext(); - - const { state: credentials } = useBackendState(); - const creds = - credentials.status !== "loggedIn" || !credentials.isUserAdministrator - ? undefined - : credentials; - const { api } = useBankCoreApiContext(); - - const [options, setOptions] = useState<Options>({ - compareWithPrevious: true, - dayMetric: true, - endOnFirstFail: false, - hourMetric: true, - includeHeader: true, - monthMetric: true, - yearMetric: true, - }); - const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); - const [downloaded, setDownloaded] = useState<string>(); - const referenceDates = [new Date()]; - const [notification, , handleError] = useLocalNotification(); - - if (!creds) { - return <div>only admin can download stats</div>; - } - - return ( - <div> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <LocalNotificationBanner notification={notification} /> - - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Download bank stats</i18n.Translate> - </h2> - </div> - - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} - > - <div class="px-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate>Include hour metric</i18n.Translate> - </span> - </span> - <button - type="button" - name={`hour switch`} - data-enabled={options.hourMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ - ...options, - hourMetric: !options.hourMetric, - }); - }} - > - <span - aria-hidden="true" - data-enabled={options.hourMetric} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate>Include day metric</i18n.Translate> - </span> - </span> - <button - type="button" - name={`day switch`} - data-enabled={!!options.dayMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ ...options, dayMetric: !options.dayMetric }); - }} - > - <span - aria-hidden="true" - data-enabled={options.dayMetric} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate>Include month metric</i18n.Translate> - </span> - </span> - <button - type="button" - name={`month switch`} - data-enabled={!!options.monthMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ - ...options, - monthMetric: !options.monthMetric, - }); - }} - > - <span - aria-hidden="true" - data-enabled={options.monthMetric} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate>Include year metric</i18n.Translate> - </span> - </span> - <button - type="button" - name={`year switch`} - data-enabled={!!options.yearMetric} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ - ...options, - yearMetric: !options.yearMetric, - }); - }} - > - <span - aria-hidden="true" - data-enabled={options.yearMetric} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate>Include table header</i18n.Translate> - </span> - </span> - <button - type="button" - name={`header switch`} - data-enabled={!!options.includeHeader} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ - ...options, - includeHeader: !options.includeHeader, - }); - }} - > - <span - aria-hidden="true" - data-enabled={options.includeHeader} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate> - Add previous metric for compare - </i18n.Translate> - </span> - </span> - <button - type="button" - name={`compare switch`} - data-enabled={!!options.compareWithPrevious} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ - ...options, - compareWithPrevious: !options.compareWithPrevious, - }); - }} - > - <span - aria-hidden="true" - data-enabled={options.compareWithPrevious} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - <div class="sm:col-span-5"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate>Fail on first error</i18n.Translate> - </span> - </span> - <button - type="button" - name={`fail switch`} - data-enabled={!!options.endOnFirstFail} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setOptions({ - ...options, - endOnFirstFail: !options.endOnFirstFail, - }); - }} - > - <span - aria-hidden="true" - data-enabled={options.endOnFirstFail} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </div> - </div> - </div> - - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a name="cancel" - href={routeCancel.url({})} - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <button - type="submit" - name="download" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={lastStep !== undefined} - onClick={async () => { - setDownloaded(undefined); - await handleError(async () => { - const csv = await fetchAllStatus( - api, - creds.token, - options, - referenceDates, - (step, total) => { - setLastStep({ step, total }); - }, - ); - setDownloaded(csv); - }); - setLastStep(undefined); - }} - > - <i18n.Translate>Download</i18n.Translate> - </button> - </div> - </form> - </div> - {!lastStep || lastStep.step === lastStep.total ? ( - <div class="h-5 mb-5" /> - ) : ( - <div> - <div class="relative mb-5 h-5 rounded-full bg-gray-200"> - <div - class="h-full animate-pulse rounded-full bg-blue-500" - style={{ - width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`, - }} - > - <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> - <i18n.Translate> - downloading...{" "} - {Math.round((lastStep.step / lastStep.total) * 100)} - </i18n.Translate> - </span> - </div> - </div> - </div> - )} - {!downloaded ? ( - <div class="h-5 mb-5" /> - ) : ( - <a - href={ - "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded) - } - name="save file" - download={"bank-stats.csv"} - > - <Attention title={i18n.str`Download completed`}> - <i18n.Translate> - Click here to save the file in your computer. - </i18n.Translate> - </Attention> - </a> - )} - </div> - ); -} - -async function fetchAllStatus( - api: TalerCoreBankHttpClient, - token: AccessToken, - options: Options, - references: Date[], - progress: (current: number, total: number) => void, -): Promise<string> { - const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; - if (options.hourMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); - } - if (options.dayMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); - } - if (options.monthMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); - } - if (options.yearMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); - } - - /** - * convert request into frames - */ - const allFrames = allMetrics.flatMap((timeframe) => - references.map((reference) => ({ - reference, - timeframe, - moment: getTimeframesForDate(reference, timeframe), - })), - ); - const total = allFrames.length; - - /** - * call API for info - */ - const allInfo = await allFrames.reduce( - async (prev, frame, index) => { - const accumulatedMap = await prev; - progress(index, total); - // await delay() - const previous = options.compareWithPrevious - ? await api.getMonitor(token, { - timeframe: frame.timeframe, - which: frame.moment.previous, - }) - : undefined; - - if (previous && previous.type === "fail" && options.endOnFirstFail) { - throw TalerError.fromUncheckedDetail(previous.detail); - } - - const current = await api.getMonitor(token, { - timeframe: frame.timeframe, - which: frame.moment.current, - }); - - if (current.type === "fail" && options.endOnFirstFail) { - throw TalerError.fromUncheckedDetail(current.detail); - } - - const metricName = - TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; - accumulatedMap[metricName] = { - reference: frame.reference, - current: current.type !== "ok" ? undefined : current.body, - previous: - !previous || previous.type !== "ok" ? undefined : previous.body, - }; - return accumulatedMap; - }, - Promise.resolve({} as Record<string, Data>), - ); - progress(total, total); - - /** - * convert into table format - * - */ - const table: Array<string[]> = []; - if (options.includeHeader) { - table.push([ - "date", - "metric", - "reference", - "talerInCount", - "talerInVolume", - "talerOutCount", - "talerOutVolume", - "cashinCount", - "cashinFiatVolume", - "cashinRegionalVolume", - "cashoutCount", - "cashoutFiatVolume", - "cashoutRegionalVolume", - ]); - } - Object.entries(allInfo).forEach(([name, data]) => { - if (data.current) { - const row: TableRow = { - date: data.reference.getTime(), - metric: name, - reference: "current", - ...dataToRow(data.current), - }; - table.push(Object.values(row) as string[]); - } - - if (data.previous) { - const row: TableRow = { - date: data.reference.getTime(), - metric: name, - reference: "previous", - ...dataToRow(data.previous), - }; - table.push(Object.values(row) as string[]); - } - }); - - const csv = table.reduce((acc, row) => { - return acc + row.join(",") + "\n"; - }, ""); - - return csv; -} - -type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">; -function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { - return { - talerInCount: info.talerInCount, - talerInVolume: info.talerInVolume, - talerOutCount: info.talerOutCount, - talerOutVolume: info.talerOutVolume, - cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, - cashinFiatVolume: - info.type === "no-conversions" ? undefined : info.cashinFiatVolume, - cashinRegionalVolume: - info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, - cashoutCount: - info.type === "no-conversions" ? undefined : info.cashoutCount, - cashoutFiatVolume: - info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, - cashoutRegionalVolume: - info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, - }; -} - -type Data = { - reference: Date; - previous: TalerCorebankApi.MonitorResponse | undefined; - current: TalerCorebankApi.MonitorResponse | undefined; -}; -type TableRow = { - date: number; - metric: string; - reference: "current" | "previous"; - cashinCount?: number; - cashinRegionalVolume?: AmountString; - cashinFiatVolume?: AmountString; - cashoutCount?: number; - cashoutRegionalVolume?: AmountString; - cashoutFiatVolume?: AmountString; - talerInCount: number; - talerInVolume: AmountString; - talerOutCount: number; - talerOutVolume: AmountString; -}; diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -27,7 +27,7 @@ import { import { VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; @@ -44,10 +44,10 @@ export function LoginForm({ currentUser?: string; routeRegister?: RouteDefinition; }): VNode { - const backend = useBackendState(); + const session = useSessionState(); const sessionUser = - backend.state.status !== "loggedOut" ? backend.state.username : undefined; + session.state.status !== "loggedOut" ? session.state.username : undefined; const [username, setUsername] = useState<string | undefined>( currentUser ?? sessionUser, ); @@ -73,7 +73,7 @@ export function LoginForm({ }); async function doLogout() { - backend.logOut(); + session.logOut(); } const loginHandler = !username || !password ? undefined : withErrorHandler( @@ -86,7 +86,7 @@ export function LoginForm({ refreshable: true, }), (result) => { - backend.logIn({ username, token: result.body.access_token }) + session.logIn({ username, token: result.body.access_token }) }, (fail) => { switch (fail.case) { @@ -173,7 +173,7 @@ export function LoginForm({ </div> </div> - {backend.state.status !== "loggedOut" ? ( + {session.state.status !== "loggedOut" ? ( <div class="flex justify-between"> <button type="submit" diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts @@ -28,8 +28,8 @@ import { utils } from "@gnu-taler/web-util/browser"; import { useEffect, useState } from "preact/hooks"; import { mutate } from "swr"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useWithdrawalDetails } from "../../hooks/access.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useWithdrawalDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { Props, State } from "./index.js"; @@ -43,7 +43,7 @@ export function useComponentState({ }: Props): utils.RecursiveState<State> { const [settings] = usePreferences(); const [bankState, updateBankState] = useBankState(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { api } = useBankCoreApiContext(); diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -21,9 +21,9 @@ import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; import { EmptyObject, RouteDefinition } from "../route.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { useWithdrawalDetails } from "../hooks/access.js"; +import { useWithdrawalDetails } from "../hooks/account.js"; import { useEffect } from "preact/hooks"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; function ShowOperationPendingTag({ woid, @@ -33,7 +33,7 @@ function ShowOperationPendingTag({ onOperationAlreadyCompleted?: () => void; }): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const result = useWithdrawalDetails(woid); const loading = !result const error = diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -43,7 +43,7 @@ import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { mutate } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; import { useBankState } from "../hooks/bank-state.js"; import { EmptyObject, RouteDefinition } from "../route.js"; import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; @@ -80,7 +80,7 @@ export function PaytoWireTransferForm({ limit, }: Props): VNode { const [isRawPayto, setIsRawPayto] = useState(false); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const { api, config, url } = useBankCoreApiContext(); const sendingToFixedAccount = withAccount !== undefined; diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -18,7 +18,7 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useBankCoreApiContext } from "../context/config.js"; import { useNavigationContext } from "../context/navigation.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "../route.js"; export function ProfileNavigation({ @@ -38,7 +38,7 @@ export function ProfileNavigation({ }): VNode { const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const isAdminUser = credentials.status !== "loggedIn" ? false diff --git a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx @@ -19,7 +19,7 @@ import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Transactions } from "../components/Transactions/index.js"; -import { usePublicAccounts } from "../hooks/access.js"; +import { usePublicAccounts } from "../hooks/account.js"; /** * Show histories of public accounts. diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -30,7 +30,7 @@ import { useEffect } from "preact/hooks"; import { QR } from "../components/QR.js"; import { useBankCoreApiContext } from "../context/config.js"; import { useTalerWalletIntegrationAPI } from "../context/wallet-integration.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; export function QrCodeSection({ withdrawUri, @@ -42,7 +42,7 @@ export function QrCodeSection({ const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; useEffect(() => { diff --git a/packages/demobank-ui/src/pages/SolveChallengePage.tsx b/packages/demobank-ui/src/pages/SolveChallengePage.tsx @@ -39,10 +39,10 @@ import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../context/config.js"; -import { useWithdrawalDetails } from "../hooks/access.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useWithdrawalDetails } from "../hooks/account.js"; +import { useSessionState } from "../hooks/session.js"; import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; -import { useConversionInfo } from "../hooks/circuit.js"; +import { useConversionInfo } from "../hooks/regional.js"; import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; @@ -62,7 +62,7 @@ export function SolveChallengePage({ const [bankState, updateBankState] = useBankState(); const [code, setCode] = useState<string | undefined>(undefined); const [notification, notify, handleError] = useLocalNotification(); - const { state } = useBackendState(); + const { state } = useSessionState(); const creds = state.status !== "loggedIn" ? undefined : state; const { navigateTo } = useNavigationContext(); diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -33,7 +33,7 @@ import { VNode, h } from "preact"; import { forwardRef } from "preact/compat"; import { useState } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; import { RouteDefinition } from "../route.js"; @@ -65,7 +65,7 @@ function OldWithdrawalForm({ const [bankState, updateBankState] = useBankState(); const { api } = useBankCoreApiContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [amountStr, setAmountStr] = useState<string | undefined>( diff --git a/packages/demobank-ui/src/pages/WireTransfer.tsx b/packages/demobank-ui/src/pages/WireTransfer.tsx @@ -26,8 +26,8 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { useAccountDetails } from "../hooks/access.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useAccountDetails } from "../hooks/account.js"; +import { useSessionState } from "../hooks/session.js"; import { LoginForm } from "./LoginForm.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { RouteDefinition } from "../route.js"; @@ -54,7 +54,7 @@ export function WireTransfer({ onAuthorizationRequired: () => void; }): VNode { const { i18n } = useTranslationContext(); - const r = useBackendState(); + const r = useSessionState(); const account = r.state.status !== "loggedOut" ? r.state.username : "admin"; const result = useAccountDetails(account); diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -36,9 +36,9 @@ import { import { ComponentChildren, Fragment, VNode, h } from "preact"; import { mutate } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; +import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "../route.js"; import { LoginForm } from "./LoginForm.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; @@ -68,7 +68,7 @@ export function WithdrawalConfirmationQuestion({ }: Props): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [, updateBankState] = useBankState(); @@ -330,7 +330,7 @@ export function ShouldBeSameUser({ username: string; children: ComponentChildren; }): VNode { - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const { i18n } = useTranslationContext(); if (credentials.status === "loggedOut") { return ( diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -30,7 +30,7 @@ import { } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { useWithdrawalDetails } from "../hooks/access.js"; +import { useWithdrawalDetails } from "../hooks/account.js"; import { RouteDefinition } from "../route.js"; import { QrCodeSection } from "./QrCodeSection.js"; import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; diff --git a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx @@ -16,9 +16,9 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { Cashouts } from "../../components/Cashouts/index.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useSessionState } from "../../hooks/session.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; -import { CreateCashout } from "../business/CreateCashout.js"; +import { CreateCashout } from "../regional/CreateCashout.js"; import { RouteDefinition } from "../../route.js"; interface Props { @@ -48,7 +48,7 @@ export function CashoutListForAccount({ }: Props): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const accountIsTheCurrentUser = credentials.status === "loggedIn" diff --git a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx @@ -33,8 +33,8 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useAccountDetails } from "../../hooks/access.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; import { useBankState } from "../../hooks/bank-state.js"; import { RouteDefinition } from "../../route.js"; import { LoginForm } from "../LoginForm.js"; @@ -65,7 +65,7 @@ export function ShowAccountDetails({ account: string; }): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { api } = useBankCoreApiContext(); const accountIsTheCurrentUser = diff --git a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -29,7 +29,7 @@ import { import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useSessionState } from "../../hooks/session.js"; import { useBankState } from "../../hooks/bank-state.js"; import { RouteDefinition } from "../../route.js"; import { undefinedIfEmpty } from "../../utils.js"; @@ -62,7 +62,7 @@ export function UpdateAccountPassword({ account: string; }): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token; const { api } = useBankCoreApiContext(); diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -33,7 +33,7 @@ import { import { ComponentChildren, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useSessionState } from "../../hooks/session.js"; import { ErrorMessageMappingFor, TanChannel, @@ -92,7 +92,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ }): VNode { const { config, hints, url } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const [form, setForm] = useState<AccountFormData>({}); const [errors, setErrors] = useState< diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx @@ -23,7 +23,7 @@ import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useBusinessAccounts } from "../../hooks/circuit.js"; +import { useBusinessAccounts } from "../../hooks/regional.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; import { RouteDefinition } from "../../route.js"; diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx @@ -40,7 +40,7 @@ import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { Transactions } from "../../components/Transactions/index.js"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useConversionInfo, useLastMonitorInfo } from "../../hooks/circuit.js"; +import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js"; import { RouteDefinition } from "../../route.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; import { WireTransfer } from "../WireTransfer.js"; diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx @@ -30,7 +30,7 @@ import { import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useSessionState } from "../../hooks/session.js"; import { RouteDefinition } from "../../route.js"; import { AccountForm } from "./AccountForm.js"; @@ -42,7 +42,7 @@ export function CreateNewAccount({ onCreateSuccess: () => void; }): VNode { const { i18n } = useTranslationContext(); - const { state: credentials } = useBackendState(); + const { state: credentials } = useSessionState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token; const { api } = useBankCoreApiContext(); diff --git a/packages/demobank-ui/src/pages/admin/DownloadStats.tsx b/packages/demobank-ui/src/pages/admin/DownloadStats.tsx @@ -0,0 +1,585 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AccessToken, + AmountString, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { EmptyObject, RouteDefinition } from "../../route.js"; +import { getTimeframesForDate } from "./AdminHome.js"; + +interface Props { + routeCancel: RouteDefinition; +} + +type Options = { + dayMetric: boolean; + hourMetric: boolean; + monthMetric: boolean; + yearMetric: boolean; + compareWithPrevious: boolean; + endOnFirstFail: boolean; + includeHeader: boolean; +}; + +/** + * Show histories of public accounts. + */ +export function DownloadStats({ routeCancel }: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + const { api } = useBankCoreApiContext(); + + const [options, setOptions] = useState<Options>({ + compareWithPrevious: true, + dayMetric: true, + endOnFirstFail: false, + hourMetric: true, + includeHeader: true, + monthMetric: true, + yearMetric: true, + }); + const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); + const [downloaded, setDownloaded] = useState<string>(); + const referenceDates = [new Date()]; + const [notification, , handleError] = useLocalNotification(); + + if (!creds) { + return <div>only admin can download stats</div>; + } + + return ( + <div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Download bank stats</i18n.Translate> + </h2> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Include hour metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`hour switch`} + data-enabled={options.hourMetric} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ + ...options, + hourMetric: !options.hourMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.hourMetric} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Include day metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`day switch`} + data-enabled={!!options.dayMetric} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ ...options, dayMetric: !options.dayMetric }); + }} + > + <span + aria-hidden="true" + data-enabled={options.dayMetric} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Include month metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`month switch`} + data-enabled={!!options.monthMetric} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ + ...options, + monthMetric: !options.monthMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.monthMetric} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Include year metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`year switch`} + data-enabled={!!options.yearMetric} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ + ...options, + yearMetric: !options.yearMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.yearMetric} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Include table header</i18n.Translate> + </span> + </span> + <button + type="button" + name={`header switch`} + data-enabled={!!options.includeHeader} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ + ...options, + includeHeader: !options.includeHeader, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.includeHeader} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate> + Add previous metric for compare + </i18n.Translate> + </span> + </span> + <button + type="button" + name={`compare switch`} + data-enabled={!!options.compareWithPrevious} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ + ...options, + compareWithPrevious: !options.compareWithPrevious, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.compareWithPrevious} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Fail on first error</i18n.Translate> + </span> + </span> + <button + type="button" + name={`fail switch`} + data-enabled={!!options.endOnFirstFail} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setOptions({ + ...options, + endOnFirstFail: !options.endOnFirstFail, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.endOnFirstFail} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + </div> + </div> + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="download" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={lastStep !== undefined} + onClick={async () => { + setDownloaded(undefined); + await handleError(async () => { + const csv = await fetchAllStatus( + api, + creds.token, + options, + referenceDates, + (step, total) => { + setLastStep({ step, total }); + }, + ); + setDownloaded(csv); + }); + setLastStep(undefined); + }} + > + <i18n.Translate>Download</i18n.Translate> + </button> + </div> + </form> + </div> + {!lastStep || lastStep.step === lastStep.total ? ( + <div class="h-5 mb-5" /> + ) : ( + <div> + <div class="relative mb-5 h-5 rounded-full bg-gray-200"> + <div + class="h-full animate-pulse rounded-full bg-blue-500" + style={{ + width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`, + }} + > + <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> + <i18n.Translate> + downloading...{" "} + {Math.round((lastStep.step / lastStep.total) * 100)} + </i18n.Translate> + </span> + </div> + </div> + </div> + )} + {!downloaded ? ( + <div class="h-5 mb-5" /> + ) : ( + <a + href={ + "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded) + } + name="save file" + download={"bank-stats.csv"} + > + <Attention title={i18n.str`Download completed`}> + <i18n.Translate> + Click here to save the file in your computer. + </i18n.Translate> + </Attention> + </a> + )} + </div> + ); +} + +async function fetchAllStatus( + api: TalerCoreBankHttpClient, + token: AccessToken, + options: Options, + references: Date[], + progress: (current: number, total: number) => void, +): Promise<string> { + const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; + if (options.hourMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); + } + if (options.dayMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); + } + if (options.monthMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); + } + if (options.yearMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); + } + + /** + * convert request into frames + */ + const allFrames = allMetrics.flatMap((timeframe) => + references.map((reference) => ({ + reference, + timeframe, + moment: getTimeframesForDate(reference, timeframe), + })), + ); + const total = allFrames.length; + + /** + * call API for info + */ + const allInfo = await allFrames.reduce( + async (prev, frame, index) => { + const accumulatedMap = await prev; + progress(index, total); + // await delay() + const previous = options.compareWithPrevious + ? await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.previous, + }) + : undefined; + + if (previous && previous.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(previous.detail); + } + + const current = await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.current, + }); + + if (current.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(current.detail); + } + + const metricName = + TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; + accumulatedMap[metricName] = { + reference: frame.reference, + current: current.type !== "ok" ? undefined : current.body, + previous: + !previous || previous.type !== "ok" ? undefined : previous.body, + }; + return accumulatedMap; + }, + Promise.resolve({} as Record<string, Data>), + ); + progress(total, total); + + /** + * convert into table format + * + */ + const table: Array<string[]> = []; + if (options.includeHeader) { + table.push([ + "date", + "metric", + "reference", + "talerInCount", + "talerInVolume", + "talerOutCount", + "talerOutVolume", + "cashinCount", + "cashinFiatVolume", + "cashinRegionalVolume", + "cashoutCount", + "cashoutFiatVolume", + "cashoutRegionalVolume", + ]); + } + Object.entries(allInfo).forEach(([name, data]) => { + if (data.current) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "current", + ...dataToRow(data.current), + }; + table.push(Object.values(row) as string[]); + } + + if (data.previous) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "previous", + ...dataToRow(data.previous), + }; + table.push(Object.values(row) as string[]); + } + }); + + const csv = table.reduce((acc, row) => { + return acc + row.join(",") + "\n"; + }, ""); + + return csv; +} + +type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">; +function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { + return { + talerInCount: info.talerInCount, + talerInVolume: info.talerInVolume, + talerOutCount: info.talerOutCount, + talerOutVolume: info.talerOutVolume, + cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, + cashinFiatVolume: + info.type === "no-conversions" ? undefined : info.cashinFiatVolume, + cashinRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, + cashoutCount: + info.type === "no-conversions" ? undefined : info.cashoutCount, + cashoutFiatVolume: + info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, + cashoutRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, + }; +} + +type Data = { + reference: Date; + previous: TalerCorebankApi.MonitorResponse | undefined; + current: TalerCorebankApi.MonitorResponse | undefined; +}; +type TableRow = { + date: number; + metric: string; + reference: "current" | "previous"; + cashinCount?: number; + cashinRegionalVolume?: AmountString; + cashinFiatVolume?: AmountString; + cashoutCount?: number; + cashoutRegionalVolume?: AmountString; + cashoutFiatVolume?: AmountString; + talerInCount: number; + talerInVolume: AmountString; + talerOutCount: number; + talerOutVolume: AmountString; +}; diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx @@ -35,8 +35,8 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useAccountDetails } from "../../hooks/access.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; @@ -62,7 +62,7 @@ export function RemoveAccount({ const result = useAccountDetails(account); const [accountName, setAccountName] = useState<string | undefined>(); - const { state } = useBackendState(); + const { state } = useSessionState(); const token = state.status !== "loggedIn" ? undefined : state.token; const { api } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -1,809 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AbsoluteTime, - Amounts, - HttpStatusCode, - TalerError, - TalerErrorCode, - TranslatedString, - assertUnreachable, - encodeCrock, - getRandomBytes, - parsePaytoUri, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - LocalNotificationBanner, - ShowInputErrorLabel, - notifyInfo, - useLocalNotification, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; -import { useAccountDetails } from "../../hooks/access.js"; -import { useBackendState } from "../../hooks/backend.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { TransferCalculation, useCashoutEstimator, useConversionInfo, useEstimator } from "../../hooks/circuit.js"; -import { RouteDefinition } from "../../route.js"; -import { TanChannel, undefinedIfEmpty } from "../../utils.js"; -import { LoginForm } from "../LoginForm.js"; -import { - InputAmount, - RenderAmount, - doAutoFocus, -} from "../PaytoWireTransferForm.js"; - -interface Props { - account: string; - focus?: boolean; - onAuthorizationRequired: () => void; - routeClose: RouteDefinition; - routeHere: RouteDefinition; -} - -type FormType = { - isDebit: boolean; - amount: string; - subject: string; - channel: TanChannel; -}; -type ErrorFrom<T> = { - [P in keyof T]+?: string; -}; - -export function CreateCashout({ - account: accountName, - onAuthorizationRequired, - focus, - routeHere, - routeClose, -}: Props): VNode { - const { i18n } = useTranslationContext(); - const resultAccount = useAccountDetails(accountName); - const { - estimateByCredit: calculateFromCredit, - estimateByDebit: calculateFromDebit, - } = useCashoutEstimator(); - const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [, updateBankState] = useBankState(); - - const { api, config, hints } = useBankCoreApiContext(); - const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); - const [notification, notify, handleError] = useLocalNotification(); - const info = useConversionInfo(); - - if (!config.allow_conversion) { - return ( - <Fragment> - <Attention type="warning" title={i18n.str`Unable to create a cashout`}> - <i18n.Translate> - The bank configuration does not support cashout operations. - </i18n.Translate> - </Attention> - <div class="mt-5 sm:mt-6"> - <a - href={routeClose.url({})} - name="close" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - > - <i18n.Translate>Close</i18n.Translate> - </a> - </div> - </Fragment> - ); - } - - const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; - - if (!resultAccount) { - return <Loading />; - } - if (resultAccount instanceof TalerError) { - return <ErrorLoadingWithDebug error={resultAccount} />; - } - if (resultAccount.type === "fail") { - switch (resultAccount.case) { - case HttpStatusCode.Unauthorized: - return <LoginForm currentUser={accountName} />; - case HttpStatusCode.NotFound: - return <LoginForm currentUser={accountName} />; - default: - assertUnreachable(resultAccount); - } - } - if (!info) { - return <Loading />; - } - - if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} />; - } - if (info.type === "fail") { - switch (info.case) { - case HttpStatusCode.NotImplemented: { - return ( - <Attention - type="danger" - title={i18n.str`Cashout are disabled`} - > - <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> - </Attention> - ); - } - default: - assertUnreachable(info.case); - } - } - - const conversionInfo = info.body.conversion_rate; - if (!conversionInfo) { - return ( - <div>conversion enabled but server replied without conversion_rate</div> - ); - } - - const account = { - balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), - balanceIsDebit: - resultAccount.body.balance.credit_debit_indicator == "debit", - debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), - }; - - const { - fiat_currency, - regional_currency, - fiat_currency_specification, - regional_currency_specification, - } = info.body; - const regionalZero = Amounts.zeroOfCurrency(regional_currency); - const fiatZero = Amounts.zeroOfCurrency(fiat_currency); - const limit = account.balanceIsDebit - ? Amounts.sub(account.debitThreshold, account.balance).amount - : Amounts.add(account.balance, account.debitThreshold).amount; - - const zeroCalc = { - debit: regionalZero, - credit: fiatZero, - beforeFee: fiatZero, - }; - const [calculationResult, setCalculation] = useState<TransferCalculation>(zeroCalc); - const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); - const sellRate = conversionInfo.cashout_ratio; - /** - * can be in regional currency or fiat currency - * depending on the isDebit flag - */ - const inputAmount = Amounts.parseOrThrow( - `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount - }`, - ); - - useEffect(() => { - async function doAsync() { - await handleError(async () => { - const higerThanMin = form.isDebit ? - Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 : true; - const notZero = Amounts.isNonZero(inputAmount) - if (notZero && higerThanMin) { - const resp = await (form.isDebit - ? calculateFromDebit(inputAmount, sellFee) - : calculateFromCredit(inputAmount, sellFee)); - setCalculation(resp); - } else { - setCalculation(zeroCalc) - } - }); - } - doAsync(); - }, [form.amount, form.isDebit]); - - const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult - - const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; - - function updateForm(newForm: typeof form): void { - setForm(newForm); - } - const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({ - subject: !form.subject ? i18n.str`Required` : undefined, - amount: !form.amount - ? i18n.str`Required` - : !inputAmount - ? i18n.str`Invalid` - : Amounts.cmp(limit, calc.debit) === -1 - ? i18n.str`Balance is not enough` - : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 - ? i18n.str`Needs to be higher than ${Amounts.stringifyValueWithSpec(Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification).normal}` - : calculationResult === "amount-is-too-small" - ? i18n.str`Amount needs to be higher` - : Amounts.isZero(calc.credit) - ? i18n.str`The total transfer at destination will be zero` - : undefined, - channel: OLD_CASHOUT_API && !form.channel ? i18n.str`Required` : undefined, - }); - const trimmedAmountStr = form.amount?.trim(); - - async function createCashout() { - const request_uid = encodeCrock(getRandomBytes(32)); - await handleError(async () => { - // new cashout api doesn't require channel - const validChannel = - !OLD_CASHOUT_API || - config.supported_tan_channels.length === 0 || - form.channel; - - if (!creds || !form.subject || !validChannel) return; - const request = { - request_uid, - amount_credit: Amounts.stringify(calc.credit), - amount_debit: Amounts.stringify(calc.debit), - subject: form.subject, - tan_channel: form.channel, - }; - const resp = await api.createCashout(creds, request); - if (resp.type === "ok") { - notifyInfo(i18n.str`Cashout created`); - } else { - switch (resp.case) { - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "create-cashout", - id: String(resp.body.challenge_id), - sent: AbsoluteTime.never(), - location: routeHere.url({}), - request, - }); - return onAuthorizationRequired(); - } - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return notify({ - type: "error", - title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_BAD_CONVERSION: - return notify({ - type: "error", - title: i18n.str`The conversion rate was incorrectly applied`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return notify({ - type: "error", - title: i18n.str`The account does not have sufficient funds`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case HttpStatusCode.NotImplemented: - return notify({ - type: "error", - title: i18n.str`Cashout are disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return notify({ - type: "error", - title: i18n.str`Missing cashout URI in the profile`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return notify({ - type: "error", - title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - assertUnreachable(resp); - } - }); - } - const cashoutDisabled = - config.supported_tan_channels.length < 1 || - !resultAccount.body.cashout_payto_uri; - - const cashoutAccount = !resultAccount.body.cashout_payto_uri - ? undefined - : parsePaytoUri(resultAccount.body.cashout_payto_uri); - const cashoutAccountName = !cashoutAccount - ? undefined - : cashoutAccount.targetPath; - - const cashoutLegalName = !cashoutAccount - ? undefined - : cashoutAccount.params["receiver-name"]; - - return ( - <div> - <LocalNotificationBanner notification={notification} /> - - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <section class="mt-4 rounded-sm px-4 py-6 p-8 "> - <h2 id="summary-heading" class="font-medium text-lg"> - <i18n.Translate>Cashout</i18n.Translate> - </h2> - - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex"> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Conversion rate</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900">{sellRate}</dd> - </div> - - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Balance</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={account.balance} - spec={regional_currency_specification} - /> - </dd> - </div> - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Fee</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={sellFee} - spec={fiat_currency_specification} - /> - </dd> - </div> - {cashoutAccountName && cashoutLegalName ? ( - <Fragment> - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>To account</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900">{cashoutAccountName}</dd> - </div> - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Legal name</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900">{cashoutLegalName}</dd> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>If this name doesn't match the account holder's name your transaction may fail.</i18n.Translate> - </p> - </Fragment> - ) : ( - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <Attention type="warning" title={i18n.str`No cashout account`}> - <i18n.Translate> - Before doing a cashout you need to complete your profile - </i18n.Translate> - </Attention> - </div> - )} - </dl> - </section> - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} - > - <div class="px-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - {/* subject */} - - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="subject" - > - {i18n.str`Transfer subject`} - <b style={{ color: "red" }}> *</b> - </label> - <div class="mt-2"> - <input - ref={focus ? doAutoFocus : undefined} - type="text" - class="block w-full rounded-md disabled:bg-gray-200 border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="subject" - id="subject" - disabled={cashoutDisabled} - data-error={!!errors?.subject && form.subject !== undefined} - value={form.subject ?? ""} - onChange={(e) => { - form.subject = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.subject} - isDirty={form.subject !== undefined} - /> - </div> - </div> - - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="subject" - > - {i18n.str`Currency`} - </label> - - <div class="mt-2"> - <button - type="button" - name="set 50" - class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" - onClick={(e) => { - e.preventDefault(); - form.isDebit = true; - updateForm(structuredClone(form)); - }} - > - {form.isDebit ? - <svg - class="self-center flex-none h-5 w-5 text-indigo-600" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - - : - <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> - <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> - </svg> - } - - <i18n.Translate>Send {regional_currency}</i18n.Translate> - </button> - <button - type="button" - name="set 25" - class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" - onClick={(e) => { - e.preventDefault(); - form.isDebit = false; - updateForm(structuredClone(form)); - }} - > - {!form.isDebit ? - <svg - class="self-center flex-none h-5 w-5 text-indigo-600" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - - : - <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> - <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> - </svg> - } - - <i18n.Translate>Receive {fiat_currency}</i18n.Translate> - </button> - </div> - </div> - - {/* amount */} - <div class="sm:col-span-5"> - <div class="flex justify-between"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="amount" - > - {i18n.str`Amount`} - <b style={{ color: "red" }}> *</b> - </label> - {/* <button - type="button" - data-enabled={form.isDebit} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - form.isDebit = !form.isDebit; - updateForm(structuredClone(form)); - }} - > - <span - aria-hidden="true" - data-enabled={form.isDebit} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> */} - </div> - <div class="mt-2"> - <InputAmount - name="amount" - left - currency={form.isDebit ? regional_currency : fiat_currency} - value={trimmedAmountStr} - onChange={ - cashoutDisabled - ? undefined - : (value) => { - form.amount = value; - updateForm(structuredClone(form)); - } - } - /> - <ShowInputErrorLabel - message={errors?.amount} - isDirty={form.amount !== undefined} - /> - </div> - </div> - - {Amounts.isZero(calc.credit) ? undefined : ( - <div class="sm:col-span-5"> - <dl class="mt-4 space-y-4"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Total cost</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={calc.debit} - negative - withColor - spec={regional_currency_specification} - /> - </dd> - </div> - - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Balance left</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={balanceAfter} - spec={regional_currency_specification} - /> - </dd> - </div> - {Amounts.isZero(sellFee) || - Amounts.isZero(calc.beforeFee) ? undefined : ( - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Before fee</i18n.Translate> - </span> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={calc.beforeFee} - spec={fiat_currency_specification} - /> - </dd> - </div> - )} - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-lg text-gray-900 font-medium"> - <i18n.Translate>Total cashout transfer</i18n.Translate> - </dt> - <dd class="text-lg text-gray-900 font-medium"> - <RenderAmount - value={calc.credit} - withColor - spec={fiat_currency_specification} - /> - </dd> - </div> - </dl> - </div> - )} - - {/* channel, not shown if new cashout api */} - {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels - .length === 0 ? ( - <div class="sm:col-span-5"> - <Attention - type="warning" - title={i18n.str`No cashout channel available`} - > - <i18n.Translate> - Before doing a cashout the server need to provide an - second channel to confirm the operation - </i18n.Translate> - </Attention> - </div> - ) : ( - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="channel" - > - {i18n.str`Second factor authentication`} - </label> - <div class="mt-2 max-w-xl text-sm text-gray-500"> - <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> - {config.supported_tan_channels.indexOf( - TanChannel.EMAIL, - ) === -1 ? undefined : ( - <label - onClick={() => { - if (!resultAccount.body.contact_data?.email) return; - form.channel = TanChannel.EMAIL; - updateForm(structuredClone(form)); - }} - data-disabled={ - !resultAccount.body.contact_data?.email - } - data-selected={form.channel === TanChannel.EMAIL} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" - > - <input - type="radio" - name="channel" - value="Newsletter" - class="sr-only" - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span - id="project-type-0-label" - class="block text-sm font-medium text-gray-900 " - > - <i18n.Translate>Email</i18n.Translate> - </span> - {!resultAccount.body.contact_data?.email && - i18n.str`Add a email in your profile to enable this option`} - </span> - </span> - <svg - data-selected={form.channel === TanChannel.EMAIL} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </label> - )} - - {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( - <label - onClick={() => { - if (!resultAccount.body.contact_data?.phone) return; - form.channel = TanChannel.SMS; - updateForm(structuredClone(form)); - }} - data-disabled={ - !resultAccount.body.contact_data?.phone - } - data-selected={form.channel === TanChannel.SMS} - class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" - > - <input - type="radio" - name="channel" - value="Existing Customers" - class="sr-only" - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span - id="project-type-1-label" - class="block text-sm font-medium text-gray-900" - > - <i18n.Translate>SMS</i18n.Translate> - </span> - {!resultAccount.body.contact_data?.phone && - i18n.str`Add a phone number in your profile to enable this option`} - </span> - </span> - <svg - data-selected={form.channel === TanChannel.SMS} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" - clip-rule="evenodd" - /> - </svg> - </label> - )} - </div> - </div> - </div> - )} - </div> - </div> - - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <a - href={routeClose.url({})} - name="cancel" - type="button" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <button - type="submit" - name="cashout" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors} - onClick={(e) => { - e.preventDefault(); - createCashout(); - }} - > - <i18n.Translate>Cashout</i18n.Translate> - </button> - </div> - </form> - </div > - </div > - ); -} diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx @@ -1,192 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AbsoluteTime, - Amounts, - Duration, - HttpStatusCode, - TalerError, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { VNode, h } from "preact"; -import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useCashoutDetails, useConversionInfo } from "../../hooks/circuit.js"; -import { RouteDefinition } from "../../route.js"; -import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { Time } from "../../components/Time.js"; - -interface Props { - id: string; - routeClose: RouteDefinition; -} -export function ShowCashoutDetails({ id, routeClose }: Props): VNode { - const { i18n, dateLocale } = useTranslationContext(); - const cid = Number.parseInt(id, 10); - - const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); - const info = useConversionInfo(); - - if (Number.isNaN(cid)) { - return ( - <Attention - type="danger" - title={i18n.str`Cashout id should be a number`} - /> - ); - } - if (!result) { - return <Loading />; - } - if (result instanceof TalerError) { - return <ErrorLoadingWithDebug error={result} />; - } - if (result.type === "fail") { - switch (result.case) { - case HttpStatusCode.NotFound: - return ( - <Attention - type="warning" - title={i18n.str`This cashout not found. Maybe already aborted.`} - ></Attention> - ); - case HttpStatusCode.NotImplemented: - return ( - <Attention - type="warning" - title={i18n.str`Cashout are disabled`} - > - <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> - </Attention> - ); - default: - assertUnreachable(result); - } - } - if (!info) { - return <Loading />; - } - - if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} />; - } - if (info.type === "fail") { - switch (info.case) { - case HttpStatusCode.NotImplemented: { - return ( - <Attention type="danger" - title={i18n.str`Cashout are disabled`} - > - <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> - </Attention> - ); - } - default: - assertUnreachable(info.case); - } - } - - const { fiat_currency_specification, regional_currency_specification } = - info.body; - - return ( - <div> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <section class="rounded-sm px-4"> - <h2 id="summary-heading" class="font-medium text-lg"> - <i18n.Translate>Cashout detail</i18n.Translate> - </h2> - <dl class="mt-8 space-y-4"> - <div class="justify-between items-center flex"> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Subject</i18n.Translate> - </dt> - <dd class="text-sm ">{result.body.subject}</dd> - </div> - </dl> - </section> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> - <div class="px-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <dl class="space-y-4"> - {result.body.creation_time.t_s !== "never" ? ( - <div class="justify-between items-center flex "> - <dt class=" text-gray-600"> - <i18n.Translate>Created</i18n.Translate> - </dt> - <dd class="text-sm "> - <Time format="dd/MM/yyyy HH:mm:ss" - timestamp={AbsoluteTime.fromProtocolTimestamp(result.body.creation_time)} - // relative={Duration.fromSpec({ days: 1 })} - /> - </dd> - </div> - ) : undefined} - - <div class="flex justify-between items-center border-t-2 afu pt-4"> - <dt class="text-gray-600"> - <i18n.Translate>Debited</i18n.Translate> - </dt> - <dd class=" font-medium"> - <RenderAmount - value={Amounts.parseOrThrow(result.body.amount_debit)} - negative - withColor - spec={regional_currency_specification} - /> - </dd> - </div> - - <div class="flex items-center justify-between border-t-2 afu pt-4"> - <dt class="flex items-center text-gray-600"> - <span> - <i18n.Translate>Credited</i18n.Translate> - </span> - </dt> - <dd class="text-sm "> - <RenderAmount - value={Amounts.parseOrThrow(result.body.amount_credit)} - withColor - spec={fiat_currency_specification} - /> - </dd> - </div> - </dl> - </div> - </div> - </div> - </div> - </div> - - <br /> - <div style={{ display: "flex", justifyContent: "space-between" }}> - <a - href={routeClose.url({})} - name="close" - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Close</i18n.Translate> - </a> - </div> - </div> - ); -} diff --git a/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx b/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx @@ -0,0 +1,980 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AmountJson, + Amounts, + HttpStatusCode, + TalerBankConversionApi, + TalerError, + TranslatedString, + assertUnreachable +} from "@gnu-taler/taler-util"; +import { + Attention, + InternationalizationAPI, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext, + utils +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { undefinedIfEmpty } from "../../utils.js"; +import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; +import { FormErrors, FormStatus, FormValues, RecursivePartial, UIField, useFormState } from "../../hooks/form.js"; + +interface Props { + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; +} + +type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } + + +function useComponentState({ + onUpdateSuccess, + routeCancel, + routeConversionConfig, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, +}: Props): utils.RecursiveState<VNode> { + const { i18n } = useTranslationContext(); + + const result = useConversionInfo() + const info = result && !(result instanceof TalerError) && result.type === "ok" ? + result.body : undefined; + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + + if (!info) { + return <i18n.Translate>loading...</i18n.Translate> + } + + if (!creds) { + return <i18n.Translate>only admin can setup conversion</i18n.Translate> + } + + return () => { + const { i18n } = useTranslationContext(); + + const { api, config } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const initalState: FormValues<FormType> = { + amount: "100", + conv: { + cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], + cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], + cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], + cashin_ratio: info.conversion_rate.cashin_ratio, + cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, + cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], + cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1], + cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], + cashout_ratio: info.conversion_rate.cashout_ratio, + cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, + } + } + + const [form, status] = useFormState<FormType>( + initalState, + createFormValidator(i18n, info.regional_currency, info.fiat_currency) + ) + + const { + estimateByDebit: calculateCashoutFromDebit, + } = useCashoutEstimator(); + + const { + estimateByDebit: calculateCashinFromDebit, + } = useCashinEstimator(); + + const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>() + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + if (!info) return; + if (!form.amount?.value || form.amount.error) return; + const in_amount = Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`) + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee) + const cashin = await calculateCashinFromDebit(in_amount, in_fee); + + if (cashin === "amount-is-too-small") { + setCalc(undefined) + return; + } + // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) + const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee) + const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + + setCalc({ cashin, cashout }); + }); + } + doAsync(); + }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]); + + const [section, setSection] = useState<"detail" | "cashout" | "cashin">("detail") + const cashinCalc = calculationResult?.cashin === "amount-is-too-small" ? undefined : calculationResult?.cashin + const cashoutCalc = calculationResult?.cashout === "amount-is-too-small" ? undefined : calculationResult?.cashout + async function doUpdate() { + if (!creds) return + await handleError(async () => { + if (status.status === "fail") return; + const resp = await api + .getConversionInfoAPI() + .updateConversionRate(creds.token, status.result.conv) + if (resp.type === "ok") { + setSection("detail") + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + return notify({ + type: "error", + title: i18n.str`Wrong credentials`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + case HttpStatusCode.NotImplemented: { + return notify({ + type: "error", + title: i18n.str`Conversion is disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) + const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) + + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; + + + return ( + <div> + <ProfileNavigation current="conversion" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} + /> + + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Conversion</i18n.Translate> + </h2> + <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> + <label + data-enabled={section === "detail"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("detail") + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Details</i18n.Translate> + </span> + </span> + </span> + </label> + + <label + data-enabled={section === "cashout"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashout") + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashout</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "cashin"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashin") + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashin</i18n.Translate> + </span> + </span> + </span> + </label> + </div> + + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + {section == "cashin" && + <ConversionForm id="cashin" + inputCurrency={info.fiat_currency} + outputCurrency={info.regional_currency} + fee={form?.conv?.cashin_fee} + minimum={form?.conv?.cashin_min_amount} + ratio={form?.conv?.cashin_ratio} + rounding={form?.conv?.cashin_rounding_mode} + tiny={form?.conv?.cashin_tiny_amount} + />} + + {section == "cashout" && <Fragment> + <ConversionForm id="cashout" + inputCurrency={info.regional_currency} + outputCurrency={info.fiat_currency} + fee={form?.conv?.cashout_fee} + minimum={form?.conv?.cashout_min_amount} + ratio={form?.conv?.cashout_ratio} + rounding={form?.conv?.cashout_rounding_mode} + tiny={form?.conv?.cashout_tiny_amount} + /> + </Fragment>} + + {section == "detail" && <Fragment> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashin ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {info.conversion_rate.cashin_ratio} + </dd> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {info.conversion_rate.cashout_ratio} + </dd> + </div> + </div> + + {both_low || both_high ? <div class="p-4"> + <Attention title={i18n.str`Bad ratios`} type="warning"> + <i18n.Translate> + One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1. + </i18n.Translate> + </Attention> + </div> : undefined} + + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Initial amount`}</label> + <InputAmount + name="amount" + left + currency={info.fiat_currency} + value={form.amount?.value ?? ""} + onChange={form.amount?.onUpdate} + /> + <ShowInputErrorLabel + message={form.amount?.error} + isDirty={form.amount?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Use it to test how the conversion will affect the amount.</i18n.Translate> + </p> + </div> + </div> + </div> + + {!cashoutCalc || !cashinCalc ? undefined : ( + <div class="px-6 pt-6"> + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Sending to this bank</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashinCalc.debit} + negative + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu "> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashinCalc.beforeFee} + spec={info.fiat_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Cashin after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashinCalc.credit} + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Sending from this bank</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashoutCalc.debit} + negative + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashoutCalc.beforeFee} + spec={info.regional_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Cashout after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashoutCalc.credit} + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + </dl> + </div> + + {cashoutCalc && status.status === "ok" && Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ? <div class="p-4"> + <Attention title={i18n.str`Bad configuration`} type="warning"> + <i18n.Translate> + This configuration allows users to cash out more of what has been cashed in. + </i18n.Translate> + </Attention> + </div> : undefined} + </div> + )} + </Fragment>} + + + <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> + <a name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + {section == "cashin" || section == "cashout" ? <Fragment> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={async () => { + doUpdate() + }} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> : <div />} + </div> + + + </form> + </div> + </div> + ); + + } +} + +export const ConversionConfig = utils.recursive(useComponentState); + +/** + * + * @param i18n + * @param regional + * @param fiat + * @returns form validator + */ +function createFormValidator(i18n: InternationalizationAPI, regional: string, fiat: string) { + return function check(state: FormValues<FormType>): FormStatus<FormType> { + + const cashin_min_amount = Amounts.parse(`${fiat}:${state.conv.cashin_min_amount}`) + const cashin_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`) + const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`) + + const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`) + const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`) + const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`) + + const am = Amounts.parse(`${fiat}:${state.amount}`) + + const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "") + const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "") + + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ + cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : + !cashin_min_amount ? i18n.str`invalid` : + undefined, + cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : + !cashin_tiny_amount ? i18n.str`invalid` : + undefined, + cashin_fee: !state.conv.cashin_fee ? i18n.str`required` : + !cashin_fee ? i18n.str`invalid` : + undefined, + + cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : + !cashout_min_amount ? i18n.str`invalid` : + undefined, + cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : + !cashout_tiny_amount ? i18n.str`invalid` : + undefined, + cashout_fee: !state.conv.cashin_fee ? i18n.str`required` : + !cashout_fee ? i18n.str`invalid` : + undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, + + cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, + cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, + }), + + amount: !state.amount ? i18n.str`required` : + !am ? i18n.str`invalid` : + undefined, + }) + + const result: RecursivePartial<FormType> = { + amount: am, + conv: { + cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, + cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, + cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, + cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? (state.conv.cashin_rounding_mode!) : undefined, + cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined, + cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, + cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, + cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, + cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? (state.conv.cashout_rounding_mode!) : undefined, + cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined, + } + + } + return errors === undefined ? + { status: "ok", result: result as FormType, errors } : + { status: "fail", result, errors } + } +} + + +function ConversionForm({ id, inputCurrency, outputCurrency, fee, minimum, ratio, rounding, tiny }: { + inputCurrency: string, + outputCurrency: string, + minimum: UIField | undefined, + tiny: UIField | undefined, + fee: UIField | undefined, + rounding: UIField | undefined, + ratio: UIField | undefined, + id: string, +}): VNode { + const { i18n } = useTranslationContext(); + return <Fragment> + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="cashin_min_amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum amount`}</label> + <InputAmount + name="cashin_min_amount" + left + currency={inputCurrency} + value={minimum?.value ?? ""} + onChange={minimum?.onUpdate} + /> + <ShowInputErrorLabel + message={minimum?.error} + isDirty={minimum?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`Ratio`} + </label> + <div class="mt-2"> + <input + type="number" + class="block rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="current" + id="cashin_ratio" + data-error={!!ratio?.error && ratio?.value !== undefined} + value={ratio?.value ?? ""} + onChange={(e) => { + ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={ratio?.error} + isDirty={ratio?.value !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Conversion ratio between currencies + </i18n.Translate> + </p> + </div> + + <div class="px-6 pt-4"> + <Attention title={i18n.str`Example conversion`}> + <i18n.Translate>1 {inputCurrency} will be converted into {ratio?.value} {outputCurrency}</i18n.Translate> + </Attention> + </div> + + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="cashin_tiny_amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Rounding value`}</label> + <InputAmount + name="cashin_tiny_amount" + left + currency={outputCurrency} + value={tiny?.value ?? ""} + onChange={tiny?.onUpdate} + /> + <ShowInputErrorLabel + message={tiny?.error} + isDirty={tiny?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Smallest difference between two amounts after the ratio is applied.</i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Rounding mode`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("zero") + }} + data-selected={rounding?.value === "zero"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Zero</i18n.Translate> + </span> + <i18n.Translate>Amount will be round below to the largest possible value smaller than the input.</i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "zero"} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("up") + }} + data-selected={rounding?.value === "up"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Up</i18n.Translate> + </span> + <i18n.Translate>Amount will be round up to the smallest possible value larger than the input.</i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "up"} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("nearest") + }} + data-selected={rounding?.value === "nearest"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Nearest</i18n.Translate> + </span> + <i18n.Translate>Amount will be round to the closest possible value.</i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "nearest"} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + </div> + </div> + </div> + </div> + </div> + + <div class="px-6 pt-4"> + <Attention title={i18n.str`Examples`}> + <section class="grid grid-cols-1 gap-y-3 text-gray-600"> + <details class="group text-sm"> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.24 with rounding value 0.1 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 mt-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.26 with rounding value 0.1 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.24 with rounding value 0.3 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.5 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.26 with rounding value 0.3 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + </section> + </Attention> + </div> + + + + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="cashin_fee" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Fee`}</label> + <InputAmount + name="cashin_fee" + left + currency={outputCurrency} + value={fee?.value ?? ""} + onChange={fee?.onUpdate} + /> + <ShowInputErrorLabel + message={fee?.error} + isDirty={fee?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Amount to be deducted before amount is credited.</i18n.Translate> + </p> + </div> + </div> + </div> + + </Fragment> +} diff --git a/packages/demobank-ui/src/pages/regional/CreateCashout.tsx b/packages/demobank-ui/src/pages/regional/CreateCashout.tsx @@ -0,0 +1,809 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + encodeCrock, + getRandomBytes, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { TransferCalculation, useCashoutEstimator, useConversionInfo, useEstimator } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { TanChannel, undefinedIfEmpty } from "../../utils.js"; +import { LoginForm } from "../LoginForm.js"; +import { + InputAmount, + RenderAmount, + doAutoFocus, +} from "../PaytoWireTransferForm.js"; + +interface Props { + account: string; + focus?: boolean; + onAuthorizationRequired: () => void; + routeClose: RouteDefinition; + routeHere: RouteDefinition; +} + +type FormType = { + isDebit: boolean; + amount: string; + subject: string; + channel: TanChannel; +}; +type ErrorFrom<T> = { + [P in keyof T]+?: string; +}; + +export function CreateCashout({ + account: accountName, + onAuthorizationRequired, + focus, + routeHere, + routeClose, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const resultAccount = useAccountDetails(accountName); + const { + estimateByCredit: calculateFromCredit, + estimateByDebit: calculateFromDebit, + } = useCashoutEstimator(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); + + const { api, config, hints } = useBankCoreApiContext(); + const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); + const [notification, notify, handleError] = useLocalNotification(); + const info = useConversionInfo(); + + if (!config.allow_conversion) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Unable to create a cashout`}> + <i18n.Translate> + The bank configuration does not support cashout operations. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; + + if (!resultAccount) { + return <Loading />; + } + if (resultAccount instanceof TalerError) { + return <ErrorLoadingWithDebug error={resultAccount} />; + } + if (resultAccount.type === "fail") { + switch (resultAccount.case) { + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={accountName} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={accountName} />; + default: + assertUnreachable(resultAccount); + } + } + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention + type="danger" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + const conversionInfo = info.body.conversion_rate; + if (!conversionInfo) { + return ( + <div>conversion enabled but server replied without conversion_rate</div> + ); + } + + const account = { + balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), + balanceIsDebit: + resultAccount.body.balance.credit_debit_indicator == "debit", + debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), + }; + + const { + fiat_currency, + regional_currency, + fiat_currency_specification, + regional_currency_specification, + } = info.body; + const regionalZero = Amounts.zeroOfCurrency(regional_currency); + const fiatZero = Amounts.zeroOfCurrency(fiat_currency); + const limit = account.balanceIsDebit + ? Amounts.sub(account.debitThreshold, account.balance).amount + : Amounts.add(account.balance, account.debitThreshold).amount; + + const zeroCalc = { + debit: regionalZero, + credit: fiatZero, + beforeFee: fiatZero, + }; + const [calculationResult, setCalculation] = useState<TransferCalculation>(zeroCalc); + const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); + const sellRate = conversionInfo.cashout_ratio; + /** + * can be in regional currency or fiat currency + * depending on the isDebit flag + */ + const inputAmount = Amounts.parseOrThrow( + `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount + }`, + ); + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + const higerThanMin = form.isDebit ? + Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 : true; + const notZero = Amounts.isNonZero(inputAmount) + if (notZero && higerThanMin) { + const resp = await (form.isDebit + ? calculateFromDebit(inputAmount, sellFee) + : calculateFromCredit(inputAmount, sellFee)); + setCalculation(resp); + } else { + setCalculation(zeroCalc) + } + }); + } + doAsync(); + }, [form.amount, form.isDebit]); + + const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult + + const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; + + function updateForm(newForm: typeof form): void { + setForm(newForm); + } + const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({ + subject: !form.subject ? i18n.str`Required` : undefined, + amount: !form.amount + ? i18n.str`Required` + : !inputAmount + ? i18n.str`Invalid` + : Amounts.cmp(limit, calc.debit) === -1 + ? i18n.str`Balance is not enough` + : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 + ? i18n.str`Needs to be higher than ${Amounts.stringifyValueWithSpec(Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification).normal}` + : calculationResult === "amount-is-too-small" + ? i18n.str`Amount needs to be higher` + : Amounts.isZero(calc.credit) + ? i18n.str`The total transfer at destination will be zero` + : undefined, + channel: OLD_CASHOUT_API && !form.channel ? i18n.str`Required` : undefined, + }); + const trimmedAmountStr = form.amount?.trim(); + + async function createCashout() { + const request_uid = encodeCrock(getRandomBytes(32)); + await handleError(async () => { + // new cashout api doesn't require channel + const validChannel = + !OLD_CASHOUT_API || + config.supported_tan_channels.length === 0 || + form.channel; + + if (!creds || !form.subject || !validChannel) return; + const request = { + request_uid, + amount_credit: Amounts.stringify(calc.credit), + amount_debit: Amounts.stringify(calc.debit), + subject: form.subject, + tan_channel: form.channel, + }; + const resp = await api.createCashout(creds, request); + if (resp.type === "ok") { + notifyInfo(i18n.str`Cashout created`); + } else { + switch (resp.case) { + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "create-cashout", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({}), + request, + }); + return onAuthorizationRequired(); + } + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return notify({ + type: "error", + title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_BAD_CONVERSION: + return notify({ + type: "error", + title: i18n.str`The conversion rate was incorrectly applied`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`The account does not have sufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.NotImplemented: + return notify({ + type: "error", + title: i18n.str`Cashout are disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return notify({ + type: "error", + title: i18n.str`Missing cashout URI in the profile`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return notify({ + type: "error", + title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + assertUnreachable(resp); + } + }); + } + const cashoutDisabled = + config.supported_tan_channels.length < 1 || + !resultAccount.body.cashout_payto_uri; + + const cashoutAccount = !resultAccount.body.cashout_payto_uri + ? undefined + : parsePaytoUri(resultAccount.body.cashout_payto_uri); + const cashoutAccountName = !cashoutAccount + ? undefined + : cashoutAccount.targetPath; + + const cashoutLegalName = !cashoutAccount + ? undefined + : cashoutAccount.params["receiver-name"]; + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <section class="mt-4 rounded-sm px-4 py-6 p-8 "> + <h2 id="summary-heading" class="font-medium text-lg"> + <i18n.Translate>Cashout</i18n.Translate> + </h2> + + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Conversion rate</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900">{sellRate}</dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Balance</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={account.balance} + spec={regional_currency_specification} + /> + </dd> + </div> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Fee</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={sellFee} + spec={fiat_currency_specification} + /> + </dd> + </div> + {cashoutAccountName && cashoutLegalName ? ( + <Fragment> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>To account</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900">{cashoutAccountName}</dd> + </div> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Legal name</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900">{cashoutLegalName}</dd> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>If this name doesn't match the account holder's name your transaction may fail.</i18n.Translate> + </p> + </Fragment> + ) : ( + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <Attention type="warning" title={i18n.str`No cashout account`}> + <i18n.Translate> + Before doing a cashout you need to complete your profile + </i18n.Translate> + </Attention> + </div> + )} + </dl> + </section> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + {/* subject */} + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="subject" + > + {i18n.str`Transfer subject`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full rounded-md disabled:bg-gray-200 border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="subject" + id="subject" + disabled={cashoutDisabled} + data-error={!!errors?.subject && form.subject !== undefined} + value={form.subject ?? ""} + onChange={(e) => { + form.subject = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.subject} + isDirty={form.subject !== undefined} + /> + </div> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="subject" + > + {i18n.str`Currency`} + </label> + + <div class="mt-2"> + <button + type="button" + name="set 50" + class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + form.isDebit = true; + updateForm(structuredClone(form)); + }} + > + {form.isDebit ? + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + + : + <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> + <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> + </svg> + } + + <i18n.Translate>Send {regional_currency}</i18n.Translate> + </button> + <button + type="button" + name="set 25" + class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + form.isDebit = false; + updateForm(structuredClone(form)); + }} + > + {!form.isDebit ? + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + + : + <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> + <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> + </svg> + } + + <i18n.Translate>Receive {fiat_currency}</i18n.Translate> + </button> + </div> + </div> + + {/* amount */} + <div class="sm:col-span-5"> + <div class="flex justify-between"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="amount" + > + {i18n.str`Amount`} + <b style={{ color: "red" }}> *</b> + </label> + {/* <button + type="button" + data-enabled={form.isDebit} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + form.isDebit = !form.isDebit; + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={form.isDebit} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> */} + </div> + <div class="mt-2"> + <InputAmount + name="amount" + left + currency={form.isDebit ? regional_currency : fiat_currency} + value={trimmedAmountStr} + onChange={ + cashoutDisabled + ? undefined + : (value) => { + form.amount = value; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={errors?.amount} + isDirty={form.amount !== undefined} + /> + </div> + </div> + + {Amounts.isZero(calc.credit) ? undefined : ( + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Total cost</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={calc.debit} + negative + withColor + spec={regional_currency_specification} + /> + </dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Balance left</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={balanceAfter} + spec={regional_currency_specification} + /> + </dd> + </div> + {Amounts.isZero(sellFee) || + Amounts.isZero(calc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Before fee</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={calc.beforeFee} + spec={fiat_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Total cashout transfer</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={calc.credit} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + )} + + {/* channel, not shown if new cashout api */} + {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels + .length === 0 ? ( + <div class="sm:col-span-5"> + <Attention + type="warning" + title={i18n.str`No cashout channel available`} + > + <i18n.Translate> + Before doing a cashout the server need to provide an + second channel to confirm the operation + </i18n.Translate> + </Attention> + </div> + ) : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Second factor authentication`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + {config.supported_tan_channels.indexOf( + TanChannel.EMAIL, + ) === -1 ? undefined : ( + <label + onClick={() => { + if (!resultAccount.body.contact_data?.email) return; + form.channel = TanChannel.EMAIL; + updateForm(structuredClone(form)); + }} + data-disabled={ + !resultAccount.body.contact_data?.email + } + data-selected={form.channel === TanChannel.EMAIL} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Email</i18n.Translate> + </span> + {!resultAccount.body.contact_data?.email && + i18n.str`Add a email in your profile to enable this option`} + </span> + </span> + <svg + data-selected={form.channel === TanChannel.EMAIL} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === + -1 ? undefined : ( + <label + onClick={() => { + if (!resultAccount.body.contact_data?.phone) return; + form.channel = TanChannel.SMS; + updateForm(structuredClone(form)); + }} + data-disabled={ + !resultAccount.body.contact_data?.phone + } + data-selected={form.channel === TanChannel.SMS} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-1-label" + class="block text-sm font-medium text-gray-900" + > + <i18n.Translate>SMS</i18n.Translate> + </span> + {!resultAccount.body.contact_data?.phone && + i18n.str`Add a phone number in your profile to enable this option`} + </span> + </span> + <svg + data-selected={form.channel === TanChannel.SMS} + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + </div> + </div> + </div> + )} + </div> + </div> + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeClose.url({})} + name="cancel" + type="button" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="cashout" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={(e) => { + e.preventDefault(); + createCashout(); + }} + > + <i18n.Translate>Cashout</i18n.Translate> + </button> + </div> + </form> + </div > + </div > + ); +} diff --git a/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -0,0 +1,192 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + Amounts, + Duration, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { Time } from "../../components/Time.js"; + +interface Props { + id: string; + routeClose: RouteDefinition; +} +export function ShowCashoutDetails({ id, routeClose }: Props): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const cid = Number.parseInt(id, 10); + + const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); + const info = useConversionInfo(); + + if (Number.isNaN(cid)) { + return ( + <Attention + type="danger" + title={i18n.str`Cashout id should be a number`} + /> + ); + } + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: + return ( + <Attention + type="warning" + title={i18n.str`This cashout not found. Maybe already aborted.`} + ></Attention> + ); + case HttpStatusCode.NotImplemented: + return ( + <Attention + type="warning" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + default: + assertUnreachable(result); + } + } + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + const { fiat_currency_specification, regional_currency_specification } = + info.body; + + return ( + <div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <section class="rounded-sm px-4"> + <h2 id="summary-heading" class="font-medium text-lg"> + <i18n.Translate>Cashout detail</i18n.Translate> + </h2> + <dl class="mt-8 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Subject</i18n.Translate> + </dt> + <dd class="text-sm ">{result.body.subject}</dd> + </div> + </dl> + </section> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <dl class="space-y-4"> + {result.body.creation_time.t_s !== "never" ? ( + <div class="justify-between items-center flex "> + <dt class=" text-gray-600"> + <i18n.Translate>Created</i18n.Translate> + </dt> + <dd class="text-sm "> + <Time format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp(result.body.creation_time)} + // relative={Duration.fromSpec({ days: 1 })} + /> + </dd> + </div> + ) : undefined} + + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-gray-600"> + <i18n.Translate>Debited</i18n.Translate> + </dt> + <dd class=" font-medium"> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_debit)} + negative + withColor + spec={regional_currency_specification} + /> + </dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-gray-600"> + <span> + <i18n.Translate>Credited</i18n.Translate> + </span> + </dt> + <dd class="text-sm "> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_credit)} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + </div> + </div> + </div> + </div> + + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <a + href={routeClose.url({})} + name="close" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </div> + ); +} diff --git a/packages/demobank-ui/src/stories.test.ts b/packages/demobank-ui/src/stories.test.ts @@ -30,7 +30,6 @@ import * as components from "./components/index.examples.js"; import * as pages from "./pages/index.stories.js"; import { ComponentChildren, VNode, h as create } from "preact"; -import { BackendStateProviderTesting } from "./context/backend.js"; import { BankCoreApiProviderTesting } from "./context/config.js"; setupI18n("en", { en: {} }); @@ -57,15 +56,6 @@ function DefaultTestingContext({ }: { children: ComponentChildren; }): VNode { - const ctx1 = create(BackendStateProviderTesting, { - children, - state: { - status: "loggedIn", - username: "test", - token: "pwd" as AccessToken, - isUserAdministrator: false, - }, - }); const cfg: TalerCorebankApi.Config = { name: "libeufin-bank", allow_deletions: true, @@ -86,7 +76,7 @@ function DefaultTestingContext({ version: "1:0:0", }; const ctx2 = create(BankCoreApiProviderTesting, { - children: ctx1, + children: [], state: cfg, url: "http://localhost", });