commit 38d9785d612b9ef572fda370a5a0065d35d8fc74
parent 93e2da604268665883b3e18d5b16cc1f0535c670
Author: Sebastian <sebasjm@taler-systems.com>
Date: Wed, 1 Jul 2026 12:19:37 -0300
partially #10531
Diffstat:
3 files changed, 231 insertions(+), 19 deletions(-)
diff --git a/packages/taler-merchant-webui/src/components/form/InputArray.tsx b/packages/taler-merchant-webui/src/components/form/InputArray.tsx
@@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
+import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { InputProps, useField } from "./useField.js";
import { DropdownList } from "./InputSearchOnList.js";
@@ -36,6 +36,7 @@ export interface Props<T> extends InputProps<T> {
toStr?: (v?: any) => string;
fromStr?: (s: string) => any;
unique?: boolean;
+ withQuantity?: boolean;
}
const defaultToString = (f?: any): string => (f ? String(f) : "");
@@ -51,6 +52,7 @@ export function InputArray<T>({
help,
addonBefore,
getSuggestion,
+ withQuantity,
fromStr = defaultFromString,
toStr = defaultToString,
}: Props<keyof T>): VNode {
@@ -155,6 +157,26 @@ export function InputArray<T>({
) : undefined}
{array.map((v, i) => (
<div key={i} class="tags has-addons mt-3 mb-0">
+ {!withQuantity ? undefined : (
+ <Fragment>
+ <a
+ class="tag is-medium mb-0"
+ // onClick={() => {
+ // onChange(array.filter((f) => f !== v) as T[keyof T]);
+ // }}
+ >
+ <i class="icon mdi mdi-minus" />
+ </a>
+ <a
+ class="tag is-medium mb-0"
+ // onClick={() => {
+ // onChange(array.filter((f) => f !== v) as T[keyof T]);
+ // }}
+ >
+ <i class="icon mdi mdi-plus" />
+ </a>
+ </Fragment>
+ )}
<span
class="tag is-medium is-info mb-0"
style={{ maxWidth: "90%" }}
diff --git a/packages/taler-merchant-webui/src/index.dev.html b/packages/taler-merchant-webui/src/index.dev.html
@@ -32,7 +32,7 @@
<meta name="taler-support" content="uri,api" />
<meta
http-equiv="Content-Security-Policy"
- content="default-src 'none'; manifest-src 'self';frame-src blob:; script-src 'self'; connect-src *; img-src 'self' data:; style-src 'self';font-src 'self';base-uri 'self';form-action 'self'"
+ content="default-src 'none'; manifest-src 'self';frame-src blob:; script-src 'self' 'sha256-AWapjIoD8YptQkmLxE4yFkrc6JqFdkzck9WOzXcZwcc='; connect-src *; img-src 'self' data:; style-src 'self';font-src 'self';base-uri 'self';form-action 'self'"
/>
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="manifest" href="manifest.json" />
diff --git a/packages/taler-merchant-webui/src/paths/instance/orders/create/CreatePage.tsx b/packages/taler-merchant-webui/src/paths/instance/orders/create/CreatePage.tsx
@@ -25,7 +25,11 @@ import {
Amounts,
Duration,
HttpStatusCode,
+ OrderChoice,
+ OrderInput,
+ OrderOutput,
OrderVersion,
+ TalerError,
TalerErrorCode,
TalerMerchantApi,
TalerProtocolDuration,
@@ -33,7 +37,9 @@ import {
} from "@gnu-taler/taler-util";
import {
Button,
+ newSafeHandlerBuilder,
RenderAmountBulma,
+ simpleSafeHandler,
useNotificationContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
@@ -68,6 +74,12 @@ import {
LimitedKycActionWarning,
MissingBankAccountsWarning,
} from "../../accounts/list/index.js";
+import { InputArray } from "../../../../components/form/InputArray.js";
+import {
+ ConfirmModal,
+ SimpleModal,
+} from "../../../../components/modal/index.js";
+import { useInstanceTokenFamilies } from "../../../../hooks/tokenfamily.js";
const TALER_SCREEN_ID = 42;
@@ -147,6 +159,7 @@ interface Entity extends TalerForm {
payments: Partial<Payments>;
shipping: Partial<Shipping>;
extra: Record<string, string>;
+ choices: OrderChoice[];
}
function isInvalidUrl(url: string) {
@@ -289,13 +302,16 @@ export function CreatePage({
? undefined
: {
order: {
- version: OrderVersion.V0,
- amount: price,
- // version: OrderVersion.V1,
- // choices: [{
- // amount: price,
- // max_fee: value.payments.max_fee as AmountString,
- // }],
+ // version: OrderVersion.V0,
+ // amount: price,
+ // max_fee: value.payments.max_fee as AmountString,
+ version: OrderVersion.V1,
+ choices: [
+ {
+ amount: price,
+ max_fee: value.payments.max_fee as AmountString,
+ },
+ ],
summary: summary,
products: productList,
extra: undefinedIfEmpty(value.extra),
@@ -386,6 +402,7 @@ export function CreatePage({
const [editingProduct, setEditingProduct] = useState<
TalerMerchantApi.ProductSold | undefined
>(undefined);
+ const [editChoices, setEditChoices] = useState(false);
const { currency } = useCurrenciesContext();
@@ -464,6 +481,16 @@ export function CreatePage({
<div>
<MissingBankAccountsWarning />
<LimitedKycActionWarning />
+ {/* {editChoices ? ( */}
+ <ChoicesListForm
+ initial={value.choices ?? []}
+ onUpdate={(cs) => {
+ valueHandler((d) => ({ ...d, choices: cs }));
+ setEditChoices(false);
+ }}
+ focus
+ />
+ {/* // ) : undefined} */}
<section class="section is-main-section">
<div class="columns">
<div class="column" />
@@ -550,15 +577,46 @@ export function CreatePage({
: `rise of ${discountOrRiseRounded}%`)
}
tooltip={i18n.str`Amount to be paid by the customer`}
+ side={
+ <Tooltip
+ text={i18n.str`Switch to multiple payment choices`}
+ >
+ <button
+ class="button is-info"
+ type="button"
+ accessKey="+"
+ onClick={() => setEditChoices(true)}
+ >
+ <i class="icon mdi mdi-menu mdi-36px" />
+ </button>
+ </Tooltip>
+ }
/>
</Fragment>
) : (
- <InputCurrency
- name="pricing.order_price"
- focus={true}
- label={i18n.str`Order price`}
- tooltip={i18n.str`Final order price`}
- />
+ <Fragment>
+ <InputCurrency
+ name="pricing.order_price"
+ focus={true}
+ label={i18n.str`Order price`}
+ tooltip={i18n.str`Final order price`}
+ readonly={value.choices !== undefined}
+ side={
+ <Tooltip
+ text={i18n.str`Switch to multiple payment choices`}
+ >
+ <button
+ class="button is-info"
+ type="button"
+ accessKey="+"
+ onClick={() => setEditChoices(true)}
+ >
+ <i class="icon mdi mdi-menu mdi-36px" />
+ </button>
+ </Tooltip>
+ }
+ />
+ </Fragment>
)}
<Input
@@ -609,7 +667,7 @@ export function CreatePage({
>
<InputGroup
name="payments"
- label={i18n.str`Taler payment options`}
+ label={i18n.str`Taler payment settings`}
tooltip={i18n.str`Override default Taler payment settings for this order`}
>
<FragmentPersonaFlag
@@ -762,6 +820,19 @@ export function CreatePage({
: i18n.str`No product with age restriction in this order`
}
/>
+ <InputArray
+ name="categories_map"
+ label={i18n.str`Categories`}
+ getSuggestion={async (v: string) => {
+ return ["asd1", "asd2", "asd3"].map((cat) => {
+ return { description: cat, id: String(cat) };
+ });
+ }}
+ // readonly={categories.length === 0}
+ help={i18n.str`Search by category description or id`}
+ tooltip={i18n.str`Categories where this product will be listed on.`}
+ unique
+ />
</FragmentPersonaFlag>
</InputGroup>
</FragmentPersonaFlag>
@@ -913,7 +984,126 @@ function getAll(s: object): string[] {
});
}
-function describeMissingFields(errors: object | undefined): string[] {
- if (!errors) return [];
- return getAll(errors);
+function ChoicesListForm({
+ focus,
+ initial,
+ onUpdate,
+}: {
+ focus?: boolean;
+ initial: OrderChoice[];
+ onUpdate: (cs?: OrderChoice[]) => void;
+}) {
+ const [choices, setChoices] = useState<OrderChoice[]>(initial);
+ const { i18n } = useTranslationContext();
+ const [value, setValue] = useState<Partial<OrderChoice>>({});
+
+ const errors = undefinedIfEmpty<FormErrors<OrderChoice>>({
+ description: !value?.description ? i18n.str`Required` : undefined,
+ amount: !value?.amount ? i18n.str`Required` : undefined,
+ max_fee:
+ !value || !value.max_fee
+ ? undefined
+ : !!value.amount
+ ? Amounts.cmp(value.amount, value.max_fee) === -1
+ ? i18n.str`Fee can not be higher than amount`
+ : undefined
+ : undefined,
+ });
+
+ // Total price for the choice. The exchange will subtract deposit
+ // fees from that amount before transferring it to the merchant.
+ // amount: AmountString;
+
+ // // Human readable description of the semantics of the choice
+ // // within the contract to be shown to the user at payment.
+ // description?: string;
+
+ // // Map from IETF 47 language tags to localized descriptions.
+ // description_i18n?: InternationalizedString;
+
+ // // Inputs that must be provided by the customer, if this choice is selected.
+ // // Defaults to empty array if not specified.
+ // inputs?: OrderInput[];
+
+ // // Outputs provided by the merchant, if this choice is selected.
+ // // Defaults to empty array if not specified.
+ // outputs?: OrderOutput[];
+
+ // // Maximum total deposit fee accepted by the merchant for this contract.
+ // // Overrides defaults of the merchant instance.
+ // max_fee?: AmountString;
+ const result = useInstanceTokenFamilies();
+
+ const tokens =
+ result instanceof TalerError || !result || result.type === "fail"
+ ? []
+ : result.body.token_families;
+
+ return (
+ <ConfirmModal
+ title={i18n.str`Payment choices`}
+ onCancel={() => onUpdate(undefined)}
+ confirm={
+ !choices.length
+ ? undefined
+ : {
+ handler: simpleSafeHandler(() => onUpdate(choices)),
+ label: i18n.str`Confirm`,
+ }
+ }
+ >
+ <pre>{JSON.stringify(choices, undefined, 2)}</pre>
+ <FormProvider<OrderChoice>
+ errors={errors}
+ object={value}
+ key={choices.length}
+ valueHandler={setValue as any}
+ >
+ <InputCurrency<OrderChoice>
+ name="amount"
+ label={i18n.str`Amount`}
+ focus={focus}
+ tooltip={i18n.str`Total price of the choice.`}
+ />
+ <Input<OrderChoice>
+ name="description"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`Describe the semantic of the choice`}
+ />
+ <InputCurrency<OrderChoice>
+ name="max_fee"
+ label={i18n.str`Max deposit fee`}
+ tooltip={i18n.str`Maximum total deposit fee accepted for this contract.`}
+ />
+ <InputArray<OrderChoice>
+ label={i18n.str`Input tokens`}
+ name="inputs"
+ tooltip={i18n.str`Required for the payment`}
+ readonly={!tokens.length}
+ getSuggestion={async (d)=> tokens.map(t => ({id: t.slug, description: t.description}))}
+ unique
+ withQuantity
+ />
+ <InputArray<OrderChoice>
+ label={i18n.str`Output tokens`}
+ name="outputs"
+ tooltip={i18n.str`After the payment completed`}
+ readonly={!tokens.length}
+ unique
+ withQuantity
+ />
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ onClick={() => {
+ setChoices((prev) => [value as any, ...prev]);
+ setValue({});
+ }}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ </div>
+ </FormProvider>
+ </ConfirmModal>
+ );
}