summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2024-02-27 01:18:23 -0300
committerSebastian <sebasjm@gmail.com>2024-02-27 01:18:23 -0300
commitee40a5e25c44ef478ee13426549e548d2610a215 (patch)
tree05978a180aaa2d334ef65b829d611cdd383fda37
parentde8468fcd7f1c74b820486fb6d8854c758458780 (diff)
downloadwallet-core-ee40a5e25c44ef478ee13426549e548d2610a215.tar.gz
wallet-core-ee40a5e25c44ef478ee13426549e548d2610a215.tar.bz2
wallet-core-ee40a5e25c44ef478ee13426549e548d2610a215.zip
conversion UI
-rw-r--r--packages/demobank-ui/src/context/config.ts28
-rw-r--r--packages/demobank-ui/src/hooks/circuit.ts61
-rw-r--r--packages/demobank-ui/src/pages/ConversionConfig.tsx982
-rw-r--r--packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx180
-rw-r--r--packages/demobank-ui/src/pages/business/CreateCashout.tsx1
-rw-r--r--packages/web-util/src/components/utils.ts28
6 files changed, 954 insertions, 326 deletions
diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts
index 1cabab51c..529108275 100644
--- a/packages/demobank-ui/src/context/config.ts
+++ b/packages/demobank-ui/src/context/config.ts
@@ -16,7 +16,13 @@
import {
AccessToken,
+ AmountJson,
+ HttpStatusCode,
LibtoolVersion,
+ OperationFail,
+ OperationOk,
+ TalerBankConversionApi,
+ TalerBankConversionHttpClient,
TalerCorebankApi,
TalerCoreBankHttpClient,
TalerError,
@@ -44,6 +50,7 @@ import {
import {
revalidateBusinessAccounts,
revalidateCashouts,
+ revalidateConversionInfo,
} from "../hooks/circuit.js";
/**
@@ -89,7 +96,7 @@ export const BankCoreApiProvider = ({
const [checked, setChecked] = useState<ConfigResult>();
const { i18n } = useTranslationContext();
const url = new URL(baseUrl);
- const api = new CacheAwareApi(url.href, new BrowserHttpLib());
+ const api = new CacheAwareTalerCoreBankHttpClient(url.href, new BrowserHttpLib());
useEffect(() => {
api
.getConfig()
@@ -149,8 +156,20 @@ export const BankCoreApiProvider = ({
children,
});
};
+class CacheAwareTalerBankConversionHttpClient extends TalerBankConversionHttpClient {
+ constructor(baseUrl: string, httpClient?: HttpRequestLibrary) {
+ super(baseUrl, httpClient);
+ }
+ async updateConversionRate(auth: AccessToken, body: TalerBankConversionApi.ConversionRate) {
+ const resp = await super.updateConversionRate(auth, body);
+ if (resp.type === "ok") {
+ await revalidateConversionInfo();
+ }
+ return resp
+ }
+}
-export class CacheAwareApi extends TalerCoreBankHttpClient {
+class CacheAwareTalerCoreBankHttpClient extends TalerCoreBankHttpClient {
constructor(baseUrl: string, httpClient?: HttpRequestLibrary) {
super(baseUrl, httpClient);
}
@@ -223,6 +242,11 @@ export class CacheAwareApi extends TalerCoreBankHttpClient {
}
return resp;
}
+
+ getConversionInfoAPI(): TalerBankConversionHttpClient {
+ const api = super.getConversionInfoAPI();
+ return new CacheAwareTalerBankConversionHttpClient(api.baseUrl, this.httpLib)
+ }
}
export const BankCoreApiProviderTesting = ({
diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts
index 7d8884797..2c0a58a5e 100644
--- a/packages/demobank-ui/src/hooks/circuit.ts
+++ b/packages/demobank-ui/src/hooks/circuit.ts
@@ -47,7 +47,7 @@ type EstimatorFunction = (
fee: AmountJson,
) => Promise<TransferCalculation>;
-type CashoutEstimators = {
+type ConversionEstimators = {
estimateByCredit: EstimatorFunction;
estimateByDebit: EstimatorFunction;
};
@@ -84,7 +84,53 @@ export function useConversionInfo() {
return undefined;
}
-export function useEstimator(): CashoutEstimators {
+export function useCashinEstimator(): ConversionEstimators {
+ const { api } = useBankCoreApiContext();
+ return {
+ estimateByCredit: async (fiatAmount, fee) => {
+ const resp = await api.getConversionInfoAPI().getCashinRate({
+ credit: fiatAmount,
+ });
+ if (resp.type === "fail") {
+ // can't happen
+ // not-supported: it should not be able to call this function
+ // wrong-calculation: we are using just one parameter
+ throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint);
+ }
+ const credit = Amounts.parseOrThrow(resp.body.amount_credit);
+ const debit = Amounts.parseOrThrow(resp.body.amount_debit);
+ const beforeFee = Amounts.sub(credit, fee).amount;
+
+ return {
+ debit,
+ beforeFee,
+ credit,
+ };
+ },
+ estimateByDebit: async (regionalAmount, fee) => {
+ const resp = await api.getConversionInfoAPI().getCashinRate({
+ debit: regionalAmount,
+ });
+ if (resp.type === "fail") {
+ // can't happen
+ // not-supported: it should not be able to call this function
+ // wrong-calculation: we are using just one parameter
+ throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint);
+ }
+ const credit = Amounts.parseOrThrow(resp.body.amount_credit);
+ const debit = Amounts.parseOrThrow(resp.body.amount_debit);
+ const beforeFee = Amounts.add(credit, fee).amount;
+
+ return {
+ debit,
+ beforeFee,
+ credit,
+ };
+ },
+ };
+}
+
+export function useCashoutEstimator(): ConversionEstimators {
const { api } = useBankCoreApiContext();
return {
estimateByCredit: async (fiatAmount, fee) => {
@@ -130,6 +176,13 @@ export function useEstimator(): CashoutEstimators {
};
}
+/**
+ * @deprecated use useCashoutEstimator
+ */
+export function useEstimator(): ConversionEstimators {
+ return useCashoutEstimator()
+}
+
export function revalidateBusinessAccounts() {
return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true });
}
@@ -147,7 +200,7 @@ export function useBusinessAccounts() {
token,
{},
{
- limit: PAGE_SIZE+1,
+ limit: PAGE_SIZE + 1,
offset: String(offset),
order: "asc",
},
@@ -174,7 +227,7 @@ export function useBusinessAccounts() {
const isFirstPage = !offset;
const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : []
- if (result.length == PAGE_SIZE+1) {
+ if (result.length == PAGE_SIZE + 1) {
result.pop()
}
const pagination = {
diff --git a/packages/demobank-ui/src/pages/ConversionConfig.tsx b/packages/demobank-ui/src/pages/ConversionConfig.tsx
index 73a6ab3ee..efe2d1756 100644
--- a/packages/demobank-ui/src/pages/ConversionConfig.tsx
+++ b/packages/demobank-ui/src/pages/ConversionConfig.tsx
@@ -15,29 +15,32 @@
*/
import {
- AmountString,
+ AmountJson,
Amounts,
HttpStatusCode,
- OperationOk,
- OperationResult,
TalerBankConversionApi,
+ TalerError,
TranslatedString,
assertUnreachable
} from "@gnu-taler/taler-util";
import {
+ Attention,
+ InternationalizationAPI,
LocalNotificationBanner,
ShowInputErrorLabel,
useLocalNotification,
- useTranslationContext
+ useTranslationContext,
+ utils
} from "@gnu-taler/web-util/browser";
-import { VNode, h } from "preact";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
import { useBankCoreApiContext } from "../context/config.js";
import { useBackendState } from "../hooks/backend.js";
+import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../hooks/circuit.js";
import { RouteDefinition } from "../route.js";
-import { ProfileNavigation } from "./ProfileNavigation.js";
-import { useState } from "preact/hooks";
import { undefinedIfEmpty } from "../utils.js";
-import { InputAmount } from "./PaytoWireTransferForm.js";
+import { InputAmount, RenderAmount } from "./PaytoWireTransferForm.js";
+import { ProfileNavigation } from "./ProfileNavigation.js";
interface Props {
routeMyAccountDetails: RouteDefinition;
@@ -49,285 +52,806 @@ interface Props {
onUpdateSuccess: () => void;
}
-type FormType<T> = {
- [k in keyof T]: string | undefined;
+type UIField = {
+ value: string | undefined;
+ onUpdate: (s: string) => void;
+ error: TranslatedString | undefined;
+}
+
+type FormHandler<T> = {
+ [k in keyof T]?:
+ T[k] extends string ? UIField :
+ T[k] extends AmountJson ? UIField :
+ FormHandler<T[k]>;
}
-type ErrorsType<T> = {
- [k in keyof T]?: TranslatedString;
+type FormValues<T> = {
+ [k in keyof T]:
+ T[k] extends string ? (string | undefined) :
+ T[k] extends AmountJson ? (string | undefined) :
+ FormValues<T[k]>;
}
+type RecursivePartial<T> = {
+ [k in keyof T]?:
+ T[k] extends string ? (string) :
+ T[k] extends AmountJson ? (AmountJson) :
+ RecursivePartial<T[k]>;
+}
-type FormHandler<T> = {
- [k in keyof T]?: {
- value: string | undefined;
- onUpdate: (s: string) => void;
- error: TranslatedString | undefined;
- }
+type FormErrors<T> = {
+ [k in keyof T]?:
+ T[k] extends string ? (TranslatedString) :
+ T[k] extends AmountJson ? (TranslatedString) :
+ FormErrors<T[k]>;
}
-function useFormState<T>(defaultValue: FormType<T>, validate: (f: FormType<T>) => ErrorsType<T>): FormHandler<T> {
- const [form, updateForm] = useState<FormType<T>>(defaultValue)
-
- const errors = undefinedIfEmpty<ErrorsType<T>>(validate(form))
-
- const p = (Object.keys(form) as Array<keyof T>)
- console.log("FORM", p)
- const handler = p.reduce((prev, fieldName) => {
- console.log("fie;d", fieldName)
- const currentValue = form[fieldName]
- const currentError = errors !== undefined ? errors[fieldName] : undefined
- prev[fieldName] = {
+
+type FormStatus<T> = {
+ status: "ok",
+ result: T,
+ errors: undefined,
+} | {
+ status: "fail",
+ result: RecursivePartial<T>,
+ errors: FormErrors<T>,
+}
+type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate }
+
+function constructFormHandler<T>(form: FormValues<T>, updateForm: (d: FormValues<T>) => void, errors: FormErrors<T> | undefined): FormHandler<T> {
+ const keys = (Object.keys(form) as Array<keyof T>)
+
+ const handler = keys.reduce((prev, fieldName) => {
+ const currentValue: any = form[fieldName];
+ const currentError: any = errors ? errors[fieldName] : undefined;
+ function updater(newValue: any) {
+ updateForm({ ...form, [fieldName]: newValue })
+ }
+ if (typeof currentValue === "object") {
+ const group = constructFormHandler(currentValue, updater, currentError)
+ // @ts-expect-error asdasd
+ prev[fieldName] = group
+ return prev;
+ }
+ const field: UIField = {
error: currentError,
value: currentValue,
- onUpdate: (newValue) => {
- updateForm({ ...form, [fieldName]: newValue })
- }
+ onUpdate: updater
}
+ // @ts-expect-error asdasd
+ prev[fieldName] = field
return prev
}, {} as FormHandler<T>)
- return handler
+ return handler;
}
-/**
- * Show histories of public accounts.
- */
-export function ConversionConfig({
+function useFormState<T>(defaultValue: FormValues<T>, check: (f: FormValues<T>) => FormStatus<T>): [FormHandler<T>, FormStatus<T>] {
+ const [form, updateForm] = useState<FormValues<T>>(defaultValue)
+
+ const status = check(form)
+ const handler = constructFormHandler(form, updateForm, status.errors)
+
+ return [handler, status]
+}
+
+function useComponentState({
+ onUpdateSuccess,
+ routeCancel,
+ routeConversionConfig,
routeMyAccountCashout,
routeMyAccountDelete,
routeMyAccountDetails,
routeMyAccountPassword,
- routeConversionConfig,
- routeCancel,
- onUpdateSuccess,
-}: Props): VNode {
- const { i18n } = useTranslationContext();
+}: Props): utils.RecursiveState<VNode> {
+
+ const result = useConversionInfo()
+ const info = result && !(result instanceof TalerError) && result.type === "ok" ?
+ result.body : undefined;
const { state: credentials } = useBackendState();
const creds =
credentials.status !== "loggedIn" || !credentials.isUserAdministrator
? undefined
: credentials;
- const { api, config } = useBankCoreApiContext();
- const [notification, notify, handleError] = useLocalNotification();
+ if (!info) {
+ return <div>waiting...</div>
+ }
if (!creds) {
return <div>only admin can setup conversion</div>;
}
- const form = useFormState<TalerBankConversionApi.ConversionRate>({
- cashin_min_amount: undefined,
- cashin_tiny_amount: undefined,
- cashin_fee: undefined,
- cashin_ratio: undefined,
- cashin_rounding_mode: undefined,
- cashout_min_amount: undefined,
- cashout_tiny_amount: undefined,
- cashout_fee: undefined,
- cashout_ratio: undefined,
- cashout_rounding_mode: undefined,
- }, (state) => {
- return ({
- cashin_min_amount: !state.cashin_min_amount ? i18n.str`required` :
- !Amounts.parse(`${config.currency}:${state.cashin_min_amount}`) ? i18n.str`invalid` :
- undefined,
+ return () => {
+ const { i18n } = useTranslationContext();
- })
- })
-
-
- async function doUpdate() {
- if (!creds) return
- await handleError(async () => {
- const resp = await api
- .getConversionInfoAPI()
- .updateConversionRate(creds.token, {
-
- } as any)
- if (resp.type === "ok") {
- onUpdateSuccess()
- } else {
- switch (resp.case) {
- case HttpStatusCode.Unauthorized: {
- return notify({
- type: "error",
- title: i18n.str`Wrong credentials`,
- description: resp.detail.hint as TranslatedString,
- debug: resp.detail,
- });
- }
- case HttpStatusCode.NotImplemented: {
- return notify({
- type: "error",
- title: i18n.str`Conversion is disabled`,
- description: resp.detail.hint as TranslatedString,
- debug: resp.detail,
- });
+ const { api, config } = useBankCoreApiContext();
+
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const initalState: FormValues<FormType> = {
+ amount: "100",
+ conv: {
+ cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1],
+ cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1],
+ cashin_fee: info.conversion_rate.cashin_fee.split(":")[1],
+ cashin_ratio: info.conversion_rate.cashin_ratio,
+ cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode,
+ cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1],
+ cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1],
+ cashout_fee: info.conversion_rate.cashout_fee.split(":")[1],
+ cashout_ratio: info.conversion_rate.cashout_ratio,
+ cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode,
+ }
+ }
+
+ const [form, status] = useFormState<FormType>(
+ initalState,
+ checkConversionForm(i18n, info.regional_currency, info.fiat_currency)
+ )
+
+ const {
+ estimateByDebit: calculateCashoutFromDebit,
+ } = useCashoutEstimator();
+
+ const {
+ estimateByDebit: calculateCashinFromDebit,
+ } = useCashinEstimator();
+
+ const [calc, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>()
+
+ useEffect(() => {
+ async function doAsync() {
+ await handleError(async () => {
+ if (!info) return;
+ if (!form.amount?.value || form.amount.error) return;
+ const in_amount = Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`)
+ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee)
+ const cashin = await calculateCashinFromDebit(in_amount, in_fee);
+
+
+ // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`)
+ const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee)
+ const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee);
+
+ setCalc({ cashin, cashout });
+ });
+ }
+ doAsync();
+ }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]);
+
+ const [section, setSection] = useState<"detail" | "cashout" | "cashin">("detail")
+
+ async function doUpdate() {
+ if (!creds) return
+ await handleError(async () => {
+ if (status.status === "fail") return;
+ const resp = await api
+ .getConversionInfoAPI()
+ .updateConversionRate(creds.token, status.result.conv)
+ if (resp.type === "ok") {
+ setSection("detail")
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized: {
+ return notify({
+ type: "error",
+ title: i18n.str`Wrong credentials`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ }
+ case HttpStatusCode.NotImplemented: {
+ return notify({
+ type: "error",
+ title: i18n.str`Conversion is disabled`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ }
+ default:
+ assertUnreachable(resp);
}
- default:
- assertUnreachable(resp);
}
- }
- });
- }
+ });
+ }
+
+ const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio)
+ const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio)
+ const both_high = in_ratio > 1 && out_ratio > 1;
+ const both_low = in_ratio < 1 && out_ratio < 1;
- return (
- <div>
- <ProfileNavigation current="conversion"
- routeMyAccountCashout={routeMyAccountCashout}
- routeMyAccountDelete={routeMyAccountDelete}
- routeMyAccountDetails={routeMyAccountDetails}
- routeMyAccountPassword={routeMyAccountPassword}
- routeConversionConfig={routeConversionConfig}
- />
+ return (
+ <div>
+ <ProfileNavigation current="conversion"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
- <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
<LocalNotificationBanner notification={notification} />
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
- <div class="px-4 sm:px-0">
- <h2 class="text-base font-semibold leading-7 text-gray-900">
- <i18n.Translate>Conversion</i18n.Translate>
- </h2>
- </div>
+ <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>
- <form
- class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
- autoCapitalize="none"
- autoCorrect="off"
- onSubmit={(e) => {
- e.preventDefault();
- }}
- >
- <div class="px-6 pt-6">
- <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
- <div class="sm:col-span-5">
- <label
- for="cashout_amount_min"
- class="block text-sm font-medium leading-6 text-gray-900"
- >{i18n.str`Minimum amount`}</label>
- <InputAmount
- name="cashout_amount_min"
- left
- currency={config.currency}
- value={form.cashin_min_amount?.value ?? ""}
- onChange={form.cashin_min_amount?.onUpdate}
+ <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")
+ }}
/>
- <ShowInputErrorLabel
- message={form.cashin_min_amount?.error}
- isDirty={form.cashin_min_amount?.value !== undefined}
+ <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")
+ }}
/>
- <p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate>
- </p>
- </div>
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Config cashin</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
</div>
+
</div>
- <div class="px-6 pt-6">
- <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
- <div class="sm:col-span-5">
+ <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" && <Fragment>
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="cashin_min_amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum amount`}</label>
+ <InputAmount
+ name="cashin_min_amount"
+ left
+ currency={config.currency}
+ value={form.conv?.cashin_min_amount?.value ?? ""}
+ onChange={form.conv?.cashin_min_amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashin_min_amount?.error}
+ isDirty={form.conv?.cashin_min_amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="cashin_tiny_amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum difference`}</label>
+ <InputAmount
+ name="cashin_tiny_amount"
+ left
+ currency={config.currency}
+ value={form.conv?.cashin_tiny_amount?.value ?? ""}
+ onChange={form.conv?.cashin_tiny_amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashin_tiny_amount?.error}
+ isDirty={form.conv?.cashin_tiny_amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Smallest difference between two amounts</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="cashin_fee"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Fee`}</label>
+ <InputAmount
+ name="cashin_fee"
+ left
+ currency={config.currency}
+ value={form.conv?.cashin_fee?.value ?? ""}
+ onChange={form.conv?.cashin_fee?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashin_fee?.error}
+ isDirty={form.conv?.cashin_fee?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Operation fee</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
<label
- for="cashout_amount_tiny"
class="block text-sm font-medium leading-6 text-gray-900"
- >{i18n.str`Minimum difference`}</label>
- <InputAmount
- name="cashout_amount_tiny"
- left
- currency={config.currency}
- value={form.cashin_min_amount?.value ?? ""}
- onChange={form.cashin_min_amount?.onUpdate}
- />
- <ShowInputErrorLabel
- message={form.cashin_min_amount?.error}
- isDirty={form.cashin_min_amount?.value !== undefined}
- />
+ for="password"
+ >
+ {i18n.str`Ratio`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="number"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="current"
+ id="cashin_ratio"
+ data-error={!!form.conv?.cashin_ratio?.error && form.conv?.cashin_ratio?.value !== undefined}
+ value={form.conv?.cashin_ratio?.value ?? ""}
+ onChange={(e) => {
+ form.conv?.cashin_ratio?.onUpdate(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashin_ratio?.error}
+ isDirty={form.conv?.cashin_ratio?.value !== undefined}
+ />
+ </div>
<p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>Smallest difference between two amounts</i18n.Translate>
+ <i18n.Translate>
+ Conversion ratio between currencies
+ </i18n.Translate>
</p>
</div>
- </div>
- </div>
- <div class="px-6 pt-6">
- <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
- <div class="sm:col-span-5">
+ </Fragment>}
+
+
+
+ {section == "cashout" && <Fragment>
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="cashout_min_amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum amount`}</label>
+ <InputAmount
+ name="cashout_min_amount"
+ left
+ currency={config.currency}
+ value={form.conv?.cashout_min_amount?.value ?? ""}
+ onChange={form.conv?.cashout_min_amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashout_min_amount?.error}
+ isDirty={form.conv?.cashout_min_amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="cashout_tiny_amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum difference`}</label>
+ <InputAmount
+ name="cashout_tiny_amount"
+ left
+ currency={config.currency}
+ value={form.conv?.cashout_tiny_amount?.value ?? ""}
+ onChange={form.conv?.cashout_tiny_amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashout_tiny_amount?.error}
+ isDirty={form.conv?.cashout_tiny_amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Smallest difference between two amounts</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="cashout_fee"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Fee`}</label>
+ <InputAmount
+ name="cashout_fee"
+ left
+ currency={config.currency}
+ value={form.conv?.cashout_fee?.value ?? ""}
+ onChange={form.conv?.cashout_fee?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashout_fee?.error}
+ isDirty={form.conv?.cashout_fee?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Operation fee</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
<label
- for="cashin_fee"
class="block text-sm font-medium leading-6 text-gray-900"
- >{i18n.str`Fee`}</label>
- <InputAmount
- name="cashin_fee"
- left
- currency={config.currency}
- value={form.cashin_min_amount?.value ?? ""}
- onChange={form.cashin_fee?.onUpdate}
- />
- <ShowInputErrorLabel
- message={form.cashin_fee?.error}
- isDirty={form.cashin_fee?.value !== undefined}
- />
+ for="password"
+ >
+ {i18n.str`Ratio`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="number"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="current"
+ id="cashout_ratio"
+ data-error={!!form.conv?.cashout_ratio?.error && form.conv?.cashout_ratio?.value !== undefined}
+ value={form.conv?.cashout_ratio?.value ?? ""}
+ onChange={(e) => {
+ form.conv?.cashout_ratio?.onUpdate(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={form.conv?.cashout_ratio?.error}
+ isDirty={form.conv?.cashout_ratio?.value !== undefined}
+ />
+ </div>
<p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>Operation fee</i18n.Translate>
+ <i18n.Translate>
+ Conversion ratio between currencies
+ </i18n.Translate>
</p>
</div>
- </div>
- </div>
+ </Fragment>}
+
+
+
+
+ {section == "detail" && <Fragment>
+ <div class="px-6 pt-6">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Cashin ratio</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ {info.conversion_rate.cashin_ratio}
+ </dd>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Cashout ratio</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ {info.conversion_rate.cashout_ratio}
+ </dd>
+ </div>
+ </div>
+
+ {both_low || both_high ? <div class="p-4">
+ <Attention title={i18n.str`Bad ratios`} type="warning">
+ <i18n.Translate>
+ One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1.
+ </i18n.Translate>
+ </Attention>
+ </div> : undefined}
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Test amount`}</label>
+ <InputAmount
+ name="amount"
+ left
+ currency={info.fiat_currency}
+ value={form.amount?.value ?? ""}
+ onChange={form.amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.amount?.error}
+ isDirty={form.amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Use it to test how the conversion will affect the amount.</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {!calc ? undefined : (
+ <div class="px-6 pt-6">
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Sending to this bank</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.cashin.debit}
+ negative
+ withColor
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ {Amounts.isZero(calc.cashin.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between afu ">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Converted</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.cashin.beforeFee}
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Cashin after fee</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={calc.cashin.credit}
+ withColor
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Sending from this bank</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.cashout.debit}
+ negative
+ withColor
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
- <div class="px-6 pt-6">
- <label
- class="block text-sm font-medium leading-6 text-gray-900"
- for="password"
- >
- {i18n.str`Ratio`}
- </label>
- <div class="mt-2">
- <input
- type="number"
- class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
- name="current"
- id="cashout_ratio"
- data-error={!!form.cashin_ratio?.error && form.cashout_ratio?.value !== undefined}
- value={form.cashout_ratio?.value ?? ""}
- onChange={(e) => {
- form.cashout_ratio?.onUpdate(e.currentTarget.value);
- }}
- autocomplete="off"
- />
- <ShowInputErrorLabel
- message={form.cashin_ratio?.error}
- isDirty={form.cashout_ratio?.value !== undefined}
- />
+ {Amounts.isZero(calc.cashout.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between afu">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Converted</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.cashout.beforeFee}
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Cashout after fee</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={calc.cashout.credit}
+ withColor
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+
+ {calc && status.status === "ok" && Amounts.cmp(status.result.amount, calc.cashout.credit) < 0 ? <div class="p-4">
+ <Attention title={i18n.str`Bad configuration`} type="warning">
+ <i18n.Translate>
+ This configuration allows users to cash out more of what has been cashed in.
+ </i18n.Translate>
+ </Attention>
+ </div> : undefined}
+ </div>
+ )}
+ </Fragment>}
+
+
+ <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4">
+ <a name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ {section == "cashin" || section == "cashout" ? <Fragment>
+ <button
+ type="submit"
+ name="update conversion"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ onClick={async () => {
+ doUpdate()
+ }}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </Fragment> : <div />}
</div>
- <p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>
- Your current password, for security
- </i18n.Translate>
- </p>
- </div>
- <div class="flex items-center justify-between mt-6 gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <a name="cancel"
- href={routeCancel.url({})}
- class="text-sm font-semibold leading-6 text-gray-900"
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </a>
- <button
- type="submit"
- name="update conversion"
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={async () => {
- doUpdate()
- }}
- >
- <i18n.Translate>Update</i18n.Translate>
- </button>
- </div>
- </form>
+
+ </form>
+ </div>
</div>
- </div>
- );
+ );
+
+ }
+}
+
+/**
+ * Show histories of public accounts.
+ */
+export const ConversionConfig = utils.recursive(useComponentState);
+
+function checkConversionForm(i18n: InternationalizationAPI, regional: string, fiat: string) {
+ return function check(state: FormValues<FormType>): FormStatus<FormType> {
+
+ const cashin_min_amount = Amounts.parse(`${fiat}:${state.conv.cashin_min_amount}`)
+ const cashin_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`)
+ const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`)
+
+ const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`)
+ const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`)
+ const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`)
+
+ const am = Amounts.parse(`${fiat}:${state.amount}`)
+
+ const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "")
+ const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "")
+
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({
+ cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` :
+ !cashin_min_amount ? i18n.str`invalid` :
+ undefined,
+ cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` :
+ !cashin_tiny_amount ? i18n.str`invalid` :
+ undefined,
+ cashin_fee: !state.conv.cashin_fee ? i18n.str`required` :
+ !cashin_fee ? i18n.str`invalid` :
+ undefined,
+
+ cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` :
+ !cashout_min_amount ? i18n.str`invalid` :
+ undefined,
+ cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` :
+ !cashout_tiny_amount ? i18n.str`invalid` :
+ undefined,
+ cashout_fee: !state.conv.cashin_fee ? i18n.str`required` :
+ !cashout_fee ? i18n.str`invalid` :
+ undefined,
+
+ cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined,
+ cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined,
+
+ cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined,
+ cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined,
+ }),
+
+ amount: !state.amount ? i18n.str`required` :
+ !am ? i18n.str`invalid` :
+ undefined,
+ })
+
+ const result: RecursivePartial<FormType> = {
+ amount: am,
+ conv: {
+ cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined,
+ cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined,
+ cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined,
+ cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? (state.conv.cashin_rounding_mode!) : undefined,
+ cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined,
+ cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined,
+ cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined,
+ cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined,
+ cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? (state.conv.cashout_rounding_mode!) : undefined,
+ cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined,
+ }
+
+ }
+ return errors === undefined ?
+ { status: "ok", result: result as FormType, errors } :
+ { status: "fail", result, errors }
+ }
}
diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
index 00b6767ac..177bf3c20 100644
--- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
+++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -238,12 +238,69 @@ export function PaytoWireTransferForm({
*/}
<div class="">
<h2 class="text-base font-semibold leading-7 text-gray-900">{title}</h2>
- <div>
- <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <label
+ class={
+ "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
+ (!isRawPayto
+ ? "border-indigo-600 ring-2 ring-indigo-600"
+ : "border-gray-300")
+ }
+ >
+ <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={() => {
+ if (parsed && parsed.isKnown) {
+ switch (parsed.targetType) {
+ case "iban": {
+ setAccount(parsed.iban);
+ break;
+ }
+ case "x-taler-bank": {
+ setAccount(parsed.account);
+ break;
+ }
+ case "bitcoin": {
+ break;
+ }
+ default: {
+ assertUnreachable(parsed)
+ }
+ }
+ const amountStr = parsed.params["amount"] ?? `${config.currency}:0`;
+ if (amountStr) {
+ const amount = Amounts.parse(parsed.params["amount"]);
+ if (amount) {
+ setAmount(Amounts.stringifyValue(amount));
+ }
+ }
+ const subject = parsed.params["message"];
+ if (subject) {
+ setSubject(subject);
+ }
+ }
+ setIsRawPayto(false);
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Using a form</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+
+ {sendingToFixedAccount ? undefined : (
<label
class={
"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
- (!isRawPayto
+ (isRawPayto
? "border-indigo-600 ring-2 ring-indigo-600"
: "border-gray-300")
}
@@ -251,111 +308,52 @@ export function PaytoWireTransferForm({
<input
type="radio"
name="project-type"
- value="Newsletter"
+ value="Existing Customers"
class="sr-only"
- aria-labelledby="project-type-0-label"
- aria-describedby="project-type-0-description-0 project-type-0-description-1"
+ aria-labelledby="project-type-1-label"
+ aria-describedby="project-type-1-description-0 project-type-1-description-1"
onChange={() => {
- if (parsed && parsed.isKnown) {
- switch (parsed.targetType) {
- case "iban": {
- setAccount(parsed.iban);
- break;
- }
+ if (account) {
+ let payto;
+ switch (paytoType) {
case "x-taler-bank": {
- setAccount(parsed.account);
+ payto = buildPayto("x-taler-bank", url.host, account);
+ if (parsedAmount) {
+ payto.params["amount"] =
+ Amounts.stringify(parsedAmount);
+ }
+ if (subject) {
+ payto.params["message"] = subject;
+ }
break;
}
- case "bitcoin": {
+ case "iban": {
+ payto = buildPayto("iban", account, undefined);
+ if (parsedAmount) {
+ payto.params["amount"] =
+ Amounts.stringify(parsedAmount);
+ }
+ if (subject) {
+ payto.params["message"] = subject;
+ }
break;
}
- default: {
- assertUnreachable(parsed)
- }
- }
- const amountStr = parsed.params["amount"] ?? `${config.currency}:0`;
- if (amountStr) {
- const amount = Amounts.parse(parsed.params["amount"]);
- if (amount) {
- setAmount(Amounts.stringifyValue(amount));
- }
- }
- const subject = parsed.params["message"];
- if (subject) {
- setSubject(subject);
+ default: assertUnreachable(paytoType)
}
+ rawPaytoInputSetter(stringifyPaytoUri(payto));
}
- setIsRawPayto(false);
+ setIsRawPayto(true);
}}
/>
<span class="flex flex-1">
<span class="flex flex-col">
- <span class="block text-sm font-medium text-gray-900">
- <i18n.Translate>Using a form</i18n.Translate>
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Import payto:// URI</i18n.Translate>
</span>
</span>
</span>
</label>
-
- {sendingToFixedAccount ? undefined : (
- <label
- class={
- "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
- (isRawPayto
- ? "border-indigo-600 ring-2 ring-indigo-600"
- : "border-gray-300")
- }
- >
- <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={() => {
- if (account) {
- let payto;
- switch (paytoType) {
- case "x-taler-bank": {
- payto = buildPayto("x-taler-bank", url.host, account);
- if (parsedAmount) {
- payto.params["amount"] =
- Amounts.stringify(parsedAmount);
- }
- if (subject) {
- payto.params["message"] = subject;
- }
- break;
- }
- case "iban": {
- payto = buildPayto("iban", account, undefined);
- if (parsedAmount) {
- payto.params["amount"] =
- Amounts.stringify(parsedAmount);
- }
- if (subject) {
- payto.params["message"] = subject;
- }
- break;
- }
- default: assertUnreachable(paytoType)
- }
- rawPaytoInputSetter(stringifyPaytoUri(payto));
- }
- setIsRawPayto(true);
- }}
- />
- <span class="flex flex-1">
- <span class="flex flex-col">
- <span class="block text-sm font-medium text-gray-900">
- <i18n.Translate>Import payto:// URI</i18n.Translate>
- </span>
- </span>
- </span>
- </label>
- )}
- </div>
+ )}
</div>
</div>
diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx
index 1b51e3222..7adacb775 100644
--- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx
+++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx
@@ -329,6 +329,7 @@ export function CreateCashout({
const cashoutAccountName = !cashoutAccount
? undefined
: cashoutAccount.targetPath;
+
return (
<div>
<LocalNotificationBanner notification={notification} />
diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts
index 34693f7d7..75c3fc0fe 100644
--- a/packages/web-util/src/components/utils.ts
+++ b/packages/web-util/src/components/utils.ts
@@ -12,6 +12,7 @@ export function compose<SType extends { status: string }, PType>(
hook: (p: PType) => RecursiveState<SType>,
viewMap: StateViewMap<SType>,
): (p: PType) => VNode {
+
function withHook(stateHook: () => RecursiveState<SType>): () => VNode {
function ComposedComponent(): VNode {
const state = stateHook();
@@ -35,6 +36,33 @@ export function compose<SType extends { status: string }, PType>(
};
}
+export function recursive<PType>(
+ hook: (p: PType) => RecursiveState<VNode>,
+): (p: PType) => VNode {
+
+ function withHook(stateHook: () => RecursiveState<VNode>): () => VNode {
+ function ComposedComponent(): VNode {
+ const state = stateHook();
+
+ if (typeof state === "function") {
+ const subComponent = withHook(state);
+ return createElement(subComponent, {});
+ }
+
+ return state;
+ }
+
+ return ComposedComponent;
+ }
+
+ return (p: PType) => {
+ const h = withHook(() => hook(p));
+ return h();
+ };
+}
+
+
+
/**
*
* @param obj VNode