diff options
Diffstat (limited to 'packages')
-rw-r--r-- | packages/demobank-ui/src/context/config.ts | 28 | ||||
-rw-r--r-- | packages/demobank-ui/src/hooks/circuit.ts | 61 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/ConversionConfig.tsx | 982 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx | 180 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/business/CreateCashout.tsx | 1 | ||||
-rw-r--r-- | packages/web-util/src/components/utils.ts | 28 |
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 |