summaryrefslogtreecommitdiff
path: root/packages/merchant-backoffice-ui/src/components/form
diff options
context:
space:
mode:
Diffstat (limited to 'packages/merchant-backoffice-ui/src/components/form')
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx109
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/Input.tsx116
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputArray.tsx139
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx91
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx68
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputDate.tsx164
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx189
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx86
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputImage.tsx122
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx53
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx61
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx52
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx47
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx447
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx204
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx61
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx186
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx94
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx162
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputStock.tsx223
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputTab.tsx90
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx147
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx91
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx116
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx63
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/TextField.tsx71
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/useField.tsx92
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx41
28 files changed, 3385 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx
new file mode 100644
index 000000000..a5f3c1d2f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx
@@ -0,0 +1,109 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useMemo } from "preact/hooks";
+
+type Updater<S> = (value: (prevState: S) => S) => void;
+
+export interface Props<T> {
+ object?: Partial<T>;
+ errors?: FormErrors<T>;
+ name?: string;
+ valueHandler: Updater<Partial<T>> | null;
+ children: ComponentChildren;
+}
+
+const noUpdater: Updater<Partial<unknown>> = () => (s: unknown) => s;
+
+export function FormProvider<T>({
+ object = {},
+ errors = {},
+ name = "",
+ valueHandler,
+ children,
+}: Props<T>): VNode {
+ const initialObject = useMemo(() => object, []);
+ const value = useMemo<FormType<T>>(
+ () => ({
+ errors,
+ object,
+ initialObject,
+ valueHandler: valueHandler ? valueHandler : noUpdater,
+ name,
+ toStr: {},
+ fromStr: {},
+ }),
+ [errors, object, valueHandler],
+ );
+
+ return (
+ <FormContext.Provider value={value}>
+ <form
+ class="field"
+ onSubmit={(e) => {
+ e.preventDefault();
+ // if (valueHandler) valueHandler(object);
+ }}
+ >
+ {children}
+ </form>
+ </FormContext.Provider>
+ );
+}
+
+export interface FormType<T> {
+ object: Partial<T>;
+ initialObject: Partial<T>;
+ errors: FormErrors<T>;
+ toStr: FormtoStr<T>;
+ name: string;
+ fromStr: FormfromStr<T>;
+ valueHandler: Updater<Partial<T>>;
+}
+
+const FormContext = createContext<FormType<unknown>>(null!);
+
+/**
+ * FIXME:
+ * USE MEMORY EVENTS INSTEAD OF CONTEXT
+ * @deprecated
+ */
+
+export function useFormContext<T>() {
+ return useContext<FormType<T>>(FormContext);
+}
+
+export type FormErrors<T> = {
+ [P in keyof T]?: string | FormErrors<T[P]>;
+};
+
+export type FormtoStr<T> = {
+ [P in keyof T]?: (f?: T[P]) => string;
+};
+
+export type FormfromStr<T> = {
+ [P in keyof T]?: (f: string) => T[P];
+};
+
+export type FormUpdater<T> = {
+ [P in keyof T]?: (f: keyof T) => (v: T[P]) => void;
+};
diff --git a/packages/merchant-backoffice-ui/src/components/form/Input.tsx b/packages/merchant-backoffice-ui/src/components/form/Input.tsx
new file mode 100644
index 000000000..899061c35
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/Input.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useField, InputProps } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ inputType?: "text" | "number" | "multiline" | "password";
+ expand?: boolean;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+ inputExtra?: any;
+ side?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+const TextInput = ({ inputType, error, ...rest }: any) =>
+ inputType === "multiline" ? (
+ <textarea
+ {...rest}
+ class={error ? "textarea is-danger" : "textarea"}
+ rows="3"
+ />
+ ) : (
+ <input
+ {...rest}
+ class={error ? "input is-danger" : "input"}
+ type={inputType}
+ />
+ );
+
+export function Input<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ expand,
+ help,
+ children,
+ inputType,
+ inputExtra,
+ side,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ <TextInput
+ error={error}
+ {...inputExtra}
+ inputType={inputType}
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={toStr(value)}
+ onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void =>
+ onChange(fromStr(e.currentTarget.value))
+ }
+ />
+ {help}
+ {children}
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {side}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx
new file mode 100644
index 000000000..b0b9eaefc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx
@@ -0,0 +1,139 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+ addonBefore?: string;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputArray<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ addonBefore,
+ isValid = () => true,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error: formError, value, onChange, required } = useField<T>(name);
+ const [localError, setLocalError] = useState<string | null>(null);
+
+ const error = localError || formError;
+
+ const array: any[] = (value ? value! : []) as any;
+ const [currentValue, setCurrentValue] = useState("");
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ {addonBefore && (
+ <div class="control">
+ <a class="button is-static">{addonBefore}</a>
+ </div>
+ )}
+ <p class="control is-expanded has-icons-right">
+ <input
+ class={error ? "input is-danger" : "input"}
+ type="text"
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={currentValue}
+ onChange={(e): void => setCurrentValue(e.currentTarget.value)}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ <p class="control">
+ <button
+ class="button is-info has-tooltip-left"
+ disabled={!currentValue}
+ onClick={(): void => {
+ const v = fromStr(currentValue);
+ if (!isValid(v)) {
+ setLocalError(
+ i18n.str`The value ${v} is invalid for a payment url`,
+ );
+ return;
+ }
+ setLocalError(null);
+ onChange([v, ...array] as any);
+ setCurrentValue("");
+ }}
+ data-tooltip={i18n.str`add element to the list`}
+ >
+ <i18n.Translate>add</i18n.Translate>
+ </button>
+ </p>
+ </div>
+ {help}
+ {error && <p class="help is-danger"> {error} </p>}
+ {array.map((v, i) => (
+ <div key={i} class="tags has-addons mt-3 mb-0">
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ {v}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(array.filter((f) => f !== v) as any);
+ setCurrentValue(toStr(v));
+ }}
+ />
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx b/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx
new file mode 100644
index 000000000..bdb2feb6b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ name: T;
+ readonly?: boolean;
+ expand?: boolean;
+ threeState?: boolean;
+ toBoolean?: (v?: any) => boolean | undefined;
+ fromBoolean?: (s: boolean | undefined) => any;
+}
+
+const defaultToBoolean = (f?: any): boolean | undefined => f || "";
+const defaultFromBoolean = (v: boolean | undefined): any => v as any;
+
+export function InputBoolean<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ threeState,
+ expand,
+ fromBoolean = defaultFromBoolean,
+ toBoolean = defaultToBoolean,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const onCheckboxClick = (): void => {
+ const c = toBoolean(value);
+ if (c === false && threeState) return onChange(undefined as any);
+ return onChange(fromBoolean(!c));
+ };
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ class={toBoolean(value) === undefined ? "is-indeterminate" : ""}
+ checked={toBoolean(value)}
+ placeholder={placeholder}
+ readonly={readonly}
+ name={String(name)}
+ disabled={readonly}
+ onChange={onCheckboxClick}
+ />
+ <span class="check" />
+ </label>
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
new file mode 100644
index 000000000..11396b88e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, h, VNode } from "preact";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { InputProps } from "./useField.js";
+import { AmountString } from "@gnu-taler/taler-util";
+import { useSessionContext } from "../../context/session.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ addonAfter?: ComponentChildren;
+ children?: ComponentChildren;
+ side?: ComponentChildren;
+}
+
+export function InputCurrency<T>({
+ name,
+ readonly,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ expand,
+ addonAfter,
+ children,
+ side,
+}: Props<keyof T>): VNode {
+ const { config } = useSessionContext();
+ return (
+ <InputWithAddon<T>
+ name={name}
+ readonly={readonly}
+ addonBefore={config.currency}
+ side={side}
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ addonAfter={addonAfter}
+ inputType="number"
+ expand={expand}
+ toStr={(v?: AmountString) => v?.split(":")[1] || ""}
+ fromStr={(v: string) => (!v ? undefined : `${config.currency}:${v}`)}
+ inputExtra={{ min: 0 }}
+ >
+ {children}
+ </InputWithAddon>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx
new file mode 100644
index 000000000..812505f6a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx
@@ -0,0 +1,164 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { DatePicker } from "../picker/DatePicker.js";
+import { InputProps, useField } from "./useField.js";
+import { dateFormatForSettings, usePreference } from "../../hooks/preference.js";
+
+export interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ //FIXME: create separated components InputDate and InputTimestamp
+ withTimestampSupport?: boolean;
+ side?: ComponentChildren;
+}
+
+export function InputDate<T>({
+ name,
+ readonly,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ expand,
+ withTimestampSupport,
+ side,
+}: Props<keyof T>): VNode {
+ const [opened, setOpened] = useState(false);
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference()
+
+ const { error, required, value, onChange } = useField<T>(name);
+
+ let strValue = "";
+ if (!value) {
+ strValue = withTimestampSupport ? "unknown" : "";
+ } else if (value instanceof Date) {
+ strValue = format(value, dateFormatForSettings(settings));
+ } else if (value.t_s) {
+ strValue =
+ value.t_s === "never"
+ ? withTimestampSupport
+ ? "never"
+ : ""
+ : format(new Date(value.t_s * 1000), dateFormatForSettings(settings));
+ }
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={strValue}
+ placeholder={placeholder}
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {help}
+ </p>
+ <div
+ class="control"
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ >
+ <a class="button is-static">
+ <span class="icon">
+ <i class="mdi mdi-calendar" />
+ </span>
+ </a>
+ </div>
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+
+ {!readonly && (
+ <span
+ data-tooltip={
+ withTimestampSupport
+ ? i18n.str`change value to unknown date`
+ : i18n.str`change value to empty`
+ }
+ >
+ <button
+ class="button is-info mr-3"
+ onClick={() => onChange(undefined as any)}
+ >
+ <i18n.Translate>clear</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {withTimestampSupport && (
+ <span data-tooltip={i18n.str`change value to never`}>
+ <button
+ class="button is-info"
+ onClick={() => onChange({ t_s: "never" } as any)}
+ >
+ <i18n.Translate>never</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {side}
+ </div>
+ <DatePicker
+ opened={opened}
+ closeFunction={() => setOpened(false)}
+ dateReceiver={(d) => {
+ if (withTimestampSupport) {
+ onChange({ t_s: d.getTime() / 1000 } as any);
+ } else {
+ onChange(d as any);
+ }
+ }}
+ />
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx
new file mode 100644
index 000000000..ad3cb0e32
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx
@@ -0,0 +1,189 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { formatDuration, intervalToDuration } from "date-fns";
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { SimpleModal } from "../modal/index.js";
+import { DurationPicker } from "../picker/DurationPicker.js";
+import { InputProps, useField } from "./useField.js";
+import { Duration } from "@gnu-taler/taler-util";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ readonly?: boolean;
+ withForever?: boolean;
+ side?: ComponentChildren;
+ withoutClear?: boolean;
+}
+
+export function InputDuration<T>({
+ name,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ readonly,
+ withForever,
+ withoutClear,
+ side,
+}: Props<keyof T>): VNode {
+ const [opened, setOpened] = useState(false);
+ const { i18n } = useTranslationContext();
+
+ const { error, required, value: anyValue, onChange } = useField<T>(name);
+ let strValue = "";
+ const value: Duration = anyValue
+ if (!value) {
+ strValue = "";
+ } else if (value.d_ms === "forever") {
+ strValue = i18n.str`forever`;
+ } else {
+ if (value.d_ms === undefined) {
+ throw Error(`assertion error: duration should have a d_ms but got '${JSON.stringify(value)}'`)
+ }
+ strValue = formatDuration(
+ intervalToDuration({ start: 0, end: value.d_ms }),
+ {
+ locale: {
+ formatDistance: (name, value) => {
+ switch (name) {
+ case "xMonths":
+ return i18n.str`${value}M`;
+ case "xYears":
+ return i18n.str`${value}Y`;
+ case "xDays":
+ return i18n.str`${value}d`;
+ case "xHours":
+ return i18n.str`${value}h`;
+ case "xMinutes":
+ return i18n.str`${value}min`;
+ case "xSeconds":
+ return i18n.str`${value}sec`;
+ }
+ },
+ localize: {
+ day: () => "s",
+ month: () => "m",
+ ordinalNumber: () => "th",
+ dayPeriod: () => "p",
+ quarter: () => "w",
+ era: () => "e",
+ },
+ },
+ },
+ );
+ }
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal is-flex-grow-3">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+
+ <div class="is-flex-grow-3">
+ <div class="field-body ">
+ <div class="field">
+ <div class="field has-addons">
+ <p class={expand ? "control is-expanded " : "control "}>
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={strValue}
+ placeholder={placeholder}
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ <div
+ class="control"
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ >
+ <a class="button is-static">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ </a>
+ </div>
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {withForever && (
+ <span data-tooltip={i18n.str`change value to never`}>
+ <button
+ class="button is-info mr-3"
+ onClick={() => onChange({ d_ms: "forever" } as any)}
+ >
+ <i18n.Translate>forever</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {!readonly && !withoutClear && (
+ <span data-tooltip={i18n.str`change value to empty`}>
+ <button
+ class="button is-info "
+ onClick={() => onChange(undefined as any)}
+ >
+ <i18n.Translate>clear</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {side}
+ </div>
+ <span>
+ {help}
+ </span>
+ </div>
+
+
+ {opened && (
+ <SimpleModal onCancel={() => setOpened(false)}>
+ <DurationPicker
+ days
+ hours
+ minutes
+ value={!value || value.d_ms === "forever" ? 0 : value.d_ms}
+ onChange={(v) => {
+ onChange({ d_ms: v } as any);
+ }}
+ />
+ </SimpleModal>
+ )}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx b/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx
new file mode 100644
index 000000000..92b9e8b16
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useGroupField } from "./useGroupField.js";
+
+export interface Props<T> {
+ name: T;
+ children: ComponentChildren;
+ label: ComponentChildren;
+ tooltip?: ComponentChildren;
+ alternative?: ComponentChildren;
+ fixed?: boolean;
+ initialActive?: boolean;
+}
+
+export function InputGroup<T>({
+ name,
+ label,
+ children,
+ tooltip,
+ alternative,
+ fixed,
+ initialActive,
+}: Props<keyof T>): VNode {
+ const [active, setActive] = useState(initialActive || fixed);
+ const group = useGroupField<T>(name);
+
+ return (
+ <div class="card">
+ <header class="card-header">
+ <p class="card-header-title">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ {group?.hasError && (
+ <span class="icon has-text-danger" data-tooltip={tooltip}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ {!fixed && (
+ <button
+ class="card-header-icon"
+ aria-label="more options"
+ onClick={(): void => setActive(!active)}
+ >
+ <span class="icon">
+ {active ? (
+ <i class="mdi mdi-arrow-up" />
+ ) : (
+ <i class="mdi mdi-arrow-down" />
+ )}
+ </span>
+ </button>
+ )}
+ </header>
+ {active ? (
+ <div class="card-content">{children}</div>
+ ) : alternative ? (
+ <div class="card-content">{alternative}</div>
+ ) : undefined}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx b/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx
new file mode 100644
index 000000000..d284b476f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx
@@ -0,0 +1,122 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, h, VNode } from "preact";
+import { useRef, useState } from "preact/hooks";
+import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ addonAfter?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+export function InputImage<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ children,
+ expand,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const image = useRef<HTMLInputElement>(null);
+ const { i18n } = useTranslationContext();
+ const [sizeError, setSizeError] = useState(false);
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ {value && (
+ <img
+ src={value}
+ style={{ width: 200, height: 200 }}
+ onClick={() => image.current?.click()}
+ />
+ )}
+ <input
+ ref={image}
+ style={{ display: "none" }}
+ type="file"
+ name={String(name)}
+ placeholder={placeholder}
+ readonly={readonly}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return onChange(undefined!);
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return onChange(undefined!);
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = window.btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return onChange(`data:${f[0].type};base64,${b64}` as any);
+ });
+ }}
+ />
+ {help}
+ {children}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ {sizeError && (
+ <p class="help is-danger">
+ <i18n.Translate>Image should be smaller than 1 MB</i18n.Translate>
+ </p>
+ )}
+ {!value && (
+ <button class="button" onClick={() => image.current?.click()}>
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ )}
+ {value && (
+ <button class="button" onClick={() => onChange(undefined!)}>
+ <i18n.Translate>Remove</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx b/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx
new file mode 100644
index 000000000..d4b13d555
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Fragment, h } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Input } from "./Input.js";
+
+export function InputLocation({ name }: { name: string }) {
+ const { i18n } = useTranslationContext();
+ return (
+ <>
+ <Input name={`${name}.country`} label={i18n.str`Country`} />
+ <Input
+ name={`${name}.address_lines`}
+ inputType="multiline"
+ label={i18n.str`Address`}
+ toStr={(v: string[] | undefined) => (!v ? "" : v.join("\n"))}
+ fromStr={(v: string) => v.split("\n")}
+ />
+ <Input
+ name={`${name}.building_number`}
+ label={i18n.str`Building number`}
+ />
+ <Input name={`${name}.building_name`} label={i18n.str`Building name`} />
+ <Input name={`${name}.street`} label={i18n.str`Street`} />
+ <Input name={`${name}.post_code`} label={i18n.str`Post code`} />
+ <Input name={`${name}.town_location`} label={i18n.str`Town location`} />
+ <Input name={`${name}.town`} label={i18n.str`Town`} />
+ <Input name={`${name}.district`} label={i18n.str`District`} />
+ <Input
+ name={`${name}.country_subdivision`}
+ label={i18n.str`Country subdivision`}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
new file mode 100644
index 000000000..38444b85d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h } from "preact";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { InputProps } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ side?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+export function InputNumber<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ expand,
+ children,
+ side,
+}: Props<keyof T>) {
+ return (
+ <InputWithAddon<T>
+ name={name}
+ readonly={readonly}
+ fromStr={(v) => (!v ? undefined : parseInt(v, 10))}
+ toStr={(v) => `${v}`}
+ inputType="number"
+ expand={expand}
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ inputExtra={{ min: 0 }}
+ side={side}
+ >
+ {children}
+ </InputWithAddon>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx
new file mode 100644
index 000000000..fcecd8932
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputArray } from "./InputArray.js";
+import { PAYTO_REGEX } from "../../utils/constants.js";
+import { InputProps } from "./useField.js";
+
+export type Props<T> = InputProps<T>;
+
+const PAYTO_START_REGEX = /^payto:\/\//;
+
+export function InputPayto<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+}: Props<keyof T>): VNode {
+ return (
+ <InputArray<T>
+ name={name}
+ readonly={readonly}
+ addonBefore="payto://"
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ isValid={(v) => v && PAYTO_REGEX.test(v)}
+ toStr={(v?: string) => (!v ? "" : v.replace(PAYTO_START_REGEX, ""))}
+ fromStr={(v: string) => `payto://${v}`}
+ />
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx
new file mode 100644
index 000000000..cc5326bbe
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx
@@ -0,0 +1,47 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import * as tests from "@gnu-taler/web-util/testing";
+import { InputPaytoForm } from "./InputPaytoForm.js";
+import { FormProvider } from "./FormProvider.js";
+import { useState } from "preact/hooks";
+
+export default {
+ title: "Components/Form/PayTo",
+ component: InputPaytoForm,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const Example = tests.createExample(() => {
+ const initial = {
+ accounts: [],
+ };
+ const [form, updateForm] = useState<Partial<typeof initial>>(initial);
+ return (
+ <FormProvider valueHandler={updateForm} object={form}>
+ <InputPaytoForm name="accounts" label="Accounts:" />
+ </FormProvider>
+ );
+}, {});
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
new file mode 100644
index 000000000..a0c15c77c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
@@ -0,0 +1,447 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import {
+ parsePaytoUri,
+ PaytoUriGeneric,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { COUNTRY_TABLE } from "../../utils/constants.js";
+import { undefinedIfEmpty } from "../../utils/table.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { Input } from "./Input.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputSelector } from "./InputSelector.js";
+import { InputProps, useField } from "./useField.js";
+import { useEffect, useState } from "preact/hooks";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+// type Entity = PaytoUriGeneric
+// https://datatracker.ietf.org/doc/html/rfc8905
+type Entity = {
+ // iban, bitcoin, x-taler-bank. it defined the format
+ target: string;
+ // path1 if the first field to be used
+ path1?: string;
+ // path2 if the second field to be used, optional
+ path2?: string;
+ // params of the payto uri
+ params: {
+ "receiver-name"?: string;
+ sender?: string;
+ message?: string;
+ amount?: string;
+ instruction?: string;
+ [name: string]: string | undefined;
+ };
+};
+
+function isEthereumAddress(address: string) {
+ if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) {
+ return false;
+ } else if (
+ /^(0x|0X)?[0-9a-f]{40}$/.test(address) ||
+ /^(0x|0X)?[0-9A-F]{40}$/.test(address)
+ ) {
+ return true;
+ }
+ return checkAddressChecksum(address);
+}
+
+function checkAddressChecksum(address: string) {
+ //TODO implement ethereum checksum
+ return true;
+}
+
+function validateBitcoin_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ try {
+ const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid bitcoin address.`;
+}
+
+function validateEthereum_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ try {
+ const valid = isEthereumAddress(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid Ethereum address.`;
+}
+
+/**
+ * validates
+ * bank.com/
+ * bank.com
+ * bank.com/path
+ * bank.com/path/subpath/
+ */
+const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+(\/[a-zA-Z0-9-.]+)*\/?$/
+
+function validateTalerBank_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ console.log(addr, DOMAIN_REGEX.test(addr))
+ try {
+ const valid = DOMAIN_REGEX.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid host.`;
+}
+
+/**
+ * An IBAN is validated by converting it into an integer and performing a
+ * basic mod-97 operation (as described in ISO 7064) on it.
+ * If the IBAN is valid, the remainder equals 1.
+ *
+ * The algorithm of IBAN validation is as follows:
+ * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid
+ * 2.- Move the four initial characters to the end of the string
+ * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
+ * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97
+ *
+ * If the remainder is 1, the check digit test is passed and the IBAN might be valid.
+ *
+ */
+function validateIBAN_path1(
+ iban: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ // Check total length
+ if (iban.length < 4)
+ return i18n.str`IBAN numbers usually have more that 4 digits`;
+ if (iban.length > 34)
+ return i18n.str`IBAN numbers usually have less that 34 digits`;
+
+ const A_code = "A".charCodeAt(0);
+ const Z_code = "Z".charCodeAt(0);
+ const IBAN = iban.toUpperCase();
+ // check supported country
+ const code = IBAN.substr(0, 2);
+ const found = code in COUNTRY_TABLE;
+ if (!found) return i18n.str`IBAN country code not found`;
+
+ // 2.- Move the four initial characters to the end of the string
+ const step2 = IBAN.substr(4) + iban.substr(0, 4);
+ const step3 = Array.from(step2)
+ .map((letter) => {
+ const code = letter.charCodeAt(0);
+ if (code < A_code || code > Z_code) return letter;
+ return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
+ })
+ .join("");
+
+ function calculate_iban_checksum(str: string): number {
+ const numberStr = str.substr(0, 5);
+ const rest = str.substr(5);
+ const number = parseInt(numberStr, 10);
+ const result = number % 97;
+ if (rest.length > 0) {
+ return calculate_iban_checksum(`${result}${rest}`);
+ }
+ return result;
+ }
+
+ const checksum = calculate_iban_checksum(step3);
+ if (checksum !== 1)
+ return i18n.str`IBAN number is not valid, checksum is wrong`;
+ return undefined;
+}
+
+// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank']
+const targets = [
+ "Choose one...",
+ "iban",
+ "x-taler-bank",
+ "bitcoin",
+ "ethereum",
+];
+const noTargetValue = targets[0];
+const defaultTarget: Entity = {
+ target: noTargetValue,
+ params: {},
+};
+
+export function InputPaytoForm<T>({
+ name,
+ readonly,
+ label,
+ tooltip,
+}: Props<keyof T>): VNode {
+ const { value: initialValueStr, onChange } = useField<T>(name);
+
+ const initialPayto = parsePaytoUri(initialValueStr ?? "");
+ const paths = !initialPayto ? [] : initialPayto.targetPath.split("/");
+ const initialPath1 = paths.length >= 1 ? paths[0] : undefined;
+ const initialPath2 = paths.length >= 2 ? paths[1] : undefined;
+ const initial: Entity =
+ initialPayto === undefined
+ ? defaultTarget
+ : {
+ target: initialPayto.targetType,
+ params: initialPayto.params,
+ path1: initialPath1,
+ path2: initialPath2,
+ };
+ const [value, setValue] = useState<Partial<Entity>>(initial);
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ target: value.target === noTargetValue ? i18n.str`required` : undefined,
+ path1: !value.path1
+ ? i18n.str`required`
+ : value.target === "iban"
+ ? validateIBAN_path1(value.path1, i18n)
+ : value.target === "bitcoin"
+ ? validateBitcoin_path1(value.path1, i18n)
+ : value.target === "ethereum"
+ ? validateEthereum_path1(value.path1, i18n)
+ : value.target === "x-taler-bank"
+ ? validateTalerBank_path1(value.path1, i18n)
+ : undefined,
+ path2:
+ value.target === "x-taler-bank"
+ ? !value.path2
+ ? i18n.str`required`
+ : undefined
+ : undefined,
+ params: undefinedIfEmpty({
+ "receiver-name": !value.params?.["receiver-name"]
+ ? i18n.str`required`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const path1WithSlash = value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1
+ const str =
+ hasErrors || !value.target
+ ? undefined
+ : stringifyPaytoUri({
+ targetType: value.target,
+ targetPath: value.path2
+ ? `${path1WithSlash}${value.path2}`
+ : value.path1 ?? "",
+ params: value.params ?? ({} as any),
+ isKnown: false,
+ });
+ useEffect(() => {
+ onChange(str as any);
+ }, [str]);
+
+ // const submit = useCallback((): void => {
+ // // const accounts: TalerMerchantApi.AccountAddDetails[] = paytos;
+ // // const alreadyExists =
+ // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1;
+ // // if (!alreadyExists) {
+ // const newValue: TalerMerchantApi.AccountAddDetails = {
+ // payto_uri: paytoURL,
+ // };
+ // if (value.auth) {
+ // if (value.auth.url) {
+ // newValue.credit_facade_url = value.auth.url;
+ // }
+ // if (value.auth.type === "none") {
+ // newValue.credit_facade_credentials = {
+ // type: "none",
+ // };
+ // }
+ // if (value.auth.type === "basic") {
+ // newValue.credit_facade_credentials = {
+ // type: "basic",
+ // username: value.auth.username ?? "",
+ // password: value.auth.password ?? "",
+ // };
+ // }
+ // }
+ // onChange(newValue as any);
+ // // }
+ // // valueHandler(defaultTarget);
+ // }, [value]);
+
+ //FIXME: translating plural singular
+ return (
+ <InputGroup name="payto" label={label} fixed tooltip={tooltip}>
+ <FormProvider<Entity>
+ name="tax"
+ errors={errors}
+ object={value}
+ valueHandler={setValue}
+ >
+ <InputSelector<Entity>
+ name="target"
+ label={i18n.str`Account type`}
+ tooltip={i18n.str`Method to use for wire transfer`}
+ values={targets}
+ readonly={readonly}
+ toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)}
+ />
+
+ {value.target === "ach" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`Routing`}
+ readonly={readonly}
+ tooltip={i18n.str`Routing number.`}
+ />
+ <Input<Entity>
+ name="path2"
+ label={i18n.str`Account`}
+ readonly={readonly}
+ tooltip={i18n.str`Account number.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bic" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`Code`}
+ readonly={readonly}
+ tooltip={i18n.str`Business Identifier Code.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "iban" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`IBAN`}
+ tooltip={i18n.str`International Bank Account Number.`}
+ readonly={readonly}
+ placeholder="DE1231231231"
+ inputExtra={{ style: { textTransform: "uppercase" } }}
+ />
+ </Fragment>
+ )}
+ {value.target === "upi" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Account`}
+ tooltip={i18n.str`Unified Payment Interface.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bitcoin" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Bitcoin protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ethereum" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Ethereum protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ilp" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Interledger protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "void" && <Fragment />}
+ {value.target === "x-taler-bank" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Host`}
+ fromStr={(v) => {
+ if (v.startsWith("http")) {
+ try {
+ const url = new URL(v);
+ return url.host + url.pathname;
+ } catch {
+ return v;
+ }
+ }
+ return v;
+ }}
+ tooltip={i18n.str`Bank host.`}
+ help={<Fragment>
+ <div><i18n.Translate>Without scheme and may include subpath:</i18n.Translate></div>
+ <div>bank.com/</div>
+ <div>bank.com/path/subpath/</div>
+ </Fragment>}
+ />
+ <Input<Entity>
+ name="path2"
+ readonly={readonly}
+ label={i18n.str`Account`}
+ tooltip={i18n.str`Bank account.`}
+ />
+ </Fragment>
+ )}
+
+ {/**
+ * Show additional fields apart from the payto
+ */}
+ {value.target !== noTargetValue && (
+ <Fragment>
+ <Input
+ name="params.receiver-name"
+ readonly={readonly}
+ label={i18n.str`Owner's name`}
+ tooltip={i18n.str`Legal name of the person holding the account.`}
+ />
+ </Fragment>
+ )}
+ </FormProvider>
+ </InputGroup>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx
new file mode 100644
index 000000000..9956a6427
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx
@@ -0,0 +1,204 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import emptyImage from "../../assets/empty.png";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { TranslatedString } from "@gnu-taler/taler-util";
+
+type Entity = {
+ id: string,
+ description: string;
+ image?: string;
+ extra?: string;
+};
+
+export interface Props<T extends Entity> {
+ selected?: T;
+ onChange: (p?: T) => void;
+ label: TranslatedString;
+ list: T[];
+ withImage?: boolean;
+}
+
+interface Search {
+ name: string;
+}
+
+export function InputSearchOnList<T extends Entity>({
+ selected,
+ onChange,
+ label,
+ list,
+ withImage,
+}: Props<T>): VNode {
+ const [nameForm, setNameForm] = useState<Partial<Search>>({
+ name: "",
+ });
+
+ const errors: FormErrors<Search> = {
+ name: undefined,
+ };
+ const { i18n } = useTranslationContext();
+
+ if (selected) {
+ return (
+ <article class="media">
+ {withImage &&
+ <figure class="media-left">
+ <p class="image is-128x128">
+ <img src={selected.image ? selected.image : emptyImage} />
+ </p>
+ </figure>
+ }
+ <div class="media-content">
+ <div class="content">
+ <p class="media-meta">
+ <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b>
+ </p>
+ <p>
+ <i18n.Translate>Description</i18n.Translate>:{" "}
+ {selected.description}
+ </p>
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ onClick={() => onChange(undefined)}
+ >
+ clear
+ </button>
+ </div>
+ </div>
+ </div>
+ </article>
+ );
+ }
+
+ return (
+ <FormProvider<Search>
+ errors={errors}
+ object={nameForm}
+ valueHandler={setNameForm}
+ >
+ <InputWithAddon<Search>
+ name="name"
+ label={label}
+ tooltip={i18n.str`enter description or id`}
+ addonAfter={
+ <span class="icon">
+ <i class="mdi mdi-magnify" />
+ </span>
+ }
+ >
+ <div>
+ <DropdownList
+ name={nameForm.name}
+ list={list}
+ onSelect={(p) => {
+ setNameForm({ name: "" });
+ onChange(p);
+ }}
+ withImage={!!withImage}
+ />
+ </div>
+ </InputWithAddon>
+ </FormProvider>
+ );
+}
+
+interface DropdownListProps<T extends Entity> {
+ name?: string;
+ onSelect: (p: T) => void;
+ list: T[];
+ withImage: boolean;
+}
+
+function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) {
+ const { i18n } = useTranslationContext();
+ if (!name) {
+ /* FIXME
+ this BR is added to occupy the space that will be added when the
+ dropdown appears
+ */
+ return (
+ <div>
+ <br />
+ </div>
+ );
+ }
+ const filtered = list.filter(
+ (p) => p.id.includes(name) || p.description.includes(name),
+ );
+
+ return (
+ <div class="dropdown is-active">
+ <div
+ class="dropdown-menu"
+ id="dropdown-menu"
+ role="menu"
+ style={{ minWidth: "20rem" }}
+ >
+ <div class="dropdown-content">
+ {!filtered.length ? (
+ <div class="dropdown-item">
+ <i18n.Translate>
+ no match found with that description or id
+ </i18n.Translate>
+ </div>
+ ) : (
+ filtered.map((p) => (
+ <div
+ key={p.id}
+ class="dropdown-item"
+ onClick={() => onSelect(p)}
+ style={{ cursor: "pointer" }}
+ >
+ <article class="media">
+ {withImage &&
+ <div class="media-left">
+ <div class="image" style={{ minWidth: 64 }}>
+ <img
+ src={p.image ? p.image : emptyImage}
+ style={{ width: 64, height: 64 }}
+ />
+ </div>
+ </div>
+ }
+ <div class="media-content">
+ <div class="content">
+ <p>
+ <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined}
+ <br />
+ {p.description}
+ </p>
+ </div>
+ </div>
+ </article>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx
new file mode 100644
index 000000000..4de84d984
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "./FormProvider.js";
+import { InputSecured } from "./InputSecured.js";
+
+export default {
+ title: "Components/Form/InputSecured",
+ component: InputSecured,
+};
+
+type T = { auth_token: string | null };
+
+export const InitialValueEmpty = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: "" });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ Initial value: ''
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
+
+export const InitialValueToken = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: "token" });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
+
+export const InitialValueNull = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: null });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ Initial value: ''
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx
new file mode 100644
index 000000000..4a35ad96c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx
@@ -0,0 +1,186 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputProps, useField } from "./useField.js";
+
+export type Props<T> = InputProps<T>;
+
+const TokenStatus = ({ prev, post }: any) => {
+ const { i18n } = useTranslationContext();
+ if (
+ (prev === undefined || prev === null) &&
+ (post === undefined || post === null)
+ )
+ return null;
+ return prev === post ? null : post === null ? (
+ <span class="tag is-danger is-align-self-center ml-2">
+ <i18n.Translate>Deleting</i18n.Translate>
+ </span>
+ ) : (
+ <span class="tag is-warning is-align-self-center ml-2">
+ <i18n.Translate>Changing</i18n.Translate>
+ </span>
+ );
+};
+
+export function InputSecured<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+}: Props<keyof T>): VNode {
+ const { error, value, initial, onChange, toStr, fromStr } = useField<T>(name);
+
+ const [active, setActive] = useState(false);
+ const [newValue, setNuewValue] = useState("");
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ {!active ? (
+ <Fragment>
+ <div class="field has-addons">
+ <button
+ class="button"
+ onClick={(): void => {
+ setActive(!active);
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-reset" />
+ </div>
+ <span>
+ <i18n.Translate>Manage access token</i18n.Translate>
+ </span>
+ </button>
+ <TokenStatus prev={initial} post={value} />
+ </div>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <div class="field has-addons">
+ <div class="control">
+ <a class="button is-static">secret-token:</a>
+ </div>
+ <div class="control is-expanded">
+ <input
+ class="input"
+ type="text"
+ placeholder={placeholder}
+ readonly={readonly || !active}
+ disabled={readonly || !active}
+ name={String(name)}
+ value={newValue}
+ onInput={(e): void => {
+ setNuewValue(e.currentTarget.value);
+ }}
+ />
+ {help}
+ </div>
+ <div class="control">
+ <button
+ class="button is-info"
+ disabled={fromStr(newValue) === value}
+ onClick={(): void => {
+ onChange(fromStr(newValue));
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-outline" />
+ </div>
+ <span>
+ <i18n.Translate>Update</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ </div>
+ </Fragment>
+ )}
+ {error ? <p class="help is-danger">{error}</p> : null}
+ </div>
+ </div>
+ {active && (
+ <div class="field is-horizontal">
+ <div class="field-body is-flex-grow-3">
+ <div class="level" style={{ width: "100%" }}>
+ <div class="level-right is-flex-grow-1">
+ <div class="level-item">
+ <button
+ class="button is-danger"
+ disabled={null === value || undefined === value}
+ onClick={(): void => {
+ onChange(null!);
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-open-variant" />
+ </div>
+ <span>
+ <i18n.Translate>Remove</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ <div class="level-item">
+ <button
+ class="button "
+ onClick={(): void => {
+ onChange(initial!);
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-open-variant" />
+ </div>
+ <span>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx
new file mode 100644
index 000000000..f567f7247
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx
@@ -0,0 +1,94 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ values: any[];
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputSelector<T>({
+ name,
+ readonly,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ values,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-icons-right">
+ <p class={expand ? "control is-expanded select" : "control select "}>
+ <select
+ class={error ? "select is-danger" : "select"}
+ name={String(name)}
+ disabled={readonly}
+ readonly={readonly}
+ onChange={(e) => {
+ onChange(fromStr(e.currentTarget.value));
+ }}
+ >
+ {placeholder && <option>{placeholder}</option>}
+ {values.map((v, i) => {
+ return (
+ <option key={i} value={v} selected={value === v}>
+ {toStr(v)}
+ </option>
+ );
+ })}
+ </select>
+
+ {help}
+ </p>
+ {required && (
+ <span class="icon has-text-danger is-right" style={{height: "2.5em"}}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx
new file mode 100644
index 000000000..d7cf04553
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx
@@ -0,0 +1,162 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { addDays } from "date-fns";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "./FormProvider.js";
+import { InputStock, Stock } from "./InputStock.js";
+
+export default {
+ title: "Components/Form/InputStock",
+ component: InputStock,
+};
+
+type T = { stock?: Stock };
+
+export const CreateStockEmpty = () => {
+ const [state, setState] = useState<Partial<T>>({});
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockUnknownRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 10,
+ lost: 0,
+ sold: 0,
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockNoRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 10,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: "never" },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockWithRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 15,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const UpdatingProductWithManagedStock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 100,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" alreadyExist />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const UpdatingProductWithInfiniteStock = () => {
+ const [state, setState] = useState<Partial<T>>({});
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" alreadyExist />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx
new file mode 100644
index 000000000..8104d1f9f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { TalerMerchantApi, TalerProtocolTimestamp } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h } from "preact";
+import { useLayoutEffect, useState } from "preact/hooks";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { InputDate } from "./InputDate.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputLocation } from "./InputLocation.js";
+import { InputNumber } from "./InputNumber.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ alreadyExist?: boolean;
+}
+
+type Entity = Stock;
+
+export interface Stock {
+ current: number;
+ lost: number;
+ sold: number;
+ address?: TalerMerchantApi.Location;
+ nextRestock?: TalerProtocolTimestamp;
+}
+
+interface StockDelta {
+ incoming: number;
+ lost: number;
+}
+
+export function InputStock<T>({
+ name,
+ tooltip,
+ label,
+ alreadyExist,
+}: Props<keyof T>) {
+ const { error, value, onChange } = useField<T>(name);
+
+ const [errors, setErrors] = useState<FormErrors<Entity>>({});
+
+ const [formValue, valueHandler] = useState<Partial<Entity>>(value);
+ const [addedStock, setAddedStock] = useState<StockDelta>({
+ incoming: 0,
+ lost: 0,
+ });
+ const { i18n } = useTranslationContext();
+
+ useLayoutEffect(() => {
+ if (!formValue) {
+ onChange(undefined as any);
+ } else {
+ onChange({
+ ...formValue,
+ current: (formValue?.current || 0) + addedStock.incoming,
+ lost: (formValue?.lost || 0) + addedStock.lost,
+ } as any);
+ }
+ }, [formValue, addedStock]);
+
+ if (!formValue) {
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-addons">
+ {!alreadyExist ? (
+ <button
+ class="button"
+ data-tooltip={i18n.str`click here to configure the stock of the product, leave it as is and the backend will not control stock`}
+ onClick={(): void => {
+ valueHandler({
+ current: 0,
+ lost: 0,
+ sold: 0,
+ } as Stock as any);
+ }}
+ >
+ <span>
+ <i18n.Translate>Manage stock</i18n.Translate>
+ </span>
+ </button>
+ ) : (
+ <button
+ class="button"
+ data-tooltip={i18n.str`this product has been configured without stock control`}
+ disabled
+ >
+ <span>
+ <i18n.Translate>Infinite</i18n.Translate>
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+ }
+
+ const currentStock =
+ (formValue.current || 0) - (formValue.lost || 0) - (formValue.sold || 0);
+
+ const stockAddedErrors: FormErrors<typeof addedStock> = {
+ lost:
+ currentStock + addedStock.incoming < addedStock.lost
+ ? i18n.str`lost cannot be greater than current and incoming (max ${currentStock + addedStock.incoming
+ })`
+ : undefined,
+ };
+
+ // const stockUpdateDescription = stockAddedErrors.lost ? '' : (
+ // !!addedStock.incoming || !!addedStock.lost ?
+ // i18n.str`current stock will change from ${currentStock} to ${currentStock + addedStock.incoming - addedStock.lost}` :
+ // i18n.str`current stock will stay at ${currentStock}`
+ // )
+
+ return (
+ <Fragment>
+ <div class="card">
+ <header class="card-header">
+ <p class="card-header-title">
+ {label}
+ {tooltip && (
+ <span class="icon" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </p>
+ </header>
+ <div class="card-content">
+ <FormProvider<Entity>
+ name="stock"
+ errors={errors}
+ object={formValue}
+ valueHandler={valueHandler}
+ >
+ {alreadyExist ? (
+ <Fragment>
+ <FormProvider
+ name="added"
+ errors={stockAddedErrors}
+ object={addedStock}
+ valueHandler={setAddedStock as any}
+ >
+ <InputNumber name="incoming" label={i18n.str`Incoming`} />
+ <InputNumber name="lost" label={i18n.str`Lost`} />
+ </FormProvider>
+
+ {/* <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ {stockUpdateDescription}
+ </div>
+ </div>
+ </div> */}
+ </Fragment>
+ ) : (
+ <InputNumber<Entity>
+ name="current"
+ label={i18n.str`Current`}
+ side={
+ <button
+ class="button is-danger"
+ data-tooltip={i18n.str`remove stock control for this product`}
+ onClick={(): void => {
+ valueHandler(undefined as any);
+ }}
+ >
+ <span>
+ <i18n.Translate>without stock</i18n.Translate>
+ </span>
+ </button>
+ }
+ />
+ )}
+
+ <InputDate<Entity>
+ name="nextRestock"
+ label={i18n.str`Next restock`}
+ withTimestampSupport
+ />
+
+ <InputGroup<Entity> name="address" label={i18n.str`Warehouse address`}>
+ <InputLocation name="address" />
+ </InputGroup>
+ </FormProvider>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+// (
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputTab.tsx b/packages/merchant-backoffice-ui/src/components/form/InputTab.tsx
new file mode 100644
index 000000000..1cd88d31a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputTab.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ values: any[];
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputTab<T>({
+ name,
+ readonly,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ values,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-icons-right">
+ <p class={expand ? "control is-expanded " : "control "}>
+ <div class="tabs is-toggle is-fullwidth is-small">
+ <ul>
+ {values.map((v, i) => {
+ return (
+ <li key={i} class={value === v ? "is-active" : ""}
+ onClick={(e) => { onChange(v) }}
+ >
+ <a style={{ cursor: "initial" }}>
+ <span>{toStr(v)}</span>
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ {help}
+ </p>
+ {required && (
+ <span class="icon has-text-danger is-right" style={{ height: "2.5em" }}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx b/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx
new file mode 100644
index 000000000..4392c7659
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useCallback, useState } from "preact/hooks";
+import * as yup from "yup";
+import { TaxSchema as schema } from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { Input } from "./Input.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputProps, useField } from "./useField.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+type Entity = TalerMerchantApi.Tax;
+export function InputTaxes<T>({
+ name,
+ readonly,
+ label,
+}: Props<keyof T>): VNode {
+ const { value: taxes, onChange } = useField<T>(name);
+
+ const [value, valueHandler] = useState<Partial<Entity>>({});
+ // const [errors, setErrors] = useState<FormErrors<Entity>>({})
+
+ let errors: FormErrors<Entity> = {};
+
+ try {
+ schema.validateSync(value, { abortEarly: false });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = useCallback((): void => {
+ onChange([value as any, ...taxes] as any);
+ valueHandler({});
+ }, [value]);
+
+ const { i18n } = useTranslationContext();
+
+ //FIXME: translating plural singular
+ return (
+ <InputGroup
+ name="tax"
+ label={label}
+ alternative={
+ taxes.length > 0 && (
+ <p>This product has {taxes.length} applicable taxes configured.</p>
+ )
+ }
+ >
+ <FormProvider<Entity>
+ name="tax"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body" style={{ display: "block" }}>
+ {taxes.map((v: any, i: number) => (
+ <div
+ key={i}
+ class="tags has-addons mt-3 mb-0 mr-3"
+ style={{ flexWrap: "nowrap" }}
+ >
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ <b>{v.tax}</b>: {v.name}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(taxes.filter((f: any) => f !== v) as any);
+ valueHandler(v);
+ }}
+ />
+ </div>
+ ))}
+ {!taxes.length && i18n.str`No taxes configured for this product.`}
+ </div>
+ </div>
+
+ <Input<Entity>
+ name="tax"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`Taxes can be in currencies that differ from the main currency used by the merchant.`}
+ >
+ <i18n.Translate>
+ Enter currency and value separated with a colon, e.g.
+ &quot;USD:2.3&quot;.
+ </i18n.Translate>
+ </Input>
+
+ <Input<Entity>
+ name="name"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`Legal name of the tax, e.g. VAT or import duties.`}
+ />
+
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ data-tooltip={i18n.str`add tax to the tax list`}
+ disabled={hasErrors}
+ onClick={submit}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ </div>
+ </FormProvider>
+ </InputGroup>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx
new file mode 100644
index 000000000..8c935f33b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ name: T;
+ readonly?: boolean;
+ expand?: boolean;
+ threeState?: boolean;
+ toBoolean?: (v?: any) => boolean | undefined;
+ fromBoolean?: (s: boolean | undefined) => any;
+}
+
+const defaultToBoolean = (f?: any): boolean | undefined => f || "";
+const defaultFromBoolean = (v: boolean | undefined): any => v as any;
+
+export function InputToggle<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ threeState,
+ expand,
+ fromBoolean = defaultFromBoolean,
+ toBoolean = defaultToBoolean,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const onCheckboxClick = (): void => {
+ const c = toBoolean(value);
+ if (c === false && threeState) return onChange(undefined as any);
+ return onChange(fromBoolean(!c));
+ };
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label" >
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
+ <input
+ type="checkbox"
+ class={toBoolean(value) === undefined ? "is-indeterminate" : "toggle-checkbox"}
+ checked={toBoolean(value)}
+ placeholder={placeholder}
+ readonly={readonly}
+ name={String(name)}
+ disabled={readonly}
+ onChange={onCheckboxClick}
+ />
+ <div class={`toggle-switch ${readonly ? "disabled" : ""}`} style={{ cursor: readonly ? "default" : undefined }}></div>
+ </label>
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx
new file mode 100644
index 000000000..b8cd4c2d2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ inputType?: "text" | "number" | "password";
+ addonBefore?: ComponentChildren;
+ addonAfter?: ComponentChildren;
+ addonAfterAction?: () => void;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+ inputExtra?: any;
+ children?: ComponentChildren;
+ side?: ComponentChildren;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputWithAddon<T>({
+ name,
+ readonly,
+ addonBefore,
+ children,
+ expand,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ inputType,
+ inputExtra,
+ side,
+ addonAfter,
+ addonAfterAction,
+ toStr = defaultToString,
+ fromStr = defaultFromString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ {addonBefore && (
+ <div class="control">
+ <a class="button is-static">{addonBefore}</a>
+ </div>
+ )}
+ <p
+ class={`control${expand ? " is-expanded" : ""}${required ? " has-icons-right" : ""
+ }`}
+ >
+ <input
+ {...(inputExtra || {})}
+ class={error ? "input is-danger" : "input"}
+ type={inputType}
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={toStr(value)}
+ onChange={(e): void => onChange(fromStr(e.currentTarget.value))}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {children}
+ </p>
+ {addonAfter && (
+ <div class="control" onClick={addonAfterAction} style={{ cursor: addonAfterAction ? "pointer" : undefined }}>
+ <a class="button is-static">{addonAfter}</a>
+ </div>
+ )}
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ <span class="has-text-grey">{help}</span>
+ </div>
+ {expand ? <div>{side}</div> : side}
+ </div>
+
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx b/packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx
new file mode 100644
index 000000000..f5f9d5b4f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx
@@ -0,0 +1,63 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+
+export function JumpToElementById({ testIfExist, onSelect, placeholder, description }: { placeholder: TranslatedString, description: TranslatedString, testIfExist: (id: string) => Promise<boolean>, onSelect: (id: string) => void }): VNode {
+ const { i18n } = useTranslationContext()
+
+ const [error, setError] = useState<string | undefined>(
+ undefined,
+ );
+
+ const [id, setId] = useState<string>()
+ async function check(currentId: string | undefined): Promise<void> {
+ if (!currentId) {
+ setError(i18n.str`missing id`);
+ return;
+ }
+ try {
+ const exi = await testIfExist(currentId);
+ if (exi) {
+ onSelect(currentId);
+ setError(undefined);
+ } else {
+ setError(i18n.str`not found`);
+ }
+ } catch {
+ setError(i18n.str`not found`);
+ }
+ }
+
+ return <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <div class="field has-addons">
+ <div class="control">
+ <input
+ class={error ? "input is-danger" : "input"}
+ type="text"
+ value={id ?? ""}
+ onChange={(e) => setId(e.currentTarget.value)}
+ placeholder={placeholder}
+ />
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ <span
+ class="has-tooltip-bottom"
+ data-tooltip={description}
+ >
+ <button
+ class="button"
+ onClick={(e) => check(id)}
+ >
+ <span class="icon">
+ <i class="mdi mdi-arrow-right" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/TextField.tsx b/packages/merchant-backoffice-ui/src/components/form/TextField.tsx
new file mode 100644
index 000000000..8f897c2d8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/TextField.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useField, InputProps } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ inputType?: "text" | "number" | "multiline" | "password";
+ expand?: boolean;
+ side?: ComponentChildren;
+ children: ComponentChildren;
+}
+
+export function TextField<T>({
+ name,
+ tooltip,
+ label,
+ expand,
+ help,
+ children,
+ side,
+}: Props<keyof T>): VNode {
+ const { error } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ {children}
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {side}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/useField.tsx b/packages/merchant-backoffice-ui/src/components/form/useField.tsx
new file mode 100644
index 000000000..49bba4984
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/useField.tsx
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useFormContext } from "./FormProvider.js";
+
+interface Use<V> {
+ error?: string;
+ required: boolean;
+ value: any;
+ initial: any;
+ onChange: (v: V) => void;
+ toStr: (f: V | undefined) => string;
+ fromStr: (v: string) => V;
+}
+
+export function useField<T>(name: keyof T): Use<T[typeof name]> {
+ const { errors, object, initialObject, toStr, fromStr, valueHandler } =
+ useFormContext<T>();
+ type P = typeof name;
+ type V = T[P];
+ const [isDirty, setDirty] = useState(false);
+ const updateField =
+ (field: P) =>
+ (value: V): void => {
+ setDirty(true);
+ return valueHandler((prev) => {
+ return setValueDeeper(prev, String(field).split("."), value);
+ });
+ };
+
+ const defaultToString = (f?: V): string => String(!f ? "" : f);
+ const defaultFromString = (v: string): V => v as any;
+ const value = readField(object, String(name));
+ const initial = readField(initialObject, String(name));
+ const hasError = readField(errors, String(name));
+ return {
+ error: isDirty ? hasError : undefined,
+ required: !isDirty && hasError,
+ value,
+ initial,
+ onChange: updateField(name) as any,
+ toStr: toStr[name] ? toStr[name]! : defaultToString,
+ fromStr: fromStr[name] ? fromStr[name]! : defaultFromString,
+ };
+}
+/**
+ * read the field of an object an support accessing it using '.'
+ *
+ * @param object
+ * @param name
+ * @returns
+ */
+const readField = (object: any, name: string) => {
+ return name
+ .split(".")
+ .reduce((prev, current) => prev && prev[current], object);
+};
+
+const setValueDeeper = (object: any, names: string[], value: any): any => {
+ if (names.length === 0) return value;
+ const [head, ...rest] = names;
+ return { ...object, [head]: setValueDeeper(object[head] || {}, rest, value) };
+};
+
+export interface InputProps<T> {
+ name: T;
+ label: ComponentChildren;
+ placeholder?: string;
+ tooltip?: ComponentChildren;
+ readonly?: boolean;
+ help?: ComponentChildren;
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx b/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx
new file mode 100644
index 000000000..4fbfc4a75
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx
@@ -0,0 +1,41 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useFormContext } from "./FormProvider.js";
+
+interface Use {
+ hasError?: boolean;
+}
+
+export function useGroupField<T>(name: keyof T): Use {
+ const f = useFormContext<T>();
+ if (!f) return {};
+
+ return {
+ hasError: readField(f.errors, String(name)),
+ };
+}
+
+const readField = (object: any, name: string) => {
+ return name
+ .split(".")
+ .reduce((prev, current) => prev && prev[current], object);
+};