summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/pages/PaytoWireTransferForm.tsx')
-rw-r--r--packages/bank-ui/src/pages/PaytoWireTransferForm.tsx792
1 files changed, 792 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
new file mode 100644
index 000000000..791a3b440
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -0,0 +1,792 @@
+/*
+ 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 {
+ AbsoluteTime,
+ AmountJson,
+ AmountString,
+ Amounts,
+ CurrencySpecification,
+ FRAC_SEPARATOR,
+ HttpStatusCode,
+ PaytoString,
+ PaytoUri,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ buildPayto,
+ parsePaytoUri,
+ stringifyPaytoUri
+} from "@gnu-taler/taler-util";
+import {
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, Ref, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { mutate } from "swr";
+import { useBankCoreApiContext } from "../context/config.js";
+import { useSessionState } from "../hooks/session.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { EmptyObject, RouteDefinition } from "../route.js";
+import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js";
+
+interface Props {
+ title: TranslatedString;
+ focus?: boolean;
+ withAccount?: string;
+ withSubject?: string;
+ withAmount?: string;
+ onSuccess: () => void;
+ onAuthorizationRequired: () => void;
+ routeCancel?: RouteDefinition;
+ routeCashout?: RouteDefinition;
+ routeHere: RouteDefinition<{
+ account?: string,
+ subject?: string,
+ amount?: string,
+ }>;
+ limit: AmountJson;
+}
+
+export function PaytoWireTransferForm({
+ focus,
+ title,
+ withAccount,
+ withSubject,
+ withAmount,
+ onSuccess,
+ routeCancel,
+ routeCashout,
+ routeHere,
+ onAuthorizationRequired,
+ limit,
+}: Props): VNode {
+ const [isRawPayto, setIsRawPayto] = useState(false);
+ const { state: credentials } = useSessionState();
+ const { bank: api, config, url } = useBankCoreApiContext();
+
+ const sendingToFixedAccount = withAccount !== undefined;
+
+ const [account, setAccount] = useState<string | undefined>(withAccount);
+ const [subject, setSubject] = useState<string | undefined>(withSubject);
+ const [amount, setAmount] = useState<string | undefined>(withAmount);
+ const [, updateBankState] = useBankState();
+
+ const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
+ undefined,
+ );
+ const { i18n } = useTranslationContext();
+
+ const trimmedAmountStr = amount?.trim();
+ const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`);
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const paytoType = config.wire_type === "X_TALER_BANK" ? "x-taler-bank" as const : "iban" as const;
+
+ const errorsWire = undefinedIfEmpty({
+ account: !account
+ ? i18n.str`Required`
+ : paytoType === "iban" ? validateIBAN(account, i18n) :
+ paytoType === "x-taler-bank" ? validateTalerBank(account, i18n) :
+ undefined,
+ subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n),
+ amount: !trimmedAmountStr
+ ? i18n.str`Required`
+ : !parsedAmount
+ ? i18n.str`Not valid`
+ : validateAmount(parsedAmount, limit, i18n),
+ });
+
+ const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
+
+
+ const errorsPayto = undefinedIfEmpty({
+ rawPaytoInput: !rawPaytoInput
+ ? i18n.str`Required`
+ : !parsed ? i18n.str`Does not follow the pattern`
+ : validateRawPayto(parsed, limit, url.host, i18n, paytoType),
+ });
+
+ async function doSend() {
+ let payto_uri: PaytoString | undefined;
+ let sendingAmount: AmountString | undefined;
+
+ if (credentials.status !== "loggedIn") return;
+ if (isRawPayto) {
+ const p = parsePaytoUri(rawPaytoInput!);
+ if (!p) return;
+ sendingAmount = p.params.amount as AmountString;
+ delete p.params.amount;
+ // if this payto is valid then it already have message
+ payto_uri = stringifyPaytoUri(p);
+ } else {
+ if (!account || !subject) return;
+ let payto;
+ switch (paytoType) {
+ case "x-taler-bank": {
+ payto = buildPayto("x-taler-bank", url.host, account);
+ break;
+ }
+ case "iban": {
+ payto = buildPayto("iban", account, undefined);
+ break;
+ }
+ default: assertUnreachable(paytoType)
+ }
+
+ payto.params.message = encodeURIComponent(subject);
+ payto_uri = stringifyPaytoUri(payto);
+ sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString;
+ }
+ const puri = payto_uri;
+ const sAmount = sendingAmount;
+
+ await handleError(async () => {
+ const request = {
+ payto_uri: puri,
+ amount: sAmount,
+ };
+ const resp = await api.createTransaction(credentials, request);
+ mutate(() => true);
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Not enough permission to complete the operation.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case TalerErrorCode.BANK_UNKNOWN_CREDITOR:
+ return notify({
+ type: "error",
+ title: i18n.str`The destination account "${puri}" was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case TalerErrorCode.BANK_SAME_ACCOUNT:
+ return notify({
+ type: "error",
+ title: i18n.str`The origin and the destination of the transfer can't be the same.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The origin account "${puri}" was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "create-transaction",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({ account: account ?? "", amount, subject }),
+ sent: AbsoluteTime.never(),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ notifyInfo(i18n.str`Wire transfer created!`);
+ onSuccess();
+ setAmount(undefined);
+ setAccount(undefined);
+ setSubject(undefined);
+ rawPaytoInputSetter(undefined);
+ });
+ }
+
+ return (
+ <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">
+ {/**
+ * FIXME: Scan a qr code
+ */}
+ <div class="">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">{title}</h2>
+ <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 ? undefined : parsed.params["amount"];
+ if (amountStr) {
+ const amount = Amounts.parse(amountStr);
+ 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
+ ? "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>
+ )}
+ {routeCashout ? (
+ <a
+ name="do cashout"
+ href={routeCashout.url({})}
+ class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </a>
+ ) : (
+ undefined
+ )}
+ </div>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md sm:rounded-xl md:col-span-2 w-fit mx-auto"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="p-4 sm:p-8">
+ {!isRawPayto ? (
+ <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ {(() => {
+ switch (paytoType) {
+ case "x-taler-bank": {
+ return <TextField
+ id="x-taler-bank"
+ label={i18n.str`Recipient`}
+ help={i18n.str`Id of the recipient's account`}
+ error={errorsWire?.account}
+ onChange={setAccount}
+ value={account}
+ placeholder={i18n.str`username`}
+ focus={focus}
+ disabled={sendingToFixedAccount}
+ />
+ }
+ case "iban": {
+ return <TextField
+ id="iban"
+ label={i18n.str`Recipient`}
+ help={i18n.str`IBAN of the recipient's account`}
+ placeholder={"CC0123456789" as TranslatedString}
+ error={errorsWire?.account}
+ onChange={(v) => setAccount(v.toUpperCase())}
+ value={account}
+ focus={focus}
+ disabled={sendingToFixedAccount}
+ />
+ }
+ default: assertUnreachable(paytoType)
+ }
+ })()}
+
+ <div class="sm:col-span-5">
+ <label
+ for="subject"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Transfer subject`}</label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="subject"
+ id="subject"
+ autocomplete="off"
+ placeholder={i18n.str`Subject`}
+ value={subject ?? ""}
+ required
+ onInput={(e): void => {
+ setSubject(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.subject}
+ isDirty={subject !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Some text to identify the transfer
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ for="amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Amount`}</label>
+ <InputAmount
+ name="amount"
+ left
+ currency={limit.currency}
+ value={trimmedAmountStr}
+ onChange={(d) => {
+ setAmount(d);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.amount}
+ isDirty={trimmedAmountStr !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Amount to transfer</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full">
+ <div class="sm:col-span-6">
+ <label
+ for="address"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Payto URI:`}</label>
+ <div class="mt-2">
+ <textarea
+ ref={focus ? doAutoFocus : undefined}
+ name="address"
+ id="address"
+ type="textarea"
+ rows={5}
+ class="block overflow-hidden w-44 sm:w-96 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={rawPaytoInput ?? ""}
+ required
+ title={i18n.str`Uniform resource identifier of the target account`}
+
+ placeholder={((): TranslatedString => {
+ switch (paytoType) {
+ case "x-taler-bank": return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]`
+ case "iban": return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`
+ }
+ })()}
+ onInput={(e): void => {
+ rawPaytoInputSetter(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsPayto?.rawPaytoInput}
+ isDirty={rawPaytoInput !== undefined}
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ {routeCancel ? (
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ ) : (
+ <div />
+ )}
+ <button
+ type="submit"
+ name="send"
+ 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={isRawPayto ? !!errorsPayto : !!errorsWire}
+ onClick={(e) => {
+ e.preventDefault();
+ doSend();
+ }}
+ >
+ <i18n.Translate>Send</i18n.Translate>
+ </button>
+ </div>
+ <LocalNotificationBanner notification={notification} />
+ </form>
+ </div>
+ );
+}
+
+/**
+ * Show the element when the load ended
+ * @param element
+ */
+export function doAutoFocus(element: HTMLElement | null) {
+ if (element) {
+ setTimeout(() => {
+ element.focus({ preventScroll: true });
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ }, 100);
+ }
+}
+
+export function InputAmount(
+ {
+ currency,
+ name,
+ value,
+ error,
+ left,
+ onChange,
+ }: {
+ error?: string;
+ currency: string;
+ name: string;
+ left?: boolean | undefined;
+ value: string | undefined;
+ onChange?: (s: string) => void;
+ },
+ ref: Ref<HTMLInputElement>,
+): VNode {
+ const { config } = useBankCoreApiContext();
+ return (
+ <div class="mt-2">
+ <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600">
+ <div class="pointer-events-none inset-y-0 flex items-center px-3">
+ <span class="text-gray-500 sm:text-sm">{currency}</span>
+ </div>
+ <input
+ type="number"
+ data-left={left}
+ class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"
+ placeholder="0.00"
+ aria-describedby="price-currency"
+ ref={ref}
+ name={name}
+ id={name}
+ autocomplete="off"
+ value={value ?? ""}
+ disabled={!onChange}
+ onInput={(e) => {
+ if (!onChange) return;
+ const l = e.currentTarget.value.length;
+ const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR);
+ if (
+ sep_pos !== -1 &&
+ l - sep_pos - 1 >
+ config.currency_specification.num_fractional_input_digits
+ ) {
+ e.currentTarget.value = e.currentTarget.value.substring(
+ 0,
+ sep_pos +
+ config.currency_specification.num_fractional_input_digits +
+ 1,
+ );
+ }
+ onChange(e.currentTarget.value);
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+ </div>
+ );
+}
+
+export function RenderAmount({
+ value,
+ spec,
+ negative,
+ withColor,
+ hideSmall,
+}: {
+ spec: CurrencySpecification;
+ value: AmountJson;
+ hideSmall?: boolean;
+ negative?: boolean;
+ withColor?: boolean;
+}): VNode {
+ const neg = !!negative; // convert to true or false
+
+ const { currency, normal, small } = Amounts.stringifyValueWithSpec(
+ value,
+ spec,
+ );
+
+ return (
+ <span
+ data-negative={withColor ? neg : undefined}
+ class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600"
+ >
+ {negative ? "- " : undefined}
+ {currency} {normal}{" "}
+ {!hideSmall && small && <sup class="-ml-1">{small}</sup>}
+ </span>
+ );
+}
+
+
+function validateRawPayto(parsed: PaytoUri, limit: AmountJson, host: string, i18n: InternationalizationAPI, type: "iban" | "x-taler-bank"): TranslatedString | undefined {
+ if (!parsed.isKnown) {
+ return i18n.str`The target type is unknown, use "${type}"`
+ }
+ let result: TranslatedString | undefined;
+ switch (type) {
+ case "x-taler-bank": {
+ if (parsed.targetType !== "x-taler-bank") {
+ return i18n.str`Only "x-taler-bank" target are supported`
+ }
+
+ if (parsed.host !== host) {
+ return i18n.str`Only this host is allowed. Use "${host}"`
+ }
+
+ if (!parsed.account) {
+ return i18n.str`Missing account name`
+ }
+ const result = validateTalerBank(parsed.account, i18n)
+ if (result) return result
+ break;
+ }
+ case "iban": {
+ if (parsed.targetType !== "iban") {
+ return i18n.str`Only "IBAN" target are supported`
+ }
+ const result = validateIBAN(parsed.iban, i18n)
+ if (result) return result
+ break;
+ }
+ default: assertUnreachable(type)
+ }
+ if (!parsed.params.amount) {
+ return i18n.str`Missing "amount" parameter to specify the amount to be transferred`
+ }
+ const amount = Amounts.parse(parsed.params.amount)
+ if (!amount) {
+ return i18n.str`The "amount" parameter is not valid`
+ }
+ result = validateAmount(amount, limit, i18n)
+ if (result) return result;
+
+ if (!parsed.params.message) {
+ return i18n.str`Missing the "message" parameter to specify a reference text for the transfer`
+ }
+ const subject = parsed.params.message
+ result = validateSubject(subject, i18n)
+ if (result) return result;
+
+ return undefined
+}
+
+function validateAmount(amount: AmountJson, limit: AmountJson, i18n: InternationalizationAPI): TranslatedString | undefined {
+ if (amount.currency !== limit.currency) {
+ return i18n.str`The only currency allowed is "${limit.currency}"`
+ }
+ if (Amounts.isZero(amount)) {
+ return i18n.str`Can't transfer zero amount`
+ }
+ if (Amounts.cmp(limit, amount) === -1) {
+ return i18n.str`Balance is not enough`
+ }
+ return undefined
+}
+
+function validateSubject(text: string, i18n: InternationalizationAPI): TranslatedString | undefined {
+ if (text.length < 2) {
+ return i18n.str`Use a longer subject`
+ }
+ return undefined
+}
+
+interface PaytoFieldProps {
+ id: string,
+ label: TranslatedString;
+ help?: TranslatedString;
+ placeholder?: TranslatedString;
+ error: string | undefined;
+ value: string | undefined;
+ rightIcons?: VNode;
+ onChange: (p: string) => void;
+ focus?: boolean;
+ disabled?: boolean;
+}
+
+function Wrapper({ withIcon, children }: { withIcon: boolean, children: ComponentChildren }): VNode {
+ if (withIcon) {
+ return <div class="flex justify-between">
+ {children}
+ </div>
+ }
+ return <Fragment>{children}</Fragment>
+}
+
+export function TextField({
+ id,
+ label,
+ help,
+ focus,
+ disabled,
+ onChange,
+ placeholder,
+ rightIcons,
+ value,
+ error,
+}: PaytoFieldProps): VNode {
+ return <div class="sm:col-span-5">
+ <label
+ for={id}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{label}</label>
+ <div class="mt-2">
+ <Wrapper withIcon={rightIcons !== undefined}>
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name={id}
+ id={id}
+ disabled={disabled}
+ value={value ?? ""}
+ placeholder={placeholder}
+ autocomplete="off"
+ required
+ onInput={(e): void => {
+ onChange(e.currentTarget.value);
+ }}
+ />
+ {rightIcons}
+ </Wrapper>
+ <ShowInputErrorLabel
+ message={error}
+ isDirty={value !== undefined}
+ />
+ </div>
+ {help &&
+ <p class="mt-2 text-sm text-gray-500">
+ {help}
+ </p>
+ }
+ </div>
+}