commit 521454a1bd15511c54a2481dfc36402f13b8a7fa
parent e3595b049cf5b76e512c061e833ff39379d8f446
Author: Sebastian <sebasjm@gmail.com>
Date: Tue, 1 Jul 2025 11:34:10 -0300
fix #10104 partially completed
Diffstat:
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>
+
</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>
);