taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 521454a1bd15511c54a2481dfc36402f13b8a7fa
parent e3595b049cf5b76e512c061e833ff39379d8f446
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue,  1 Jul 2025 11:34:10 -0300

fix #10104 partially completed

Diffstat:
Mpackages/bank-ui/src/Routing.tsx | 25+++++++++++++++++++++----
Mpackages/bank-ui/src/app.tsx | 2++
Mpackages/bank-ui/src/components/Transactions/state.ts | 4++--
Mpackages/bank-ui/src/hooks/account.ts | 14+++-----------
Mpackages/bank-ui/src/hooks/regional.ts | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Apackages/bank-ui/src/pages/ConversionRateClassDetails.tsx | 846+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/bank-ui/src/pages/NewConversionRateClass.tsx | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/bank-ui/src/pages/admin/AccountForm.tsx | 164-------------------------------------------------------------------------------
Mpackages/bank-ui/src/pages/admin/AccountList.tsx | 11++++-------
Mpackages/bank-ui/src/pages/admin/AdminHome.tsx | 13++++++++-----
Mpackages/bank-ui/src/pages/admin/ConversionClassList.tsx | 142+++++++++++++++++++++++++++++++++++--------------------------------------------
Apackages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx | 636+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/bank-ui/src/pages/regional/ConversionConfig.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
13 files changed, 1843 insertions(+), 300 deletions(-)

diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -60,6 +60,8 @@ import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; import { CreateCashout } from "./pages/regional/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; +import { NewConversionRateClass } from "./pages/NewConversionRateClass.js"; +import { ConversionRateClassDetails } from "./pages/ConversionRateClassDetails.js"; const TALER_SCREEN_ID = 100; @@ -297,7 +299,7 @@ const privatePages = { myAccountDetails: urlPattern(/\/my-profile/, () => "#/my-profile"), myAccountPassword: urlPattern(/\/my-password/, () => "#/my-password"), myAccountCashouts: urlPattern(/\/my-cashouts/, () => "#/my-cashouts"), - conversionConfig: urlPattern(/\/conversion/, () => "#/conversion"), + conversionConfig: urlPattern(/\/conversion$/, () => "#/conversion"), accountDetails: urlPattern<{ account: string }>( /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/details/, ({ account }) => `#/profile/${account}/details`, @@ -327,7 +329,7 @@ const privatePages = { () => "#/new-conversion-rate-class", ), conversionRateClassDetails: urlPattern<{ classId: string }>( - /\/conversion-rate-class\/(?<id>[a-zA-Z0-9_-]+)\/details/, + /\/conversion-rate-class\/(?<classId>[0-9]+)\/details/, ({ classId }) => `#/conversion-rate-class/${classId}/details`, ), }; @@ -680,10 +682,25 @@ function PrivateRouting({ ); } case "conversionRateClassCreate": { - return <ShowNotifications />; + return ( + <NewConversionRateClass + onCreated={() => navigateTo(privatePages.home.url({}))} + routeCancel={privatePages.home} + /> + ); } case "conversionRateClassDetails": { - return <ShowNotifications />; + const id = Number.parseInt(location.values.classId, 10); + if (Number.isNaN(id)) { + return <div>class id is not a number "{location.values.classId}"</div>; + } + // return <div>2222</div> + return ( + <ConversionRateClassDetails + classId={id} + routeCancel={privatePages.home} + /> + ); } case "notifications": { return <ShowNotifications />; diff --git a/packages/bank-ui/src/app.tsx b/packages/bank-ui/src/app.tsx @@ -47,6 +47,7 @@ import { revalidateBusinessAccounts, revalidateCashouts, revalidateConversionInfo, + revalidateConversionRateClassDetails, } from "./hooks/regional.js"; const WITH_LOCAL_STORAGE_CACHE = false; @@ -223,6 +224,7 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { revalidateConversionInfo(), revalidateCashouts(), revalidateTransactions(), + revalidateConversionRateClassDetails(), ]); } diff --git a/packages/bank-ui/src/components/Transactions/state.ts b/packages/bank-ui/src/components/Transactions/state.ts @@ -84,7 +84,7 @@ export function useComponentState({ error: undefined, routeCreateWireTransfer, transactions, - onGoNext: result.isLastPage ? undefined : result.loadNext, - onGoStart: result.isFirstPage ? undefined : result.loadFirst, + onGoNext: result.loadNext, + onGoStart: result.loadFirst, }; } diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts @@ -29,6 +29,7 @@ import { useSessionState } from "./session.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import { + PaginatedResult, useAsync, useBankCoreApiContext, useLongPolling, @@ -215,13 +216,6 @@ export function usePublicAccounts( ); } -type PaginatedResult<T> = OperationOk<T> & { - isLastPage: boolean; - isFirstPage: boolean; - loadNext(): void; - loadFirst(): void; -}; - // TODO: consider sending this to web-util export function buildPaginatedResult<DataType, OffsetId>( data: DataType[], @@ -241,14 +235,12 @@ export function buildPaginatedResult<DataType, OffsetId>( type: "ok", case: "ok", body: result, - isLastPage, - isFirstPage, - loadNext: () => { + loadNext: isLastPage ? undefined :() => { if (!result.length) return; const id = getId(result[result.length - 1]); setOffset(id); }, - loadFirst: () => { + loadFirst: isFirstPage ? undefined : () => { setOffset(undefined); }, }; diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -241,7 +241,7 @@ export function useEstimator(): ConversionEstimators { export async function revalidateBusinessAccounts() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", + (key) => Array.isArray(key) && key[key.length - 1] === "listAccounts", undefined, { revalidate: true }, ); @@ -257,10 +257,8 @@ export function useBusinessAccounts() { const [offset, setOffset] = useState<number | undefined>(); function fetcher([token, aid]: [AccessToken, number]) { - // FIXME: add account name filter - return api.getAccounts( + return api.listAccounts( token, - {}, { limit: PAGINATED_LIST_REQUEST, offset: aid ? String(aid) : undefined, @@ -270,9 +268,9 @@ export function useBusinessAccounts() { } const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getAccounts">, + TalerCoreBankResultByMethod<"listAccounts">, TalerHttpError - >([token, offset ?? 0, "getAccounts"], fetcher, { + >([token, offset ?? 0, "listAccounts"], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -531,10 +529,10 @@ export function useLastMonitorInfo( return undefined; } - export function revalidateConversionRateClasses() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "useConversionRateClasses", + (key) => + Array.isArray(key) && key[key.length - 1] === "useConversionRateClasses", undefined, { revalidate: true }, ); @@ -550,14 +548,11 @@ export function useConversionRateClasses() { const [offset, setOffset] = useState<number | undefined>(); function fetcher([token, aid]: [AccessToken, number]) { - return api.listConversionRateClasses( - token, - { - limit: PAGINATED_LIST_REQUEST, - offset: aid ? String(aid) : undefined, - order: "asc", - }, - ); + return api.listConversionRateClasses(token, { + limit: PAGINATED_LIST_REQUEST, + offset: aid ? String(aid) : undefined, + order: "asc", + }); } const { data, error } = useSWR< @@ -585,4 +580,83 @@ export function useConversionRateClasses() { setOffset, (d) => d.conversion_rate_class_id, ); -} -\ No newline at end of file +} + +export function revalidateConversionRateClassDetails() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "useConversionRateClassDetails", + undefined, + { revalidate: true }, + ); +} + +export function useConversionRateClassDetails(classId: number) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + async function fetcher([username, token]: [number, AccessToken]) { + return await api.getConversionRateClass(token, username); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getConversionRateClass">, + TalerHttpError + >([classId, token, "useConversionRateClassDetails"], fetcher, {}); + + if (data) return data; + if (error) return error; + return undefined; + +} + +export function useConversionRateClassUsers(classId: number | undefined, username?: string) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [offset, setOffset] = useState<number | undefined>(); + + function fetcher([token, aid, username, classId]: [AccessToken, number, string, number]) { + return api.listAccounts(token, { + limit: PAGINATED_LIST_REQUEST, + offset: aid ? String(aid) : undefined, + order: "asc", + account: username, + conversionRateId: classId, + }); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"listAccounts">, + TalerHttpError + >([token, offset ?? 0, username, classId, "useConversionRateClassUsers"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult( + data.body.accounts, + offset, + setOffset, + (d) => d.row_id!, + ); +} diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -0,0 +1,846 @@ +import { + Amounts, + assertUnreachable, + HttpStatusCode, + InternationalizationAPI, + RoundingMode, + TalerBankConversionApi, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + Attention, + InputToggle, + Loading, + LocalNotificationBanner, + RenderAmount, + RouteDefinition, + ShowInputErrorLabel, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { + FormErrors, + FormStatus, + FormValues, + useFormState, +} from "../hooks/form.js"; +import { + useConversionInfo, + useConversionRateClassDetails, + useConversionRateClassUsers, +} from "../hooks/regional.js"; +import { useSessionState } from "../hooks/session.js"; +import { RecursivePartial, undefinedIfEmpty } from "../utils.js"; +import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { ConversionForm } from "./regional/ConversionConfig.js"; + +interface Props { + classId: number; + routeCancel: RouteDefinition; +} + +type FormType = { + name: string; + description: string; + conv: Omit< + Omit<TalerBankConversionApi.ConversionRate, "cashout_tiny_amount">, + "cashin_tiny_amount" + >; +}; + +export function ConversionRateClassDetails({ + routeCancel, + classId, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const detailsResult = useConversionRateClassDetails(classId); + const conversionInfoResult = useConversionInfo(); + const conversionInfo = + conversionInfoResult && + !(conversionInfoResult instanceof TalerError) && + conversionInfoResult.type === "ok" + ? conversionInfoResult.body + : undefined; + + if (!detailsResult || !conversionInfo) { + return <Loading />; + } + if (detailsResult instanceof TalerError) { + return <ErrorLoadingWithDebug error={detailsResult} />; + } + if (detailsResult.type === "fail") { + switch (detailsResult.case) { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.NotImplemented: + return ( + <Attention type="danger" title={i18n.str`Conversion is disabled`}> + <i18n.Translate> + Conversion should be enabled in the configuration, the conversion + rate should be initialized with fee(s), rates and a rounding mode. + </i18n.Translate> + </Attention> + ); + default: + assertUnreachable(detailsResult); + } + } + return ( + <Form + conversionInfo={conversionInfo} + detailsResult={detailsResult.body} + routeCancel={routeCancel} + classId={classId} + /> + ); +} + +function Form({ + conversionInfo, + detailsResult, + routeCancel, + classId, +}: { + conversionInfo: TalerBankConversionApi.TalerConversionInfoConfig; + detailsResult: TalerCorebankApi.ConversionRateClass; + routeCancel: RouteDefinition; + classId: number; +}) { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { + lib: { bank }, + config, + } = useBankCoreApiContext(); + const [notification, notify, handleError] = useLocalNotification(); + + const [section, setSection] = useState< + "detail" | "cashout" | "cashin" | "users" + >("detail"); + + const initalState: FormValues<FormType> = { + name: detailsResult.name, + description: detailsResult.description, + conv: { + cashin_min_amount: detailsResult.cashin_min_amount?.split(":")[1], + cashin_fee: detailsResult.cashin_fee?.split(":")[1], + cashin_ratio: detailsResult?.cashin_ratio, + cashin_rounding_mode: detailsResult?.cashin_rounding_mode, + cashout_min_amount: detailsResult.cashout_min_amount?.split(":")[1], + cashout_fee: detailsResult.cashout_fee?.split(":")[1], + cashout_ratio: detailsResult.cashout_ratio, + cashout_rounding_mode: detailsResult.cashout_rounding_mode, + }, + }; + + const [form, status] = useFormState<FormType>( + initalState, + createFormValidator( + i18n, + conversionInfo.regional_currency, + conversionInfo.fiat_currency, + ), + ); + + async function doUpdateClass() { + if (!creds) return; + if (status.status !== "ok") { + console.log("can submit due to form error", status.errors); + return; + } + + await bank.updateConversionRateClass(creds.token, classId, { + name: status.result.name, + description: status.result.description, + + cashin_fee: status.result.conv.cashin_fee, + cashin_min_amount: status.result.conv.cashin_min_amount, + cashin_ratio: status.result.conv.cashin_ratio, + cashin_rounding_mode: status.result.conv.cashin_rounding_mode, + + cashout_fee: status.result.conv.cashout_fee, + cashout_min_amount: status.result.conv.cashout_min_amount, + cashout_ratio: status.result.conv.cashout_ratio, + cashout_rounding_mode: status.result.conv.cashout_rounding_mode, + }); + setSection("detail"); + } + + const doUpdateDetails = + !creds || + section !== "detail" || + status.errors?.name || + status.errors?.description || + (status.result.name === initalState.name && + status.result.description === initalState.description) + ? undefined + : doUpdateClass; + + const doUpdateCashin = + !creds || + section !== "cashin" || + status.errors?.conv?.cashin_fee || + status.errors?.conv?.cashin_min_amount || + status.errors?.conv?.cashin_ratio || + status.errors?.conv?.cashin_rounding_mode + ? undefined + : doUpdateClass; + + const doUpdateCashout = + !creds || + section !== "cashout" || + // no errors on fields + status.errors?.conv?.cashout_fee || + status.errors?.conv?.cashout_min_amount || + status.errors?.conv?.cashout_ratio || + status.errors?.conv?.cashout_rounding_mode || + // at least on field changed + (status.result?.conv?.cashout_fee === initalState.conv.cashout_fee && + status.result?.conv?.cashout_min_amount === + initalState.conv.cashout_min_amount && + status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio && + status.result?.conv?.cashout_rounding_mode === + initalState.conv.cashout_rounding_mode) + ? undefined + : doUpdateClass; + + console.log("ERROR", status.errors); + const default_rate = conversionInfo.conversion_rate; + + const final_cashin_ratio = + detailsResult.cashin_ratio ?? default_rate.cashin_ratio; + const final_cashin_fee = detailsResult.cashin_fee ?? default_rate.cashin_fee; + const final_cashin_min = + detailsResult.cashin_min_amount ?? default_rate.cashin_min_amount; + const final_cashin_rounding = + detailsResult.cashin_rounding_mode ?? default_rate.cashin_rounding_mode; + + const final_cashout_ratio = + detailsResult.cashout_ratio ?? default_rate.cashout_ratio; + const final_cashout_fee = + detailsResult.cashout_fee ?? default_rate.cashout_fee; + const final_cashout_min = + detailsResult.cashout_min_amount ?? default_rate.cashout_min_amount; + const final_cashout_rounding = + detailsResult.cashout_rounding_mode ?? default_rate.cashout_rounding_mode; + + const in_ratio = Number.parseFloat(final_cashin_ratio); + const out_ratio = Number.parseFloat(final_cashout_ratio); + + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Conversion</i18n.Translate> + </h2> + <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> + <label + data-enabled={section === "detail"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("detail"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Details</i18n.Translate> + </span> + </span> + </span> + </label> + + <label + data-enabled={section === "cashout"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashout"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashout</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "cashin"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashin"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashin</i18n.Translate> + </span> + </span> + </span> + </label> + + <label + data-enabled={section === "users"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("users"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Accounts</i18n.Translate> + </span> + </span> + </span> + </label> + </div> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + {section == "cashin" && ( + <ConversionForm + id="cashin" + inputCurrency={conversionInfo.fiat_currency} + outputCurrency={conversionInfo.regional_currency} + fee={form?.conv?.cashin_fee} + minimum={form?.conv?.cashin_min_amount} + ratio={form?.conv?.cashin_ratio} + rounding={form?.conv?.cashin_rounding_mode} + fallback_fee={default_rate.cashin_fee.split(":")[1]} + fallback_minimum={default_rate.cashin_min_amount.split(":")[1]} + fallback_ratio={default_rate.cashin_ratio} + fallback_rounding={default_rate.cashin_rounding_mode} + /> + )} + + {section == "cashout" && ( + <Fragment> + <ConversionForm + id="cashout" + inputCurrency={conversionInfo.regional_currency} + outputCurrency={conversionInfo.fiat_currency} + fee={form?.conv?.cashout_fee} + minimum={form?.conv?.cashout_min_amount} + ratio={form?.conv?.cashout_ratio} + rounding={form?.conv?.cashout_rounding_mode} + fallback_fee={default_rate.cashout_fee.split(":")[1]} + fallback_minimum={default_rate.cashout_min_amount.split(":")[1]} + fallback_ratio={default_rate.cashout_ratio} + fallback_rounding={default_rate.cashout_rounding_mode} + /> + </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>Name</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <input + ref={doAutoFocus} + type="text" + name="name" + id="name" + 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" + value={form?.name?.value ?? ""} + // disabled={fixedUser} + enterkeyhint="next" + placeholder="identification" + autocomplete="username" + title={i18n.str`Username of the account`} + required + onInput={(e): void => { + form?.name?.onUpdate(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={form?.name?.error} + isDirty={form?.name?.value !== undefined} + /> + </dd> + </div> + </div> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Description</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <input + type="text" + name="description" + id="description" + 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" + value={form?.description?.value ?? ""} + enterkeyhint="next" + // placeholder="identification" + autocomplete="username" + title={i18n.str`Username of the account`} + onInput={(e): void => { + form?.description?.onUpdate(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={form?.description?.error} + isDirty={form?.description?.value !== undefined} + /> + </dd> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashin minimum</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={Amounts.parseOrThrow(final_cashin_min)} + spec={conversionInfo.fiat_currency_specification} + /> + </dd> + </div> + </div> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashin ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900">{final_cashin_ratio}</dd> + </div> + </div> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashin fee</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={Amounts.parseOrThrow(final_cashin_fee)} + spec={conversionInfo.regional_currency_specification} + /> + </dd> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout minimum</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={Amounts.parseOrThrow(final_cashout_min)} + spec={conversionInfo.regional_currency_specification} + /> + </dd> + </div> + </div> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {conversionInfo.conversion_rate.cashout_ratio} + </dd> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout fee</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={Amounts.parseOrThrow(final_cashout_fee)} + spec={conversionInfo.fiat_currency_specification} + /> + </dd> + </div> + </div> + + {both_low || both_high ? ( + <div class="p-4"> + <Attention title={i18n.str`Bad ratios`} type="warning"> + <i18n.Translate> + One of the ratios should be higher or equal than 1 an the + other should be lower or equal than 1. + </i18n.Translate> + </Attention> + </div> + ) : undefined} + </Fragment> + )} + + {section == "users" && ( + <AccountsOnConversionClass classId={classId} /> + )} + + <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + {section == "cashin" ? ( + <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" + disabled={!doUpdateCashin} + onClick={doUpdateCashin} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : undefined} + {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" + disabled={!doUpdateCashout} + onClick={doUpdateCashout} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : undefined} + {section == "detail" ? ( + <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" + disabled={!doUpdateDetails} + onClick={doUpdateDetails} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : undefined} + </div> + </form> + </div> + </div> + ); +} + +export 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_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`); + + const cashout_min_amount = Amounts.parse( + `${regional}:${state.conv.cashout_min_amount}`, + ); + const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`); + + const cashin_ratio_f = Number.parseFloat(state.conv.cashin_ratio ?? ""); + const cashout_ratio_f = Number.parseFloat(state.conv.cashout_ratio ?? ""); + + const cashin_ratio = Number.isNaN(cashin_ratio_f) + ? undefined + : cashin_ratio_f; + const cashout_ratio = Number.isNaN(cashout_ratio_f) + ? undefined + : cashout_ratio_f; + + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ + cashin_min_amount: !state.conv.cashin_min_amount + ? undefined + : !cashin_min_amount + ? i18n.str`Invalid` + : undefined, + cashin_fee: !state.conv.cashin_fee + ? undefined + : !cashin_fee + ? i18n.str`Invalid` + : undefined, + + cashout_min_amount: !state.conv.cashout_min_amount + ? undefined + : !cashout_min_amount + ? i18n.str`Invalid` + : undefined, + cashout_fee: !state.conv.cashin_fee + ? undefined + : !cashout_fee + ? i18n.str`Invalid` + : undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode + ? undefined + : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode + ? undefined + : undefined, + + cashin_ratio: !state.conv.cashin_ratio + ? undefined + : Number.isNaN(cashin_ratio) + ? i18n.str`Invalid` + : undefined, + cashout_ratio: !state.conv.cashout_ratio + ? undefined + : Number.isNaN(cashout_ratio) + ? i18n.str`Invalid` + : undefined, + }), + + description: undefined, + name: !state.name ? i18n.str`Required` : undefined, + }); + + const result: RecursivePartial<FormType> = { + name: !errors?.name ? state.name : undefined, + description: state.description, + conv: { + cashin_fee: + !errors?.conv?.cashin_fee && cashin_fee + ? Amounts.stringify(cashin_fee) + : undefined, + cashin_min_amount: + !errors?.conv?.cashin_min_amount && cashin_min_amount + ? Amounts.stringify(cashin_min_amount) + : undefined, + cashin_ratio: + !errors?.conv?.cashin_ratio && cashin_ratio + ? String(cashin_ratio) + : undefined, + cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode + ? (state.conv.cashin_rounding_mode! as RoundingMode) + : undefined, + cashout_fee: + !errors?.conv?.cashout_fee && cashout_fee + ? Amounts.stringify(cashout_fee) + : undefined, + cashout_min_amount: + !errors?.conv?.cashout_min_amount && cashout_min_amount + ? Amounts.stringify(cashout_min_amount) + : undefined, + cashout_ratio: + !errors?.conv?.cashout_ratio && cashout_ratio + ? String(cashout_ratio) + : undefined, + cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode + ? (state.conv.cashout_rounding_mode! as RoundingMode) + : undefined, + }, + }; + return errors === undefined + ? { status: "ok", result: result as FormType, errors } + : { status: "fail", result: result as FormType, errors }; + }; +} + +function AccountsOnConversionClass({ classId }: { classId: number }): VNode { + const { i18n } = useTranslationContext(); + + const [filter, setFilter] = useState<{ classId?: number; account?: string }>({ + classId, + }); + const userListResult = useConversionRateClassUsers( + filter.classId, + filter.account, + ); + if (!userListResult) { + return <Loading />; + } + if (userListResult instanceof TalerError) { + return <ErrorLoadingWithDebug error={userListResult} />; + } + if (userListResult.type === "fail") { + switch (userListResult.case) { + case HttpStatusCode.Unauthorized: + return ( + <Attention type="danger" title={i18n.str`Conversion is disabled`}> + <i18n.Translate> + Conversion should be enabled in the configuration, the conversion + rate should be initialized with fee(s), rates and a rounding mode. + </i18n.Translate> + </Attention> + ); + default: + assertUnreachable(userListResult); + } + } + return ( + <Fragment> + <div class="px-4 mt-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Accounts</i18n.Translate> + </h1> + </div> + </div> + </div> + <div class="px-4 mt-8"> + <InputToggle + label={i18n.str`Show all`} + name="show_all" + threeState={false} + handler={{ + value: filter.classId === undefined, + onChange(v) { + filter.classId = !v ? classId : undefined; + setFilter(structuredClone(filter)); + }, + name: "show_all", + }} + /> + </div> + <div class="mt-4 flow-root"> + <div class="overflow-x-auto"> + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + {!userListResult.body.length ? ( + <div class="py-3.5 pl-4 pr-3 "> + <i18n.Translate> + No users in this conversion rate class + </i18n.Translate> + </div> + ) : ( + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{i18n.str`Username`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Name`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Conversion rate`}</th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {userListResult.body.map((item, idx) => { + return ( + <tr + key={idx} + class="data-[status=deleted]:bg-gray-100" + data-status={item.status} + > + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + {item.username} + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {item.name} + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {"<pending>"} + </td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + {!userListResult.loadFirst && !userListResult.loadNext ? undefined : ( + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!userListResult.loadFirst} + onClick={userListResult.loadFirst} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!userListResult.loadNext} + onClick={userListResult.loadNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + )} + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/NewConversionRateClass.tsx b/packages/bank-ui/src/pages/NewConversionRateClass.tsx @@ -0,0 +1,113 @@ +import { + LocalNotificationBanner, + notifyInfo, + RouteDefinition, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useSessionState } from "../hooks/session.js"; +import { ConversionRateClassForm } from "./admin/ConversionRateClassForm.js"; +import { useState } from "preact/hooks"; +import { TalerCorebankApi } from "@gnu-taler/taler-util"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { assertUnreachable } from "@gnu-taler/taler-util"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; + +interface Props { + routeCancel: RouteDefinition; + onCreated: () => void; +} +export function NewConversionRateClass({ + routeCancel, + onCreated, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const [submitData, setSubmitData] = useState< + TalerCorebankApi.ConversionRateClassInput | undefined + >(); + + async function doCreate() { + if (!submitData || !token) return; + await handleError(async () => { + const resp = await api.createConversionRateClass(token, submitData); + if (resp.type === "ok") { + notifyInfo(i18n.str`Conversion rate class created.`); + onCreated(); + return; + } + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + break; + } + case TalerErrorCode.BANK_NAME_REUSE: { + break; + } + case HttpStatusCode.Forbidden: { + break; + } + case HttpStatusCode.NotFound: { + break; + } + case HttpStatusCode.NotImplemented: { + break; + } + default: { + assertUnreachable(resp); + } + } + }); + } + + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>New conversion rate class</i18n.Translate> + </h2> + </div> + + <ConversionRateClassForm + template={undefined} + purpose="create" + onChange={(a) => { + setSubmitData(a); + }} + > + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="create" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!submitData} + onClick={(e) => { + e.preventDefault(); + doCreate(); + }} + > + <i18n.Translate>Create</i18n.Translate> + </button> + </div> + </ConversionRateClassForm> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -778,167 +778,3 @@ function getAccountId( return p.account; return "<unsupported>"; } - -{ - /* <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="cashout" - > - {} - </label> - <div class="mt-2"> - <input - type="text" - ref={focus && purpose === "update" ? doAutoFocus : undefined} - data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} - class="block w-full disabled:bg-gray-100 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="cashout" - id="cashout" - disabled={purpose === "show"} - value={form.cashout_payto_uri ?? defaultValue.cashout_payto_uri} - onChange={(e) => { - form.cashout_payto_uri = e.currentTarget.value as PaytoString; - if (!form.cashout_payto_uri) { - form.cashout_payto_uri = undefined - } - updateForm(structuredClone(form)); - }} - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.cashout_payto_uri} - isDirty={form.cashout_payto_uri !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate></i18n.Translate> - </p> - </div> */ -} - -// function PaytoField({ -// name, -// label, -// help, -// type, -// value, -// disabled, -// onChange, -// error, -// }: { -// error: TranslatedString | undefined; -// name: string; -// label: TranslatedString; -// help: TranslatedString; -// onChange: (s: string) => void; -// type: "iban" | "x-taler-bank" | "bitcoin"; -// disabled?: boolean; -// value: string | undefined; -// }): VNode { -// if (type === "iban") { -// return ( -// <div class="sm:col-span-5"> -// <label -// class="block text-sm font-medium leading-6 text-gray-900" -// for={name} -// > -// {label} -// </label> -// <div class="mt-2"> -// <div class="flex justify-between"> -// <input -// type="text" -// class="mr-4 w-full block-inline disabled:bg-gray-100 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={name} -// id={name} -// disabled={disabled} -// value={value ?? ""} -// onChange={(e) => { -// onChange(e.currentTarget.value); -// }} -// /> -// <CopyButton -// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " -// getContent={() => value ?? ""} -// /> -// </div> -// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> -// </div> -// <p class="mt-2 text-sm text-gray-500">{help}</p> -// </div> -// ); -// } -// if (type === "x-taler-bank") { -// return ( -// <div class="sm:col-span-5"> -// <label -// class="block text-sm font-medium leading-6 text-gray-900" -// for={name} -// > -// {label} -// </label> -// <div class="mt-2"> -// <div class="flex justify-between"> -// <input -// type="text" -// class="mr-4 w-full block-inline disabled:bg-gray-100 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={name} -// id={name} -// disabled={disabled} -// value={value ?? ""} -// onChange={(e) => { -// onChange(e.currentTarget.value); -// }} -// /> -// <CopyButton -// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " -// getContent={() => value ?? ""} -// /> -// </div> -// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> -// </div> -// <p class="mt-2 text-sm text-gray-500"> -// {help} -// </p> -// </div> -// ); -// } -// if (type === "bitcoin") { -// return ( -// <div class="sm:col-span-5"> -// <label -// class="block text-sm font-medium leading-6 text-gray-900" -// for={name} -// > -// {label} -// </label> -// <div class="mt-2"> -// <div class="flex justify-between"> -// <input -// type="text" -// class="mr-4 w-full block-inline disabled:bg-gray-100 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={name} -// id={name} -// disabled={disabled} -// value={value ?? ""} -// /> -// <CopyButton -// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " -// getContent={() => value ?? ""} -// /> -// <ShowInputErrorLabel -// message={error} -// isDirty={value !== undefined} -// /> -// </div> -// </div> -// <p class="mt-2 text-sm text-gray-500"> -// {/* <i18n.Translate>bitcoin address</i18n.Translate> */} -// {help} -// </p> -// </div> -// ); -// } -// assertUnreachable(type); -// } diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -65,9 +65,6 @@ export function AccountList({ assertUnreachable(result); } - const onGoStart = result.isFirstPage ? undefined : result.loadFirst; - const onGoNext = result.isLastPage ? undefined : result.loadNext; - const accounts = result.body; return ( <Fragment> @@ -214,16 +211,16 @@ export function AccountList({ <button name="first page" class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoStart} - onClick={onGoStart} + disabled={!result.loadFirst} + onClick={result.loadFirst} > <i18n.Translate>First page</i18n.Translate> </button> <button name="next page" class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoNext} - onClick={onGoNext} + disabled={!result.loadNext} + onClick={result.loadNext} > <i18n.Translate>Next</i18n.Translate> </button> diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -53,7 +53,7 @@ interface Props { subject?: string; amount?: string; }>; - + routeCreateAccount: RouteDefinition; routeRemoveAccount: RouteDefinition<{ account: string }>; routeShowAccount: RouteDefinition<{ account: string }>; @@ -74,6 +74,7 @@ export function AdminHome({ routeShowConversionRateClass, onAuthorizationRequired, }: Props): VNode { + const { config } = useBankCoreApiContext(); return ( <Fragment> <Metrics routeDownloadStats={routeDownloadStats} /> @@ -91,10 +92,12 @@ export function AdminHome({ routeShowAccount={routeShowAccount} routeUpdatePasswordAccount={routeUpdatePasswordAccount} /> - <ConversionClassList - routeCreate={routeCreateConversionRateClass} - routeShowDetails={routeShowConversionRateClass} - /> + {!config.allow_conversion ? undefined : ( + <ConversionClassList + routeCreate={routeCreateConversionRateClass} + routeShowDetails={routeShowConversionRateClass} + /> + )} </Fragment> ); } diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx @@ -14,8 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + AmountString, Amounts, + DecimalNumber, HttpStatusCode, + RoundingMode, TalerError, assertUnreachable, } from "@gnu-taler/taler-util"; @@ -28,12 +31,8 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { - useBusinessAccounts, - useConversionRateClasses, -} from "../../hooks/regional.js"; +import { useConversionRateClasses } from "../../hooks/regional.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { LoginForm } from "../LoginForm.js"; const TALER_SCREEN_ID = 121; @@ -92,10 +91,37 @@ export function ConversionClassList({ } } - const onGoStart = result.isFirstPage ? undefined : result.loadFirst; - const onGoNext = result.isLastPage ? undefined : result.loadNext; - const classes = result.body; + + function DescribeRatio({ + fee, + min, + ratio, + rounding, + }: { + min?: AmountString; + ratio?: DecimalNumber; + fee?: AmountString; + rounding?: RoundingMode; + }): VNode { + const { config } = useBankCoreApiContext(); + + if (!rounding || !ratio) { + return <Fragment>-</Fragment>; + } + return ( + <Fragment> + {ratio} + {!min ? undefined : ( + <RenderAmount + spec={config.currency_specification} + value={Amounts.parseOrThrow(min)} + /> + )} + </Fragment> + ); + } + return ( <Fragment> <div class="px-4 sm:px-6 lg:px-8 mt-8"> @@ -130,33 +156,19 @@ export function ConversionClassList({ <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" - >{i18n.str`Fee`}</th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Ratio`}</th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Min amount`}</th> - <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0"> - <span class="sr-only">{i18n.str`Rounding`}</span> - </th> + >{i18n.str`Name`}</th> <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" - >{i18n.str`Fee`}</th> + >{i18n.str`Description`}</th> <th scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Ratio`}</th> + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{i18n.str`Cashin`}</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Min amount`}</th> - <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0"> - <span class="sr-only">{i18n.str`Rounding`}</span> - </th> + >{i18n.str`Cashout`}</th> </tr> </thead> <tbody class="divide-y divide-gray-200"> @@ -164,60 +176,32 @@ export function ConversionClassList({ return ( <tr key={idx} class=""> <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> - {!row.cashin_fee ? ( - "-" - ) : ( - <RenderAmount - spec={config.currency_specification} - value={Amounts.parseOrThrow(row.cashin_fee)} - /> - )} - </td> - <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {row.cashin_ratio} + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + {row.name} + </a> </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {!row.cashin_min_amount ? ( - "-" - ) : ( - <RenderAmount - spec={config.currency_specification} - value={Amounts.parseOrThrow( - row.cashin_min_amount, - )} - /> - )} - </td> - <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> - {row.cashin_rounding_mode} - </td> - <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> - {!row.cashout_fee ? ( - "-" - ) : ( - <RenderAmount - spec={config.currency_specification} - value={Amounts.parseOrThrow(row.cashout_fee)} - /> - )} + {row.description} </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {row.cashout_ratio} + <DescribeRatio + ratio={row.cashin_ratio} + fee={row.cashin_fee} + min={row.cashin_min_amount} + rounding={row.cashin_rounding_mode} + /> </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {!row.cashout_min_amount ? ( - "-" - ) : ( - <RenderAmount - spec={config.currency_specification} - value={Amounts.parseOrThrow( - row.cashout_min_amount, - )} - /> - )} - </td> - <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> - {row.cashout_rounding_mode} + <DescribeRatio + ratio={row.cashout_ratio} + fee={row.cashout_fee} + min={row.cashout_min_amount} + rounding={row.cashout_rounding_mode} + /> </td> </tr> ); @@ -234,16 +218,16 @@ export function ConversionClassList({ <button name="first page" class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoStart} - onClick={onGoStart} + disabled={!result.loadFirst} + onClick={result.loadFirst} > <i18n.Translate>First page</i18n.Translate> </button> <button name="next page" class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoNext} - onClick={onGoNext} + disabled={!result.loadNext} + onClick={result.loadNext} > <i18n.Translate>Next</i18n.Translate> </button> diff --git a/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx b/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx @@ -0,0 +1,636 @@ +/* + 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 { + AmountString, + Amounts, + DecimalNumber, + RoundingMode, + TalerCorebankApi, + TranslatedString, + assertUnreachable +} from "@gnu-taler/taler-util"; +import { + InputToggle, + ShowInputErrorLabel, + useBankCoreApiContext, + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Fragment } from "preact/jsx-runtime"; +import { useSessionState } from "../../hooks/session.js"; +import { + ErrorMessageMappingFor, + undefinedIfEmpty +} from "../../utils.js"; +import { + InputAmount, + doAutoFocus +} from "../PaytoWireTransferForm.js"; + +const TALER_SCREEN_ID = 120; + +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; + +export type ConversionRateClassFormData = { + name?: string; + description?: string; + + cashin_enabled?: boolean; + cashin_min_amount?: string; + cashin_ratio?: string; + cashin_fee?: string; + cashin_rounding_mode?: RoundingMode; + + cashout_enabled?: boolean; + cashout_min_amount?: string; + cashout_ratio?: DecimalNumber; + cashout_fee?: string; + cashout_rounding_mode?: RoundingMode; +}; + +type ChangeByPurposeType = { + create: (a: TalerCorebankApi.ConversionRateClassInput | undefined) => void; + update: (a: TalerCorebankApi.ConversionRateClassInput | undefined) => void; + show: undefined; +}; +/** + * + * @param param0 + * @returns + */ +export function ConversionRateClassForm< + PurposeType extends keyof ChangeByPurposeType, +>({ + template, + username, + purpose, + onChange, + focus, + children, +}: { + focus?: boolean; + children: ComponentChildren; + username?: string; + template: TalerCorebankApi.ConversionRateClass | undefined; + onChange: ChangeByPurposeType[PurposeType]; + purpose: PurposeType; +}): VNode { + const { config, url } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const [form, setForm] = useState<ConversionRateClassFormData>({}); + + const [errors, setErrors] = useState< + ErrorMessageMappingFor<typeof defaultValue> | undefined + >(undefined); + + const defaultValue: ConversionRateClassFormData = { + cashin_fee: Amounts.stringifyValue( + template?.cashin_fee ?? `${config.currency}:0`, + ), + cashin_min_amount: Amounts.stringifyValue( + template?.cashin_min_amount ?? `${config.currency}:0`, + ), + cashout_fee: Amounts.stringifyValue( + template?.cashout_fee ?? `${config.currency}:0`, + ), + cashin_ratio: template?.cashin_ratio ?? "0", + cashin_rounding_mode: template?.cashin_rounding_mode, + + cashout_min_amount: Amounts.stringifyValue( + template?.cashout_min_amount ?? `${config.currency}:0`, + ), + cashout_ratio: template?.cashout_ratio ?? "0", + cashout_rounding_mode: template?.cashout_rounding_mode, + + cashin_enabled: + template?.cashin_ratio !== undefined && + Number.parseInt(template.cashin_ratio, 10) > 0, + + cashout_enabled: + template?.cashout_ratio !== undefined && + Number.parseInt(template.cashout_ratio, 10) > 0, + + name: template?.name, + description: template?.description, + }; + + const userIsAdmin = + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; + + const editableForm = + userIsAdmin && (purpose === "create" || purpose === "update"); + + function updateForm(newForm: typeof defaultValue): void { + const errors = undefinedIfEmpty< + ErrorMessageMappingFor<typeof defaultValue> + >({ + name: !editableForm + ? undefined // disabled + : purpose === "update" && newForm.name === undefined + ? undefined // the field hasn't been changed + : !newForm.name + ? i18n.str`Required` + : undefined, + cashin_fee: + !editableForm || !newForm.cashin_enabled + ? undefined + : !newForm.cashin_fee + ? i18n.str`Required` + : undefined, + cashout_fee: + !editableForm || !newForm.cashout_fee + ? undefined + : !newForm.cashout_fee + ? i18n.str`Required` + : undefined, + cashin_min_amount: + !editableForm || !newForm.cashin_min_amount + ? undefined + : !newForm.cashin_min_amount + ? i18n.str`Required` + : undefined, + cashout_min_amount: + !editableForm || !newForm.cashout_min_amount + ? undefined + : !newForm.cashout_min_amount + ? i18n.str`Required` + : undefined, + }); + setErrors(errors); + + setForm(newForm); + if (!onChange) return; + + if (errors) { + onChange(undefined); + } else { + switch (purpose) { + case "create": { + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["create"]; + const result: TalerCorebankApi.ConversionRateClassInput = { + name: newForm.name!, + }; + callback(result); + return; + } + case "update": { + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["update"]; + + const result: TalerCorebankApi.ConversionRateClassInput = { + name: newForm.name!, + }; + callback(result); + return; + } + case "show": { + return; + } + default: { + assertUnreachable(purpose); + } + } + } + } + return ( + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <pre>{JSON.stringify(errors, undefined, 2)}</pre> + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Name`} + {editableForm && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + ref={focus && purpose === "create" ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-100 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="username" + id="username" + data-error={!!errors?.name && form.name !== undefined} + disabled={!editableForm} + value={form.name ?? defaultValue.name} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Conversion rate name</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Description`} + </label> + <div class="mt-2"> + <input + ref={focus && purpose === "create" ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-100 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="username" + id="username" + data-error={ + !!errors?.description && form.description !== undefined + } + disabled={!editableForm} + value={form.description ?? defaultValue.description} + onChange={(e) => { + form.description = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.description} + isDirty={form.description !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Short description of the class</i18n.Translate> + </p> + </div> + + <InputToggle + label={i18n.str`Enable cashin`} + name="cashin" + threeState={false} + disabled={!editableForm} + handler={{ + value: form.cashin_enabled ?? defaultValue.cashin_enabled, + onChange(v) { + form.cashin_enabled = v; + updateForm(structuredClone(form)); + }, + name: "cashin", + }} + /> + <InputToggle + label={i18n.str`Enable cashout`} + name="cashout" + threeState={false} + disabled={!editableForm} + handler={{ + value: form.cashout_enabled ?? defaultValue.cashout_enabled, + onChange(v) { + form.cashout_enabled = v; + updateForm(structuredClone(form)); + }, + name: "cashout", + }} + /> + + {!form.cashin_enabled ? undefined : ( + <Fragment> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Cashin 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"> + {(["nearest", "zero", "up"] as Array<RoundingMode>).map( + (ROUNDING_MODE) => { + let LABEL: TranslatedString; + switch (ROUNDING_MODE) { + case "zero": { + LABEL = i18n.str`To zero`; + break; + } + case "up": { + LABEL = i18n.str`Round up`; + break; + } + case "nearest": { + LABEL = i18n.str`To nearest int`; + break; + } + default: { + assertUnreachable(ROUNDING_MODE); + } + } + return ( + <label + onClick={(e) => { + form.cashin_rounding_mode = ROUNDING_MODE; + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show"} + data-selected={ + (form.cashin_rounding_mode ?? + defaultValue.cashin_rounding_mode) === + ROUNDING_MODE + } + 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 " + > + {LABEL} + </span> + </span> + </span> + <svg + data-selected={ + (form.cashin_rounding_mode ?? + defaultValue.cashin_rounding_mode) === + ROUNDING_MODE + } + 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 class="sm:col-span-5"> + <label + for="cashin_fee" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashin fee`}</label> + <InputAmount + name="cashin_fee" + left + currency={config.currency} + value={form.cashin_fee ?? defaultValue.cashin_fee} + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashin_fee = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashin_fee ? String(errors?.cashin_fee) : undefined + } + isDirty={form.cashin_fee !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashin min amount`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={ + form.cashin_min_amount ?? defaultValue.cashin_min_amount + } + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashin_min_amount = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashin_min_amount + ? String(errors?.cashin_min_amount) + : undefined + } + isDirty={form.cashin_min_amount !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + </Fragment> + )} + + {!form.cashout_enabled ? undefined : ( + <Fragment> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Cashout 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"> + {(["nearest", "zero", "up"] as Array<RoundingMode>).map( + (ROUNDING_MODE) => { + let LABEL: TranslatedString; + switch (ROUNDING_MODE) { + case "zero": { + LABEL = i18n.str`To zero`; + break; + } + case "up": { + LABEL = i18n.str`Round up`; + break; + } + case "nearest": { + LABEL = i18n.str`To nearest int`; + break; + } + default: { + assertUnreachable(ROUNDING_MODE); + } + } + return ( + <label + onClick={(e) => { + form.cashout_rounding_mode = ROUNDING_MODE; + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show"} + data-selected={ + (form.cashout_rounding_mode ?? + defaultValue.cashout_rounding_mode) === + ROUNDING_MODE + } + 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 " + > + {LABEL} + </span> + </span> + </span> + <svg + data-selected={ + (form.cashout_rounding_mode ?? + defaultValue.cashout_rounding_mode) === + ROUNDING_MODE + } + 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 class="sm:col-span-5"> + <label + for="cashout_min_amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashout min amount`}</label> + <InputAmount + name="cashout_min_amount" + left + currency={config.currency} + value={ + form.cashout_min_amount ?? defaultValue.cashout_min_amount + } + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashout_min_amount = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashout_min_amount + ? String(errors?.cashout_min_amount) + : undefined + } + isDirty={form.cashout_min_amount !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashout fee`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={form.cashout_fee ?? defaultValue.cashout_fee} + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashout_fee = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashout_fee + ? String(errors?.cashout_fee) + : undefined + } + isDirty={form.cashout_fee !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + </Fragment> + )} + </div> + </div> + {children} + </form> + ); +} diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -119,11 +119,16 @@ function useComponentState({ 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, + cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], + + cashout_min_amount: info.conversion_rate.cashout_min_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, + cashout_tiny_amount: + info.conversion_rate.cashout_tiny_amount.split(":")[1], }, }; @@ -433,7 +438,7 @@ function useComponentState({ value={cashinCalc.debit} negative withColor - spec={info.regional_currency_specification} + spec={info.fiat_currency_specification} /> </dd> </div> @@ -448,7 +453,7 @@ function useComponentState({ <dd class="text-sm text-gray-900"> <RenderAmount value={cashinCalc.beforeFee} - spec={info.fiat_currency_specification} + spec={info.regional_currency_specification} /> </dd> </div> @@ -461,7 +466,7 @@ function useComponentState({ <RenderAmount value={cashinCalc.credit} withColor - spec={info.fiat_currency_specification} + spec={info.regional_currency_specification} /> </dd> </div> @@ -481,7 +486,7 @@ function useComponentState({ value={cashoutCalc.debit} negative withColor - spec={info.fiat_currency_specification} + spec={info.regional_currency_specification} /> </dd> </div> @@ -496,7 +501,7 @@ function useComponentState({ <dd class="text-sm text-gray-900"> <RenderAmount value={cashoutCalc.beforeFee} - spec={info.regional_currency_specification} + spec={info.fiat_currency_specification} /> </dd> </div> @@ -509,7 +514,7 @@ function useComponentState({ <RenderAmount value={cashoutCalc.credit} withColor - spec={info.regional_currency_specification} + spec={info.fiat_currency_specification} /> </dd> </div> @@ -687,7 +692,7 @@ function createFormValidator( }; } -function ConversionForm({ +export function ConversionForm({ id, inputCurrency, outputCurrency, @@ -695,13 +700,21 @@ function ConversionForm({ minimum, ratio, rounding, + fallback_fee, + fallback_minimum, + fallback_ratio, + fallback_rounding, }: { inputCurrency: string; outputCurrency: string; minimum: UIField | undefined; + fallback_minimum?: string; fee: UIField | undefined; + fallback_fee?: string; rounding: UIField | undefined; + fallback_rounding?: string; ratio: UIField | undefined; + fallback_ratio?: string; id: string; }): VNode { const { i18n } = useTranslationContext(); @@ -727,9 +740,17 @@ function ConversionForm({ /> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - Only cashout operation above this threshold will be allowed + Only cashout operation above this threshold will be allowed. </i18n.Translate> + &nbsp; </p> + {!fallback_minimum ? undefined : ( + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + If none specified the fallback value is {fallback_minimum}. + </i18n.Translate> + </p> + )} </div> </div> </div> @@ -762,13 +783,20 @@ function ConversionForm({ <p class="mt-2 text-sm text-gray-500"> <i18n.Translate>Conversion ratio between currencies</i18n.Translate> </p> + {!fallback_ratio ? undefined : ( + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + If none specified the fallback value is {fallback_ratio}. + </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} + 1 {inputCurrency} will be converted into{" "} + {ratio?.value ?? fallback_ratio} {outputCurrency} </i18n.Translate> </Attention> </div> @@ -902,6 +930,14 @@ function ConversionForm({ </svg> </label> </div> + {!fallback_rounding ? undefined : ( + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + If none specified the fallback value is "{fallback_rounding} + ". + </i18n.Translate> + </p> + )} </div> </div> </div> @@ -1111,6 +1147,14 @@ function ConversionForm({ </p> </div> </div> + {!fallback_fee ? undefined : ( + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + If none specified the fallback value is "{fallback_fee} + ". + </i18n.Translate> + </p> + )} </div> </Fragment> );