diff options
Diffstat (limited to 'packages/bank-ui/src/pages')
31 files changed, 1705 insertions, 1087 deletions
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> |