taler-typescript-core

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

commit 38d9785d612b9ef572fda370a5a0065d35d8fc74
parent 93e2da604268665883b3e18d5b16cc1f0535c670
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Wed,  1 Jul 2026 12:19:37 -0300

partially #10531

Diffstat:
Mpackages/taler-merchant-webui/src/components/form/InputArray.tsx | 24+++++++++++++++++++++++-
Mpackages/taler-merchant-webui/src/index.dev.html | 2+-
Mpackages/taler-merchant-webui/src/paths/instance/orders/create/CreatePage.tsx | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
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> + ); }