diff options
author | Özgür Kesim <oec-taler@kesim.org> | 2023-10-06 16:33:05 +0200 |
---|---|---|
committer | Özgür Kesim <oec-taler@kesim.org> | 2023-10-06 16:33:05 +0200 |
commit | fe7b51ef2736edbf04f5bbd9d19f2a2d04baccc2 (patch) | |
tree | 66c68c8d6a666f6e74dc663c9ee4f07879f6626c /packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx | |
parent | 35611f0bf9cf67638b171c2a300fab1797d3d8f0 (diff) | |
parent | 97d7be7503168f4f3bbd05905d32aa76ca1636b2 (diff) |
Merge branch 'master' into age-withdraw
Diffstat (limited to 'packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx')
-rw-r--r-- | packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx | 640 |
1 files changed, 363 insertions, 277 deletions
diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index d8c1644b1..52dbd4ff6 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -17,42 +17,51 @@ import { AmountJson, Amounts, - buildPayto, HttpStatusCode, Logger, + TranslatedString, + buildPayto, parsePaytoUri, - stringifyPaytoUri, + stringifyPaytoUri } from "@gnu-taler/taler-util"; import { RequestError, + notify, + notifyError, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { h, VNode, Fragment, Ref } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import { notifyError } from "../hooks/notification.js"; +import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; import { useAccessAPI } from "../hooks/access.js"; import { buildRequestErrorMessage, undefinedIfEmpty, validateIBAN, } from "../utils.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; +import { useConfigState } from "../hooks/config.js"; +import { useConfigContext } from "../context/config.js"; const logger = new Logger("PaytoWireTransferForm"); export function PaytoWireTransferForm({ focus, + title, onSuccess, + onCancel, limit, }: { + title: TranslatedString, focus?: boolean; onSuccess: () => void; + onCancel: (() => void) | undefined; limit: AmountJson; }): VNode { const [isRawPayto, setIsRawPayto] = useState(false); - const [iban, setIban] = useState<string | undefined>(undefined); - const [subject, setSubject] = useState<string | undefined>(undefined); - const [amount, setAmount] = useState<string | undefined>(undefined); + // FIXME: remove this + const [iban, setIban] = useState<string | undefined>(); + const [subject, setSubject] = useState<string | undefined>(); + const [amount, setAmount] = useState<string | undefined>(); const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( undefined, @@ -70,295 +79,372 @@ export function PaytoWireTransferForm({ const errorsWire = undefinedIfEmpty({ iban: !iban - ? i18n.str`Missing IBAN` + ? i18n.str`required` : !IBAN_REGEX.test(iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(iban, i18n), - subject: !subject ? i18n.str`Missing subject` : undefined, + ? i18n.str`IBAN should have just uppercased letters and numbers` + : validateIBAN(iban, i18n), + subject: !subject ? i18n.str`required` : undefined, amount: !trimmedAmountStr - ? i18n.str`Missing amount` + ? i18n.str`required` : !parsedAmount - ? i18n.str`Amount is not valid` - : Amounts.isZero(parsedAmount) - ? i18n.str`Should be greater than 0` - : Amounts.cmp(limit, parsedAmount) === -1 - ? i18n.str`balance is not enough` - : undefined, + ? i18n.str`not valid` + : Amounts.isZero(parsedAmount) + ? i18n.str`should be greater than 0` + : Amounts.cmp(limit, parsedAmount) === -1 + ? i18n.str`balance is not enough` + : undefined, }); const { createTransaction } = useAccessAPI(); - if (!isRawPayto) - return ( - <div> - <form - class="pure-form" - name="wire-transfer-form" - onSubmit={(e) => { - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" - > - <label for="iban">{i18n.str`Receiver IBAN:`}</label> - <input - ref={ref} - type="text" - id="iban" - name="iban" - value={iban ?? ""} - placeholder="CC0123456789" - required - pattern={ibanRegex} - onInput={(e): void => { - setIban(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errorsWire?.iban} - isDirty={iban !== undefined} - /> - <label for="subject">{i18n.str`Transfer subject:`}</label> - <input - type="text" - name="subject" - id="subject" - placeholder="subject" - value={subject ?? ""} - required - onInput={(e): void => { - setSubject(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errorsWire?.subject} - isDirty={subject !== undefined} - /> - <label for="amount">{i18n.str`Amount:`}</label> - <div style={{ width: "max-content", display: "flex" }}> - <input - type="text" - readonly - class="currency-indicator" - size={limit.currency.length} - maxLength={limit.currency.length} - tabIndex={-1} - style={{ - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - borderRight: 0, - }} - value={limit.currency} - /> - <input - type="number" - name="amount" - id="amount" - placeholder="amount" - required - style={{ - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderLeft: 0, - width: 150, - }} - value={amount ?? ""} - onInput={(e): void => { - setAmount(e.currentTarget.value); - }} - /> - </div> - <ShowInputErrorLabel - message={errorsWire?.amount} - isDirty={amount !== undefined} - /> - <p style={{ display: "flex", justifyContent: "space-between" }}> - <input - type="submit" - class="pure-button pure-button-primary" - disabled={!!errorsWire} - value="Send" - onClick={async (e) => { - e.preventDefault(); - if (!(iban && subject && amount)) { - return; - } - const ibanPayto = buildPayto("iban", iban, undefined); - ibanPayto.params.message = encodeURIComponent(subject); - const paytoUri = stringifyPaytoUri(ibanPayto); - - try { - await createTransaction({ - paytoUri, - amount: `${limit.currency}:${amount}`, - }); - onSuccess(); - setAmount(undefined); - setIban(undefined); - setSubject(undefined); - } catch (error) { - if (error instanceof RequestError) { - notifyError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.BadRequest - ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` - : undefined, - }), - ); - } else { - notifyError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); - } - } - }} - /> - <input - type="button" - class="pure-button" - value="Clear" - onClick={async (e) => { - e.preventDefault(); - setAmount(undefined); - setIban(undefined); - setSubject(undefined); - }} - /> - </p> - </form> - <p> - <a - href="#" - onClick={(e) => { - setIsRawPayto(true); - e.preventDefault(); - }} - > - {i18n.str`Want to try the raw payto://-format?`} - </a> - </p> - </div> - ); - const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); const errorsPayto = undefinedIfEmpty({ rawPaytoInput: !rawPaytoInput ? i18n.str`required` : !parsed - ? i18n.str`does not follow the pattern` - : !parsed.params.amount - ? i18n.str`use the "amount" parameter to specify the amount to be transferred` - : Amounts.parse(parsed.params.amount) === undefined - ? i18n.str`the amount is not valid` - : !parsed.params.message - ? i18n.str`use the "message" parameter to specify a reference text for the transfer` - : !parsed.isKnown || parsed.targetType !== "iban" - ? i18n.str`only "IBAN" target are supported` - : !IBAN_REGEX.test(parsed.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(parsed.iban, i18n), + ? i18n.str`does not follow the pattern` + : !parsed.isKnown || parsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !parsed.params.amount + ? i18n.str`use the "amount" parameter to specify the amount to be transferred` + : Amounts.parse(parsed.params.amount) === undefined + ? i18n.str`the amount is not valid` + : !parsed.params.message + ? i18n.str`use the "message" parameter to specify a reference text for the transfer` + : !IBAN_REGEX.test(parsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : validateIBAN(parsed.iban, i18n), }); - return ( - <div> - <p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p> - <form - class="pure-form" - name="payto-form" - onSubmit={(e) => { - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" - > - <p> - <label for="address">{i18n.str`payto URI:`}</label> - <input - name="address" - type="text" - size={50} - ref={ref} - id="address" - value={rawPaytoInput ?? ""} - required - placeholder={i18n.str`payto address`} - // pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`} - onInput={(e): void => { - rawPaytoInputSetter(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errorsPayto?.rawPaytoInput} - isDirty={rawPaytoInput !== undefined} - /> - <br /> - <div style={{ fontSize: "small", marginTop: 4 }}> - Hint: - <code> - payto://iban/[receiver-iban]?message=[subject]&amount=[ - {limit.currency} - :X.Y] - </code> - </div> - </p> - <p> - <input - class="pure-button pure-button-primary" - type="button" - disabled={!!errorsPayto} - value={i18n.str`Send`} - onClick={async () => { - if (!rawPaytoInput) { - logger.error("Didn't get any raw Payto string!"); - return; + async function doSend() { + let payto_uri: string | undefined; + + if (rawPaytoInput) { + payto_uri = rawPaytoInput + } else { + if (!iban || !subject) return; + const ibanPayto = buildPayto("iban", iban, undefined); + ibanPayto.params.message = encodeURIComponent(subject); + payto_uri = stringifyPaytoUri(ibanPayto); + } + + try { + await createTransaction({ + payto_uri, + amount: `${limit.currency}:${amount}`, + }); + onSuccess(); + setAmount(undefined); + setIban(undefined); + setSubject(undefined); + rawPaytoInputSetter(undefined) + } catch (error) { + if (error instanceof RequestError) { + notify( + buildRequestErrorMessage(i18n, error.cause, { + onClientError: (status) => + status === HttpStatusCode.BadRequest + ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` + : undefined, + }), + ); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString + ) + } + } + + } + + 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="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {title} + </h2> + <div> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-1 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 && parsed.targetType === "iban") { + setIban(parsed.iban) + const amount = Amounts.parse(parsed.params["amount"]) + if (amount) { + setAmount(Amounts.stringifyValue(amount)) + } + const subject = parsed.params["subject"] + 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> - try { - await createTransaction({ - paytoUri: rawPaytoInput, - }); - onSuccess(); - rawPaytoInputSetter(undefined); - } catch (error) { - if (error instanceof RequestError) { - notifyError( - buildRequestErrorMessage(i18n, error.cause, { - onClientError: (status) => - status === HttpStatusCode.BadRequest - ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.` - : undefined, - }), - ); - } else { - notifyError({ - title: i18n.str`Operation failed, please report`, - description: - error instanceof Error - ? error.message - : JSON.stringify(error), - }); + <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 (iban) { + const payto = buildPayto("iban", iban, undefined) + if (parsedAmount) { + payto.params["amount"] = Amounts.stringify(parsedAmount) + } + if (subject) { + payto.params["message"] = subject } + rawPaytoInputSetter(stringifyPaytoUri(payto)) } - }} - /> - </p> - <p> - <a - href="/account" - onClick={() => { - 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>Import payto:// URI</i18n.Translate> + </span> + </span> + </span> + </label> + </div> + </div> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 w-fit mx-auto" + autoCapitalize="none" + autoCorrect="off" + onSubmit={e => { + e.preventDefault() + }} + > + <div class="px-4 py-6 sm:p-8"> + {!isRawPayto ? + <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + + <div class="sm:col-span-5"> + <label for="iban" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Recipient`}</label> + <div class="mt-2"> + <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 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="iban" + id="iban" + value={iban ?? ""} + placeholder="CC0123456789" + autocomplete="off" + required + pattern={ibanRegex} + onInput={(e): void => { + setIban(e.currentTarget.value.toUpperCase()); + }} + /> + <ShowInputErrorLabel + message={errorsWire?.iban} + isDirty={iban !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>IBAN of the recipient's account</i18n.Translate> + </p> + </div> + + <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="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" >some text to identify the transfer</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={subject !== undefined} + /> + <p class="mt-2 text-sm text-gray-500" >amount to transfer</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={3} + class="block overflow-hidden w-64 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 + placeholder={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"> + {onCancel ? + <button type="button" class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} > - {i18n.str`Use wire-transfer form?`} - </a> - </p> - </form> + <i18n.Translate>Cancel</i18n.Translate> + </button> + : <div /> + } + <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={isRawPayto ? !!errorsPayto : !!errorsWire} + onClick={(e) => { + e.preventDefault() + doSend() + }} + > + <i18n.Translate>Send</i18n.Translate> + </button> + </div> + </form> + </div > + ) + +} + +/** + * Show the element when the load ended + * @param element + */ +export function doAutoFocus(element: HTMLElement | null) { + if (element) { + setTimeout(() => { + element.focus() + element.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center" + }) + }, 100) + } +} + +const FRAC_SEPARATOR = "." + +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 cfg = useConfigContext() + 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="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 > cfg.currency_fraction_limit) { + e.currentTarget.value = e.currentTarget.value.substring(0, sep_pos + cfg.currency_fraction_limit + 1) + } + onChange(e.currentTarget.value); + }} + /> + </div> + <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> </div> ); } + +export function RenderAmount({ value, negative }: { value: AmountJson, negative?: boolean }): VNode { + const cfg = useConfigContext() + const str = Amounts.stringifyValue(value) + const sep_pos = str.indexOf(FRAC_SEPARATOR) + if (sep_pos !== -1 && str.length - sep_pos - 1 > cfg.currency_fraction_digits) { + const limit = sep_pos + cfg.currency_fraction_digits + 1 + const normal = str.substring(0, limit) + const small = str.substring(limit) + return <span class="whitespace-nowrap"> + {negative ? "-" : undefined} + {value.currency} {normal} <sup class="-ml-2">{small}</sup> + </span> + } + return <span class="whitespace-nowrap"> + {negative ? "-" : undefined} + {value.currency} {str} + </span> +}
\ No newline at end of file |