taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-merchant-webui/src/components/form/InputArray.tsx | 72++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/taler-merchant-webui/src/components/modal/index.tsx | 2+-
Mpackages/taler-merchant-webui/src/paths/instance/orders/create/CreatePage.tsx | 432++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
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> + ); +}