commit 4c4ff19f0c84c6f947a8abce0ea9bdf6f8ffb60b
parent d70655b71c66882a71bcc711fdb5fa230fbd6344
Author: Sebastian <sebasjm@taler-systems.com>
Date: Wed, 8 Jul 2026 17:05:53 -0300
#10531 merchant can create order with choices
Diffstat:
3 files changed, 376 insertions(+), 130 deletions(-)
diff --git a/packages/taler-merchant-webui/src/components/form/InputArray.tsx b/packages/taler-merchant-webui/src/components/form/InputArray.tsx
@@ -65,6 +65,20 @@ export function InputArray<T>({
>([]);
const { i18n } = useTranslationContext();
+ type Info = Record<string, number>
+ const info: Info = (array).reduce(
+ (prev, cur) => {
+ const id = toStr(cur)
+ if (prev[id] === undefined) {
+ prev[id] = 0;
+ }
+ prev[id] += 1;
+ return prev;
+ },
+ {} as Info,
+ );
+ const toRender = !unique ? (array.map(a => toStr(a))) : Object.keys(info);
+
return (
<div class="field is-horizontal">
<div class="field-label is-normal">
@@ -134,8 +148,7 @@ export function InputArray<T>({
{help}
{error && (
<p class="help is-danger" style={{ fontSize: 16 }}>
- {" "}
- {error}{" "}
+ {error}
</p>
)}
@@ -145,8 +158,12 @@ export function InputArray<T>({
name={currentValue}
list={suggestions}
onSelect={(p): void => {
- if (!unique || array.indexOf(p as any) === -1) {
- onChange([p, ...array] as T[keyof T]);
+ const alreadyExist =
+ (array as Array<string>).findIndex(
+ (c) => c === p.id,
+ ) !== -1;
+ if (!unique || !alreadyExist) {
+ onChange([fromStr(p.id), ...array] as T[keyof T]);
}
setCurrentValue("");
setSuggestions([]);
@@ -155,40 +172,47 @@ export function InputArray<T>({
/>
</div>
) : undefined}
- {array.map((v, i) => (
+ {toRender.map((v, i) => (
<div key={i} class="tags has-addons mt-3 mb-0">
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(array.filter((f) => toStr(f) !== (v)) as T[keyof T]);
+ }}
+ />
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ {v}
+ </span>
{!withQuantity ? undefined : (
<Fragment>
<a
class="tag is-medium mb-0"
- // onClick={() => {
- // onChange(array.filter((f) => f !== v) as T[keyof T]);
- // }}
+ onClick={() => {
+ const idx = array.findIndex((f) => toStr(f) === (v));
+ console.log("minus", v, array, idx)
+ if (idx === -1) return;
+ const narray = [...array]
+ narray.splice(idx, 1);
+ onChange(narray as T[keyof T])
+ }}
>
<i class="icon mdi mdi-minus" />
- </a>
+ </a>
<a
class="tag is-medium mb-0"
- // onClick={() => {
- // onChange(array.filter((f) => f !== v) as T[keyof T]);
- // }}
+ onClick={() => {
+ const ns = [fromStr(v), ...array] as T[keyof T]
+ onChange(ns);
+ }}
>
<i class="icon mdi mdi-plus" />
</a>
</Fragment>
)}
- <span
- class="tag is-medium is-info mb-0"
- style={{ maxWidth: "90%" }}
- >
- {getSuggestion ? (v as any).description : toStr(v)}
- </span>
- <a
- class="tag is-medium is-danger is-delete mb-0"
- onClick={() => {
- onChange(array.filter((f) => f !== v) as T[keyof T]);
- }}
- />
+ {!withQuantity ? undefined : <div>{info[v]}</div>}
</div>
))}
</div>
diff --git a/packages/taler-merchant-webui/src/components/modal/index.tsx b/packages/taler-merchant-webui/src/components/modal/index.tsx
@@ -94,7 +94,7 @@ export function ConfirmModal({
class="modal-background "
onClick={preventBackgroundClose ? undefined : onCancel}
/>
- <div class="modal-card" style={{ maxWidth: 700 }}>
+ <div class="modal-card" style={{ maxWidth: 900 }}>
<header class="modal-card-head">
<p class="modal-card-title">
<b>{title}</b>
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
@@ -26,22 +26,23 @@ import {
Duration,
HttpStatusCode,
OrderChoice,
- OrderInput,
- OrderOutput,
+ OrderInputToken,
+ OrderInputType,
+ OrderOutputToken,
+ OrderOutputType,
OrderVersion,
TalerError,
TalerErrorCode,
TalerMerchantApi,
TalerProtocolDuration,
- assertUnreachable,
+ assertUnreachable
} from "@gnu-taler/taler-util";
import {
Button,
- newSafeHandlerBuilder,
RenderAmountBulma,
simpleSafeHandler,
useNotificationContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { format, isFuture } from "date-fns";
import { Fragment, VNode, h } from "preact";
@@ -52,6 +53,7 @@ import {
TalerForm,
} from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js";
+import { InputArray } from "../../../../components/form/InputArray.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDate } from "../../../../components/form/InputDate.js";
import { InputDurationDropdown } from "../../../../components/form/InputDurationDropdown.js";
@@ -60,6 +62,9 @@ import { InputLocation } from "../../../../components/form/InputLocation.js";
import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputToggle } from "../../../../components/form/InputToggle.js";
import { FragmentPersonaFlag } from "../../../../components/menu/SideBar.js";
+import {
+ ConfirmModal
+} from "../../../../components/modal/index.js";
import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js";
import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js";
import { ProductList } from "../../../../components/product/ProductList.js";
@@ -68,18 +73,13 @@ import { useCurrenciesContext } from "../../../../context/currency.js";
import { useSessionContext } from "../../../../context/session.js";
import { WithId } from "../../../../declaration.js";
import { UIElement } from "../../../../hooks/preference.js";
+import { useInstanceTokenFamilies } from "../../../../hooks/tokenfamily.js";
import { rate } from "../../../../utils/amount.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
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;
@@ -194,13 +194,16 @@ export function CreatePage({
const errors = undefinedIfEmpty<FormErrors<Entity>>({
pricing: undefinedIfEmpty<FormErrors<Pricing>>({
summary: !value.pricing?.summary ? i18n.str`Required` : undefined,
- order_price: !value.pricing?.order_price
- ? i18n.str`Required`
- : !parsedPrice
- ? i18n.str`Invalid`
- : Amounts.isZero(parsedPrice)
- ? i18n.str`Must be greater than 0`
- : undefined,
+ order_price:
+ value.choices !== undefined // optional only if multiple-choice order
+ ? undefined
+ : !value.pricing?.order_price
+ ? i18n.str`Required`
+ : !parsedPrice
+ ? i18n.str`Invalid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`Must be greater than 0`
+ : undefined,
}),
payments: undefinedIfEmpty({
wire_transfer_delay: !value.payments?.wire_transfer_delay
@@ -297,46 +300,61 @@ export function CreatePage({
AbsoluteTime.addDuration(AbsoluteTime.now(), wireDelay),
);
+ const orderCommons: undefined | TalerMerchantApi.OrderCommon =
+ !value.payments || !value.shipping || !summary || errors !== undefined
+ ? undefined
+ : {
+ summary: summary,
+ products: productList,
+ extra: undefinedIfEmpty(value.extra),
+ pay_deadline,
+ refund_deadline,
+ wire_transfer_deadline,
+ auto_refund: value.payments.auto_refund_delay
+ ? Duration.toTalerProtocolDuration(value.payments.auto_refund_delay)
+ : undefined,
+ delivery_date: value.shipping.delivery_date
+ ? { t_s: value.shipping.delivery_date.getTime() / 1000 }
+ : undefined,
+ delivery_location: value.shipping.delivery_location,
+ fulfillment_url: value.shipping.fulfillment_url,
+ fulfillment_message: value.shipping.fulfillment_message,
+ minimum_age: value.payments.minimum_age,
+ };
+
+ const orderV0: TalerMerchantApi.OrderV0 | undefined =
+ orderCommons === undefined || price === undefined
+ ? undefined
+ : {
+ version: OrderVersion.V0,
+ amount: price,
+ max_fee: value.payments?.max_fee as AmountString,
+ ...orderCommons,
+ };
+
+ const orderV1: TalerMerchantApi.OrderV1 | undefined =
+ orderCommons === undefined || price !== undefined
+ ? undefined
+ : {
+ version: OrderVersion.V1,
+ choices: value.choices,
+ ...orderCommons,
+ };
+
+ const orderInfo: TalerMerchantApi.Order | undefined = orderV0 ?? orderV1
+
const request: undefined | TalerMerchantApi.PostOrderRequest =
- !value.payments || !value.shipping || !price || !summary
+ !orderInfo || !value.payments
? undefined
: {
- order: {
- // 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),
- pay_deadline,
- refund_deadline,
- wire_transfer_deadline,
- auto_refund: value.payments.auto_refund_delay
- ? Duration.toTalerProtocolDuration(
- value.payments.auto_refund_delay,
- )
- : undefined,
- delivery_date: value.shipping.delivery_date
- ? { t_s: value.shipping.delivery_date.getTime() / 1000 }
- : undefined,
- delivery_location: value.shipping.delivery_location,
- fulfillment_url: value.shipping.fulfillment_url,
- fulfillment_message: value.shipping.fulfillment_message,
- minimum_age: value.payments.minimum_age,
- },
+ order: orderInfo,
inventory_products: inventoryList.map((p) => ({
product_id: p.product.id,
quantity: p.quantity,
})),
create_token: value.payments.createToken,
};
+
const { actionHandler, showError } = useNotificationContext();
const create = actionHandler(
@@ -402,7 +420,7 @@ export function CreatePage({
const [editingProduct, setEditingProduct] = useState<
TalerMerchantApi.ProductSold | undefined
>(undefined);
- const [editChoices, setEditChoices] = useState(false);
+ const [editChoices, setEditChoices] = useState(true);
const { currency } = useCurrenciesContext();
@@ -481,7 +499,7 @@ export function CreatePage({
<div>
<MissingBankAccountsWarning />
<LimitedKycActionWarning />
- {/* {editChoices ? ( */}
+ {editChoices ? (
<ChoicesListForm
initial={value.choices ?? []}
onUpdate={(cs) => {
@@ -490,7 +508,7 @@ export function CreatePage({
}}
focus
/>
- {/* // ) : undefined} */}
+ ) : undefined}
<section class="section is-main-section">
<div class="columns">
<div class="column" />
@@ -618,6 +636,11 @@ export function CreatePage({
/>
</Fragment>
)}
+ {!value.choices ? undefined : (
+ <Fragment>
+ <RenderChoices choices={value.choices} />
+ </Fragment>
+ )}
<Input
name="pricing.summary"
@@ -820,19 +843,6 @@ 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 in which this product will be listed.`}
- unique
- />
</FragmentPersonaFlag>
</InputGroup>
</FragmentPersonaFlag>
@@ -993,7 +1003,15 @@ function ChoicesListForm({
initial: OrderChoice[];
onUpdate: (cs?: OrderChoice[]) => void;
}) {
+ const [formKey, setFormKey] = useState(0);
+ function resetForm() {
+ setFormKey((f) => f + 1);
+ setValue({});
+ setEditing(undefined);
+ }
const [choices, setChoices] = useState<OrderChoice[]>(initial);
+ const [editing, setEditing] = useState<number | undefined>();
+ const dirty = choices !== initial;
const { i18n } = useTranslationContext();
const [value, setValue] = useState<Partial<OrderChoice>>({});
@@ -1010,28 +1028,10 @@ function ChoicesListForm({
: 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[];
+ // FIXME: missing support for valid_at in output token
+ // FIXME: this only support token output type
+ // FIXME: no support for i18n in description
- // // 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 =
@@ -1039,12 +1039,20 @@ function ChoicesListForm({
? []
: result.body.token_families;
+ const tokenMap = tokens.reduce(
+ (prev, cur) => {
+ prev[cur.slug] = cur;
+ return prev;
+ },
+ {} as Record<string, TalerMerchantApi.TokenFamilySummary>,
+ );
+
return (
<ConfirmModal
- title={i18n.str`Payment choices`}
+ title={i18n.str`Payment with multiple choices`}
onCancel={() => onUpdate(undefined)}
confirm={
- !choices.length
+ !choices.length || !dirty
? undefined
: {
handler: simpleSafeHandler(() => onUpdate(choices)),
@@ -1052,11 +1060,45 @@ function ChoicesListForm({
}
}
>
- <pre>{JSON.stringify(choices, undefined, 2)}</pre>
+ {!choices.length ? undefined : choices.length < 3 ? (
+ <RenderChoices
+ choices={choices}
+ onEdit={(idx) => {
+ const ch = choices[idx];
+ setValue(ch);
+ setEditing(idx);
+ }}
+ onRemove={(idx) => {
+ const nc = [...choices];
+ nc.splice(idx, 1);
+ setChoices(nc);
+ }}
+ />
+ ) : (
+ <InputGroup
+ name=""
+ label={i18n.str`${choices.length} choices`}
+ alternative={choices.map((c) => c.description).join(", ")}
+ >
+ <RenderChoices
+ choices={choices}
+ onEdit={(idx) => {
+ const ch = choices[idx];
+ setValue(ch);
+ setEditing(idx);
+ }}
+ onRemove={(idx) => {
+ const nc = [...choices];
+ nc.splice(idx, 1);
+ setChoices(nc);
+ }}
+ />
+ </InputGroup>
+ )}
<FormProvider<OrderChoice>
errors={errors}
object={value}
- key={choices.length}
+ key={formKey}
valueHandler={setValue as any}
>
<InputCurrency<OrderChoice>
@@ -1070,17 +1112,19 @@ function ChoicesListForm({
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}))}
+ getSuggestion={async () =>
+ tokens.map((t) => ({ id: t.slug, description: t.name }))
+ }
+ toStr={(d: OrderInputToken) => d.token_family_slug}
+ fromStr={(id: string): OrderInputToken => ({
+ type: OrderInputType.Token,
+ token_family_slug: tokenMap[id].slug,
+ })}
unique
withQuantity
/>
@@ -1089,21 +1133,199 @@ function ChoicesListForm({
name="outputs"
tooltip={i18n.str`After the payment completed`}
readonly={!tokens.length}
+ getSuggestion={async () =>
+ tokens.map((t) => ({ id: t.slug, description: t.name }))
+ }
+ toStr={(d: OrderOutputToken) => d.token_family_slug}
+ fromStr={(id: string): OrderOutputToken => ({
+ type: OrderOutputType.Token,
+ token_family_slug: tokenMap[id].slug,
+ })}
unique
withQuantity
/>
+ <InputCurrency<OrderChoice>
+ name="max_fee"
+ label={i18n.str`Max deposit fee`}
+ tooltip={i18n.str`Maximum total deposit fee accepted for this payment choice, overriding the contract definition.`}
+ />
<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>
+ {editing !== undefined ? (
+ <Fragment>
+ <button
+ class="button "
+ disabled={!!errors}
+ onClick={() => {
+ resetForm();
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info"
+ disabled={!!errors}
+ onClick={() => {
+ setChoices((prev) => {
+ const nv = [...prev];
+ nv[editing] = value as OrderChoice;
+ return nv;
+ });
+ resetForm();
+ }}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </Fragment>
+ ) : (
+ <button
+ class="button is-info"
+ disabled={!!errors}
+ onClick={() => {
+ setChoices((prev) => [value as any, ...prev]);
+ resetForm();
+ }}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ )}
</div>
</FormProvider>
</ConfirmModal>
);
}
+
+function RenderChoices({
+ choices,
+ onEdit,
+ onRemove,
+}: {
+ onEdit?: (idx: number) => void;
+ onRemove?: (idx: number) => void;
+ choices: OrderChoice[];
+}): VNode {
+ const { config } = useSessionContext();
+ const { i18n } = useTranslationContext();
+ const actionColumn = onEdit !== undefined || onRemove !== undefined;
+ return (
+ <Fragment>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <td>
+ <i18n.Translate>Amount</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>Description</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>Input tokens</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>Output tokens</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>Max fee</i18n.Translate>
+ </td>
+ {!actionColumn ? undefined : <td />}
+ </tr>
+ </thead>
+ <tbody>
+ {choices.map((c, idx) => {
+ type TokenCount = { [s: string]: number };
+ const tInput = (c.inputs ?? [])
+ .map((i) => i.token_family_slug)
+ .reduce((prev, cur) => {
+ if (prev[cur] === undefined) {
+ prev[cur] = 0;
+ }
+ prev[cur] += 1;
+ return prev;
+ }, {} as TokenCount);
+ const tOutput = (c.outputs ?? [])
+ .map((i) => {
+ switch (i.type) {
+ case OrderOutputType.Token:
+ return i.token_family_slug;
+ case OrderOutputType.TaxReceipt:
+ return i.donau_urls.join(", ");
+ default:
+ assertUnreachable(i);
+ }
+ })
+ .reduce((prev, cur) => {
+ if (prev[cur] === undefined) {
+ prev[cur] = 0;
+ }
+ prev[cur] += 1;
+ return prev;
+ }, {} as TokenCount);
+ return (
+ <tr>
+ <td>
+ <RenderAmountBulma
+ value={Amounts.parseOrThrow(c.amount)}
+ specMap={config.currencies}
+ />
+ </td>
+ <td>{c.description}</td>
+ <td>
+ {Object.entries(tInput)
+ .map(([id, value]) => {
+ return `${id} (${value})`;
+ })
+ .join(", ")}
+ </td>
+ <td>
+ {(c.outputs ?? [])
+ .map((i) => {
+ switch (i.type) {
+ case OrderOutputType.Token:
+ return i.token_family_slug;
+ case OrderOutputType.TaxReceipt:
+ return i.donau_urls.join(", ");
+ }
+ })
+ .join(", ")}
+ </td>
+ <td>
+ {c.max_fee ? (
+ <RenderAmountBulma
+ value={Amounts.parseOrThrow(c.max_fee)}
+ specMap={config.currencies}
+ />
+ ) : (
+ <i18n.Translate>Contract default</i18n.Translate>
+ )}
+ </td>
+ {!actionColumn ? undefined : (
+ <td>
+ {!onEdit ? undefined : (
+ <button
+ type="button"
+ class="button is-info"
+ style={{ padding: 8, margin: 4, height: "1em" }}
+ onClick={(): void => onEdit(idx)}
+ >
+ <i class="icon mdi mdi-pen" />
+ </button>
+ )}
+ {!onRemove ? undefined : (
+ <button
+ type="button"
+ class="button is-danger"
+ style={{ padding: 8, margin: 4, height: "1em" }}
+ onClick={(): void => onRemove(idx)}
+ >
+ <i class="icon mdi mdi-trash-can" />
+ </button>
+ )}
+ </td>
+ )}
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </Fragment>
+ );
+}