summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/pages/regional/CreateCashout.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/pages/regional/CreateCashout.tsx')
-rw-r--r--packages/bank-ui/src/pages/regional/CreateCashout.tsx717
1 files changed, 717 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
new file mode 100644
index 000000000..8e54bbd4e
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
@@ -0,0 +1,717 @@
+/*
+ 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,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ encodeCrock,
+ getRandomBytes,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import {
+ TransferCalculation,
+ useCashoutEstimator,
+ useConversionInfo,
+} from "../../hooks/regional.js";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { TanChannel, undefinedIfEmpty } from "../../utils.js";
+import { LoginForm } from "../LoginForm.js";
+import {
+ InputAmount,
+ RenderAmount,
+ doAutoFocus,
+} from "../PaytoWireTransferForm.js";
+
+interface Props {
+ account: string;
+ focus?: boolean;
+ onAuthorizationRequired: () => void;
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition;
+}
+
+type FormType = {
+ isDebit: boolean;
+ amount: string;
+ subject: string;
+ channel: TanChannel;
+};
+type ErrorFrom<T> = {
+ [P in keyof T]+?: string;
+};
+
+export function CreateCashout({
+ account: accountName,
+ onAuthorizationRequired,
+ focus,
+ routeHere,
+ routeClose,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const resultAccount = useAccountDetails(accountName);
+ const {
+ estimateByCredit: calculateFromCredit,
+ estimateByDebit: calculateFromDebit,
+ } = useCashoutEstimator();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const [, updateBankState] = useBankState();
+
+ const {
+ lib: { bank: api },
+ config,
+ hints,
+ } = useBankCoreApiContext();
+ const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
+ const [notification, notify, handleError] = useLocalNotification();
+ const info = useConversionInfo();
+
+ if (!config.allow_conversion) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Unable to create a cashout`}>
+ <i18n.Translate>
+ The bank configuration does not support cashout operations.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="close"
+ class="inline-flex w-full justify-center 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"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ if (!resultAccount) {
+ return <Loading />;
+ }
+ if (resultAccount instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resultAccount} />;
+ }
+ if (resultAccount.type === "fail") {
+ switch (resultAccount.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={accountName} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={accountName} />;
+ default:
+ assertUnreachable(resultAccount);
+ }
+ }
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ const conversionInfo = info.body.conversion_rate;
+ if (!conversionInfo) {
+ return (
+ <div>conversion enabled but server replied without conversion_rate</div>
+ );
+ }
+
+ const account = {
+ balance: Amounts.parseOrThrow(resultAccount.body.balance.amount),
+ balanceIsDebit:
+ resultAccount.body.balance.credit_debit_indicator == "debit",
+ debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold),
+ };
+
+ const {
+ fiat_currency,
+ regional_currency,
+ fiat_currency_specification,
+ regional_currency_specification,
+ } = info.body;
+ const regionalZero = Amounts.zeroOfCurrency(regional_currency);
+ const fiatZero = Amounts.zeroOfCurrency(fiat_currency);
+ const limit = account.balanceIsDebit
+ ? Amounts.sub(account.debitThreshold, account.balance).amount
+ : Amounts.add(account.balance, account.debitThreshold).amount;
+
+ const zeroCalc = {
+ debit: regionalZero,
+ credit: fiatZero,
+ beforeFee: fiatZero,
+ };
+ const [calculationResult, setCalculation] =
+ useState<TransferCalculation>(zeroCalc);
+ const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee);
+ const sellRate = conversionInfo.cashout_ratio;
+ /**
+ * can be in regional currency or fiat currency
+ * depending on the isDebit flag
+ */
+ const inputAmount = Amounts.parseOrThrow(
+ `${form.isDebit ? regional_currency : fiat_currency}:${
+ !form.amount ? "0" : form.amount
+ }`,
+ );
+
+ useEffect(() => {
+ async function doAsync() {
+ await handleError(async () => {
+ const higerThanMin = form.isDebit
+ ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1
+ : true;
+ const notZero = Amounts.isNonZero(inputAmount);
+ if (notZero && higerThanMin) {
+ const resp = await (form.isDebit
+ ? calculateFromDebit(inputAmount, sellFee)
+ : calculateFromCredit(inputAmount, sellFee));
+ setCalculation(resp);
+ } else {
+ setCalculation(zeroCalc);
+ }
+ });
+ }
+ doAsync();
+ }, [form.amount, form.isDebit]);
+
+ const calc =
+ calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult;
+
+ const balanceAfter = Amounts.sub(account.balance, calc.debit).amount;
+
+ function updateForm(newForm: typeof form): void {
+ setForm(newForm);
+ }
+ const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({
+ subject: !form.subject ? i18n.str`Required` : undefined,
+ amount: !form.amount
+ ? i18n.str`Required`
+ : !inputAmount
+ ? i18n.str`Invalid`
+ : Amounts.cmp(limit, calc.debit) === -1
+ ? i18n.str`Balance is not enough`
+ : form.isDebit &&
+ Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1
+ ? i18n.str`Needs to be higher than ${
+ Amounts.stringifyValueWithSpec(
+ Amounts.parseOrThrow(conversionInfo.cashout_min_amount),
+ regional_currency_specification,
+ ).normal
+ }`
+ : calculationResult === "amount-is-too-small"
+ ? i18n.str`Amount needs to be higher`
+ : Amounts.isZero(calc.credit)
+ ? i18n.str`The total transfer at destination will be zero`
+ : undefined,
+ });
+ const trimmedAmountStr = form.amount?.trim();
+
+ async function createCashout() {
+ const request_uid = encodeCrock(getRandomBytes(32));
+ await handleError(async () => {
+ // new cashout api doesn't require channel
+ const validChannel =
+ config.supported_tan_channels.length === 0 || form.channel;
+
+ if (!creds || !form.subject || !validChannel) return;
+ const request = {
+ request_uid,
+ amount_credit: Amounts.stringify(calc.credit),
+ amount_debit: Amounts.stringify(calc.debit),
+ subject: form.subject,
+ tan_channel: form.channel,
+ };
+ const resp = await api.createCashout(creds, request);
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Cashout created`);
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "create-cashout",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({}),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ return notify({
+ type: "error",
+ title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_BAD_CONVERSION:
+ return notify({
+ type: "error",
+ title: i18n.str`The conversion rate was incorrectly applied`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`The account does not have sufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotImplemented:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout are disabled`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`Missing cashout URI in the profile`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ assertUnreachable(resp);
+ }
+ });
+ }
+ const cashoutDisabled =
+ config.supported_tan_channels.length < 1 ||
+ !resultAccount.body.cashout_payto_uri;
+
+ const cashoutAccount = !resultAccount.body.cashout_payto_uri
+ ? undefined
+ : parsePaytoUri(resultAccount.body.cashout_payto_uri);
+ const cashoutAccountName = !cashoutAccount
+ ? undefined
+ : cashoutAccount.targetPath;
+
+ const cashoutLegalName = !cashoutAccount
+ ? undefined
+ : cashoutAccount.params["receiver-name"];
+
+ return (
+ <div>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <section class="mt-4 rounded-sm px-4 py-6 p-8 ">
+ <h2 id="summary-heading" class="font-medium text-lg">
+ <i18n.Translate>Cashout</i18n.Translate>
+ </h2>
+
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Conversion rate</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">{sellRate}</dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Balance</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={account.balance}
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Fee</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={sellFee}
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ {cashoutAccountName && cashoutLegalName ? (
+ <Fragment>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>To account</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">{cashoutAccountName}</dd>
+ </div>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Legal name</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">{cashoutLegalName}</dd>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ If this name doesn't match the account holder's name your
+ transaction may fail.
+ </i18n.Translate>
+ </p>
+ </Fragment>
+ ) : (
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <Attention type="warning" title={i18n.str`No cashout account`}>
+ <i18n.Translate>
+ Before doing a cashout you need to complete your profile
+ </i18n.Translate>
+ </Attention>
+ </div>
+ )}
+ </dl>
+ </section>
+ <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-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ {/* subject */}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="subject"
+ >
+ {i18n.str`Transfer subject`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full rounded-md disabled:bg-gray-200 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="subject"
+ id="subject"
+ disabled={cashoutDisabled}
+ data-error={!!errors?.subject && form.subject !== undefined}
+ value={form.subject ?? ""}
+ onChange={(e) => {
+ form.subject = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.subject}
+ isDirty={form.subject !== undefined}
+ />
+ </div>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="subject"
+ >
+ {i18n.str`Currency`}
+ </label>
+
+ <div class="mt-2">
+ <button
+ type="button"
+ name="set 50"
+ class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ form.isDebit = true;
+ updateForm(structuredClone(form));
+ }}
+ >
+ {form.isDebit ? (
+ <svg
+ class="self-center flex-none h-5 w-5 text-indigo-600"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ ) : (
+ <svg
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5"
+ >
+ <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ </svg>
+ )}
+
+ <i18n.Translate>Send {regional_currency}</i18n.Translate>
+ </button>
+ <button
+ type="button"
+ name="set 25"
+ class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ form.isDebit = false;
+ updateForm(structuredClone(form));
+ }}
+ >
+ {!form.isDebit ? (
+ <svg
+ class="self-center flex-none h-5 w-5 text-indigo-600"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ ) : (
+ <svg
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5"
+ >
+ <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ </svg>
+ )}
+
+ <i18n.Translate>Receive {fiat_currency}</i18n.Translate>
+ </button>
+ </div>
+ </div>
+
+ {/* amount */}
+ <div class="sm:col-span-5">
+ <div class="flex justify-between">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="amount"
+ >
+ {i18n.str`Amount`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ {/* <button
+ type="button"
+ data-enabled={form.isDebit}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isDebit = !form.isDebit;
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={form.isDebit}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button> */}
+ </div>
+ <div class="mt-2">
+ <InputAmount
+ name="amount"
+ left
+ currency={form.isDebit ? regional_currency : fiat_currency}
+ value={trimmedAmountStr}
+ onChange={
+ cashoutDisabled
+ ? undefined
+ : (value) => {
+ form.amount = value;
+ updateForm(structuredClone(form));
+ }
+ }
+ />
+ <ShowInputErrorLabel
+ message={errors?.amount}
+ isDirty={form.amount !== undefined}
+ />
+ </div>
+ </div>
+
+ {Amounts.isZero(calc.credit) ? undefined : (
+ <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>Total cost</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.debit}
+ negative
+ withColor
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Balance left</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={balanceAfter}
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+ {Amounts.isZero(sellFee) ||
+ Amounts.isZero(calc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Before fee</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.beforeFee}
+ spec={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>Total cashout transfer</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={calc.credit}
+ withColor
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </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">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ type="button"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="cashout"
+ 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={!!errors}
+ onClick={(e) => {
+ e.preventDefault();
+ createCashout();
+ }}
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}