diff options
Diffstat (limited to 'packages')
13 files changed, 422 insertions, 285 deletions
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index 733d55a0f..8ed66d4cf 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -258,7 +258,7 @@ export function Routing(): VNode { <ShowCashoutDetails id={cid} onCancel={() => { - route("/account"); + route("/my-cashouts"); }} /> )} diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index 651a7a034..59bb4a16b 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -93,7 +93,7 @@ export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { {Object.entries(txByDate).map(([date, txs], idx) => { return <Fragment key={idx}> <tr class="border-t border-gray-200"> - <th colSpan={4} scope="colgroup" class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3"> + <th colSpan={6} scope="colgroup" class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3"> {date} </th> </tr> @@ -102,9 +102,12 @@ export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { const confirmationTime = item.confirmation_time ? item.confirmation_time.t_s === "never" ? i18n.str`never` : format(item.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss") : "-" - return (<tr key={idx} class="border-b border-gray-200 last:border-none"> + return (<tr key={idx} class="border-b border-gray-200 hover:bg-gray-200 last:border-none"> - <td class="relative py-2 pl-2 pr-2 text-sm "> + <td onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }} class="relative py-2 pl-2 pr-2 text-sm "> <div class="font-medium text-gray-900">{creationTime}</div> {/* <dl class="font-normal sm:hidden"> <dt class="sr-only sm:hidden"><i18n.Translate>Amount</i18n.Translate></dt> @@ -128,18 +131,28 @@ export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { </dd> </dl> */} </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{confirmationTime}</td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600"><RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} spec={resp.body.regional_currency_specification} /></td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600"><RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} spec={resp.body.fiat_currency_specification} /></td> + <td onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }}class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 cursor-pointer">{confirmationTime}</td> + <td onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }}class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer"><RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} spec={resp.body.regional_currency_specification} /></td> + <td onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }}class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600 cursor-pointer"><RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} spec={resp.body.fiat_currency_specification} /></td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{item.status}</td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> - <a href="#" onClick={(e) => { + <td onClick={(e) => { e.preventDefault(); onSelected(item.id); - }}> - {item.subject} - </a> + }}class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 cursor-pointer">{item.status}</td> + <td onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }} class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> + {item.subject} </td> </tr>) })} @@ -150,27 +163,9 @@ export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { </table> - {/* <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" aria-label="Pagination"> - <div class="flex flex-1 justify-between sm:justify-end"> - <button - class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onPrev} - onClick={onPrev} - > - <i18n.Translate>First page</i18n.Translate> - </button> - <button - class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onNext} - onClick={onNext} - > - <i18n.Translate>Next</i18n.Translate> - </button> - </div> - </nav> */} + </div> </div> ); - // } } diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index b483a5420..bb9f5801e 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -18,7 +18,7 @@ import { useState } from "preact/hooks"; import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { useBackendState } from "./backend.js"; -import { AccessToken, AmountJson, AmountString, Amounts, OperationOk, TalerBankConversionResultByMethod, TalerCoreBankErrorsByMethod, TalerCoreBankResultByMethod, TalerCorebankApi, TalerError, TalerHttpError } from "@gnu-taler/taler-util"; +import { AccessToken, AmountJson, AmountString, Amounts, OperationOk, TalerBankConversionResultByMethod, TalerCoreBankErrorsByMethod, TalerCoreBankResultByMethod, TalerCorebankApi, TalerError, TalerHttpError, opFixedSuccess } from "@gnu-taler/taler-util"; import _useSWR, { SWRHook } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; import { assertUnreachable } from "../pages/WithdrawalOperationPage.js"; @@ -174,6 +174,43 @@ type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number } function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { return c !== undefined } +export function useOnePendingCashouts(account: string) { + const { state: credentials } = useBackendState(); + const { api, config } = useBankCoreApiContext(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + + async function fetcher([username, token]: [string, AccessToken]) { + const list = await api.getAccountCashouts({ username, token }) + if (list.type !== "ok") { + return list; + } + const pendingCashout = list.body.cashouts.find(c => c.status === "pending") + if (!pendingCashout) return opFixedSuccess(undefined) + const cashoutInfo = await api.getCashoutById({ username, token }, pendingCashout?.cashout_id) + if (cashoutInfo.type !== "ok") { + return cashoutInfo; + } + return opFixedSuccess({ ...cashoutInfo.body, id: pendingCashout.cashout_id }) + } + + const { data, error } = useSWR<OperationOk<CashoutWithId | undefined> | TalerCoreBankErrorsByMethod<"getAccountCashouts"> | TalerCoreBankErrorsByMethod<"getCashoutById">, TalerHttpError>( + !config.allow_conversion ? undefined : [account, token, "getAccountCashouts"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} + export function useCashouts(account: string) { const { state: credentials } = useBackendState(); const { api, config } = useBankCoreApiContext(); @@ -182,13 +219,11 @@ export function useCashouts(account: string) { async function fetcher([username, token]: [string, AccessToken]) { const list = await api.getAccountCashouts({ username, token }) if (list.type !== "ok") { - console.error(list) return list; } const all: Array<CashoutWithId | undefined> = await Promise.all(list.body.cashouts.map(c => { return api.getCashoutById({ username, token }, c.cashout_id).then(r => { if (r.type === "fail") { - console.error("failed", r) return undefined } return { ...r.body, id: c.cashout_id } diff --git a/packages/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx index cfee684fa..d760543c6 100644 --- a/packages/demobank-ui/src/pages/AccountPage/views.tsx +++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx @@ -21,6 +21,8 @@ import { Transactions } from "../../components/Transactions/index.js"; import { usePreferences } from "../../hooks/preferences.js"; import { PaymentOptions } from "../PaymentOptions.js"; import { State } from "./index.js"; +import { useCashouts, useOnePendingCashouts } from "../../hooks/circuit.js"; +import { TalerError } from "@gnu-taler/taler-util"; export function InvalidIbanView({ error }: State.InvalidIban) { return ( @@ -57,8 +59,23 @@ export function ReadyView({ account, limit, goToConfirmOperation }: State.Ready) return <Fragment> <ShowDemoInfo /> + <PendingCashouts account={account}/> <PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} /> <Transactions account={account} /> </Fragment>; } + +function PendingCashouts({account}: {account: string}):VNode { + const { i18n } = useTranslationContext(); + const result = useOnePendingCashouts(account) + if (!result || result instanceof TalerError || result.type !== "ok" || !result.body) { + return <Fragment /> + } + + return <Attention title={i18n.str`You have pending cashout operation to complete`} > + <i18n.Translate> + Cashout with subject "{result.body.subject}", look for the code and complete the operation <a target="_blank" rel="noreferrer noopener" class="font-semibold text-blue-700 hover:text-blue-600" href={`#/cashout/${result.body.id}`}>here</a>. + </i18n.Translate> + </Attention> +}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index 34c39e9d3..24012cd2b 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -46,7 +46,6 @@ export function BankFrame({ useEffect(() => { if (error) { const desc = (error instanceof Error ? error.stack : String(error)) as TranslatedString - console.log(error) if (error instanceof Error) { notifyException(i18n.str`Internal error, please report.`, error) } else { diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 76d20867e..bbe33eb57 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -33,7 +33,7 @@ export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJ const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>(); return ( - <div class="mt-2"> + <div class="mt-4"> <fieldset> <legend class="px-4 text-base font-semibold leading-6 text-gray-900"> diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index e035c7fed..33bf18abc 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -447,7 +447,7 @@ export function InputAmount( <input type="number" data-left={left} - class="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" + 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} diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx index 1a4b4b865..61a55fe16 100644 --- a/packages/demobank-ui/src/pages/ProfileNavigation.tsx +++ b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -3,7 +3,7 @@ import { Fragment, VNode, h } from "preact"; import { useBankCoreApiContext } from "../context/config.js"; import { assertUnreachable } from "./WithdrawalOperationPage.js"; -export function ProfileNavigation({ current }: { current: "details" | "credentials" | "cashouts" }): VNode { +export function ProfileNavigation({ current, noCashout }: { noCashout?: boolean, current: "details" | "credentials" | "cashouts" }): VNode { const { i18n } = useTranslationContext() const { config } = useBankCoreApiContext() return <div> @@ -44,7 +44,7 @@ export function ProfileNavigation({ current }: { current: "details" | "credentia <span><i18n.Translate>Credentials</i18n.Translate></span> <span aria-hidden="true" data-selected={current == "credentials"} class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"></span> </a> - {config.allow_conversion ? + {config.allow_conversion && !noCashout ? <a href="#/my-cashouts" data-selected={current == "cashouts"} class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"> <span>Cashouts</span> <span aria-hidden="true" data-selected={current == "cashouts"} class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"></span> diff --git a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx index 3ef3f568c..4332284e8 100644 --- a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx @@ -99,7 +99,7 @@ export function ShowAccountDetails({ <Fragment> <LocalNotificationBanner notification={notification} /> {accountIsTheCurrentUser ? - <ProfileNavigation current="details" /> + <ProfileNavigation current="details" noCashout={credentials.status === "loggedIn" ? credentials.isUserAdministrator : undefined} /> : <h1 class="text-base font-semibold leading-6 text-gray-900"> <i18n.Translate>Account "{account}"</i18n.Translate> @@ -128,6 +128,7 @@ export function ShowAccountDetails({ <AccountForm focus={update} + noCashout={credentials.status === "loggedIn" ? credentials.isUserAdministrator : undefined} username={account} template={result.body} purpose={update ? "update" : "show"} diff --git a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx index d7f5155c9..3c00ad1b8 100644 --- a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx +++ b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -81,7 +81,7 @@ export function UpdateAccountPassword({ <Fragment> <LocalNotificationBanner notification={notification} /> {accountIsTheCurrentUser ? - <ProfileNavigation current="credentials" /> : + <ProfileNavigation current="credentials" noCashout={credentials.status === "loggedIn" ? credentials.isUserAdministrator : undefined} /> : <h1 class="text-base font-semibold leading-6 text-gray-900"> <i18n.Translate>Account "{accountName}"</i18n.Translate> </h1> diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx index b38d40012..526deeeab 100644 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -27,11 +27,13 @@ export function AccountForm({ purpose, onChange, focus, + noCashout, children, }: { focus?: boolean, children: ComponentChildren, username?: string, + noCashout?: boolean, template: TalerCorebankApi.AccountData | undefined; onChange: (a: AccountFormData | undefined) => void; purpose: "create" | "update" | "show"; @@ -44,14 +46,14 @@ export function AccountForm({ const { i18n } = useTranslationContext(); function updateForm(newForm: typeof initial): void { - + console.log(newForm) const parsed = !newForm.cashout_payto_uri ? undefined : buildPayto("iban", newForm.cashout_payto_uri, undefined);; const errors = undefinedIfEmpty<RecursivePartial<typeof initial>>({ cashout_payto_uri: (!newForm.cashout_payto_uri - ? i18n.str`required` + ? undefined : !parsed ? i18n.str`does not follow the pattern` : !parsed.isKnown || parsed.targetType !== "iban" @@ -81,10 +83,10 @@ export function AccountForm({ if (errors) { onChange(undefined) } else { - const cashout = buildPayto("iban", newForm.cashout_payto_uri!, undefined) + const cashout = !newForm.cashout_payto_uri? undefined :buildPayto("iban", newForm.cashout_payto_uri, undefined) const account: AccountFormData = { ...newForm as any, - cashout_payto_uri: stringifyPaytoUri(cashout) + cashout_payto_uri: !cashout ? undefined : stringifyPaytoUri(cashout) } onChange(account); } @@ -194,6 +196,9 @@ export function AccountForm({ onChange={(e) => { if (form.contact_data) { form.contact_data.email = e.currentTarget.value; + if (!form.contact_data.email) { + form.contact_data.email = undefined + } updateForm(structuredClone(form)); } }} @@ -220,12 +225,15 @@ export function AccountForm({ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" name="phone" id="phone" - disabled={purpose !== "create"} + disabled={purpose === "show"} value={form.contact_data?.phone ?? ""} data-error={!!errors?.contact_data?.phone && form.contact_data?.phone !== undefined} onChange={(e) => { if (form.contact_data) { form.contact_data.phone = e.currentTarget.value; + if (!form.contact_data.email) { + form.contact_data.email = undefined + } updateForm(structuredClone(form)); } }} @@ -240,44 +248,45 @@ export function AccountForm({ </div> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="cashout" - > - {i18n.str`Cashout IBAN`} - {purpose !== "show" && <b style={{ color: "red" }}> *</b>} - </label> - <div class="mt-2"> - <input - type="text" - ref={focus && purpose === "update" ? doAutoFocus : undefined} - data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="cashout" - id="cashout" - disabled={purpose === "show"} - value={form.cashout_payto_uri ?? ""} - onChange={(e) => { - form.cashout_payto_uri = e.currentTarget.value as PaytoString; - updateForm(structuredClone(form)); - }} - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.cashout_payto_uri} - isDirty={form.cashout_payto_uri !== undefined} - /> + {!noCashout && + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="cashout" + > + {i18n.str`Cashout IBAN`} + {purpose !== "show" && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + type="text" + ref={focus && purpose === "update" ? doAutoFocus : undefined} + data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="cashout" + id="cashout" + disabled={purpose === "show"} + value={form.cashout_payto_uri ?? ""} + onChange={(e) => { + form.cashout_payto_uri = e.currentTarget.value as PaytoString; + if (!form.cashout_payto_uri) { + form.cashout_payto_uri= undefined + } + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.cashout_payto_uri} + isDirty={form.cashout_payto_uri !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>account number where the money is going to be sent when doing cashouts</i18n.Translate> + </p> </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>account number where the money is going to be sent when doing cashouts</i18n.Translate> - </p> - </div> - <div class="sm:col-span-5"> - <pre> - {JSON.stringify(errors, undefined, 2)} - </pre> - </div> + } + </div> </div> {children} diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index 2f77f3960..3d3f30250 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -18,7 +18,8 @@ import { TalerError, TranslatedString, encodeCrock, - getRandomBytes + getRandomBytes, + parsePaytoUri } from "@gnu-taler/taler-util"; import { Attention, @@ -83,7 +84,7 @@ export function CreateCashout({ const creds = credentials.status !== "loggedIn" ? undefined : credentials const { api, config } = useBankCoreApiContext() - const [form, setForm] = useState<Partial<FormType>>({ isDebit: true, amount: "2" }); + const [form, setForm] = useState<Partial<FormType>>({ isDebit: true, }); const [notification, notify, handleError] = useLocalNotification() const info = useConversionInfo(); @@ -171,7 +172,7 @@ export function CreateCashout({ : Amounts.cmp(limit, calc.debit) === -1 ? i18n.str`balance is not enough` : Amounts.cmp(calc.credit, sellFee) === -1 - ? i18n.str`the total amount to transfer does not cover the fees` + ? i18n.str`need to be higher due to fees` : Amounts.isZero(calc.credit) ? i18n.str`the total transfer at destination will be zero` : undefined, @@ -242,7 +243,9 @@ export function CreateCashout({ } }) } - + const cashoutAccount = !resultAccount.body.cashout_payto_uri ? undefined : + parsePaytoUri(resultAccount.body.cashout_payto_uri); + const cashoutAccountName = !cashoutAccount ? undefined : cashoutAccount.targetPath return ( <div> <LocalNotificationBanner notification={notification} /> @@ -275,6 +278,24 @@ export function CreateCashout({ <RenderAmount value={sellFee} spec={fiat_currency_specification} /> </dd> </div> + {cashoutAccountName ? + <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"> + <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> @@ -301,9 +322,10 @@ export function CreateCashout({ <input ref={focus ? doAutoFocus : undefined} 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 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" + 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={!resultAccount.body.cashout_payto_uri} data-error={!!errors?.subject && form.subject !== undefined} value={form.subject ?? ""} onChange={(e) => { @@ -346,7 +368,7 @@ export function CreateCashout({ left currency={limit.currency} value={trimmedAmountStr} - onChange={(value) => { + onChange={!resultAccount.body.cashout_payto_uri ? undefined : (value) => { form.amount = value; updateForm(structuredClone(form)); }} @@ -411,7 +433,7 @@ export function CreateCashout({ {/* channel */} <div class="sm:col-span-5"> - <label + <label class="block text-sm font-medium leading-6 text-gray-900" for="channel" > @@ -421,16 +443,19 @@ export function CreateCashout({ <div class="mt-2 max-w-xl text-sm text-gray-500"> <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> - <label onClick={()=>{ + <label onClick={() => { + if (!resultAccount.body.contact_data?.email) return; form.channel = TanChannel.EMAIL updateForm(structuredClone(form)) - }} data-selected={form.channel === TanChannel.EMAIL} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"> + }} data-disabled={!resultAccount.body.contact_data?.email} data-selected={form.channel === TanChannel.EMAIL} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"> <input type="radio" name="channel" value="Newsletter" class="sr-only" /> <span class="flex flex-1"> <span class="flex flex-col"> <span id="project-type-0-label" class="block text-sm font-medium text-gray-900 "> <i18n.Translate>Email</i18n.Translate> </span> + {!resultAccount.body.contact_data?.email && i18n.str`add a email in your profile to enable this option`} </span> </span> <svg data-selected={form.channel === TanChannel.EMAIL} class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> @@ -438,16 +463,19 @@ export function CreateCashout({ </svg> </label> - <label onClick={()=>{ + <label onClick={() => { + if (!resultAccount.body.contact_data?.phone) return; form.channel = TanChannel.SMS updateForm(structuredClone(form)) - }} data-selected={form.channel === TanChannel.SMS} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"> - <input type="radio" name="channel" value="Existing Customers" class="sr-only" /> + }} data-disabled={!resultAccount.body.contact_data?.phone} data-selected={form.channel === TanChannel.SMS} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"> + <input type="radio" name="channel" value="Existing Customers" class="sr-only" /> <span class="flex flex-1"> <span class="flex flex-col"> <span id="project-type-1-label" class="block text-sm font-medium text-gray-900"> <i18n.Translate>SMS</i18n.Translate> </span> + {!resultAccount.body.contact_data?.phone && i18n.str`add a phone number in your profile to enable this option`} </span> </span> <svg data-selected={form.channel === TanChannel.SMS} class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx index 52ff713e2..6fd9eb18c 100644 --- a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx +++ b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + Amounts, TalerError, TranslatedString } from "@gnu-taler/taler-util"; @@ -32,7 +33,7 @@ import { ShowInputErrorLabel } from "@gnu-taler/web-util/browser"; import { useBankCoreApiContext } from "../../context/config.js"; import { useBackendState } from "../../hooks/backend.js"; import { - useCashoutDetails + useCashoutDetails, useConversionInfo } from "../../hooks/circuit.js"; import { undefinedIfEmpty, @@ -40,6 +41,7 @@ import { } from "../../utils.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { LocalNotificationBanner } from "@gnu-taler/web-util/browser"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; interface Props { id: string; @@ -58,7 +60,12 @@ export function ShowCashoutDetails({ const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); const [code, setCode] = useState<string | undefined>(undefined); const [notification, notify, handleError] = useLocalNotification() + const info = useConversionInfo(); + if (Number.isNaN(cid)) { + //TODO: better error message + return <div>cashout id should be a number</div> + } if (!result) { return <Loading /> } @@ -74,206 +81,252 @@ export function ShowCashoutDetails({ default: assertUnreachable(result) } } - if (Number.isNaN(cid)) { - //TODO: better error message - return <div>cashout id should be a number</div> + if (!info) { + return <Loading /> + } + + if (info instanceof TalerError) { + return <ErrorLoading error={info} /> } + const errors = undefinedIfEmpty({ code: !code ? i18n.str`required` : undefined, }); const isPending = String(result.body.status).toUpperCase() === "PENDING"; + const { fiat_currency_specification, regional_currency_specification } = info.body + async function doAbortCashout() { + if (!creds) return; + await handleError(async () => { + const resp = await api.abortCashoutById(creds, cid); + if (resp.type === "ok") { + onCancel(); + } else { + switch (resp.case) { + case "not-found": return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "already-confirmed": return notify({ + type: "error", + title: i18n.str`Cashout was already confimed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "cashout-not-supported": return notify({ + type: "error", + title: i18n.str`Cashout operation is not supported.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: { + assertUnreachable(resp) + } + } + } + }) + } + async function doConfirmCashout() { + if (!creds || !code) return; + await handleError(async () => { + const resp = await api.confirmCashoutById(creds, cid, { + tan: code, + }); + if (resp.type === "ok") { + mutate(() => true)//clean cashout state + } else { + switch (resp.case) { + case "not-found": return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "no-enough-balance": return notify({ + type: "error", + title: i18n.str`The account does not have sufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "incorrect-exchange-rate": return notify({ + type: "error", + title: i18n.str`The exchange rate was incorrectly applied`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "already-aborted": return notify({ + type: "error", + title: i18n.str`The cashout operation is already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case "no-cashout-payto": return notify({ + type: "error", + title: i18n.str`Missing destination account.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "too-many-attempts": return notify({ + type: "error", + title: i18n.str`Too many failed attempts.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "cashout-not-supported": return notify({ + type: "error", + title: i18n.str`Cashout operation is not supported.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "invalid-code": return notify({ + type: "error", + title: i18n.str`The code for this cashout is invalid.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } + }) + } + return ( <div> <LocalNotificationBanner notification={notification} /> - <h1>Cashout details {id}</h1> - <form class="pure-form"> - <fieldset> - <label> - <i18n.Translate>Subject</i18n.Translate> - </label> - <input readOnly value={result.body.subject} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Created</i18n.Translate> - </label> - <input readOnly value={result.body.creation_time.t_s === "never" ? i18n.str`never` : format(result.body.creation_time.t_s, "dd/MM/yyyy HH:mm:ss")} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Confirmed</i18n.Translate> - </label> - <input readOnly value={result.body.confirmation_time === undefined ? "-" : - (result.body.confirmation_time.t_s === "never" ? - i18n.str`never` : - format(result.body.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss")) - } /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Debited</i18n.Translate> - </label> - <input readOnly value={result.body.amount_debit} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Credit</i18n.Translate> - </label> - <input readOnly value={result.body.amount_credit} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Status</i18n.Translate> - </label> - <input readOnly value={result.body.status} /> - </fieldset> - {/* <fieldset> - <label> - <i18n.Translate>Destination</i18n.Translate> - </label> - <input readOnly value={result.body.credit_payto_uri} /> - </fieldset> */} - {isPending ? ( - <fieldset> - <label> - <i18n.Translate>Code</i18n.Translate> - </label> - <input - value={code ?? ""} - onChange={(e) => { - setCode(e.currentTarget.value); + <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"> + + <section class="rounded-sm px-4"> + <h2 id="summary-heading" class="font-medium text-lg"><i18n.Translate>Cashout detail</i18n.Translate></h2> + <dl class="mt-8 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"><i18n.Translate>Subject</i18n.Translate></dt> + <dd class="text-sm ">{result.body.subject}</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>Status</i18n.Translate></span> + </dt> + <dd data-status={result.body.status} class="text-sm uppercase data-[status=pending]:text-yellow-600 data-[status=aborted]:text-red-600 data-[status=confirmed]:text-green-600" > + {result.body.status} + </dd> + </div> + </dl> + </section> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <dl class="space-y-4"> + + {result.body.creation_time.t_s !== "never" ? + <div class="justify-between items-center flex "> + <dt class=" text-gray-600"><i18n.Translate>Created</i18n.Translate></dt> + <dd class="text-sm "> + {format(result.body.creation_time.t_s * 1000, "dd/MM/yyyy HH:mm:ss")} + </dd> + </div> + : undefined} + + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-gray-600"><i18n.Translate>Debited</i18n.Translate></dt> + <dd class=" font-medium"> + <RenderAmount value={Amounts.parseOrThrow(result.body.amount_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-gray-600"> + <span><i18n.Translate>Credited</i18n.Translate></span> + + </dt> + <dd class="text-sm "> + <RenderAmount value={Amounts.parseOrThrow(result.body.amount_credit)} withColor spec={fiat_currency_specification} /> + </dd> + </div> + + {result.body.confirmation_time && result.body.confirmation_time.t_s !== "never" ? + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class=" font-medium"><i18n.Translate>Confirmed</i18n.Translate></dt> + <dd class=" font-medium"> + {format(result.body.confirmation_time.t_s * 1000, "dd/MM/yyyy HH:mm:ss")} + </dd> + </div> + : undefined} + </dl> + </div> + </div> + </div> + + </div> + + {!isPending ? undefined : + <Fragment> + + <div /> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5" + autoCapitalize="none" + autoCorrect="off" + onSubmit={e => { + e.preventDefault() }} - /> - <ShowInputErrorLabel - message={errors?.code} - isDirty={code !== undefined} - /> - </fieldset> - ) : undefined} - </form> + > + <div class="px-4 py-6 sm:p-8"> + <label for="withdraw-amount"> + Enter the confirmation code + </label> + <div class="mt-2"> + <div class="relative rounded-md shadow-sm"> + <input + type="text" + // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 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" + aria-describedby="answer" + autoFocus + 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" + value={code ?? ""} + required + + name="answer" + id="answer" + autocomplete="off" + onChange={(e): void => { + setCode(e.currentTarget.value) + }} + /> + </div> + <ShowInputErrorLabel message={errors?.code} isDirty={code !== undefined} /> + </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"> + <button type="button" + class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" + onClick={doAbortCashout} + > + <i18n.Translate>Abort</i18n.Translate></button> + <button type="submit" + 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) => { + doConfirmCashout() + }} + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + + </form> + </Fragment>} + </div> + <br /> <div style={{ display: "flex", justifyContent: "space-between" }}> - <button - class="pure-button pure-button-secondary btn-cancel" - onClick={(e) => { - e.preventDefault(); - onCancel(); - }} + <button type="button" class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} > - {i18n.str`Back`} - </button> - {isPending ? ( - <div> - <button - type="submit" - class="pure-button pure-button-primary button-error" - onClick={async (e) => { - e.preventDefault(); - if (!creds) return; - await handleError(async () => { - const resp = await api.abortCashoutById(creds, cid); - if (resp.type === "ok") { - onCancel(); - } else { - switch (resp.case) { - case "not-found": return notify({ - type: "error", - title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "already-confirmed": return notify({ - type: "error", - title: i18n.str`Cashout was already confimed.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "cashout-not-supported": return notify({ - type: "error", - title: i18n.str`Cashout operation is not supported.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - default: { - assertUnreachable(resp) - } - } - } - }) - }} - > - {i18n.str`Abort`} - </button> - - <button - type="submit" - disabled={!code} - class="pure-button pure-button-primary " - onClick={async (e) => { - e.preventDefault(); - if (!creds || !code) return; - await handleError(async () => { - const resp = await api.confirmCashoutById(creds, cid, { - tan: code, - }); - if (resp.type === "ok") { - mutate(() => true)//clean cashout state - } else { - switch (resp.case) { - case "not-found": return notify({ - type: "error", - title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "no-enough-balance": return notify({ - type: "error", - title: i18n.str`The account does not have sufficient funds`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case "incorrect-exchange-rate": return notify({ - type: "error", - title: i18n.str`The exchange rate was incorrectly applied`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case "already-aborted": return notify({ - type: "error", - title: i18n.str`The cashout operation is already aborted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case "no-cashout-payto": return notify({ - type: "error", - title: i18n.str`Missing destination account.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "too-many-attempts": return notify({ - type: "error", - title: i18n.str`Too many failed attempts.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "cashout-not-supported": return notify({ - type: "error", - title: i18n.str`Cashout operation is not supported.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - default: assertUnreachable(resp) - } - } - }) - }} - > - {i18n.str`Confirm`} - </button> - </div> - ) : ( - <div /> - )} + <i18n.Translate>Cancel</i18n.Translate></button> </div> </div> ); |