diff options
47 files changed, 2133 insertions, 1419 deletions
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx index 75f070e4b..fbf5aa9ec 100644 --- a/packages/bank-ui/src/Routing.tsx +++ b/packages/bank-ui/src/Routing.tsx @@ -22,6 +22,7 @@ import { import { Fragment, VNode, h } from "preact"; import { + AbsoluteTime, AccessToken, HttpStatusCode, TranslatedString, @@ -30,13 +31,13 @@ import { import { useEffect } from "preact/hooks"; import { useBankCoreApiContext } from "./context/config.js"; import { useNavigationContext } from "./context/navigation.js"; -import { useSettingsContext } from "./context/settings.js"; import { useSessionState } from "./hooks/session.js"; import { AccountPage } from "./pages/AccountPage/index.js"; import { BankFrame } from "./pages/BankFrame.js"; import { LoginForm } from "./pages/LoginForm.js"; import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; import { RegistrationPage } from "./pages/RegistrationPage.js"; +import { ShowNotifications } from "./pages/ShowNotifications.js"; import { SolveChallengePage } from "./pages/SolveChallengePage.js"; import { WireTransfer } from "./pages/WireTransfer.js"; import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js"; @@ -58,7 +59,10 @@ export function Routing(): VNode { if (session.state.status === "loggedIn") { const { isUserAdministrator, username } = session.state; return ( - <BankFrame account={username} routeAccountDetails={privatePages.myAccountDetails}> + <BankFrame + account={username} + routeAccountDetails={privatePages.myAccountDetails} + > <PrivateRouting username={username} isAdmin={isUserAdministrator} /> </BankFrame> ); @@ -90,7 +94,6 @@ function PublicRounting({ }: { onLoggedUser: (username: string, token: AccessToken) => void; }): VNode { - const settings = useSettingsContext(); const { i18n } = useTranslationContext(); const location = useCurrentLocation(publicPages); const { navigateTo } = useNavigationContext(); @@ -109,12 +112,11 @@ function PublicRounting({ async function doAutomaticLogin(username: string, password: string) { await handleError(async () => { - const resp = await authenticator(username) - .createAccessToken(password, { - scope: "readwrite", - duration: { d_us: "forever" }, - refreshable: true, - }); + const resp = await authenticator(username).createAccessToken(password, { + scope: "readwrite", + duration: { d_us: "forever" }, + refreshable: true, + }); if (resp.type === "ok") { onLoggedUser(username, resp.body.access_token); } else { @@ -125,6 +127,7 @@ function PublicRounting({ title: i18n.str`Wrong credentials for "${username}"`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -132,6 +135,7 @@ function PublicRounting({ title: i18n.str`Account not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); default: assertUnreachable(resp); @@ -198,14 +202,12 @@ export const privatePages = { () => "#/account/charge-wallet", ), homeWireTransfer: urlPattern<{ - account?: string, - subject?: string, - amount?: string, - }>( - /\/account\/wire-transfer/, - () => "#/account/wire-transfer", - ), + account?: string; + subject?: string; + amount?: string; + }>(/\/account\/wire-transfer/, () => "#/account/wire-transfer"), home: urlPattern(/\/account/, () => "#/account"), + notifications: urlPattern(/\/notifications/, () => "#/notifications"), solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"), cashoutCreate: urlPattern(/\/new-cashout/, () => "#/new-cashout"), cashoutDetails: urlPattern<{ cid: string }>( @@ -213,9 +215,9 @@ export const privatePages = { ({ cid }) => `#/cashout/${cid}`, ), wireTranserCreate: urlPattern<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>( /\/wire-transfer\/(?<account>[a-zA-Z0-9]+)/, ({ account }) => `#/wire-transfer/${account}`, @@ -278,7 +280,6 @@ function PrivateRouting({ switch (location.name) { case "operationDetails": { - return ( <WithdrawalOperationPage operationId={location.values.wopid} @@ -293,7 +294,6 @@ function PrivateRouting({ ); } case "startOperation": { - return ( <WithdrawalOperationPage operationId={location.values.wopid} @@ -563,17 +563,19 @@ function PrivateRouting({ ); } case "conversionConfig": { - return <ConversionConfig - routeMyAccountCashout={privatePages.myAccountCashouts} - routeMyAccountDelete={privatePages.myAccountDelete} - routeMyAccountDetails={privatePages.myAccountDetails} - routeMyAccountPassword={privatePages.myAccountPassword} - routeConversionConfig={privatePages.conversionConfig} - routeCancel={privatePages.home} - onUpdateSuccess={() => { - navigateTo(privatePages.home.url({})) - }} - />; + return ( + <ConversionConfig + routeMyAccountCashout={privatePages.myAccountCashouts} + routeMyAccountDelete={privatePages.myAccountDelete} + routeMyAccountDetails={privatePages.myAccountDetails} + routeMyAccountPassword={privatePages.myAccountPassword} + routeConversionConfig={privatePages.conversionConfig} + routeCancel={privatePages.home} + onUpdateSuccess={() => { + navigateTo(privatePages.home.url({})); + }} + /> + ); } case "homeWireTransfer": { return ( @@ -598,6 +600,9 @@ function PrivateRouting({ /> ); } + case "notifications": { + return <ShowNotifications />; + } default: assertUnreachable(location); } diff --git a/packages/bank-ui/src/app.tsx b/packages/bank-ui/src/app.tsx index 3a7fafccf..893942059 100644 --- a/packages/bank-ui/src/app.tsx +++ b/packages/bank-ui/src/app.tsx @@ -88,7 +88,7 @@ export function App() { </TranslationProvider> </SettingsProvider> ); -}; +} // @ts-expect-error creating a new property for window object window.setGlobalLogLevelFromString = setGlobalLogLevelFromString; diff --git a/packages/bank-ui/src/components/Cashouts/views.tsx b/packages/bank-ui/src/components/Cashouts/views.tsx index 7f16d5840..22b8d8c1b 100644 --- a/packages/bank-ui/src/components/Cashouts/views.tsx +++ b/packages/bank-ui/src/components/Cashouts/views.tsx @@ -17,7 +17,6 @@ import { AbsoluteTime, Amounts, - Duration, HttpStatusCode, TalerError, assertUnreachable, @@ -32,19 +31,19 @@ import { Fragment, VNode, h } from "preact"; import { useConversionInfo } from "../../hooks/regional.js"; import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js"; -import { State } from "./index.js"; import { Time } from "../Time.js"; +import { State } from "./index.js"; export function FailedView({ error }: State.Failed) { const { i18n } = useTranslationContext(); switch (error.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 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> ); } @@ -69,11 +68,11 @@ export function ReadyView({ switch (resp.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 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> ); } @@ -89,8 +88,8 @@ export function ReadyView({ cur.creation_time.t_s === "never" ? "" : format(cur.creation_time.t_s * 1000, "dd/MM/yyyy", { - locale: dateLocale, - }); + locale: dateLocale, + }); if (!prev[d]) { prev[d] = []; } @@ -156,9 +155,12 @@ export function ReadyView({ > <td class="relative py-2 pl-2 pr-2 text-sm "> <div class="font-medium text-gray-900"> - <Time format="HH:mm:ss" - timestamp={AbsoluteTime.fromProtocolTimestamp(item.creation_time)} - // relative={Duration.fromSpec({ days: 1 })} + <Time + format="HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + item.creation_time, + )} + // relative={Duration.fromSpec({ days: 1 })} /> </div> { @@ -200,7 +202,6 @@ export function ReadyView({ </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> - {item.subject} </td> </a> diff --git a/packages/bank-ui/src/components/Time.tsx b/packages/bank-ui/src/components/Time.tsx index 39ce33f60..5c8afe212 100644 --- a/packages/bank-ui/src/components/Time.tsx +++ b/packages/bank-ui/src/components/Time.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (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 @@ -16,16 +16,21 @@ import { AbsoluteTime, Duration } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { formatISO, format, formatDuration, intervalToDuration } from "date-fns"; +import { + formatISO, + format, + formatDuration, + intervalToDuration, +} from "date-fns"; import { Fragment, h, VNode } from "preact"; /** - * + * * @param timestamp time to be formatted * @param relative duration threshold, if the difference is lower * the timestamp will be formatted as relative time from "now" - * - * @returns + * + * @returns */ export function Time({ timestamp, @@ -33,34 +38,38 @@ export function Time({ format: formatString, }: { timestamp: AbsoluteTime | undefined; - relative?: Duration, + relative?: Duration; format: string; }): VNode { - const { i18n, dateLocale } = useTranslationContext() - if (!timestamp) return <Fragment /> + const { i18n, dateLocale } = useTranslationContext(); + if (!timestamp) return <Fragment />; if (timestamp.t_ms === "never") { - return <time >{i18n.str`never`}</time> + return <time>{i18n.str`never`}</time>; } const now = AbsoluteTime.now(); - const diff = AbsoluteTime.difference(now, timestamp) + const diff = AbsoluteTime.difference(now, timestamp); if (relative && now.t_ms !== "never" && Duration.cmp(diff, relative) === -1) { const d = intervalToDuration({ start: now.t_ms, - end: timestamp.t_ms - }) - d.seconds = 0 - const duration = formatDuration(d, { locale: dateLocale }) - const isFuture = AbsoluteTime.cmp(now, timestamp) < 0 + end: timestamp.t_ms, + }); + d.seconds = 0; + const duration = formatDuration(d, { locale: dateLocale }); + const isFuture = AbsoluteTime.cmp(now, timestamp) < 0; if (isFuture) { - return <time dateTime={formatISO(timestamp.t_ms)}> - <i18n.Translate>in {duration}</i18n.Translate> - </time> + return ( + <time dateTime={formatISO(timestamp.t_ms)}> + <i18n.Translate>in {duration}</i18n.Translate> + </time> + ); } else { - return <time dateTime={formatISO(timestamp.t_ms)}> - <i18n.Translate>{duration} ago</i18n.Translate> - </time> + return ( + <time dateTime={formatISO(timestamp.t_ms)}> + <i18n.Translate>{duration} ago</i18n.Translate> + </time> + ); } } return ( diff --git a/packages/bank-ui/src/components/Transactions/index.ts b/packages/bank-ui/src/components/Transactions/index.ts index c8bb1e108..4cad6f306 100644 --- a/packages/bank-ui/src/components/Transactions/index.ts +++ b/packages/bank-ui/src/components/Transactions/index.ts @@ -23,11 +23,13 @@ import { RouteDefinition } from "../../route.js"; export interface Props { account: string; - routeCreateWireTransfer: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, - }> | undefined; + routeCreateWireTransfer: + | RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }> + | undefined; } export type State = State.Loading | State.LoadingUriError | State.Ready; @@ -49,11 +51,13 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; error: undefined; - routeCreateWireTransfer: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, - }> | undefined; + routeCreateWireTransfer: + | RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }> + | undefined; transactions: Transaction[]; onGoStart?: () => void; onGoNext?: () => void; diff --git a/packages/bank-ui/src/components/Transactions/state.ts b/packages/bank-ui/src/components/Transactions/state.ts index 3e9103b59..e792ddfa0 100644 --- a/packages/bank-ui/src/components/Transactions/state.ts +++ b/packages/bank-ui/src/components/Transactions/state.ts @@ -23,7 +23,10 @@ import { import { useTransactions } from "../../hooks/account.js"; import { Props, State, Transaction } from "./index.js"; -export function useComponentState({ account, routeCreateWireTransfer }: Props): State { +export function useComponentState({ + account, + routeCreateWireTransfer, +}: Props): State { const txResult = useTransactions(account); if (!txResult) { return { @@ -38,36 +41,35 @@ export function useComponentState({ account, routeCreateWireTransfer }: Props): }; } - const transactions = - txResult.result - .map((tx) => { - const negative = tx.direction === "debit"; - const cp = parsePaytoUri( - negative ? tx.creditor_payto_uri : tx.debtor_payto_uri, - ); - const counterpart = - (cp === undefined || !cp.isKnown - ? undefined - : cp.targetType === "iban" - ? cp.iban - : cp.targetType === "x-taler-bank" - ? cp.account - : cp.targetType === "bitcoin" - ? `${cp.targetPath.substring(0, 6)}...` - : undefined) ?? "unknown"; + const transactions = txResult.result + .map((tx) => { + const negative = tx.direction === "debit"; + const cp = parsePaytoUri( + negative ? tx.creditor_payto_uri : tx.debtor_payto_uri, + ); + const counterpart = + (cp === undefined || !cp.isKnown + ? undefined + : cp.targetType === "iban" + ? cp.iban + : cp.targetType === "x-taler-bank" + ? cp.account + : cp.targetType === "bitcoin" + ? `${cp.targetPath.substring(0, 6)}...` + : undefined) ?? "unknown"; - const when = AbsoluteTime.fromProtocolTimestamp(tx.date); - const amount = Amounts.parse(tx.amount); - const subject = tx.subject; - return { - negative, - counterpart, - when, - amount, - subject, - }; - }) - .filter((x): x is Transaction => x !== undefined); + const when = AbsoluteTime.fromProtocolTimestamp(tx.date); + const amount = Amounts.parse(tx.amount); + const subject = tx.subject; + return { + negative, + counterpart, + when, + amount, + subject, + }; + }) + .filter((x): x is Transaction => x !== undefined); return { status: "ready", diff --git a/packages/bank-ui/src/components/Transactions/views.tsx b/packages/bank-ui/src/components/Transactions/views.tsx index 7da9fc5a9..417b34c71 100644 --- a/packages/bank-ui/src/components/Transactions/views.tsx +++ b/packages/bank-ui/src/components/Transactions/views.tsx @@ -19,9 +19,8 @@ import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useBankCoreApiContext } from "../../context/config.js"; import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; -import { State } from "./index.js"; -import { Duration } from "@gnu-taler/taler-util"; import { Time } from "../Time.js"; +import { State } from "./index.js"; export function ReadyView({ transactions, @@ -30,24 +29,26 @@ export function ReadyView({ onGoStart, }: State.Ready): VNode { const { i18n, dateLocale } = useTranslationContext(); - const { config } = useBankCoreApiContext() + const { config } = useBankCoreApiContext(); if (!transactions.length) { - return <div class="px-4 mt-4"> - <div class="sm:flex sm:items-center"> - <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Transactions history</i18n.Translate> - </h1> + return ( + <div class="px-4 mt-4"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Transactions history</i18n.Translate> + </h1> + </div> </div> - </div> - <Attention type="low" title={i18n.str`No transactions yet.`}> - <i18n.Translate> - You can start sending a wire transfer or withdrawing to your wallet. - </i18n.Translate> - </Attention> - </div>; + <Attention type="low" title={i18n.str`No transactions yet.`}> + <i18n.Translate> + You can start sending a wire transfer or withdrawing to your wallet. + </i18n.Translate> + </Attention> + </div> + ); } const txByDate = transactions.reduce( @@ -116,9 +117,10 @@ export function ReadyView({ > <td class="relative py-2 pl-2 pr-2 text-sm "> <div class="font-medium text-gray-900"> - <Time format="HH:mm:ss" + <Time + format="HH:mm:ss" timestamp={item.when} - // relative={Duration.fromSpec({ days: 1 })} + // relative={Duration.fromSpec({ days: 1 })} /> </div> <dl class="font-normal sm:hidden"> @@ -153,7 +155,9 @@ export function ReadyView({ </dt> <dd class="mt-1 truncate text-gray-500 sm:hidden"> {item.negative ? i18n.str`to` : i18n.str`from`}{" "} - {!routeCreateWireTransfer ? item.counterpart : + {!routeCreateWireTransfer ? ( + item.counterpart + ) : ( <a name={`transfer to ${item.counterpart}`} href={routeCreateWireTransfer.url({ @@ -163,7 +167,7 @@ export function ReadyView({ > {item.counterpart} </a> - } + )} </dd> <dd class="mt-1 text-gray-500 sm:hidden"> <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> @@ -190,7 +194,9 @@ export function ReadyView({ )} </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500"> - {!routeCreateWireTransfer ? item.counterpart : + {!routeCreateWireTransfer ? ( + item.counterpart + ) : ( <a name={`wire transfer to ${item.counterpart}`} href={routeCreateWireTransfer.url({ @@ -200,7 +206,7 @@ export function ReadyView({ > {item.counterpart} </a> - } + )} </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> {item.subject} diff --git a/packages/bank-ui/src/context/config.ts b/packages/bank-ui/src/context/config.ts index cb0d599aa..f8be80a6c 100644 --- a/packages/bank-ui/src/context/config.ts +++ b/packages/bank-ui/src/context/config.ts @@ -26,11 +26,12 @@ import { TalerError, assertUnreachable, CacheEvictor, + ObservabilityEvent, } from "@gnu-taler/taler-util"; import { BrowserFetchHttpLib, ErrorLoading, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, @@ -63,6 +64,8 @@ export type Type = { conversion: TalerBankConversionHttpClient; authenticator: (user: string) => TalerAuthenticationHttpClient; hints: VersionHint[]; + onBackendActivity: (fn: Listener) => Unsuscriber; + cancelRequest: (eventId: string) => void; }; // FIXME: below @@ -78,6 +81,25 @@ export enum VersionHint { CASHOUT_BEFORE_2FA, } +const observers = new Array<(e: ObservabilityEvent) => void>(); +type Listener = (e: ObservabilityEvent) => void; +type Unsuscriber = () => void; + +const activity = Object.freeze({ + notify: (data: ObservabilityEvent) => + observers.forEach((observer) => observer(data)), + subscribe: (func: Listener): Unsuscriber => { + observers.push(func); + return () => { + observers.forEach((observer, index) => { + if (observer === func) { + observers.splice(index, 1); + } + }); + }; + }, +}); + export type ConfigResult = | undefined | { type: "ok"; config: TalerCorebankApi.Config; hints: VersionHint[] } @@ -96,7 +118,8 @@ export const BankCoreApiProvider = ({ const [checked, setChecked] = useState<ConfigResult>(); const { i18n } = useTranslationContext(); - const { bankClient, conversionClient, authClient } = buildApiClient(new URL(baseUrl)) + const { bankClient, conversionClient, authClient, cancelRequest } = + buildApiClient(new URL(baseUrl)); useEffect(() => { bankClient @@ -150,8 +173,10 @@ export const BankCoreApiProvider = ({ url: new URL(bankClient.baseUrl), config: checked.config, bank: bankClient, + onBackendActivity: activity.subscribe, conversion: conversionClient, authenticator: authClient, + cancelRequest, hints: checked.hints, }; return h(Context.Provider, { @@ -162,8 +187,8 @@ export const BankCoreApiProvider = ({ /** * build http client with cache breaker due to SWR - * @param url - * @returns + * @param url + * @returns */ function buildApiClient(url: URL) { const httpFetch = new BrowserFetchHttpLib({ @@ -172,15 +197,32 @@ function buildApiClient(url: URL) { }); const httpLib = new ObservableHttpClientLibrary(httpFetch, { observe(ev) { - console.log(ev) - } - }) + activity.notify(ev); + }, + }); - const bankClient = new TalerCoreBankHttpClient(url.href, httpLib, evictBankSwrCache); - const conversionClient = new TalerBankConversionHttpClient(bankClient.getConversionInfoAPI().href, httpLib, evictConversionSwrCache); - const authClient = (user: string) => new TalerAuthenticationHttpClient(bankClient.getAuthenticationAPI(user).href, user, httpLib); + function cancelRequest(id: string) { + httpLib.cancelRequest(id); + } - return { bankClient, conversionClient, authClient } + const bankClient = new TalerCoreBankHttpClient( + url.href, + httpLib, + evictBankSwrCache, + ); + const conversionClient = new TalerBankConversionHttpClient( + bankClient.getConversionInfoAPI().href, + httpLib, + evictConversionSwrCache, + ); + const authClient = (user: string) => + new TalerAuthenticationHttpClient( + bankClient.getAuthenticationAPI(user).href, + user, + httpLib, + ); + + return { bankClient, conversionClient, authClient, cancelRequest }; } export const BankCoreApiProviderTesting = ({ @@ -206,7 +248,6 @@ export const BankCoreApiProviderTesting = ({ }); }; - const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { async notifySuccess(op) { switch (op) { @@ -215,7 +256,7 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { revalidatePublicAccounts(), revalidateBusinessAccounts(), ]); - return + return; } case TalerCoreBankCacheEviction.CREATE_ACCOUNT: { // admin balance change on new account @@ -224,27 +265,25 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { revalidateTransactions(), revalidatePublicAccounts(), revalidateBusinessAccounts(), - ]) + ]); return; } case TalerCoreBankCacheEviction.UPDATE_ACCOUNT: { - await Promise.all([ - revalidateAccountDetails(), - ]) + await Promise.all([revalidateAccountDetails()]); return; } case TalerCoreBankCacheEviction.CREATE_TRANSACTION: { await Promise.all([ revalidateAccountDetails(), revalidateTransactions(), - ]) + ]); return; } case TalerCoreBankCacheEviction.CONFIRM_WITHDRAWAL: { await Promise.all([ revalidateAccountDetails(), revalidateTransactions(), - ]) + ]); return; } case TalerCoreBankCacheEviction.CREATE_CASHOUT: { @@ -252,7 +291,7 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { revalidateAccountDetails(), revalidateCashouts(), revalidateTransactions(), - ]) + ]); return; } case TalerCoreBankCacheEviction.UPDATE_PASSWORD: @@ -260,20 +299,21 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { case TalerCoreBankCacheEviction.CREATE_WITHDRAWAL: return; default: - assertUnreachable(op) + assertUnreachable(op); } - } -} + }, +}; -const evictConversionSwrCache: CacheEvictor<TalerBankConversionCacheEviction> = { - async notifySuccess(op) { - switch (op) { - case TalerBankConversionCacheEviction.UPDATE_RATE: { - await revalidateConversionInfo(); - return +const evictConversionSwrCache: CacheEvictor<TalerBankConversionCacheEviction> = + { + async notifySuccess(op) { + switch (op) { + case TalerBankConversionCacheEviction.UPDATE_RATE: { + await revalidateConversionInfo(); + return; + } + default: + assertUnreachable(op); } - default: - assertUnreachable(op) - } - } -}
\ No newline at end of file + }, + }; diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts index aa0745253..5fe12573c 100644 --- a/packages/bank-ui/src/hooks/account.ts +++ b/packages/bank-ui/src/hooks/account.ts @@ -62,7 +62,11 @@ export function useAccountDetails(account: string) { } export function revalidateWithdrawalDetails() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", undefined, { revalidate: true }); + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", + undefined, + { revalidate: true }, + ); } export function useWithdrawalDetails(wid: string) { @@ -110,7 +114,9 @@ export function useWithdrawalDetails(wid: string) { export function revalidateTransactionDetails() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", undefined, { revalidate: true } + (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", + undefined, + { revalidate: true }, ); } export function useTransactionDetails(account: string, tid: number) { @@ -149,7 +155,9 @@ export function useTransactionDetails(account: string, tid: number) { export async function revalidatePublicAccounts() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", undefined, { revalidate: true } + (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", + undefined, + { revalidate: true }, ); } export function usePublicAccounts( @@ -193,9 +201,10 @@ export function usePublicAccounts( 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 result = + data && data.type == "ok" ? structuredClone(data.body.public_accounts) : []; + if (result.length == PAGE_SIZE + 1) { + result.pop(); } const pagination = { result, @@ -243,7 +252,7 @@ export function useTransactions(account: string, initial?: number) { return await api.getTransactions( { username, token }, { - limit: PAGE_SIZE +1 , + limit: PAGE_SIZE + 1, offset: txid ? String(txid) : undefined, order: "dec", }, @@ -267,9 +276,10 @@ export function useTransactions(account: string, initial?: number) { 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 result = + data && data.type == "ok" ? structuredClone(data.body.transactions) : []; + if (result.length == PAGE_SIZE + 1) { + result.pop(); } const pagination = { result, diff --git a/packages/bank-ui/src/hooks/bank-state.ts b/packages/bank-ui/src/hooks/bank-state.ts index 83bb009cf..1d8c4f9e6 100644 --- a/packages/bank-ui/src/hooks/bank-state.ts +++ b/packages/bank-ui/src/hooks/bank-state.ts @@ -118,7 +118,7 @@ const codecForChallengeConfirmWithdrawal = .property("request", codecForString()) .build("ConfirmWithdrawalChallenge"); -const codecForAppLocation = codecForString as () => Codec<AppLocation> +const codecForAppLocation = codecForString as () => Codec<AppLocation>; const codecForChallengeCashout = (): Codec<CashoutChallenge> => buildCodecForObject<CashoutChallenge>() @@ -141,8 +141,6 @@ const codecForChallenge = (): Codec<ChallengeInProgess> => .alternative("update-password", codecForChallengeUpdatePassword()) .build("ChallengeInProgess"); - - interface BankState { currentWithdrawalOperationId: string | undefined; currentChallenge: ChallengeInProgess | undefined; @@ -163,10 +161,10 @@ 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(): [ @@ -185,4 +183,3 @@ export function useBankState(): [ } return [value, updateField, reset]; } - diff --git a/packages/bank-ui/src/hooks/form.ts b/packages/bank-ui/src/hooks/form.ts index 26354b108..afa4912eb 100644 --- a/packages/bank-ui/src/hooks/form.ts +++ b/packages/bank-ui/src/hooks/form.ts @@ -14,87 +14,102 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson, TalerBankConversionApi, TranslatedString } from "@gnu-taler/taler-util"; +import { AmountJson, 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]>; -} + [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]>; -} + [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]>; -} + [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>) + [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 }) + const currentValue: unknown = form[fieldName]; + const currentError: unknown = errors ? errors[fieldName] : undefined; + function updater(newValue: unknown) { + updateForm({ ...form, [fieldName]: newValue }); } if (typeof currentValue === "object") { - const group = constructFormHandler(currentValue, updater, currentError) - // @ts-expect-error asdasd - prev[fieldName] = group + // @ts-expect-error FIXME better typing + const group = constructFormHandler(currentValue, updater, currentError); + // @ts-expect-error FIXME better typing + prev[fieldName] = group; return prev; } const field: UIField = { + // @ts-expect-error FIXME better typing error: currentError, + // @ts-expect-error FIXME better typing value: currentValue, - onUpdate: updater - } - // @ts-expect-error asdasd - prev[fieldName] = field - return prev - }, {} as FormHandler<T>) + onUpdate: updater, + }; + // @ts-expect-error FIXME better typing + 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) +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) + const status = check(form); + const handler = constructFormHandler(form, updateForm, status.errors); - return [handler, status] -}
\ No newline at end of file + return [handler, status]; +} diff --git a/packages/bank-ui/src/hooks/preferences.ts b/packages/bank-ui/src/hooks/preferences.ts index 454dc8d80..bb3dcb153 100644 --- a/packages/bank-ui/src/hooks/preferences.ts +++ b/packages/bank-ui/src/hooks/preferences.ts @@ -61,7 +61,7 @@ const BANK_PREFERENCES_KEY = buildStorageKey( ); /** * User preferences. - * + * * @returns tuple of [state, update()] */ export function usePreferences(): [ @@ -109,4 +109,3 @@ export function getLabelForPreferences( return i18n.str`Show debug info`; } } - diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts index bf948d293..51f3edad4 100644 --- a/packages/bank-ui/src/hooks/regional.ts +++ b/packages/bank-ui/src/hooks/regional.ts @@ -31,18 +31,20 @@ import { TalerHttpError, opFixedSuccess, } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; 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"; +export type TransferCalculation = + | { + debit: AmountJson; + credit: AmountJson; + beforeFee: AmountJson; + } + | "amount-is-too-small"; type EstimatorFunction = ( amount: AmountJson, fee: AmountJson, @@ -95,7 +97,7 @@ export function useCashinEstimator(): ConversionEstimators { if (resp.type === "fail") { switch (resp.case) { case HttpStatusCode.Conflict: { - return "amount-is-too-small" + return "amount-is-too-small"; } // this below can't happen case HttpStatusCode.NotImplemented: //it should not be able to call this function @@ -120,7 +122,7 @@ export function useCashinEstimator(): ConversionEstimators { if (resp.type === "fail") { switch (resp.case) { case HttpStatusCode.Conflict: { - return "amount-is-too-small" + return "amount-is-too-small"; } // this below can't happen case HttpStatusCode.NotImplemented: //it should not be able to call this function @@ -142,7 +144,7 @@ export function useCashinEstimator(): ConversionEstimators { } export function useCashoutEstimator(): ConversionEstimators { - const { bank, conversion } = useBankCoreApiContext(); + const { conversion } = useBankCoreApiContext(); return { estimateByCredit: async (fiatAmount, fee) => { const resp = await conversion.getCashoutRate({ @@ -151,7 +153,7 @@ export function useCashoutEstimator(): ConversionEstimators { if (resp.type === "fail") { switch (resp.case) { case HttpStatusCode.Conflict: { - return "amount-is-too-small" + return "amount-is-too-small"; } // this below can't happen case HttpStatusCode.NotImplemented: //it should not be able to call this function @@ -176,7 +178,7 @@ export function useCashoutEstimator(): ConversionEstimators { if (resp.type === "fail") { switch (resp.case) { case HttpStatusCode.Conflict: { - return "amount-is-too-small" + return "amount-is-too-small"; } // this below can't happen case HttpStatusCode.NotImplemented: //it should not be able to call this function @@ -201,11 +203,15 @@ export function useCashoutEstimator(): ConversionEstimators { * @deprecated use useCashoutEstimator */ export function useEstimator(): ConversionEstimators { - return useCashoutEstimator() + return useCashoutEstimator(); } export async function revalidateBusinessAccounts() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", + undefined, + { revalidate: true }, + ); } export function useBusinessAccounts() { const { state: credentials } = useSessionState(); @@ -247,9 +253,10 @@ export function useBusinessAccounts() { data && data.type === "ok" && data.body.accounts.length <= PAGE_SIZE; const isFirstPage = !offset; - const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : [] + const result = + data && data.type == "ok" ? structuredClone(data.body.accounts) : []; if (result.length == PAGE_SIZE + 1) { - result.pop() + result.pop(); } const pagination = { result, @@ -276,7 +283,9 @@ function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { export function revalidateOnePendingCashouts() { return mutate( (key) => - Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", undefined, { revalidate: true } + Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", + undefined, + { revalidate: true }, ); } export function useOnePendingCashouts(account: string) { @@ -290,7 +299,8 @@ export function useOnePendingCashouts(account: string) { if (list.type !== "ok") { return list; } - const pendingCashout = list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; + const pendingCashout = + list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; if (!pendingCashout) return opFixedSuccess(undefined); const cashoutInfo = await api.getCashoutById( { username, token }, @@ -334,7 +344,9 @@ export function useOnePendingCashouts(account: string) { } export function revalidateCashouts() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "useCashouts", + ); } export function useCashouts(account: string) { const { state: credentials } = useSessionState(); @@ -357,7 +369,7 @@ export function useCashouts(account: string) { }), ); const cashouts = all.filter(notUndefined); - return { type: "ok" as const, body: { cashouts }}; + return { type: "ok" as const, body: { cashouts } }; } const { data, error } = useSWR< | OperationOk<{ cashouts: CashoutWithId[] }> @@ -386,7 +398,9 @@ export function useCashouts(account: string) { export function revalidateCashoutDetails() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", undefined, { revalidate: true } + (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", + undefined, + { revalidate: true }, ); } export function useCashoutDetails(cashoutId: number | undefined) { @@ -435,7 +449,9 @@ export type LastMonitor = { }; export function revalidateLastMonitorInfo() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", undefined, { revalidate: true } + (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", + undefined, + { revalidate: true }, ); } export function useLastMonitorInfo( diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts index 7776fbaa3..757346c5c 100644 --- a/packages/bank-ui/src/pages/AccountPage/index.ts +++ b/packages/bank-ui/src/pages/AccountPage/index.ts @@ -88,14 +88,14 @@ export namespace State { routeChargeWallet: RouteDefinition; routePublicAccounts: RouteDefinition; routeWireTransfer: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>; routeCreateWireTransfer: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>; routeOperationDetails: RouteDefinition<{ wopid: string }>; routeSolveSecondFactor: RouteDefinition; diff --git a/packages/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx index 7ad00cf1d..3a182ed1b 100644 --- a/packages/bank-ui/src/pages/AccountPage/views.tsx +++ b/packages/bank-ui/src/pages/AccountPage/views.tsx @@ -32,7 +32,9 @@ export function InvalidIbanView({ error }: State.InvalidIban) { const IS_PUBLIC_ACCOUNT_ENABLED = false; -function ShowDemoInfo({ routePublicAccounts }: { +function ShowDemoInfo({ + routePublicAccounts, +}: { routePublicAccounts: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); @@ -50,7 +52,10 @@ function ShowDemoInfo({ routePublicAccounts }: { This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some{" "} - <a name="public account" href={routePublicAccounts.url({})}>Public Accounts</a>. + <a name="public account" href={routePublicAccounts.url({})}> + Public Accounts + </a> + . </i18n.Translate> ) : ( <i18n.Translate> @@ -62,7 +67,9 @@ function ShowDemoInfo({ routePublicAccounts }: { ); } -function ShowPedingOperation({ routeSolveSecondFactor }: { +function ShowPedingOperation({ + routeSolveSecondFactor, +}: { routeSolveSecondFactor: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); @@ -140,7 +147,10 @@ export function ReadyView({ onOperationCreated={onOperationCreated} onAuthorizationRequired={onAuthorizationRequired} /> - <Transactions account={account} routeCreateWireTransfer={routeCreateWireTransfer} /> + <Transactions + account={account} + routeCreateWireTransfer={routeCreateWireTransfer} + /> </Fragment> ); } diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx index 427e9a156..39f042455 100644 --- a/packages/bank-ui/src/pages/BankFrame.tsx +++ b/packages/bank-ui/src/pages/BankFrame.tsx @@ -14,7 +14,14 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + Amounts, + ObservabilityEventType, + TalerError, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; import { Footer, Header, @@ -22,22 +29,23 @@ import { ToastBanner, notifyError, notifyException, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, VNode, h } from "preact"; -import { useEffect, useErrorBoundary } from "preact/hooks"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; import { useSettingsContext } from "../context/settings.js"; import { useAccountDetails } from "../hooks/account.js"; -import { useSessionState } from "../hooks/session.js"; import { useBankState } from "../hooks/bank-state.js"; import { getAllBooleanPreferences, getLabelForPreferences, usePreferences, } from "../hooks/preferences.js"; +import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "../route.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { privatePages } from "../Routing.js"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -85,13 +93,18 @@ export function BankFrame({ title="Bank" iconLinkURL={settings.iconLinkURL ?? "#"} profileURL={routeAccountDetails?.url({})} + notificationURL={ + preferences.showDebugInfo + ? privatePages.notifications.url({}) + : undefined + } onLogout={ session.state.status !== "loggedIn" ? undefined : () => { - session.logOut(); - resetBankState(); - } + session.logOut(); + resetBankState(); + } } sites={ !settings.topNavSites ? [] : Object.entries(settings.topNavSites) @@ -102,11 +115,11 @@ export function BankFrame({ <div class="text-xs font-semibold leading-6 text-gray-400"> <i18n.Translate>Preferences</i18n.Translate> </div> - <ul role="list" class="space-y-1"> + <ul role="list" class="space-y-4"> {getAllBooleanPreferences().map((set) => { const isOn: boolean = !!preferences[set]; return ( - <li key={set} class="mt-2 pl-2"> + <li key={set} class="pl-2"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span @@ -144,19 +157,23 @@ export function BankFrame({ </Header> </div> - <div class="fixed z-20 w-full"> + <div class="fixed z-20 top-14 w-full"> <div class="mx-auto w-4/5"> <ToastBanner /> + {/* <Attention type="success" title={"hola" as TranslatedString} onClose={() => { }} /> */} </div> </div> <main class="-mt-32 flex-1"> {account && routeAccountDetails && ( - <header class="py-5 bg-indigo-600 "> + <header class="py-6 bg-indigo-600"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <h1 class=" flex flex-wrap items-center justify-between sm:flex-nowrap"> <span class="text-2xl font-bold tracking-tight text-white"> - <WelcomeAccount account={account} routeAccountDetails={routeAccountDetails} /> + <WelcomeAccount + account={account} + routeAccountDetails={routeAccountDetails} + /> </span> <span class="text-2xl font-bold tracking-tight text-white"> <AccountBalance account={account} /> @@ -166,13 +183,15 @@ export function BankFrame({ </header> )} - <div class="mx-auto max-w-7xl px-4 pb-12 sm:px-6 lg:px-8"> + <div class="mx-auto max-w-7xl px-4 pb-4 sm:px-6 lg:px-8"> <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6"> {children} </div> </div> </main> + <AppActivity /> + <Footer testingUrlKey="corebank-api-base-url" GIT_HASH={GIT_HASH} @@ -182,8 +201,117 @@ export function BankFrame({ ); } -function WelcomeAccount({ account, routeAccountDetails }: { - account: string, +function Wait({ class: clazz }: { class?: string }): VNode { + return ( + <Fragment> + <style>{` + .animated-loader { + display: inline-block; + --b: 5px; + border-radius: 50%; + aspect-ratio: 1; + padding: 1px; + background: conic-gradient(#0000 10%,#4f46e5) content-box; + -webkit-mask: + repeating-conic-gradient(#0000 0deg,#000 1deg 20deg,#0000 21deg 36deg), + radial-gradient(farthest-side,#0000 calc(100% - var(--b) - 1px),#000 calc(100% - var(--b))); + -webkit-mask-composite: destination-in; + mask-composite: intersect; + animation:spinning-loader 1s infinite steps(10); + } + @keyframes spinning-loader {to{transform: rotate(1turn)}} + `}</style> + <div class={`animated-loader ${clazz}`} /> + </Fragment> + ); +} + +function AppActivity(): VNode { + const [lastEvent, setLastEvent] = useState<{ + url: string; + id: string; + when: AbsoluteTime; + }>(); + const [status, setStatus] = useState<"ok" | "fail">(); + const d = useBankCoreApiContext(); + const onBackendActivity = !d ? undefined : d.onBackendActivity; + const cancelRequest = !d ? undefined : d.cancelRequest; + const [pref] = usePreferences(); + useEffect(() => { + // console.log("ASDASDS", onBackendActivity) + if (!pref.showDebugInfo) return; + if (!onBackendActivity) return; + return onBackendActivity((ev) => { + switch (ev.type) { + case ObservabilityEventType.HttpFetchStart: { + setLastEvent(ev); + setStatus(undefined); + return; + } + case ObservabilityEventType.HttpFetchFinishError: { + setStatus("fail"); + return; + } + case ObservabilityEventType.HttpFetchFinishSuccess: { + setStatus("ok"); + return; + } + /** + * all of this are ignored + */ + case ObservabilityEventType.DbQueryStart: + case ObservabilityEventType.DbQueryFinishSuccess: + case ObservabilityEventType.DbQueryFinishError: + case ObservabilityEventType.RequestStart: + case ObservabilityEventType.RequestFinishSuccess: + case ObservabilityEventType.RequestFinishError: + case ObservabilityEventType.TaskStart: + case ObservabilityEventType.TaskStop: + case ObservabilityEventType.TaskReset: + case ObservabilityEventType.ShepherdTaskResult: + case ObservabilityEventType.DeclareTaskDependency: + case ObservabilityEventType.CryptoStart: + case ObservabilityEventType.CryptoFinishSuccess: + case ObservabilityEventType.CryptoFinishError: + return; + default: { + assertUnreachable(ev); + } + } + }); + }); + if (!pref.showDebugInfo || !lastEvent) return <Fragment />; + return ( + <div + data-status={status} + class="fixed z-20 bottom-0 w-full ease-in-out delay-1000 transition-transform data-[status=ok]:scale-y-0" + > + <div + data-status={status} + class="mx-auto w-4/5 center flex p-1 bg-gray-300 m-1 data-[status=fail]:bg-red-200 data-[status=ok]:bg-green-200 " + > + {!status ? <Wait class="w-6 h-6" /> : <div class="w-6 h-6" />} + + <p class="ml-2 my-auto text-sm text-gray-500">{lastEvent.url}</p> + {!status ? ( + <button + onClick={() => { + if (cancelRequest) cancelRequest(lastEvent.id); + }} + > + cancel + </button> + ) : undefined} + </div> + </div> + ); +} + +function WelcomeAccount({ + account, + routeAccountDetails, +}: { + account: string; routeAccountDetails: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); @@ -196,7 +324,8 @@ function WelcomeAccount({ account, routeAccountDetails }: { } if (result.type === "fail") { return ( - <a name="account details" + <a + name="account details" href={routeAccountDetails.url({})} class="underline underline-offset-2" > @@ -205,7 +334,8 @@ function WelcomeAccount({ account, routeAccountDetails }: { ); } return ( - <a name="account details" + <a + name="account details" href={routeAccountDetails.url({})} class="underline underline-offset-2" > diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx index bd20e79c8..a097417c3 100644 --- a/packages/bank-ui/src/pages/LoginForm.tsx +++ b/packages/bank-ui/src/pages/LoginForm.tsx @@ -14,15 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - HttpStatusCode -} from "@gnu-taler/taler-util"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; import { Button, LocalNotificationBanner, ShowInputErrorLabel, useLocalNotificationHandler, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; @@ -62,38 +60,42 @@ export function LoginForm({ ref.current?.focus(); }, []); - const errors = - undefinedIfEmpty({ - username: !username - ? i18n.str`Missing username` - : // : !USERNAME_REGEX.test(username) + const errors = undefinedIfEmpty({ + username: !username + ? i18n.str`Missing username` + : // : !USERNAME_REGEX.test(username) // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` undefined, - password: !password ? i18n.str`Missing password` : undefined, - }); + password: !password ? i18n.str`Missing password` : undefined, + }); async function doLogout() { session.logOut(); } - const loginHandler = !username || !password ? undefined : withErrorHandler( - async () => authenticator(username) - .createAccessToken(password, { - // scope: "readwrite" as "write", // FIX: different than merchant - scope: "readwrite", - duration: { d_us: "forever" }, - refreshable: true, - }), - (result) => { - session.logIn({ username, token: result.body.access_token }) - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: return i18n.str`Wrong credentials for "${username}"`; - case HttpStatusCode.NotFound: return i18n.str`Account not found`; - } - } - ) + const loginHandler = + !username || !password + ? undefined + : withErrorHandler( + async () => + authenticator(username).createAccessToken(password, { + // scope: "readwrite" as "write", // FIX: different than merchant + scope: "readwrite", + duration: { d_us: "forever" }, + refreshable: true, + }), + (result) => { + session.logIn({ username, token: result.body.access_token }); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + } + }, + ); return ( <div class="flex min-h-full flex-col justify-center "> diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts index e4d9d45e3..8ab5659b1 100644 --- a/packages/bank-ui/src/pages/OperationState/index.ts +++ b/packages/bank-ui/src/pages/OperationState/index.ts @@ -106,15 +106,15 @@ export namespace State { account: string; routeHere: RouteDefinition<{ wopid: string }>; onAbort: - | undefined - | (() => Promise< - TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined - >); + | undefined + | (() => Promise< + TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined + >); onConfirm: - | undefined - | (() => Promise< - TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined - >); + | undefined + | (() => Promise< + TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined + >); error: undefined; id: string; } diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts index 9c5626cce..80af1a91d 100644 --- a/packages/bank-ui/src/pages/OperationState/state.ts +++ b/packages/bank-ui/src/pages/OperationState/state.ts @@ -191,9 +191,9 @@ export function useComponentState({ routeClose, onAbort: !creds ? async () => { - onAbort(); - return undefined; - } + onAbort(); + return undefined; + } : doAbort, }; } diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx index 6eee6daa9..330fe1072 100644 --- a/packages/bank-ui/src/pages/OperationState/views.tsx +++ b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -73,6 +73,7 @@ export function NeedConfirmationView({ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.BadRequest: return notify({ @@ -80,6 +81,7 @@ export function NeedConfirmationView({ title: i18n.str`The operation id is invalid.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -87,6 +89,7 @@ export function NeedConfirmationView({ title: i18n.str`The operation was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); default: assertUnreachable(resp); @@ -111,6 +114,7 @@ export function NeedConfirmationView({ title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: return notify({ @@ -118,6 +122,7 @@ export function NeedConfirmationView({ title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.BadRequest: return notify({ @@ -125,6 +130,7 @@ export function NeedConfirmationView({ title: i18n.str`The operation id is invalid.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -132,6 +138,7 @@ export function NeedConfirmationView({ title: i18n.str`The operation was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ @@ -139,6 +146,7 @@ export function NeedConfirmationView({ title: i18n.str`Your balance is not enough.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { @@ -147,7 +155,6 @@ export function NeedConfirmationView({ sent: AbsoluteTime.never(), location: routeHere.url({ wopid: id }), request: id, - }); return onAuthorizationRequired(); } @@ -331,10 +338,7 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { ); } -export function ReadyView({ - uri, - onAbort: doAbort, -}: State.Ready): VNode { +export function ReadyView({ uri, onAbort: doAbort }: State.Ready): VNode { const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); const [notification, notify, errorHandler] = useLocalNotification(); @@ -355,6 +359,7 @@ export function ReadyView({ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, description: hasError.detail.hint as TranslatedString, debug: hasError.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.BadRequest: return notify({ @@ -362,6 +367,7 @@ export function ReadyView({ title: i18n.str`The operation id is invalid.`, description: hasError.detail.hint as TranslatedString, debug: hasError.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -369,6 +375,7 @@ export function ReadyView({ title: i18n.str`The operation was not found.`, description: hasError.detail.hint as TranslatedString, debug: hasError.detail, + when: AbsoluteTime.now(), }); default: assertUnreachable(hasError); diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx index 07dd18931..a034392d2 100644 --- a/packages/bank-ui/src/pages/PaymentOptions.tsx +++ b/packages/bank-ui/src/pages/PaymentOptions.tsx @@ -15,15 +15,15 @@ */ import { AmountJson, TalerError } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; +import { useEffect } from "preact/hooks"; +import { useWithdrawalDetails } from "../hooks/account.js"; import { useBankState } from "../hooks/bank-state.js"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "../route.js"; 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/account.js"; -import { useEffect } from "preact/hooks"; -import { useSessionState } from "../hooks/session.js"; function ShowOperationPendingTag({ woid, @@ -35,14 +35,15 @@ function ShowOperationPendingTag({ const { i18n } = useTranslationContext(); const { state: credentials } = useSessionState(); const result = useWithdrawalDetails(woid); - const loading = !result + const loading = !result; const error = !loading && (result instanceof TalerError || result.type === "fail"); const pending = - !loading && !error && - (result.body.status === "pending" || result.body.status === "selected") - && credentials.status === "loggedIn" - && credentials.username === result.body.username; + !loading && + !error && + (result.body.status === "pending" || result.body.status === "selected") && + credentials.status === "loggedIn" && + credentials.username === result.body.username; useEffect(() => { if (!loading && !pending && onOperationAlreadyCompleted) { onOperationAlreadyCompleted(); @@ -96,9 +97,9 @@ export function PaymentOptions({ routeCashout: RouteDefinition; routeChargeWallet: RouteDefinition; routeWireTransfer: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>; }): VNode { const { i18n } = useTranslationContext(); @@ -126,9 +127,7 @@ export function PaymentOptions({ <span class="flex"> <div class="text-4xl mr-4 my-auto">💵</div> <span class="grow self-center text-lg text-gray-900 align-middle text-center"> - <i18n.Translate> - to a Taler wallet - </i18n.Translate> + <i18n.Translate>to a Taler wallet</i18n.Translate> </span> <svg class="self-center flex-none h-5 w-5 text-indigo-600" diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx index 8d9df1151..d10f62cce 100644 --- a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -29,7 +29,7 @@ import { assertUnreachable, buildPayto, parsePaytoUri, - stringifyPaytoUri + stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { InternationalizationAPI, @@ -43,9 +43,9 @@ import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { mutate } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; -import { useSessionState } from "../hooks/session.js"; import { useBankState } from "../hooks/bank-state.js"; -import { EmptyObject, RouteDefinition } from "../route.js"; +import { useSessionState } from "../hooks/session.js"; +import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; interface Props { @@ -59,9 +59,9 @@ interface Props { routeCancel?: RouteDefinition; routeCashout?: RouteDefinition; routeHere: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>; limit: AmountJson; balance: AmountJson; @@ -79,7 +79,6 @@ export function PaytoWireTransferForm({ routeHere, onAuthorizationRequired, limit, - balance, }: Props): VNode { const [isRawPayto, setIsRawPayto] = useState(false); const { state: credentials } = useSessionState(); @@ -101,14 +100,19 @@ export function PaytoWireTransferForm({ const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); const [notification, notify, handleError] = useLocalNotification(); - const paytoType = config.wire_type === "X_TALER_BANK" ? "x-taler-bank" as const : "iban" as const; + const paytoType = + config.wire_type === "X_TALER_BANK" + ? ("x-taler-bank" as const) + : ("iban" as const); const errorsWire = undefinedIfEmpty({ account: !account ? i18n.str`Required` - : paytoType === "iban" ? validateIBAN(account, i18n) : - paytoType === "x-taler-bank" ? validateTalerBank(account, i18n) : - undefined, + : paytoType === "iban" + ? validateIBAN(account, i18n) + : paytoType === "x-taler-bank" + ? validateTalerBank(account, i18n) + : undefined, subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n), amount: !trimmedAmountStr ? i18n.str`Required` @@ -119,11 +123,11 @@ export function PaytoWireTransferForm({ const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); - const errorsPayto = undefinedIfEmpty({ rawPaytoInput: !rawPaytoInput ? i18n.str`Required` - : !parsed ? i18n.str`Does not follow the pattern` + : !parsed + ? i18n.str`Does not follow the pattern` : validateRawPayto(parsed, limit, url.host, i18n, paytoType), }); @@ -140,11 +144,15 @@ export function PaytoWireTransferForm({ delete p.params.amount; // if this payto is valid then it already have message payto_uri = stringifyPaytoUri(p); - acName = !p.isKnown ? undefined : - p.targetType === "iban" ? p.iban : - p.targetType === "bitcoin" ? p.targetPath : - p.targetType === "x-taler-bank" ? p.account : - assertUnreachable(p); + acName = !p.isKnown + ? undefined + : p.targetType === "iban" + ? p.iban + : p.targetType === "bitcoin" + ? p.targetPath + : p.targetType === "x-taler-bank" + ? p.account + : assertUnreachable(p); } else { if (!account || !subject) return; let payto; @@ -159,7 +167,8 @@ export function PaytoWireTransferForm({ payto = buildPayto("iban", account, undefined); break; } - default: assertUnreachable(paytoType) + default: + assertUnreachable(paytoType); } payto.params.message = encodeURIComponent(subject); @@ -184,6 +193,7 @@ export function PaytoWireTransferForm({ title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Unauthorized: return notify({ @@ -191,13 +201,25 @@ export function PaytoWireTransferForm({ title: i18n.str`Not enough permission to complete the operation.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_ADMIN_CREDITOR: + return notify({ + type: "error", + title: i18n.str`Bank administrator can't be the transfer creditor.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNKNOWN_CREDITOR: return notify({ type: "error", - title: i18n.str`The destination account "${acName ?? puri}" was not found.`, + title: i18n.str`The destination account "${ + acName ?? puri + }" was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_SAME_ACCOUNT: return notify({ @@ -205,6 +227,7 @@ export function PaytoWireTransferForm({ title: i18n.str`The origin and the destination of the transfer can't be the same.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ @@ -212,6 +235,7 @@ export function PaytoWireTransferForm({ title: i18n.str`Your balance is not enough.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -219,12 +243,17 @@ export function PaytoWireTransferForm({ title: i18n.str`The origin account "${puri}" was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { operation: "create-transaction", id: String(resp.body.challenge_id), - location: routeHere.url({ account: account ?? "", amount, subject }), + location: routeHere.url({ + account: account ?? "", + amount, + subject, + }), sent: AbsoluteTime.never(), request, }); @@ -281,10 +310,12 @@ export function PaytoWireTransferForm({ break; } default: { - assertUnreachable(parsed) + assertUnreachable(parsed); } } - const amountStr = !parsed.params ? undefined : parsed.params["amount"]; + const amountStr = !parsed.params + ? undefined + : parsed.params["amount"]; if (amountStr) { const amount = Amounts.parse(amountStr); if (amount) { @@ -350,7 +381,8 @@ export function PaytoWireTransferForm({ } break; } - default: assertUnreachable(paytoType) + default: + assertUnreachable(paytoType); } rawPaytoInputSetter(stringifyPaytoUri(payto)); } @@ -374,9 +406,7 @@ export function PaytoWireTransferForm({ > <i18n.Translate>Cashout</i18n.Translate> </a> - ) : ( - undefined - )} + ) : undefined} </div> </div> @@ -394,34 +424,39 @@ export function PaytoWireTransferForm({ {(() => { switch (paytoType) { case "x-taler-bank": { - return <TextField - id="x-taler-bank" - required - label={i18n.str`Recipient`} - help={i18n.str`Id of the recipient's account`} - error={errorsWire?.account} - onChange={setAccount} - value={account} - placeholder={i18n.str`username`} - focus={focus} - disabled={sendingToFixedAccount} - /> + return ( + <TextField + id="x-taler-bank" + required + label={i18n.str`Recipient`} + help={i18n.str`Id of the recipient's account`} + error={errorsWire?.account} + onChange={setAccount} + value={account} + placeholder={i18n.str`username`} + focus={focus} + disabled={sendingToFixedAccount} + /> + ); } case "iban": { - return <TextField - id="iban" - required - label={i18n.str`Recipient`} - help={i18n.str`IBAN of the recipient's account`} - placeholder={"CC0123456789" as TranslatedString} - error={errorsWire?.account} - onChange={(v) => setAccount(v.toUpperCase())} - value={account} - focus={focus} - disabled={sendingToFixedAccount} - /> + return ( + <TextField + id="iban" + required + label={i18n.str`Recipient`} + help={i18n.str`IBAN of the recipient's account`} + placeholder={"CC0123456789" as TranslatedString} + error={errorsWire?.account} + onChange={(v) => setAccount(v.toUpperCase())} + value={account} + focus={focus} + disabled={sendingToFixedAccount} + /> + ); } - default: assertUnreachable(paytoType) + default: + assertUnreachable(paytoType); } })()} @@ -506,11 +541,12 @@ export function PaytoWireTransferForm({ value={rawPaytoInput ?? ""} required title={i18n.str`Uniform resource identifier of the target account`} - placeholder={((): TranslatedString => { switch (paytoType) { - case "x-taler-bank": return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]` - case "iban": return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]` + case "x-taler-bank": + return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]`; + case "iban": + return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`; } })()} onInput={(e): void => { @@ -618,13 +654,13 @@ export function InputAmount( if ( sep_pos !== -1 && l - sep_pos - 1 > - config.currency_specification.num_fractional_input_digits + config.currency_specification.num_fractional_input_digits ) { e.currentTarget.value = e.currentTarget.value.substring( 0, sep_pos + - config.currency_specification.num_fractional_input_digits + - 1, + config.currency_specification.num_fractional_input_digits + + 1, ); } onChange(e.currentTarget.value); @@ -668,81 +704,94 @@ export function RenderAmount({ ); } - -function validateRawPayto(parsed: PaytoUri, limit: AmountJson, host: string, i18n: InternationalizationAPI, type: "iban" | "x-taler-bank"): TranslatedString | undefined { +function validateRawPayto( + parsed: PaytoUri, + limit: AmountJson, + host: string, + i18n: InternationalizationAPI, + type: "iban" | "x-taler-bank", +): TranslatedString | undefined { if (!parsed.isKnown) { - return i18n.str`The target type is unknown, use "${type}"` + return i18n.str`The target type is unknown, use "${type}"`; } let result: TranslatedString | undefined; switch (type) { case "x-taler-bank": { if (parsed.targetType !== "x-taler-bank") { - return i18n.str`Only "x-taler-bank" target are supported` + return i18n.str`Only "x-taler-bank" target are supported`; } if (parsed.host !== host) { - return i18n.str`Only this host is allowed. Use "${host}"` + return i18n.str`Only this host is allowed. Use "${host}"`; } if (!parsed.account) { - return i18n.str`Missing account name` + return i18n.str`Missing account name`; } - const result = validateTalerBank(parsed.account, i18n) - if (result) return result + const result = validateTalerBank(parsed.account, i18n); + if (result) return result; break; } case "iban": { if (parsed.targetType !== "iban") { - return i18n.str`Only "IBAN" target are supported` + return i18n.str`Only "IBAN" target are supported`; } - const result = validateIBAN(parsed.iban, i18n) - if (result) return result + const result = validateIBAN(parsed.iban, i18n); + if (result) return result; break; } - default: assertUnreachable(type) + default: + assertUnreachable(type); } if (!parsed.params.amount) { - return i18n.str`Missing "amount" parameter to specify the amount to be transferred` + return i18n.str`Missing "amount" parameter to specify the amount to be transferred`; } - const amount = Amounts.parse(parsed.params.amount) + const amount = Amounts.parse(parsed.params.amount); if (!amount) { - return i18n.str`The "amount" parameter is not valid` + return i18n.str`The "amount" parameter is not valid`; } - result = validateAmount(amount, limit, i18n) + result = validateAmount(amount, limit, i18n); if (result) return result; if (!parsed.params.message) { - return i18n.str`Missing the "message" parameter to specify a reference text for the transfer` + return i18n.str`Missing the "message" parameter to specify a reference text for the transfer`; } - const subject = parsed.params.message - result = validateSubject(subject, i18n) + const subject = parsed.params.message; + result = validateSubject(subject, i18n); if (result) return result; - return undefined + return undefined; } -function validateAmount(amount: AmountJson, limit: AmountJson, i18n: InternationalizationAPI): TranslatedString | undefined { +function validateAmount( + amount: AmountJson, + limit: AmountJson, + i18n: InternationalizationAPI, +): TranslatedString | undefined { if (amount.currency !== limit.currency) { - return i18n.str`The only currency allowed is "${limit.currency}"` + return i18n.str`The only currency allowed is "${limit.currency}"`; } if (Amounts.isZero(amount)) { - return i18n.str`Can't transfer zero amount` + return i18n.str`Can't transfer zero amount`; } if (Amounts.cmp(limit, amount) === -1) { - return i18n.str`Balance is not enough` + return i18n.str`Balance is not enough`; } - return undefined + return undefined; } -function validateSubject(text: string, i18n: InternationalizationAPI): TranslatedString | undefined { +function validateSubject( + text: string, + i18n: InternationalizationAPI, +): TranslatedString | undefined { if (text.length < 2) { - return i18n.str`Use a longer subject` + return i18n.str`Use a longer subject`; } - return undefined + return undefined; } interface PaytoFieldProps { - id: string, + id: string; label: TranslatedString; required?: boolean; help?: TranslatedString; @@ -755,13 +804,17 @@ interface PaytoFieldProps { disabled?: boolean; } -function Wrapper({ withIcon, children }: { withIcon: boolean, children: ComponentChildren }): VNode { +function Wrapper({ + withIcon, + children, +}: { + withIcon: boolean; + children: ComponentChildren; +}): VNode { if (withIcon) { - return <div class="flex justify-between"> - {children} - </div> + return <div class="flex justify-between">{children}</div>; } - return <Fragment>{children}</Fragment> + return <Fragment>{children}</Fragment>; } export function TextField({ @@ -777,43 +830,34 @@ export function TextField({ value, error, }: PaytoFieldProps): VNode { - return <div class="sm:col-span-5"> - <label - for={id} - class="block text-sm font-medium leading-6 text-gray-900" - >{label} - {required && - <b style={{ color: "red" }}> *</b> - } - </label> - <div class="mt-2"> - <Wrapper withIcon={rightIcons !== undefined}> - <input - ref={focus ? doAutoFocus : undefined} - type="text" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name={id} - id={id} - disabled={disabled} - value={value ?? ""} - placeholder={placeholder} - autocomplete="off" - required - onInput={(e): void => { - onChange(e.currentTarget.value); - }} - /> - {rightIcons} - </Wrapper> - <ShowInputErrorLabel - message={error} - isDirty={value !== undefined} - /> + return ( + <div class="sm:col-span-5"> + <label for={id} class="block text-sm font-medium leading-6 text-gray-900"> + {label} + {required && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <Wrapper withIcon={rightIcons !== undefined}> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name={id} + id={id} + disabled={disabled} + value={value ?? ""} + placeholder={placeholder} + autocomplete="off" + required + onInput={(e): void => { + onChange(e.currentTarget.value); + }} + /> + {rightIcons} + </Wrapper> + <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> + </div> + {help && <p class="mt-2 text-sm text-gray-500">{help}</p>} </div> - {help && - <p class="mt-2 text-sm text-gray-500"> - {help} - </p> - } - </div> + ); } diff --git a/packages/bank-ui/src/pages/ProfileNavigation.tsx b/packages/bank-ui/src/pages/ProfileNavigation.tsx index 10497f015..1775d9329 100644 --- a/packages/bank-ui/src/pages/ProfileNavigation.tsx +++ b/packages/bank-ui/src/pages/ProfileNavigation.tsx @@ -27,9 +27,9 @@ export function ProfileNavigation({ routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, - routeConversionConfig + routeConversionConfig, }: { - current: "details" | "delete" | "credentials" | "cashouts" | "conversion", + current: "details" | "delete" | "credentials" | "cashouts" | "conversion"; routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; @@ -40,9 +40,7 @@ export function ProfileNavigation({ const { config } = useBankCoreApiContext(); const { state: credentials } = useSessionState(); const isAdminUser = - credentials.status !== "loggedIn" - ? false - : credentials.isUserAdministrator; + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; const nonAdminUser = !isAdminUser; const { navigateTo } = useNavigationContext(); diff --git a/packages/bank-ui/src/pages/PublicHistoriesPage.tsx b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx index 84d703cbe..554da0c3f 100644 --- a/packages/bank-ui/src/pages/PublicHistoriesPage.tsx +++ b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx @@ -31,8 +31,8 @@ export function PublicHistoriesPage(): VNode { const result = usePublicAccounts(undefined); const firstAccount = result && - !(result instanceof TalerError) && - result.data.public_accounts.length > 0 + !(result instanceof TalerError) && + result.data.public_accounts.length > 0 ? result.data.public_accounts[0].username : undefined; @@ -71,7 +71,12 @@ export function PublicHistoriesPage(): VNode { </a> </li>, ); - txs[account.username] = <Transactions account={account.username} routeCreateWireTransfer={undefined} />; + txs[account.username] = ( + <Transactions + account={account.username} + routeCreateWireTransfer={undefined} + /> + ); } return ( diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx index da11e631d..f442857a8 100644 --- a/packages/bank-ui/src/pages/QrCodeSection.tsx +++ b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -17,13 +17,13 @@ import { HttpStatusCode, stringifyWithdrawUri, - WithdrawUriResult + WithdrawUriResult, } from "@gnu-taler/taler-util"; import { Button, LocalNotificationBanner, useLocalNotificationHandler, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useEffect } from "preact/hooks"; @@ -56,20 +56,20 @@ export function QrCodeSection({ const onAbortHandler = handleError( async () => { if (!creds) return undefined; - return api.abortWithdrawalById( - creds, - withdrawUri.withdrawalOperationId, - ) + return api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId); }, onAborted, (fail) => { switch (fail.case) { - case HttpStatusCode.BadRequest: return i18n.str`The operation id is invalid.`; - case HttpStatusCode.NotFound: return i18n.str`The operation was not found.`; - case HttpStatusCode.Conflict: return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; + case HttpStatusCode.BadRequest: + return i18n.str`The operation id is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; } - } - ) + }, + ); return ( <Fragment> diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx index e9f7e602f..2ade465c2 100644 --- a/packages/bank-ui/src/pages/RegistrationPage.tsx +++ b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -16,10 +16,7 @@ import { AccessToken, HttpStatusCode, - OperationFail, TalerErrorCode, - TranslatedString, - assertUnreachable, } from "@gnu-taler/taler-util"; import { LocalNotificationBanner, @@ -77,7 +74,7 @@ function RegistrationForm({ // const [phone, setPhone] = useState<string | undefined>(); // const [email, setEmail] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const [notification, _, handleError] = useLocalNotification(); + const [notification, , handleError] = useLocalNotification(); const settings = useSettingsContext(); const { bank: api } = useBankCoreApiContext(); @@ -125,19 +122,29 @@ function RegistrationForm({ onComplete(); } else { onError(resp, (_case) => { - switch(_case) { - case HttpStatusCode.BadRequest: return i18n.str`Server replied with invalid phone or email.`; - case HttpStatusCode.Unauthorized: return i18n.str`No enough permission to create that account.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: return i18n.str`That username can't be used because is reserved.`; - case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: return i18n.str`That username is already taken.`; - case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: return i18n.str`That account id is already taken.`; - case TalerErrorCode.BANK_MISSING_TAN_INFO: return i18n.str`No information for the selected authentication channel.`; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: return i18n.str`Authentication channel is not supported.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return i18n.str`Only admin is allow to set debt limit.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: return i18n.str`Only admin can create accounts with second factor authentication.`; + switch (_case) { + case HttpStatusCode.BadRequest: + return i18n.str`Server replied with invalid phone or email.`; + case HttpStatusCode.Unauthorized: + return i18n.str`No enough permission to create that account.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`That username can't be used because is reserved.`; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return i18n.str`That username is already taken.`; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return i18n.str`That account id is already taken.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`Only admin is allow to set debt limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return i18n.str`Only admin can create accounts with second factor authentication.`; } - }) + }); } }); } diff --git a/packages/bank-ui/src/pages/ShowNotifications.tsx b/packages/bank-ui/src/pages/ShowNotifications.tsx new file mode 100644 index 000000000..fe041fb19 --- /dev/null +++ b/packages/bank-ui/src/pages/ShowNotifications.tsx @@ -0,0 +1,55 @@ +/* + 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 { useNotifications } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { Time } from "../components/Time.js"; + +export function ShowNotifications(): VNode { + const ns = useNotifications(); + if (!ns.length) { + return <div>no notifications</div>; + } + return ( + <div> + <p>Notifications</p> + <table> + <thead></thead> + <tbody> + {ns.map((n, idx) => { + return ( + <tr key={idx}> + <td> + <Time + timestamp={n.message.when} + format="dd/MM/yyyy HH:mm:ss" + /> + </td> + <td>{n.message.title}</td> + <td> + {n.message.type === "error" + ? n.message.description + : undefined} + </td> + </tr> + ); + })} + </tbody> + </table> + {/* <ToastBanner all /> */} + </div> + ); +} diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx index b2e053b3c..528cc12df 100644 --- a/packages/bank-ui/src/pages/SolveChallengePage.tsx +++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx @@ -34,21 +34,20 @@ import { useLocalNotification, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { Time } from "../components/Time.js"; import { useBankCoreApiContext } from "../context/config.js"; +import { useNavigationContext } from "../context/navigation.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/regional.js"; +import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; import { OperationNotFound } from "./WithdrawalQRCode.js"; -import { useNavigationContext } from "../context/navigation.js"; -import { Time } from "../components/Time.js"; export function SolveChallengePage({ onChallengeCompleted, @@ -107,6 +106,7 @@ export function SolveChallengePage({ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Unauthorized: return notify({ @@ -114,6 +114,7 @@ export function SolveChallengePage({ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return notify({ @@ -121,6 +122,7 @@ export function SolveChallengePage({ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); default: assertUnreachable(resp); @@ -145,6 +147,7 @@ export function SolveChallengePage({ title: i18n.str`Challenge not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Unauthorized: return notify({ @@ -152,6 +155,7 @@ export function SolveChallengePage({ title: i18n.str`This user is not authorized to complete this challenge.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.TooManyRequests: return notify({ @@ -159,6 +163,7 @@ export function SolveChallengePage({ title: i18n.str`Too many attempts, try another code.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: return notify({ @@ -166,6 +171,7 @@ export function SolveChallengePage({ title: i18n.str`The confirmation code is wrong, try again.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: return notify({ @@ -173,6 +179,7 @@ export function SolveChallengePage({ title: i18n.str`The operation expired.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); default: assertUnreachable(resp); @@ -206,6 +213,7 @@ export function SolveChallengePage({ title: i18n.str`The operation failed.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); } // another challenge required, save the request and the ID @@ -220,6 +228,7 @@ export function SolveChallengePage({ return notify({ type: "info", title: i18n.str`The operation needs another confirmation to complete.`, + when: AbsoluteTime.now(), }); } updateBankState("currentChallenge", undefined); @@ -267,7 +276,7 @@ export function SolveChallengePage({ onStart={startChallenge} onCancel={() => { updateBankState("currentChallenge", undefined); - navigateTo(ch.location) + navigateTo(ch.location); }} /> {ch.info && ( @@ -341,15 +350,15 @@ function ChallengeDetails({ onStart: () => void; onCancel: () => void; }): VNode { - const { i18n, dateLocale } = useTranslationContext(); + const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); - const firstTime = AbsoluteTime.isNever(challenge.sent) + const firstTime = AbsoluteTime.isNever(challenge.sent); useEffect(() => { if (firstTime) { - onStart() + onStart(); } - }, []) + }, []); return ( <div class="px-4 mt-4 "> <div class="w-full"> @@ -535,9 +544,11 @@ function ChallengeDetails({ <i18n.Translate>Sent at</i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <Time format="dd/MM/yyyy HH:mm:ss" + <Time + format="dd/MM/yyyy HH:mm:ss" timestamp={challenge.sent} - relative={Duration.fromSpec({ days: 1 })} /> + relative={Duration.fromSpec({ days: 1 })} + /> </dd> </div> )} @@ -668,11 +679,11 @@ function ShowCashoutDetails({ 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 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> ); } diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx index 8c831199a..f16488b25 100644 --- a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -15,12 +15,13 @@ */ import { + AbsoluteTime, AmountJson, Amounts, HttpStatusCode, TranslatedString, assertUnreachable, - parseWithdrawUri + parseWithdrawUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -39,7 +40,11 @@ import { usePreferences } from "../hooks/preferences.js"; import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { OperationState } from "./OperationState/index.js"; -import { InputAmount, RenderAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; +import { + InputAmount, + RenderAmount, + doAutoFocus, +} from "./PaytoWireTransferForm.js"; const RefAmount = forwardRef(InputAmount); @@ -54,7 +59,7 @@ function OldWithdrawalForm({ limit: AmountJson; balance: AmountJson; focus?: boolean; - routeOperationDetails: RouteDefinition<{ wopid: string }>, + routeOperationDetails: RouteDefinition<{ wopid: string }>; onOperationCreated: (wopid: string) => void; routeCancel: RouteDefinition; }): VNode { @@ -87,23 +92,25 @@ function OldWithdrawalForm({ wopid: bankState.currentWithdrawalOperationId, }); return ( - <Attention type="warning" title={i18n.str`There is an operation already`} onClose={() => { - updateBankState("currentWithdrawalOperationId", undefined); - }}> + <Attention + type="warning" + title={i18n.str`There is an operation already`} + onClose={() => { + updateBankState("currentWithdrawalOperationId", undefined); + }} + > <span ref={focus ? doAutoFocus : undefined} /> - <i18n.Translate> - Complete the operation in - </i18n.Translate>{" "} + <i18n.Translate>Complete the operation in</i18n.Translate>{" "} <a class="font-semibold text-yellow-700 hover:text-yellow-600" name="complete operation" href={url} - // onClick={(e) => { - // e.preventDefault() - // walletInegrationApi.publishTalerAction(uri, () => { - // navigateTo(url) - // }) - // }} + // onClick={(e) => { + // e.preventDefault() + // walletInegrationApi.publishTalerAction(uri, () => { + // navigateTo(url) + // }) + // }} > <i18n.Translate>this page</i18n.Translate> </a> @@ -156,6 +163,7 @@ function OldWithdrawalForm({ title: i18n.str`The operation was rejected due to insufficient funds`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); break; } @@ -165,6 +173,7 @@ function OldWithdrawalForm({ title: i18n.str`The operation was rejected due to insufficient funds`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); break; } @@ -174,6 +183,7 @@ function OldWithdrawalForm({ title: i18n.str`Account not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); break; } @@ -213,16 +223,24 @@ function OldWithdrawalForm({ </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - Current balance is <RenderAmount value={balance} spec={config.currency_specification} /> + Current balance is{" "} + <RenderAmount + value={balance} + spec={config.currency_specification} + /> </i18n.Translate> </p> - {Amounts.cmp(limit, balance) > 0 ? + {Amounts.cmp(limit, balance) > 0 ? ( <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - Your account allows you to withdraw <RenderAmount value={limit} spec={config.currency_specification} /> + Your account allows you to withdraw{" "} + <RenderAmount + value={limit} + spec={config.currency_specification} + /> </i18n.Translate> - </p> : undefined - } + </p> + ) : undefined} <div class="mt-4"> <div class="sm:inline"> <button @@ -312,7 +330,7 @@ export function WalletWithdrawForm({ limit: AmountJson; balance: AmountJson; focus?: boolean; - routeOperationDetails: RouteDefinition<{ wopid: string }>, + routeOperationDetails: RouteDefinition<{ wopid: string }>; onAuthorizationRequired: () => void; onOperationCreated: (wopid: string) => void; onOperationAborted: () => void; @@ -374,7 +392,7 @@ export function WalletWithdrawForm({ routeClose={routeCancel} routeHere={routeOperationDetails} onAbort={onOperationAborted} - // route={routeCancel} + // route={routeCancel} /> )} </div> diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx index a3f7d6bc0..a459677f1 100644 --- a/packages/bank-ui/src/pages/WireTransfer.tsx +++ b/packages/bank-ui/src/pages/WireTransfer.tsx @@ -43,13 +43,13 @@ export function WireTransfer({ }: { onSuccess?: () => void; routeHere: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>; toAccount?: string; - withSubject?: string, - withAmount?: string, + withSubject?: string; + withAmount?: string; routeCancel?: RouteDefinition; onAuthorizationRequired: () => void; }): VNode { diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index 5925719c3..965650eb0 100644 --- a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -96,6 +96,7 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: return notify({ @@ -103,6 +104,7 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.BadRequest: return notify({ @@ -110,6 +112,7 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`The operation id is invalid.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -117,6 +120,7 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`The operation was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ @@ -124,12 +128,15 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`Your balance is not enough for the operation.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { operation: "confirm-withdrawal", id: String(resp.body.challenge_id), - location: routeHere.url({ wopid: withdrawUri.withdrawalOperationId }), + location: routeHere.url({ + wopid: withdrawUri.withdrawalOperationId, + }), sent: AbsoluteTime.never(), request: withdrawUri.withdrawalOperationId, }); @@ -157,6 +164,9 @@ export function WithdrawalConfirmationQuestion({ return notify({ type: "error", title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.BadRequest: return notify({ @@ -164,6 +174,7 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`The operation id is invalid.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -171,6 +182,7 @@ export function WithdrawalConfirmationQuestion({ title: i18n.str`The operation was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); default: { assertUnreachable(resp); @@ -218,7 +230,9 @@ export function WithdrawalConfirmationQuestion({ <Fragment> <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Payment provider's account number</i18n.Translate> + <i18n.Translate> + Payment provider's account number + </i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> {p.iban} @@ -227,7 +241,9 @@ export function WithdrawalConfirmationQuestion({ {name && ( <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Payment provider's name</i18n.Translate> + <i18n.Translate> + Payment provider's name + </i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> {name} @@ -244,7 +260,9 @@ export function WithdrawalConfirmationQuestion({ <Fragment> <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Payment provider's account id</i18n.Translate> + <i18n.Translate> + Payment provider's account id + </i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> {p.account} @@ -253,7 +271,9 @@ export function WithdrawalConfirmationQuestion({ {name && ( <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Payment provider's name</i18n.Translate> + <i18n.Translate> + Payment provider's name + </i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> {name} @@ -267,7 +287,9 @@ export function WithdrawalConfirmationQuestion({ return ( <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Payment provider's account</i18n.Translate> + <i18n.Translate> + Payment provider's account + </i18n.Translate> </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> {details.account.targetPath} diff --git a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx index b91fecd9d..fb280cf9c 100644 --- a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx +++ b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx @@ -31,7 +31,7 @@ export function WithdrawalOperationPage({ }: { onAuthorizationRequired: () => void; operationId: string; - purpose: "after-creation" | "after-confirmation", + purpose: "after-creation" | "after-confirmation"; onOperationAborted: () => void; routeClose: RouteDefinition; routeWithdrawalDetails: RouteDefinition<{ wopid: string }>; diff --git a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx index 2216b96fc..bd9352b21 100644 --- a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx +++ b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx @@ -31,7 +31,7 @@ interface Props { routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; routeCreateCashout: RouteDefinition; - routeConversionConfig:RouteDefinition; + routeConversionConfig: RouteDefinition; } export function CashoutListForAccount({ @@ -58,7 +58,8 @@ export function CashoutListForAccount({ return ( <Fragment> {accountIsTheCurrentUser ? ( - <ProfileNavigation current="cashouts" + <ProfileNavigation + current="cashouts" routeMyAccountCashout={routeMyAccountCashout} routeMyAccountDelete={routeMyAccountDelete} routeMyAccountDetails={routeMyAccountDetails} diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx index 62c8df7f8..39b2303c0 100644 --- a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -118,6 +118,7 @@ export function ShowAccountDetails({ title: i18n.str`The rights to change the account are not sufficient`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -125,6 +126,7 @@ export function ShowAccountDetails({ title: i18n.str`The username was not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: return notify({ @@ -132,6 +134,7 @@ export function ShowAccountDetails({ title: i18n.str`You can't change the legal name, please contact the your account administrator.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return notify({ @@ -139,6 +142,7 @@ export function ShowAccountDetails({ title: i18n.str`You can't change the debt limit, please contact the your account administrator.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: return notify({ @@ -146,6 +150,7 @@ export function ShowAccountDetails({ title: i18n.str`You can't change the cashout address, please contact the your account administrator.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_MISSING_TAN_INFO: return notify({ @@ -153,6 +158,7 @@ export function ShowAccountDetails({ title: i18n.str`No information for the selected authentication channel.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { @@ -170,6 +176,7 @@ export function ShowAccountDetails({ title: i18n.str`Authentication channel is not supported.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); } default: @@ -183,7 +190,8 @@ export function ShowAccountDetails({ <Fragment> <LocalNotificationBanner notification={notification} showDebug={true} /> {accountIsTheCurrentUser ? ( - <ProfileNavigation current="details" + <ProfileNavigation + current="details" routeMyAccountCashout={routeMyAccountCashout} routeMyAccountDelete={routeMyAccountDelete} routeConversionConfig={routeConversionConfig} diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx index c33aeb09e..8c0581312 100644 --- a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx +++ b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -17,6 +17,7 @@ import { AbsoluteTime, HttpStatusCode, TalerErrorCode, + TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -112,21 +113,33 @@ export function UpdateAccountPassword({ return notify({ type: "error", title: i18n.str`Not authorized to change the password, maybe the session is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ type: "error", title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: return notify({ type: "error", title: i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: return notify({ type: "error", title: i18n.str`Your current password doesn't match, can't change to a new password.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { @@ -149,7 +162,8 @@ export function UpdateAccountPassword({ <Fragment> <LocalNotificationBanner notification={notification} /> {accountIsTheCurrentUser ? ( - <ProfileNavigation current="credentials" + <ProfileNavigation + current="credentials" routeMyAccountCashout={routeMyAccountCashout} routeMyAccountDelete={routeMyAccountDelete} routeMyAccountDetails={routeMyAccountDetails} @@ -273,7 +287,6 @@ export function UpdateAccountPassword({ <i18n.Translate>Repeat the same password</i18n.Translate> </p> </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"> diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx index bce7afe11..10b6afdf9 100644 --- a/packages/bank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -18,14 +18,12 @@ import { Amounts, PaytoString, TalerCorebankApi, - TranslatedString, assertUnreachable, buildPayto, parsePaytoUri, stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { - Attention, CopyButton, ShowInputErrorLabel, useTranslationContext, @@ -41,7 +39,11 @@ import { validateIBAN, validateTalerBank, } from "../../utils.js"; -import { InputAmount, TextField, doAutoFocus } from "../PaytoWireTransferForm.js"; +import { + InputAmount, + TextField, + doAutoFocus, +} from "../PaytoWireTransferForm.js"; import { getRandomPassword } from "../rnd.js"; const EMAIL_REGEX = @@ -99,7 +101,10 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ErrorMessageMappingFor<typeof defaultValue> | undefined >(undefined); - const paytoType = config.wire_type === "X_TALER_BANK" ? "x-taler-bank" as const : "iban" as const; + const paytoType = + config.wire_type === "X_TALER_BANK" + ? ("x-taler-bank" as const) + : ("iban" as const); const cashoutPaytoType: typeof paytoType = "iban" as const; const defaultValue: AccountFormData = { @@ -110,8 +115,10 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ isPublic: template?.is_public, name: template?.name ?? "", cashout_payto_uri: - getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ?? ("" as PaytoString), - payto_uri: getAccountId(paytoType, template?.payto_uri) ?? ("" as PaytoString), + getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ?? + ("" as PaytoString), + payto_uri: + getAccountId(paytoType, template?.payto_uri) ?? ("" as PaytoString), email: template?.contact_data?.email ?? "", phone: template?.contact_data?.phone ?? "", username: username ?? "", @@ -130,9 +137,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const isCashoutEnabled = config.allow_conversion; const editableCashout = - (purpose === "create" || - (purpose === "update" && - (config.allow_edit_cashout_payto_uri || userIsAdmin))); + purpose === "create" || + (purpose === "update" && + (config.allow_edit_cashout_payto_uri || userIsAdmin)); const editableThreshold = userIsAdmin && (purpose === "create" || purpose === "update"); const editableAccount = purpose === "create" && userIsAdmin; @@ -141,7 +148,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const hasEmail = !!defaultValue.email || !!form.email; function updateForm(newForm: typeof defaultValue): void { - const trimmedAmountStr = newForm.debit_threshold?.trim(); const parsedAmount = Amounts.parse( `${config.currency}:${trimmedAmountStr}`, @@ -154,19 +160,25 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ? undefined : !editableCashout ? undefined - : !newForm.cashout_payto_uri ? undefined - : cashoutPaytoType === "iban" ? validateIBAN(newForm.cashout_payto_uri, i18n) : - cashoutPaytoType === "x-taler-bank" ? validateTalerBank(newForm.cashout_payto_uri, i18n) : - undefined, + : !newForm.cashout_payto_uri + ? undefined + : cashoutPaytoType === "iban" + ? validateIBAN(newForm.cashout_payto_uri, i18n) + : cashoutPaytoType === "x-taler-bank" + ? validateTalerBank(newForm.cashout_payto_uri, i18n) + : undefined, payto_uri: !newForm.payto_uri ? undefined : !editableAccount ? undefined - : !newForm.payto_uri ? undefined - : paytoType === "iban" ? validateIBAN(newForm.payto_uri, i18n) : - paytoType === "x-taler-bank" ? validateTalerBank(newForm.payto_uri, i18n) : - undefined, + : !newForm.payto_uri + ? undefined + : paytoType === "iban" + ? validateIBAN(newForm.payto_uri, i18n) + : paytoType === "x-taler-bank" + ? validateTalerBank(newForm.payto_uri, i18n) + : undefined, email: !newForm.email ? undefined @@ -207,30 +219,38 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ onChange(undefined); } else { let cashout; - if (newForm.cashout_payto_uri) switch (cashoutPaytoType) { - case "x-taler-bank": { - cashout = buildPayto("x-taler-bank", url.host, newForm.cashout_payto_uri); - break; - } - case "iban": { - cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined); - break; + if (newForm.cashout_payto_uri) + switch (cashoutPaytoType) { + case "x-taler-bank": { + cashout = buildPayto( + "x-taler-bank", + url.host, + newForm.cashout_payto_uri, + ); + break; + } + case "iban": { + cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined); + break; + } + default: + assertUnreachable(cashoutPaytoType); } - default: assertUnreachable(cashoutPaytoType) - } const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout); let internal; - if (newForm.payto_uri) switch (paytoType) { - case "x-taler-bank": { - internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri); - break; - } - case "iban": { - internal = buildPayto("iban", newForm.payto_uri, undefined); - break; + if (newForm.payto_uri) + switch (paytoType) { + case "x-taler-bank": { + internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri); + break; + } + case "iban": { + internal = buildPayto("iban", newForm.payto_uri, undefined); + break; + } + default: + assertUnreachable(paytoType); } - default: assertUnreachable(paytoType) - } const internalURI = !internal ? undefined : stringifyPaytoUri(internal); const threshold = !parsedAmount @@ -247,7 +267,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ username: newForm.username!, contact_data: undefinedIfEmpty({ email: !newForm.email ? undefined : newForm.email, - phone: !newForm.phone ? undefined :newForm.phone, + phone: !newForm.phone ? undefined : newForm.phone, }), debit_threshold: threshold ?? config.default_debit_threshold, cashout_payto_uri: cashoutURI, @@ -270,7 +290,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ cashout_payto_uri: cashoutURI, contact_data: undefinedIfEmpty({ email: !newForm.email ? undefined : newForm.email, - phone: !newForm.phone ? undefined :newForm.phone, + phone: !newForm.phone ? undefined : newForm.phone, }), debit_threshold: threshold, is_public: newForm.isPublic, @@ -370,7 +390,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </p> </div> - {purpose === "create" ? undefined : + {purpose === "create" ? undefined : ( <TextField id="internal-account" label={i18n.str`Internal account`} @@ -379,20 +399,23 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ? i18n.str`If empty a random account id will be assigned` : i18n.str`Share this id to receive bank transfers` } - error={errors?.payto_uri} onChange={(e) => { form.payto_uri = e as PaytoString; updateForm(structuredClone(form)); }} - rightIcons={<CopyButton - class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => form.payto_uri ?? defaultValue.payto_uri ?? ""} - />} + rightIcons={ + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => + form.payto_uri ?? defaultValue.payto_uri ?? "" + } + /> + } value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} disabled={!editableAccount} /> - } + )} <div class="sm:col-span-5"> <label @@ -422,7 +445,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ /> </div> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>To be used when second factor authentication is enabled</i18n.Translate> + <i18n.Translate> + To be used when second factor authentication is enabled + </i18n.Translate> </p> </div> @@ -454,7 +479,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ /> </div> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>To be used when second factor authentication is enabled</i18n.Translate> + <i18n.Translate> + To be used when second factor authentication is enabled + </i18n.Translate> </p> </div> @@ -468,14 +495,17 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ form.cashout_payto_uri = e as PaytoString; updateForm(structuredClone(form)); }} - value={(form.cashout_payto_uri ?? defaultValue.cashout_payto_uri) as PaytoString} + value={ + (form.cashout_payto_uri ?? + defaultValue.cashout_payto_uri) as PaytoString + } disabled={!editableCashout} /> )} {/* channel, not shown if old cashout api */} {OLD_CASHOUT_API || - config.supported_tan_channels.length === 0 ? undefined : ( + config.supported_tan_channels.length === 0 ? undefined : ( <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" @@ -486,7 +516,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <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 : ( + -1 ? undefined : ( <label onClick={(e) => { if (!hasEmail) return; @@ -544,7 +574,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ )} {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( + -1 ? undefined : ( <label onClick={(e) => { if (!hasPhone) return; @@ -619,9 +649,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ !editableThreshold ? undefined : (e) => { - form.debit_threshold = e as AmountString; - updateForm(structuredClone(form)); - } + form.debit_threshold = e as AmountString; + updateForm(structuredClone(form)); + } } /> <ShowInputErrorLabel @@ -633,7 +663,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ isDirty={form.debit_threshold !== undefined} /> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>How much the balance can go below zero.</i18n.Translate> + <i18n.Translate> + How much the balance can go below zero. + </i18n.Translate> </p> </div> @@ -673,7 +705,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </button> </div> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Public accounts have their balance publicly accessible</i18n.Translate> + <i18n.Translate> + Public accounts have their balance publicly accessible + </i18n.Translate> </p> </div> @@ -685,7 +719,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ class="text-sm text-black font-medium leading-6 " id="availability-label" > - <i18n.Translate>Is this account a payment provider?</i18n.Translate> + <i18n.Translate> + Is this account a payment provider? + </i18n.Translate> </span> </span> <button @@ -726,13 +762,17 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ); } -function getAccountId(type: "iban" | "x-taler-bank", s: PaytoString | undefined): string | undefined { +function getAccountId( + type: "iban" | "x-taler-bank", + s: PaytoString | undefined, +): string | undefined { if (s === undefined) return undefined; const p = parsePaytoUri(s); if (p === undefined) return undefined; if (!p.isKnown) return "<unknown>"; if (type === "iban" && p.targetType === "iban") return p.iban; - if (type === "x-taler-bank" && p.targetType === "x-taler-bank") return p.account; + if (type === "x-taler-bank" && p.targetType === "x-taler-bank") + return p.account; return "<unsupported>"; } diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx index 8a692aaed..3ab491960 100644 --- a/packages/bank-ui/src/pages/admin/AccountList.tsx +++ b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -24,8 +24,8 @@ import { Fragment, VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../../context/config.js"; import { useBusinessAccounts } from "../../hooks/regional.js"; -import { RenderAmount } from "../PaytoWireTransferForm.js"; import { RouteDefinition } from "../../route.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; interface Props { routeCreate: RouteDefinition; @@ -33,14 +33,12 @@ interface Props { routeShowAccount: RouteDefinition<{ account: string }>; routeRemoveAccount: RouteDefinition<{ account: string }>; routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; - routeShowCashoutsAccount: RouteDefinition<{ account: string }>; } export function AccountList({ routeCreate, routeRemoveAccount, routeShowAccount, - routeShowCashoutsAccount, routeUpdatePasswordAccount, }: Props): VNode { const result = useBusinessAccounts(); @@ -62,8 +60,8 @@ export function AccountList({ } } - const onGoStart = result.isFirstPage ? undefined : result.loadFirst - const onGoNext = result.isLastPage ? undefined : result.loadNext + const onGoStart = result.isFirstPage ? undefined : result.loadFirst; + const onGoNext = result.isLastPage ? undefined : result.loadNext; const accounts = result.result; return ( @@ -90,9 +88,7 @@ export function AccountList({ <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> {!accounts.length ? ( - <div> - {/* FIXME: ADD empty list */} - </div> + <div>{/* FIXME: ADD empty list */}</div> ) : ( <table class="min-w-full divide-y divide-gray-300"> <thead> @@ -230,7 +226,6 @@ export function AccountList({ </button> </div> </nav> - </div> </div> </div> diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx index 752d86aa6..b8b28f8a0 100644 --- a/packages/bank-ui/src/pages/admin/AdminHome.tsx +++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -53,9 +53,9 @@ interface Props { routeCreate: RouteDefinition; routeDownloadStats: RouteDefinition; routeCreateWireTransfer: RouteDefinition<{ - account?: string, - subject?: string, - amount?: string, + account?: string; + subject?: string; + amount?: string; }>; routeShowAccount: RouteDefinition<{ account: string }>; @@ -68,7 +68,6 @@ export function AdminHome({ routeCreate, routeRemoveAccount, routeShowAccount, - routeShowCashoutsAccount, routeUpdatePasswordAccount, routeDownloadStats, routeCreateWireTransfer, @@ -77,7 +76,10 @@ export function AdminHome({ return ( <Fragment> <Metrics routeDownloadStats={routeDownloadStats} /> - <WireTransfer routeHere={routeCreateWireTransfer} onAuthorizationRequired={onAuthorizationRequired} /> + <WireTransfer + routeHere={routeCreateWireTransfer} + onAuthorizationRequired={onAuthorizationRequired} + /> <Transactions account="admin" @@ -87,7 +89,6 @@ export function AdminHome({ routeCreate={routeCreate} routeRemoveAccount={routeRemoveAccount} routeShowAccount={routeShowAccount} - routeShowCashoutsAccount={routeShowCashoutsAccount} routeUpdatePasswordAccount={routeUpdatePasswordAccount} /> </Fragment> @@ -355,13 +356,16 @@ function Metrics({ </div> <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> {resp.current.body.type !== "with-conversions" || - resp.previous.body.type !== "with-conversions" ? undefined : ( + resp.previous.body.type !== "with-conversions" ? undefined : ( <Fragment> <div class="px-4 py-5 sm:p-6"> <dt class="text-base font-normal text-gray-900"> <i18n.Translate>Cashin</i18n.Translate> <div class="text-xs text-gray-500"> - <i18n.Translate>Transferred from an external account to an account in this bank.</i18n.Translate> + <i18n.Translate> + Transferred from an external account to an account in this + bank. + </i18n.Translate> </div> </dt> <MetricValue @@ -375,8 +379,11 @@ function Metrics({ <i18n.Translate>Cashout</i18n.Translate> </dt> <div class="text-xs text-gray-500"> - <i18n.Translate>Transferred from an account in this bank to an external account.</i18n.Translate> - </div> + <i18n.Translate> + Transferred from an account in this bank to an external + account. + </i18n.Translate> + </div> <MetricValue current={resp.current.body.cashoutFiatVolume} previous={resp.previous.body.cashoutFiatVolume} @@ -389,7 +396,9 @@ function Metrics({ <dt class="text-base font-normal text-gray-900"> <i18n.Translate>Payin</i18n.Translate> <div class="text-xs text-gray-500"> - <i18n.Translate>Transferred from an account to a Taler exchange.</i18n.Translate> + <i18n.Translate> + Transferred from an account to a Taler exchange. + </i18n.Translate> </div> </dt> <MetricValue @@ -402,7 +411,9 @@ function Metrics({ <dt class="text-base font-normal text-gray-900"> <i18n.Translate>Payout</i18n.Translate> <div class="text-xs text-gray-500"> - <i18n.Translate>Transferred from a Taler exchange to another account.</i18n.Translate> + <i18n.Translate> + Transferred from a Taler exchange to another account. + </i18n.Translate> </div> </dt> <MetricValue @@ -444,9 +455,9 @@ function MetricValue({ const rate = !currAmount || - Number.isNaN(currAmount) || - !prevAmount || - Number.isNaN(prevAmount) + Number.isNaN(currAmount) || + !prevAmount || + Number.isNaN(prevAmount) ? 0 : cmp === -1 ? 1 - Math.round(currAmount) / Math.round(prevAmount) diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx index 38119735e..f5755e2cd 100644 --- a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + AbsoluteTime, HttpStatusCode, TalerCorebankApi, TalerErrorCode, @@ -69,6 +70,7 @@ export function CreateNewAccount({ title: i18n.str`Server replied that phone or email is invalid`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Unauthorized: return notify({ @@ -76,6 +78,7 @@ export function CreateNewAccount({ title: i18n.str`The rights to perform the operation are not sufficient`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: return notify({ @@ -83,6 +86,7 @@ export function CreateNewAccount({ title: i18n.str`Account username is already taken`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: return notify({ @@ -90,6 +94,7 @@ export function CreateNewAccount({ title: i18n.str`Account id is already taken`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ @@ -97,6 +102,7 @@ export function CreateNewAccount({ title: i18n.str`Bank ran out of bonus credit.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: return notify({ @@ -104,6 +110,7 @@ export function CreateNewAccount({ title: i18n.str`Account username can't be used because is reserved`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return notify({ @@ -111,6 +118,7 @@ export function CreateNewAccount({ title: i18n.str`Only admin is allow to set debt limit.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_MISSING_TAN_INFO: return notify({ @@ -118,6 +126,7 @@ export function CreateNewAccount({ title: i18n.str`No information for the selected authentication channel.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: return notify({ @@ -125,6 +134,7 @@ export function CreateNewAccount({ title: i18n.str`Authentication channel is not supported.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: return notify({ @@ -132,6 +142,7 @@ export function CreateNewAccount({ title: i18n.str`Only admin can create accounts with second factor authentication.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); default: assertUnreachable(resp); diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx index fba366676..40035db51 100644 --- a/packages/bank-ui/src/pages/admin/DownloadStats.tsx +++ b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -31,7 +31,7 @@ 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 { RouteDefinition } from "../../route.js"; import { getTimeframesForDate } from "./AdminHome.js"; interface Props { @@ -341,7 +341,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { </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" + <a + name="cancel" href={routeCancel.url({})} class="text-sm font-semibold leading-6 text-gray-900" > @@ -459,9 +460,9 @@ async function fetchAllStatus( // await delay() const previous = options.compareWithPrevious ? await api.getMonitor(token, { - timeframe: frame.timeframe, - which: frame.moment.previous, - }) + timeframe: frame.timeframe, + which: frame.moment.previous, + }) : undefined; if (previous && previous.type === "fail" && options.endOnFirstFail) { diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx index 61def9a95..74172d058 100644 --- a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -127,6 +127,7 @@ export function RemoveAccount({ title: i18n.str`No enough permission to delete the account.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotFound: return notify({ @@ -134,6 +135,7 @@ export function RemoveAccount({ title: i18n.str`The username was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: return notify({ @@ -141,6 +143,7 @@ export function RemoveAccount({ title: i18n.str`Can't delete a reserved username.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: return notify({ @@ -148,6 +151,7 @@ export function RemoveAccount({ title: i18n.str`Can't delete an account with balance different than zero.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx index 8845ec9a0..818a131e0 100644 --- a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx +++ b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -15,13 +15,14 @@ */ import { + AbsoluteTime, AmountJson, Amounts, HttpStatusCode, TalerBankConversionApi, TalerError, TranslatedString, - assertUnreachable + assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, @@ -30,18 +31,30 @@ import { ShowInputErrorLabel, useLocalNotification, useTranslationContext, - utils + 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 { + 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"; +import { + FormErrors, + FormStatus, + FormValues, + RecursivePartial, + UIField, + useFormState, +} from "../../hooks/form.js"; interface Props { routeMyAccountDetails: RouteDefinition; @@ -53,11 +66,12 @@ interface Props { onUpdateSuccess: () => void; } -type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } - +type FormType = { + amount: AmountJson; + conv: TalerBankConversionApi.ConversionRate; +}; function useComponentState({ - onUpdateSuccess, routeCancel, routeConversionConfig, routeMyAccountCashout, @@ -67,9 +81,11 @@ function useComponentState({ }: Props): utils.RecursiveState<VNode> { const { i18n } = useTranslationContext(); - const result = useConversionInfo() - const info = result && !(result instanceof TalerError) && result.type === "ok" ? - result.body : undefined; + const result = useConversionInfo(); + const info = + result && !(result instanceof TalerError) && result.type === "ok" + ? result.body + : undefined; const { state: credentials } = useSessionState(); const creds = @@ -78,17 +94,17 @@ function useComponentState({ : credentials; if (!info) { - return <i18n.Translate>loading...</i18n.Translate> + return <i18n.Translate>loading...</i18n.Translate>; } if (!creds) { - return <i18n.Translate>only admin can setup conversion</i18n.Translate> + return <i18n.Translate>only admin can setup conversion</i18n.Translate>; } - return () => { + return function afterComponentLoads() { const { i18n } = useTranslationContext(); - const { bank, conversion, config } = useBankCoreApiContext(); + const { conversion } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); @@ -96,66 +112,91 @@ function useComponentState({ 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_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_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) - ) + createFormValidator(i18n, info.regional_currency, info.fiat_currency), + ); - const { - estimateByDebit: calculateCashoutFromDebit, - } = useCashoutEstimator(); + const { estimateByDebit: calculateCashoutFromDebit } = + useCashoutEstimator(); - const { - estimateByDebit: calculateCashinFromDebit, - } = useCashinEstimator(); + const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimator(); - const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>() + 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 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) + 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); + 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 + }, [ + 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 + if (!creds) return; await handleError(async () => { if (status.status === "fail") return; - const resp = await conversion.updateConversionRate(creds.token, status.result.conv) + const resp = await conversion.updateConversionRate( + creds.token, + status.result.conv, + ); if (resp.type === "ok") { - setSection("detail") + setSection("detail"); } else { switch (resp.case) { case HttpStatusCode.Unauthorized: { @@ -164,6 +205,7 @@ function useComponentState({ title: i18n.str`Wrong credentials`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); } case HttpStatusCode.NotImplemented: { @@ -172,6 +214,7 @@ function useComponentState({ title: i18n.str`Conversion is disabled`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); } default: @@ -181,16 +224,16 @@ function useComponentState({ }); } - const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) - const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) + 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" + <ProfileNavigation + current="conversion" routeMyAccountCashout={routeMyAccountCashout} routeMyAccountDelete={routeMyAccountDelete} routeMyAccountDetails={routeMyAccountDetails} @@ -200,7 +243,6 @@ function useComponentState({ <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> @@ -218,7 +260,7 @@ function useComponentState({ aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { - setSection("detail") + setSection("detail"); }} /> <span class="flex flex-1"> @@ -242,7 +284,7 @@ function useComponentState({ aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { - setSection("cashout") + setSection("cashout"); }} /> <span class="flex flex-1"> @@ -265,7 +307,7 @@ function useComponentState({ aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { - setSection("cashin") + setSection("cashin"); }} /> <span class="flex flex-1"> @@ -277,7 +319,6 @@ function useComponentState({ </span> </label> </div> - </div> <form @@ -288,8 +329,9 @@ function useComponentState({ e.preventDefault(); }} > - {section == "cashin" && - <ConversionForm id="cashin" + {section == "cashin" && ( + <ConversionForm + id="cashin" inputCurrency={info.fiat_currency} outputCurrency={info.regional_currency} fee={form?.conv?.cashin_fee} @@ -297,682 +339,830 @@ function useComponentState({ 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> + )} + + {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> + )} - <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> + {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> - {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 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> - </div> - {!cashoutCalc || !cashinCalc ? undefined : ( + {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="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> + <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> - {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> + {!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.beforeFee} - spec={info.fiat_currency_specification} + value={cashinCalc.debit} + negative + withColor + 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>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> + {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.beforeFee} + 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> - )} - <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> + </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> - </dl> + ) : undefined} </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>} - + )} + </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" + <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 />} + {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 + * + * @param i18n + * @param regional + * @param fiat * @returns form validator */ -function createFormValidator(i18n: InternationalizationAPI, regional: string, fiat: string) { +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 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 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 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 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, + 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, - }) + 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 } - } + 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, +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} + 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={`${id}_min_amount`} + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum amount`}</label> + <InputAmount + name={`${id}_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={`${id}_ratio`} + > + {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={`${id}_ratio`} + data-error={!!ratio?.error && ratio?.value !== undefined} + value={ratio?.value ?? ""} + onChange={(e) => { + ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" /> <ShowInputErrorLabel - message={minimum?.error} - isDirty={minimum?.value !== undefined} + message={ratio?.error} + isDirty={ratio?.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> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Conversion ratio between currencies</i18n.Translate> + </p> </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 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> - <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 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={`${id}_tiny_amount`} + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Rounding value`}</label> + <InputAmount + name={`${id}_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> - - <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> + + <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={`${id}_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 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> - <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 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 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 - data-selected={rounding?.value === "zero"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" + 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 - 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" - /> + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> </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> + </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 - data-selected={rounding?.value === "up"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" + 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 - 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" - /> + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> </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> + </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 - data-selected={rounding?.value === "nearest"} - class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" - viewBox="0 0 20 20" - fill="currentColor" + 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 - 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" - /> + stroke-linecap="round" + stroke-linejoin="round" + d="M19 9l-7 7-7-7" + ></path> </svg> - </label> - </div> - </div> - </div> + </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> - <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 "> + <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={`${id}_fee`} + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Fee`}</label> + <InputAmount + name={`${id}_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> - 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. + Amount to be deducted before amount is credited. </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> - </div> - - </Fragment> + </Fragment> + ); } diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx index 2f15d16b4..a76179b4d 100644 --- a/packages/bank-ui/src/pages/regional/CreateCashout.tsx +++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -39,9 +39,13 @@ 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 { + TransferCalculation, + useCashoutEstimator, + useConversionInfo, +} from "../../hooks/regional.js"; +import { useSessionState } from "../../hooks/session.js"; import { RouteDefinition } from "../../route.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; @@ -141,11 +145,11 @@ export function CreateCashout({ 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 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> ); } @@ -185,7 +189,8 @@ export function CreateCashout({ credit: fiatZero, beforeFee: fiatZero, }; - const [calculationResult, setCalculation] = useState<TransferCalculation>(zeroCalc); + const [calculationResult, setCalculation] = + useState<TransferCalculation>(zeroCalc); const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); const sellRate = conversionInfo.cashout_ratio; /** @@ -193,30 +198,33 @@ export function CreateCashout({ * depending on the isDebit flag */ const inputAmount = Amounts.parseOrThrow( - `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount + `${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) + 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) + setCalculation(zeroCalc); } }); } doAsync(); }, [form.amount, form.isDebit]); - const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult + const calc = + calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult; const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; @@ -231,8 +239,14 @@ export function CreateCashout({ ? 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}` + : 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) @@ -280,6 +294,7 @@ export function CreateCashout({ title: i18n.str`Account not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: return notify({ @@ -287,6 +302,7 @@ export function CreateCashout({ title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_BAD_CONVERSION: return notify({ @@ -294,6 +310,7 @@ export function CreateCashout({ title: i18n.str`The conversion rate was incorrectly applied`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ @@ -301,6 +318,7 @@ export function CreateCashout({ title: i18n.str`The account does not have sufficient funds`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case HttpStatusCode.NotImplemented: return notify({ @@ -308,6 +326,7 @@ export function CreateCashout({ title: i18n.str`Cashout are disabled`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: return notify({ @@ -315,6 +334,7 @@ export function CreateCashout({ title: i18n.str`Missing cashout URI in the profile`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return notify({ @@ -322,6 +342,7 @@ export function CreateCashout({ title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); } assertUnreachable(resp); @@ -406,7 +427,10 @@ export function CreateCashout({ <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> + <i18n.Translate> + If this name doesn't match the account holder's name your + transaction may fail. + </i18n.Translate> </p> </Fragment> ) : ( @@ -482,7 +506,7 @@ export function CreateCashout({ updateForm(structuredClone(form)); }} > - {form.isDebit ? + {form.isDebit ? ( <svg class="self-center flex-none h-5 w-5 text-indigo-600" viewBox="0 0 20 20" @@ -495,12 +519,17 @@ export function CreateCashout({ clip-rule="evenodd" /> </svg> - - : - <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> + ) : ( + <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> @@ -514,7 +543,7 @@ export function CreateCashout({ updateForm(structuredClone(form)); }} > - {!form.isDebit ? + {!form.isDebit ? ( <svg class="self-center flex-none h-5 w-5 text-indigo-600" viewBox="0 0 20 20" @@ -527,12 +556,17 @@ export function CreateCashout({ clip-rule="evenodd" /> </svg> - - : - <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> + ) : ( + <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> @@ -579,9 +613,9 @@ export function CreateCashout({ cashoutDisabled ? undefined : (value) => { - form.amount = value; - updateForm(structuredClone(form)); - } + form.amount = value; + updateForm(structuredClone(form)); + } } /> <ShowInputErrorLabel @@ -622,7 +656,7 @@ export function CreateCashout({ </dd> </div> {Amounts.isZero(sellFee) || - Amounts.isZero(calc.beforeFee) ? undefined : ( + 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> @@ -655,7 +689,7 @@ export function CreateCashout({ {/* channel, not shown if new cashout api */} {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels - .length === 0 ? ( + .length === 0 ? ( <div class="sm:col-span-5"> <Attention type="warning" @@ -727,7 +761,7 @@ export function CreateCashout({ )} {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( + -1 ? undefined : ( <label onClick={() => { if (!resultAccount.body.contact_data?.phone) return; @@ -803,7 +837,7 @@ export function CreateCashout({ </button> </div> </form> - </div > - </div > + </div> + </div> ); } diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx index 415f88868..3f635db7e 100644 --- a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx +++ b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -16,7 +16,6 @@ import { AbsoluteTime, Amounts, - Duration, HttpStatusCode, TalerError, assertUnreachable, @@ -26,20 +25,19 @@ import { 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 { Time } from "../../components/Time.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 { i18n } = useTranslationContext(); const cid = Number.parseInt(id, 10); const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); @@ -70,11 +68,11 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { ); 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 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: @@ -92,10 +90,11 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { 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 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> ); } @@ -134,9 +133,12 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { <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 })} + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + result.body.creation_time, + )} + // relative={Duration.fromSpec({ days: 1 })} /> </dd> </div> diff --git a/packages/bank-ui/src/route.ts b/packages/bank-ui/src/route.ts index 1f85ce54e..11f13d140 100644 --- a/packages/bank-ui/src/route.ts +++ b/packages/bank-ui/src/route.ts @@ -18,7 +18,7 @@ import { useNavigationContext } from "./context/navigation.js"; declare const __location: unique symbol; /** * special string that defined a location in the application - * + * * this help to prevent wrong path */ export type AppLocation = string & { @@ -29,7 +29,7 @@ export type EmptyObject = Record<string, never>; export function urlPattern< T extends Record<string, string | undefined> = EmptyObject, >(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> { - const url = reverse as ((p: T) => AppLocation) + const url = reverse as (p: T) => AppLocation; return { pattern: new RegExp(pattern), url, @@ -38,14 +38,16 @@ export function urlPattern< /** * defines a location in the app - * + * * pattern: how a string will trigger this location * url(): how a state serialize to a location */ export type ObjectOf<T> = Record<string, T> | EmptyObject; -export type RouteDefinition<T extends ObjectOf<string | undefined> = EmptyObject> = { +export type RouteDefinition< + T extends ObjectOf<string | undefined> = EmptyObject, +> = { pattern: RegExp; url: (p: T) => AppLocation; }; @@ -54,7 +56,9 @@ const nullRountDef = { pattern: new RegExp(/.*/), url: () => "" as AppLocation, }; -export function buildNullRoutDefinition<T extends ObjectOf<string>>(): RouteDefinition<T> { +export function buildNullRoutDefinition< + T extends ObjectOf<string>, +>(): RouteDefinition<T> { return nullRountDef; } @@ -76,7 +80,7 @@ function findMatch<T extends ObjectOf<RouteDefinition>>( const name = pageList[idx]; const found = pagesMap[name].pattern.exec(path); if (found !== null) { - const values = {} as Record<string, any> + const values = {} as Record<string, unknown>; Object.entries(params).forEach(([key, value]) => { values[key] = value; @@ -97,7 +101,7 @@ function findMatch<T extends ObjectOf<RouteDefinition>>( /** * get the type of the params of a location - * + * */ type RouteParamsType< RouteType, @@ -105,24 +109,29 @@ type RouteParamsType< > = RouteType[Key] extends RouteDefinition<infer ParamType> ? ParamType : never; /** - * Helps to create a map of a type with the key + * Helps to create a map of a type with the key */ type MapKeyValue<Type> = { - [Key in keyof Type]: Key extends string ? { - parent: Type, - name: Key, - values: RouteParamsType<Type, Key>; - } : never; -} + [Key in keyof Type]: Key extends string + ? { + parent: Type; + name: Key; + values: RouteParamsType<Type, Key>; + } + : never; +}; /** * create a enumeration of value of a mapped type */ -type EnumerationOf<T> = T[keyof T] +type EnumerationOf<T> = T[keyof T]; -type Location<T> = EnumerationOf<MapKeyValue<T>> +type Location<T> = EnumerationOf<MapKeyValue<T>>; -export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(pagesMap: T): Location<T> | undefined { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( + pagesMap: T, +): Location<T> | undefined { const pageList = Object.keys(pagesMap as object) as Array<keyof T>; const { path, params } = useNavigationContext(); diff --git a/packages/bank-ui/src/stories.test.ts b/packages/bank-ui/src/stories.test.ts index 207945865..8ed00a1e6 100644 --- a/packages/bank-ui/src/stories.test.ts +++ b/packages/bank-ui/src/stories.test.ts @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ import { - AccessToken, AmountString, TalerCorebankApi, setupI18n, @@ -51,11 +50,7 @@ describe("All the examples:", () => { }); }); -function DefaultTestingContext({ - children, -}: { - children: ComponentChildren; -}): VNode { +function DefaultTestingContext(_props: { children: ComponentChildren }): VNode { const cfg: TalerCorebankApi.Config = { name: "libeufin-bank", allow_deletions: true, diff --git a/packages/bank-ui/src/utils.ts b/packages/bank-ui/src/utils.ts index 8b0febe42..305f13803 100644 --- a/packages/bank-ui/src/utils.ts +++ b/packages/bank-ui/src/utils.ts @@ -15,6 +15,7 @@ */ import { + AbsoluteTime, AmountString, PaytoString, TalerError, @@ -73,36 +74,36 @@ export type PartialButDefined<T> = { */ export type WithIntermediate<Type> = { [prop in keyof Type]: Type[prop] extends PaytoString - ? Type[prop] | undefined - : Type[prop] extends AmountString - ? Type[prop] | undefined - : Type[prop] extends TranslatedString - ? Type[prop] | undefined - : Type[prop] extends object - ? WithIntermediate<Type[prop]> - : Type[prop] | undefined; + ? Type[prop] | undefined + : Type[prop] extends AmountString + ? Type[prop] | undefined + : Type[prop] extends TranslatedString + ? Type[prop] | undefined + : Type[prop] extends object + ? WithIntermediate<Type[prop]> + : Type[prop] | undefined; }; export type RecursivePartial<Type> = { [P in keyof Type]?: Type[P] extends (infer U)[] - ? RecursivePartial<U>[] - : Type[P] extends object - ? RecursivePartial<Type[P]> - : Type[P]; + ? RecursivePartial<U>[] + : Type[P] extends object + ? RecursivePartial<Type[P]> + : Type[P]; }; export type ErrorMessageMappingFor<Type> = { [prop in keyof Type]+?: Exclude<Type[prop], undefined> extends PaytoString // enumerate known object - ? TranslatedString - : Exclude<Type[prop], undefined> extends AmountString - ? TranslatedString - : Exclude<Type[prop], undefined> extends TranslatedString - ? TranslatedString - : // arrays: every element - Exclude<Type[prop], undefined> extends (infer U)[] - ? ErrorMessageMappingFor<U>[] - : // map: every field - Exclude<Type[prop], undefined> extends object - ? ErrorMessageMappingFor<Type[prop]> - : TranslatedString; + ? TranslatedString + : Exclude<Type[prop], undefined> extends AmountString + ? TranslatedString + : Exclude<Type[prop], undefined> extends TranslatedString + ? TranslatedString + : // arrays: every element + Exclude<Type[prop], undefined> extends (infer U)[] + ? ErrorMessageMappingFor<U>[] + : // map: every field + Exclude<Type[prop], undefined> extends object + ? ErrorMessageMappingFor<Type[prop]> + : TranslatedString; }; export enum TanChannel { @@ -155,6 +156,7 @@ export function buildRequestErrorMessage( title: i18n.str`Request timeout`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -164,6 +166,7 @@ export function buildRequestErrorMessage( title: i18n.str`Request throttled`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -173,6 +176,7 @@ export function buildRequestErrorMessage( title: i18n.str`Malformed response`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -182,6 +186,7 @@ export function buildRequestErrorMessage( title: i18n.str`Network error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -191,6 +196,7 @@ export function buildRequestErrorMessage( title: i18n.str`Unexpected request error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -200,6 +206,7 @@ export function buildRequestErrorMessage( title: i18n.str`Unexpected error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -373,11 +380,10 @@ export function validateIBAN( i18n: InternationalizationAPI, ): TranslatedString | undefined { if (!IBAN_REGEX.test(account)) { - return i18n.str`IBAN only have uppercased letters and numbers` + return i18n.str`IBAN only have uppercased letters and numbers`; } // Check total length - if (account.length < 4) - return i18n.str`IBAN numbers have more that 4 digits`; + if (account.length < 4) return i18n.str`IBAN numbers have more that 4 digits`; if (account.length > 34) return i18n.str`IBAN numbers have less that 34 digits`; @@ -423,25 +429,7 @@ export function validateTalerBank( i18n: InternationalizationAPI, ): TranslatedString | undefined { if (!USERNAME_REGEX.test(account)) { - return i18n.str`Account only have letters and numbers` + return i18n.str`Account only have letters and numbers`; } - return undefined -} - -export function validateRawIBAN( - payto: string, - i18n: InternationalizationAPI, -): TranslatedString | undefined { - return undefined -} - - - -export function validateRawTalerBank( - payto: string, - currentHost: string, - i18n: InternationalizationAPI, -): TranslatedString | undefined { - return undefined + return undefined; } - |