374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
/*
|
|
This file is part of GNU Taler
|
|
(C) 2022 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 {
|
|
AmountJson,
|
|
Amounts,
|
|
HttpStatusCode,
|
|
Logger,
|
|
TranslatedString,
|
|
buildPayto,
|
|
parsePaytoUri,
|
|
stringifyPaytoUri
|
|
} from "@gnu-taler/taler-util";
|
|
import {
|
|
RequestError,
|
|
notify,
|
|
notifyError,
|
|
useTranslationContext,
|
|
} from "@gnu-taler/web-util/browser";
|
|
import { h, VNode, Fragment, Ref } from "preact";
|
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
|
|
import { useAccessAPI } from "../hooks/access.js";
|
|
import {
|
|
buildRequestErrorMessage,
|
|
undefinedIfEmpty,
|
|
validateIBAN,
|
|
} from "../utils.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);
|
|
|
|
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
|
|
undefined,
|
|
);
|
|
const { i18n } = useTranslationContext();
|
|
const ibanRegex = "^[A-Z][A-Z][0-9]+$";
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
useEffect(() => {
|
|
if (focus) ref.current?.focus();
|
|
}, [focus, isRawPayto]);
|
|
|
|
const trimmedAmountStr = amount?.trim();
|
|
const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`);
|
|
const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
|
|
|
|
const errorsWire = undefinedIfEmpty({
|
|
iban: !iban
|
|
? i18n.str`Missing IBAN`
|
|
: !IBAN_REGEX.test(iban)
|
|
? i18n.str`IBAN should have just uppercased letters and numbers`
|
|
: validateIBAN(iban, i18n),
|
|
subject: !subject ? i18n.str`Missing subject` : undefined,
|
|
amount: !trimmedAmountStr
|
|
? i18n.str`Missing amount`
|
|
: !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,
|
|
});
|
|
|
|
const { createTransaction } = useAccessAPI();
|
|
|
|
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),
|
|
});
|
|
|
|
async function doSend() {
|
|
let paytoUri: string | undefined;
|
|
|
|
if (rawPaytoInput) {
|
|
paytoUri = rawPaytoInput
|
|
} else {
|
|
if (!iban || !subject) return;
|
|
const ibanPayto = buildPayto("iban", iban, undefined);
|
|
ibanPayto.params.message = encodeURIComponent(subject);
|
|
paytoUri = stringifyPaytoUri(ibanPayto);
|
|
}
|
|
|
|
try {
|
|
await createTransaction({
|
|
paytoUri,
|
|
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">
|
|
<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={() => {
|
|
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>
|
|
|
|
<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={() => {
|
|
setIsRawPayto(true)
|
|
}} />
|
|
<span class="flex flex-1">
|
|
<span class="flex flex-col">
|
|
<span class="block text-sm font-medium text-gray-900">
|
|
<i18n.Translate>using the payto:// format</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"
|
|
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">
|
|
{!isRawPayto ?
|
|
<Fragment>
|
|
|
|
<div class="sm:col-span-5">
|
|
<label for="iban" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Account number`}</label>
|
|
<div class="mt-2">
|
|
<input
|
|
ref={ref}
|
|
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);
|
|
}}
|
|
/>
|
|
<ShowInputErrorLabel
|
|
message={errorsWire?.iban}
|
|
isDirty={iban !== undefined}
|
|
/>
|
|
</div>
|
|
<p class="mt-2 text-sm text-gray-500" >the receiver of the money</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>
|
|
<Amount
|
|
name="amount"
|
|
currency={limit.currency}
|
|
value={trimmedAmountStr}
|
|
onChange={(d) => {
|
|
setAmount(d)
|
|
}}
|
|
/>
|
|
<ShowInputErrorLabel
|
|
message={errorsWire?.subject}
|
|
isDirty={subject !== undefined}
|
|
/>
|
|
<p class="mt-2 text-sm text-gray-500" >amount to transfer</p>
|
|
</div>
|
|
|
|
</Fragment> :
|
|
<Fragment>
|
|
<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">
|
|
<input
|
|
name="address"
|
|
id="address"
|
|
type="text"
|
|
size={50}
|
|
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" ref={ref}
|
|
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>
|
|
|
|
</Fragment>
|
|
}
|
|
</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.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 >
|
|
)
|
|
|
|
}
|
|
export function Amount(
|
|
{
|
|
currency,
|
|
name,
|
|
value,
|
|
error,
|
|
onChange,
|
|
}: {
|
|
error?: string;
|
|
currency: string;
|
|
name: string;
|
|
value: string | undefined;
|
|
onChange?: (s: string) => void;
|
|
},
|
|
ref: Ref<HTMLInputElement>,
|
|
): VNode {
|
|
return (
|
|
<div class="mt-2">
|
|
<div class="relative rounded-md shadow-sm">
|
|
<div class="pointer-events-none absolute inset-y-0 flex items-center pl-3">
|
|
<span class="text-gray-500 sm:text-sm">{currency}</span>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
class="text-right 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"
|
|
placeholder="0.00" aria-describedby="price-currency"
|
|
ref={ref}
|
|
name={name}
|
|
id={name}
|
|
autocomplete="off"
|
|
value={value ?? ""}
|
|
disabled={!onChange}
|
|
onInput={(e): void => {
|
|
if (onChange) {
|
|
onChange(e.currentTarget.value);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<ShowInputErrorLabel message={error} isDirty={value !== undefined} />
|
|
</div>
|
|
);
|
|
}
|