taler-typescript-core

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

commit 12c0a8d824c473b140906dd3e0baf0c468675d89
parent 755bb130613fb4dc5116ff9f26b88bde2b61614e
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 22:34:51 +0100

merchant web UI: edit inventory options and gate creation on advanced tools

Diffstat:
Mpackages/taler-merchant-webui/src/api/hooks/useInventory.ts | 10++++++----
Mpackages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx | 15+++++++++++++++
Mpackages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx | 318++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx | 329++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Apackages/taler-merchant-webui/src/screens/InventoryTemplateFields.tsx | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 788 insertions(+), 117 deletions(-)

diff --git a/packages/taler-merchant-webui/src/api/hooks/useInventory.ts b/packages/taler-merchant-webui/src/api/hooks/useInventory.ts @@ -27,13 +27,15 @@ import { mapWithConcurrency } from "../concurrency.js"; export type { CategoryItem }; -export function useInventory() { +export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { const { t } = useTranslation(); const token = session.value.token; const config = getClientConfig(); const swr = useSWR( - token ? ["listProducts", config.rootUrl.href, config.account, token] : null, + token && enabled + ? ["listProducts", config.rootUrl.href, config.account, token] + : null, async () => { const client = merchantClient(config); let rawProducts: Array< @@ -248,13 +250,13 @@ export function useProductDetails(productId?: string) { }; } -export function useCategories() { +export function useCategories({ enabled = true }: { enabled?: boolean } = {}) { const { t } = useTranslation(); const token = session.value.token; const config = getClientConfig(); const swr = useSWR( - token + token && enabled ? ["listCategories", config.rootUrl.href, config.account, token] : null, async () => { diff --git a/packages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx @@ -14,9 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { TemplateType } from "@gnu-taler/taler-util"; +import { preferences } from "../stores/preferences.js"; import type { VNode } from "preact"; import { useTemplates, + useInventory, + useCategories, useTemplateDetails, useMerchantConfig, usePayoutAccounts, @@ -35,12 +39,23 @@ export function CreateTemplateRoute({ editId }: { editId?: string }): VNode { const { accounts } = usePayoutAccounts(); const { devices, resource: devicesResource } = useOtpDevices(); + const enabled = + preferences.value.advancedTools || + template?.templateType === TemplateType.INVENTORY_CART; + const { products, resource: productsResource } = useInventory({ enabled }); + const { categories, resource: categoriesResource } = useCategories({ + enabled, + }); const payoutCurrencies = (accounts ?? []) .map((a: { currency?: string }) => a.currency) .filter((c: string | undefined): c is string => Boolean(c)); return ( <CreateTemplateScreen + products={products} + categories={categories} + productsResource={productsResource} + categoriesResource={categoriesResource} editId={editId} template={template} templateResource={templateResource} diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx @@ -14,11 +14,12 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { preferences, updatePreference } from "../stores/preferences.js"; import assert from "node:assert/strict"; import { test } from "node:test"; import { render } from "preact"; import { act } from "preact/test-utils"; -import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { TalerErrorCode, TemplateType } from "@gnu-taler/taler-util"; import { CreateTemplateScreen, type CreateTemplateScreenProps, @@ -99,6 +100,119 @@ function fillNewTemplate(container: HTMLElement): void { setField(container, "tmpl_amount_input", "5"); } +function chooseSellingOption(container: HTMLElement, title: string): void { + const option = [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes(title), + ); + assert.ok(option); + act(() => option.click()); +} + +for (const switchBack of [false, true]) { + test(`inventory creation sends the selected contract type (switch back: ${switchBack})`, async () => { + const contracts: unknown[] = []; + const saved: TemplateItem[] = []; + await withScreen( + { + onCreateTemplate: async (_id, _name, contract) => { + contracts.push(contract); + }, + onSave: (value) => saved.push(value), + }, + async (container) => { + act(() => updatePreference("advancedTools", true)); + fillNewTemplate(container); + chooseSellingOption(container, "Inventory products"); + assert.equal(container.querySelector("#tmpl_amount_input"), null); + if (switchBack) chooseSellingOption(container, "A fixed amount"); + await submit(container); + const expected = switchBack + ? { template_type: TemplateType.FIXED_ORDER, amount: "CHF:5" } + : { + template_type: TemplateType.INVENTORY_CART, + selected_all: true, + currency: "CHF", + }; + assert.deepEqual(contracts, [expected]); + assert.equal(saved[0]?.templateType, expected.template_type); + assert.deepEqual(saved[0]?.rawContract, expected); + }, + ); + }); +} + +test("changing an open-amount template to inventory removes its suggested total", async () => { + const calls: unknown[][] = []; + await withScreen( + { + editId: "coffee", + initialTemplate: { + id: "coffee", + name: "Coffee", + type: "custom_amount", + templateType: TemplateType.FIXED_ORDER, + rawContract: { + template_type: TemplateType.FIXED_ORDER, + currency: "CHF", + }, + suggestedAmount: "CHF:5", + rawEditableDefaults: { amount: "CHF:5", summary: "Coffee break" }, + suggestedSummary: "Coffee break", + }, + onUpdateTemplate: async (...args) => { + calls.push(args); + }, + }, + async (container) => { + act(() => updatePreference("advancedTools", true)); + chooseSellingOption(container, "Inventory products"); + await submit(container); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0]![2], { + template_type: TemplateType.INVENTORY_CART, + selected_all: true, + currency: "CHF", + }); + assert.deepEqual((calls[0]![3] as TemplateWriteExtras).editableDefaults, { + summary: "Coffee break", + }); + }, + ); +}); + +test("editing an inventory template preserves its product and category restrictions", async () => { + const contract = { + template_type: TemplateType.INVENTORY_CART, + selected_products: ["coffee"], + selected_categories: [7], + choose_one: true, + request_tip: true, + }; + const calls: unknown[][] = []; + await withScreen( + { + editId: "coffee", + initialTemplate: { + id: "coffee", + name: "Coffee", + type: "catalogue", + templateType: TemplateType.INVENTORY_CART, + rawContract: contract, + }, + onUpdateTemplate: async (...args) => { + calls.push(args); + }, + }, + async (container) => { + setField(container, "tmpl_name_input", "Coffee selection"); + await submit(container); + assert.equal(calls.length, 1); + assert.equal(calls[0]![1], "Coffee selection"); + assert.deepEqual(calls[0]![2], contract); + }, + ); +}); + test("template ID suggestions follow name and list changes without overwriting manual IDs", async () => { await withScreen({}, async (container) => { click(container, "Show advanced options"); @@ -296,6 +410,7 @@ async function withScreen( props: CreateTemplateScreenProps, check: (container: HTMLElement) => Promise<void>, ): Promise<void> { + const originalPreferences = preferences.value; const container = document.createElement("div"); document.body.appendChild(container); try { @@ -313,6 +428,7 @@ async function withScreen( } finally { act(() => render(null, container)); container.remove(); + preferences.value = originalPreferences; } } @@ -536,3 +652,203 @@ test("template creation keeps its OTP choice when currency configuration arrives }, ); }); + +test("normal mode hides inventory creation and rejects a draft after disabling advanced tools", async () => { + let writes = 0; + await withScreen( + { + onCreateTemplate: async () => { + writes++; + }, + }, + async (container) => { + act(() => updatePreference("advancedTools", false)); + assert.equal( + [...container.querySelectorAll("button")].some((b) => + b.textContent?.includes("Inventory products"), + ), + false, + ); + act(() => updatePreference("advancedTools", true)); + fillNewTemplate(container); + chooseSellingOption(container, "Inventory products"); + act(() => updatePreference("advancedTools", false)); + await submit(container); + assert.equal(writes, 0); + assert.match(container.textContent ?? "", /Enable Advanced tools/); + }, + ); +}); + +test("inventory editing in normal mode changes options while retaining missing selected IDs", async () => { + const writes: unknown[] = []; + const contract = { + template_type: TemplateType.INVENTORY_CART, + selected_products: ["missing"], + selected_categories: [7], + currency: "CHF", + min_amount: "CHF:1", + max_amount: "CHF:10", + max_pickup_duration: { d_us: 0 }, + }; + await withScreen( + { + editId: "drinks", + initialTemplate: { + id: "drinks", + name: "Drinks", + type: "catalogue", + templateType: TemplateType.INVENTORY_CART, + rawContract: contract, + }, + onUpdateTemplate: async (_id, _name, c) => { + writes.push(c); + }, + }, + async (container) => { + assert.equal(preferences.value.advancedTools, false); + assert.match(container.textContent ?? "", /missing/); + act(() => + container.querySelector<HTMLInputElement>("#tmpl_choose_one")!.click(), + ); + act(() => + container.querySelector<HTMLInputElement>("#tmpl_request_tip")!.click(), + ); + setField(container, "tmpl_max_amount", "20"); + await submit(container); + assert.deepEqual(writes, [ + { + ...contract, + max_amount: "CHF:20", + choose_one: true, + request_tip: true, + }, + ]); + }, + ); +}); + +test("invalid cart limits and empty filters prevent creation", async () => { + let writes = 0; + await withScreen( + { + onCreateTemplate: async () => { + writes++; + }, + }, + async (container) => { + act(() => updatePreference("advancedTools", true)); + fillNewTemplate(container); + chooseSellingOption(container, "Inventory products"); + setField(container, "tmpl_min_amount", "20"); + setField(container, "tmpl_max_amount", "10"); + await submit(container); + assert.equal(writes, 0); + assert.match( + container.textContent ?? "", + /minimum cart total must not exceed/, + ); + setField(container, "tmpl_min_amount", ""); + setField(container, "tmpl_max_amount", ""); + act(() => { + const scope = container.querySelector<HTMLSelectElement>( + "#tmpl_inventory_scope", + )!; + scope.value = "selected"; + scope.dispatchEvent(new Event("change", { bubbles: true })); + }); + await submit(container); + assert.equal(writes, 0); + assert.match( + container.textContent ?? "", + /Select at least one product or category/, + ); + }, + ); +}); + +test("payment timeout rejects forever and can revert to the account default", async () => { + const writes: unknown[] = []; + await withScreen( + { + editId: "drinks", + initialTemplate: { + id: "drinks", + name: "Drinks", + type: "catalogue", + templateType: TemplateType.INVENTORY_CART, + rawContract: { + template_type: TemplateType.INVENTORY_CART, + selected_all: true, + }, + payDeadlineUs: "forever", + }, + onUpdateTemplate: async (_id, _name, c) => { + writes.push(c); + }, + }, + async (container) => { + await submit(container); + assert.equal(writes.length, 0); + click(container, "Show advanced options"); + act(() => + container.querySelector<HTMLInputElement>("#tmpl_pay_default")!.click(), + ); + await submit(container); + assert.deepEqual(writes, [ + { template_type: TemplateType.INVENTORY_CART, selected_all: true }, + ]); + }, + ); +}); + +test("catalogue failures allow retry and saving existing inventory restrictions", async () => { + const writes: unknown[] = []; + let retries = 0; + const failedResource = { + data: undefined, + error: normalizeApiFailure(new Error("offline")), + isLoading: false, + isRefreshing: false, + refresh: async () => { + retries++; + }, + }; + const contract = { + template_type: TemplateType.INVENTORY_CART, + selected_products: ["missing"], + selected_categories: [99], + }; + await withScreen( + { + editId: "drinks", + initialTemplate: { + id: "drinks", + name: "Drinks", + type: "catalogue", + templateType: TemplateType.INVENTORY_CART, + rawContract: contract, + }, + productsResource: failedResource, + categoriesResource: failedResource, + onUpdateTemplate: async (_id, _name, c) => { + writes.push(c); + }, + }, + async (container) => { + assert.match( + container.textContent ?? "", + /Product catalogue could not be loaded/, + ); + assert.match( + container.textContent ?? "", + /Categories could not be loaded/, + ); + click(container, "Refresh"); + assert.equal(retries, 1); + setField(container, "tmpl_name_input", "Renamed"); + await submit(container); + assert.deepEqual(writes, [contract]); + }, + ); +}); diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx @@ -15,6 +15,15 @@ */ import type { VNode } from "preact"; +import { preferences } from "../stores/preferences.js"; +import { + InventoryTemplateFields, + type InventoryTemplateCatalogProps, +} from "./InventoryTemplateFields.js"; +import { + inventoryTemplateDraft, + validateInventoryTemplate, +} from "../utils/inventoryTemplates.js"; import { session } from "../stores/session.js"; import { useState, useLayoutEffect, useRef } from "preact/hooks"; import { useLocation } from "wouter-preact"; @@ -53,7 +62,7 @@ const DEFAULT_PAY_DURATION_US = 30 * 60 * 1_000_000; /** The starting price a new fixed-amount template is filled in with. */ -export interface CreateTemplateScreenProps { +export interface CreateTemplateScreenProps extends InventoryTemplateCatalogProps { editId?: string; initialTemplate?: TemplateItem; onSave?: (template: TemplateItem) => void; @@ -144,6 +153,10 @@ function CreateTemplateForm({ editId, initialTemplate, onSave, + products, + categories, + productsResource, + categoriesResource, }: CreateTemplateScreenProps): VNode { const { t } = useTranslation(); const [, setLocation] = useLocation(); @@ -162,7 +175,9 @@ function CreateTemplateForm({ // Form State (Spec 08 § 8.3) const [selType, setSelType] = useState<TemplateItem["type"]>( - existing?.type || "fixed", + existing?.templateType === TemplateType.INVENTORY_CART + ? "catalogue" + : existing?.type || "fixed", ); const [name, setName] = useState<string>(existing?.name || ""); const [templateId, setTemplateId] = useState<string>(existing?.id || ""); @@ -236,6 +251,28 @@ function CreateTemplateForm({ (isEditing ? existing?.templateType : undefined) ?? TemplateType.FIXED_ORDER; const canChooseWhatItSells = storedType === TemplateType.FIXED_ORDER; + const isInventory = + storedType === TemplateType.INVENTORY_CART || + (canChooseWhatItSells && selType === "catalogue"); + const advancedTools = preferences.value.advancedTools; + const [inventory, setInventory] = useState(() => + inventoryTemplateDraft( + storedType === TemplateType.INVENTORY_CART + ? existing?.rawContract + : undefined, + startingCurrency, + ), + ); + const [pickupValid, setPickupValid] = useState(true); + const inventoryTouched = useRef(false); + useLayoutEffect(() => { + if ( + !inventoryTouched.current && + storedType !== TemplateType.INVENTORY_CART && + startingCurrency + ) + setInventory((draft) => ({ ...draft, currency: startingCurrency })); + }, [startingCurrency, storedType]); // A field is either fixed by the merchant above or left to the customer; // only what is left open can be given a suggested starting value, which is @@ -268,11 +305,32 @@ function CreateTemplateForm({ if (submitting.current) return; setRawError(undefined); setIdConflict(false); - if (!startingCurrency) { + if (!startingCurrency && !isInventory) { setErrorMsg(t`Currency configuration is unavailable.`); return; } - if (!payDurationValid) { + if ( + isInventory && + storedType !== TemplateType.INVENTORY_CART && + !preferences.value.advancedTools + ) { + setErrorMsg(t`Enable Advanced tools to create an inventory template.`); + return; + } + if (isInventory) { + const invalid = validateInventoryTemplate(inventory, t); + if (invalid || !pickupValid) { + setErrorMsg(invalid ?? t`Enter a valid pickup or access duration.`); + return; + } + } + if ( + payDurationSet && + (!payDurationValid || + payDurationUs === "forever" || + !Number.isSafeInteger(payDurationUs) || + payDurationUs <= 0) + ) { setErrorMsg(t`Enter a valid payment duration.`, "tmpl_time_pay_input"); return; } @@ -322,47 +380,52 @@ function CreateTemplateForm({ const trimmedSummary = summary.trim(); - const contract = buildTemplateContract({ - storedContract: existing?.rawContract, - templateType: storedType, - canChooseWhatItSells, - selType, - summary: trimmedSummary, - amount: formattedAmount as AmountString, - currency, - payDurationUs: payDurationSet ? payDurationUs : undefined, - minimumAge, - }); - - const suggestedAmount = - amountIsOpen && suggestAmount && suggestedAmountValue.trim() - ? `${currency}:${suggestedAmountValue.trim()}` - : undefined; - const suggestedSummary = - summaryIsOpen && suggestSummary && suggestedSummaryValue.trim() - ? suggestedSummaryValue.trim() - : undefined; - - // Same for the suggested values: keep what was stored, apply what the card - // says, then drop anything the contract now fixes. - const defaults: Record<string, unknown> = { - ...(existing?.rawEditableDefaults ?? {}), - }; - if (amountIsOpen) { - if (suggestedAmount) defaults.amount = suggestedAmount; - else delete defaults.amount; - } - if (summaryIsOpen) { - if (suggestedSummary) defaults.summary = suggestedSummary; - else delete defaults.summary; - } + try { + const contract = buildTemplateContract({ + storedContract: existing?.rawContract, + templateType: storedType, + canChooseWhatItSells, + selType, + summary: trimmedSummary, + amount: formattedAmount as AmountString, + currency, + payDurationUs: payDurationSet ? payDurationUs : undefined, + minimumAge, + inventory: isInventory ? inventory : undefined, + }); - const extras: TemplateWriteExtras = { - editableDefaults: pruneConflictingDefaults(contract, defaults), - otpDeviceId, - }; + const suggestedAmount = + amountIsOpen && suggestAmount && suggestedAmountValue.trim() + ? `${currency}:${suggestedAmountValue.trim()}` + : undefined; + const suggestedSummary = + summaryIsOpen && suggestSummary && suggestedSummaryValue.trim() + ? suggestedSummaryValue.trim() + : undefined; + + // Same for the suggested values: keep what was stored, apply what the card + // says, then drop anything the contract now fixes. + const defaults: Record<string, unknown> = { + ...(existing?.rawEditableDefaults ?? {}), + }; + if (amountIsOpen) { + if (suggestedAmount) defaults.amount = suggestedAmount; + else delete defaults.amount; + } else if (isInventory) { + // A former open-amount template must not suggest a cart total: the + // backend computes it from the customer's inventory selection. + delete defaults.amount; + } + if (summaryIsOpen) { + if (suggestedSummary) defaults.summary = suggestedSummary; + else delete defaults.summary; + } + + const extras: TemplateWriteExtras = { + editableDefaults: pruneConflictingDefaults(contract, defaults), + otpDeviceId, + }; - try { submitting.current = true; setIsSubmitting(true); let finalId = editId || (manualId ? templateId : suggestedId); @@ -385,12 +448,17 @@ function CreateTemplateForm({ name, sellsText, type: selType, + templateType: contract.template_type as TalerMerchantApi.TemplateType, + rawEditableDefaults: extras.editableDefaults + ? { ...extras.editableDefaults } + : undefined, + rawContract: contract, amount: selType === "fixed" ? formattedAmount : undefined, summary: trimmedSummary || undefined, - currency: selType === "fixed" ? undefined : currency, + currency: contract.currency as string | undefined, suggestedAmount, suggestedSummary, - payDeadlineUs: payDurationUs, + payDeadlineUs: payDurationSet ? payDurationUs : undefined, minimumAge, otpDeviceId, }); @@ -436,56 +504,74 @@ function CreateTemplateForm({ )} <form onSubmit={handleSubmit} class="space-y-6 max-w-3xl"> {/* Section 1: What it Sells */} - <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> - <div> - <h2 class="text-base font-bold text-gray-900">{t`1. What it Sells`}</h2> - <p class="text-xs text-gray-500 mt-0.5"> - {canChooseWhatItSells - ? t`Choose how this template's orders are presented to customer wallets.` - : t`Kept as it is — this portal cannot change what this template sells.`} - </p> - </div> - - {!canChooseWhatItSells && ( - <div class="p-4 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-900 space-y-1"> - <p class="font-bold"> - {storedType === TalerMerchantApi.TemplateType.INVENTORY_CART - ? t`🛍️ This template sells products from your inventory.` - : t`🌐 This template sells access to a website.`} - </p> - <p> - {t`Its settings for that were made elsewhere and are kept exactly as they are. You can still change the name, the description, and the options below.`} + {(!isInventory || canChooseWhatItSells) && ( + <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> + <div> + <h2 class="text-base font-bold text-gray-900">{t`1. What it Sells`}</h2> + <p class="text-xs text-gray-500 mt-0.5"> + {canChooseWhatItSells || isInventory + ? t`Choose how this template's orders are presented to customer wallets.` + : t`Kept as it is — this portal cannot change what this template sells.`} </p> </div> - )} - {canChooseWhatItSells && ( - <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> - {getTemplateSellsOptions(t).map((opt) => ( - <button - type="button" - key={opt.type} - onClick={() => setSelType(opt.type)} - class={`p-4 text-left rounded-xl border transition-all flex items-start space-x-3 ${ - selType === opt.type - ? "border-blue-600 bg-blue-50/70 ring-2 ring-blue-500/20 font-semibold" - : "border-gray-200 bg-white hover:border-gray-300" - }`} - > - <span class="text-2xl leading-none">{opt.icon}</span> - <div> - <div class="text-sm text-gray-900 font-bold"> - {opt.title} - </div> - <div class="text-xs text-gray-500 mt-1 leading-snug"> - {opt.desc} - </div> - </div> - </button> - ))} - </div> - )} - </div> + {!canChooseWhatItSells && !isInventory && ( + <div class="p-4 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-900 space-y-1"> + <p class="font-bold"> + {t`🌐 This template sells access to a website.`} + </p> + <p> + {t`Its settings for that were made elsewhere and are kept exactly as they are. You can still change the name, the description, and the options below.`} + </p> + </div> + )} + + {canChooseWhatItSells && ( + <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> + {getTemplateSellsOptions(t) + .filter((opt) => opt.type !== "catalogue" || advancedTools) + .map((opt) => ( + <button + type="button" + key={opt.type} + onClick={() => setSelType(opt.type)} + class={`p-4 text-left rounded-xl border transition-all flex items-start space-x-3 ${ + selType === opt.type + ? "border-blue-600 bg-blue-50/70 ring-2 ring-blue-500/20 font-semibold" + : "border-gray-200 bg-white hover:border-gray-300" + }`} + > + <span class="text-2xl leading-none">{opt.icon}</span> + <div> + <div class="text-sm text-gray-900 font-bold"> + {opt.title} + </div> + <div class="text-xs text-gray-500 mt-1 leading-snug"> + {opt.desc} + </div> + </div> + </button> + ))} + </div> + )} + </div> + )} + + {isInventory && ( + <InventoryTemplateFields + value={inventory} + onChange={(draft) => { + inventoryTouched.current = true; + setInventory(draft); + }} + currencies={availableCurrencies} + onValidityChange={setPickupValid} + products={products} + categories={categories} + productsResource={productsResource} + categoriesResource={categoriesResource} + /> + )} {/* Section 2: Template Details */} <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> @@ -685,23 +771,37 @@ function CreateTemplateForm({ </div> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> - <div> - <DurationInput - {...fieldProps("tmpl_time_pay_input")} - id="tmpl_time_pay_input" - label={t`Time to Pay`} - helpText={ - payDurationSet - ? t`How long the customer has to pay once they scan the QR code.` - : t`How long the customer has to pay once they scan the QR code. Left alone, orders follow your merchant account's deadline.` - } - valueUs={payDurationUs} - onChangeUs={(us) => { - setPayDurationUs(us); - setPayDurationSet(true); - }} - onValidityChange={setPayDurationValid} - /> + <div class="space-y-2"> + <label class="block"> + <input + id="tmpl_pay_default" + type="checkbox" + checked={!payDurationSet} + onChange={(e) => { + setPayDurationSet(!e.currentTarget.checked); + setPayDurationValid(true); + }} + />{" "} + {t`Use the account payment deadline`} + </label> + {payDurationSet && ( + <DurationInput + {...fieldProps("tmpl_time_pay_input")} + id="tmpl_time_pay_input" + label={t`Time to Pay`} + helpText={ + payDurationSet + ? t`How long the customer has to pay once they scan the QR code.` + : t`How long the customer has to pay once they scan the QR code. Left alone, orders follow your merchant account's deadline.` + } + valueUs={payDurationUs} + onChangeUs={(us) => { + setPayDurationUs(us); + setPayDurationSet(true); + }} + onValidityChange={setPayDurationValid} + /> + )} {fieldError("tmpl_time_pay_input")} </div> @@ -734,7 +834,7 @@ function CreateTemplateForm({ </div> {canChooseWhatItSells && - selType !== "fixed" && + selType === "custom_amount" && availableCurrencies.length > 1 && ( <div> <label @@ -789,7 +889,7 @@ function CreateTemplateForm({ {showCustomerChanges && ( <div class="pt-2 space-y-4 border-t border-gray-100"> - {!amountIsOpen && !summaryIsOpen && ( + {!isInventory && !amountIsOpen && !summaryIsOpen && ( <p class="text-xs text-gray-600"> {t`Nothing is left to the customer — you fix both the amount and the description above.`} </p> @@ -909,7 +1009,12 @@ function CreateTemplateForm({ </button> <button type="submit" - disabled={!startingCurrency || !payDurationValid || isSubmitting} + disabled={ + (!startingCurrency && !isInventory) || + (payDurationSet && !payDurationValid) || + (isInventory && !pickupValid) || + isSubmitting + } class="px-6 py-2.5 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm transition-colors disabled:opacity-50" > {isSubmitting diff --git a/packages/taler-merchant-webui/src/screens/InventoryTemplateFields.tsx b/packages/taler-merchant-webui/src/screens/InventoryTemplateFields.tsx @@ -0,0 +1,233 @@ +/* + This file is part of GNU Taler + (C) 2026 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/> + */ +import { InputSelectMultiple } from "@gnu-taler/web-util/browser"; +import type { TranslatedString } from "@gnu-taler/taler-util"; +import type { CategoryItem, ProductItem } from "../types/domain.js"; +import type { RemoteResource } from "../api/contracts.js"; +import type { InventoryTemplateDraft } from "../utils/inventoryTemplates.js"; +import { useTranslation } from "../context/translation.js"; +import { DurationInput } from "../ui/DurationInput.js"; +import { AmountInput } from "../ui/AmountInput.js"; +import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; +import { InitialLoadingState } from "../ui/InitialLoadingState.js"; + +export interface InventoryTemplateCatalogProps { + products?: ProductItem[]; + categories?: CategoryItem[]; + productsResource?: RemoteResource<ProductItem[]>; + categoriesResource?: RemoteResource<CategoryItem[]>; +} + +export function InventoryTemplateFields({ + value, + onChange, + currencies, + onValidityChange, + products = [], + categories = [], + productsResource, + categoriesResource, +}: InventoryTemplateCatalogProps & { + value: InventoryTemplateDraft; + onChange: (value: InventoryTemplateDraft) => void; + currencies: string[]; + onValidityChange: (valid: boolean) => void; +}) { + const { t } = useTranslation(); + const set = <K extends keyof InventoryTemplateDraft>( + key: K, + v: InventoryTemplateDraft[K], + ) => onChange({ ...value, [key]: v }); + const productChoices = products.map((p) => ({ + value: p.id, + label: `${p.name} (${p.id})` as TranslatedString, + })); + for (const id of value.products) + if (!products.some((p) => p.id === id)) + productChoices.push({ value: id, label: id as TranslatedString }); + const categoryChoices = categories.map((c) => ({ + value: String(c.id), + label: `${c.name} (${c.id})` as TranslatedString, + })); + for (const id of value.categories) + if (!categories.some((c) => c.id === id)) + categoryChoices.push({ + value: String(id), + label: String(id) as TranslatedString, + }); + return ( + <section + class="bg-white p-6 rounded-xl border border-gray-200 space-y-4" + aria-label={t`Inventory products`} + > + <h2 class="text-base font-bold">{t`Inventory products`}</h2> + <label class="block space-y-1" htmlFor="tmpl_inventory_scope"> + <span>{t`Available products`}</span> + <select + id="tmpl_inventory_scope" + class="block border rounded-lg p-2" + value={value.all ? "all" : "selected"} + onChange={(e) => set("all", e.currentTarget.value === "all")} + > + <option value="all">{t`All inventory`}</option> + <option value="selected">{t`Selected products and categories`}</option> + </select> + </label> + {!value.all && ( + <div class="space-y-4"> + <p class="text-sm text-gray-600">{t`Offer the selected products and every product in the selected categories.`}</p> + {productsResource?.isLoading && ( + <InitialLoadingState>{t`Loading…`}</InitialLoadingState> + )} + {productsResource?.error && ( + <ReadErrorBanner + resource={productsResource} + title={t`Product catalogue could not be loaded`} + /> + )} + <InputSelectMultiple + name="template-products" + label={t`Products` as TranslatedString} + unique + choices={productChoices} + handler={{ + field: { + name: "template-products", + // The shared control types the handler as one choice, but consumes an array. + value: value.products as unknown as string, + onChange: (v) => + set("products", (v ?? []) as unknown as string[]), + }, + }} + /> + {categoriesResource?.isLoading && ( + <InitialLoadingState>{t`Loading…`}</InitialLoadingState> + )} + {categoriesResource?.error && ( + <ReadErrorBanner + resource={categoriesResource} + title={t`Categories could not be loaded`} + /> + )} + <InputSelectMultiple + name="template-categories" + label={t`Categories` as TranslatedString} + unique + choices={categoryChoices} + handler={{ + field: { + name: "template-categories", + value: value.categories.map(String) as unknown as string, + onChange: (v) => + set( + "categories", + ((v ?? []) as unknown as string[]).map(Number), + ), + }, + }} + /> + </div> + )} + <label class="block"> + <input + id="tmpl_choose_one" + type="checkbox" + checked={value.chooseOne} + onChange={(e) => set("chooseOne", e.currentTarget.checked)} + />{" "} + {t`Require one selection entry`} + </label> + <p class="text-sm text-gray-600">{t`The customer chooses one product entry, but may choose a quantity greater than one.`}</p> + <label class="block"> + <input + id="tmpl_request_tip" + type="checkbox" + checked={value.requestTip} + onChange={(e) => set("requestTip", e.currentTarget.checked)} + />{" "} + {t`Request a tip`} + </label> + <label class="block" htmlFor="tmpl_inventory_currency"> + {t`Currency`} + <select + id="tmpl_inventory_currency" + class="block border rounded-lg p-2" + value={value.currency} + onChange={(e) => set("currency", e.currentTarget.value)} + > + <option value="">{t`Any supported currency`}</option> + {[...new Set([...currencies, value.currency].filter(Boolean))].map( + (c) => ( + <option key={c} value={c}> + {c} + </option> + ), + )} + </select> + </label> + <p class="text-sm text-gray-600">{t`Optional cart limits exclude tips and require a currency.`}</p> + <div class="grid sm:grid-cols-2 gap-4"> + <AmountInput + id="tmpl_min_amount" + step="any" + label={t`Minimum cart total`} + value={value.minAmount} + onChange={(v) => set("minAmount", v)} + primaryCurrency={value.currency} + currencies={value.currency ? [value.currency] : []} + /> + <AmountInput + id="tmpl_max_amount" + step="any" + label={t`Maximum cart total`} + value={value.maxAmount} + onChange={(v) => set("maxAmount", v)} + primaryCurrency={value.currency} + currencies={value.currency ? [value.currency] : []} + /> + </div> + <label class="block"> + <input + id="tmpl_pickup_enabled" + type="checkbox" + checked={ + value.pickupDuration !== undefined && + value.pickupDuration !== "forever" + } + onChange={(e) => { + set( + "pickupDuration", + e.currentTarget.checked ? 3600 * 1_000_000 : undefined, + ); + onValidityChange(true); + }} + />{" "} + {t`Set a pickup or access deadline`} + </label> + {value.pickupDuration !== undefined && + value.pickupDuration !== "forever" && ( + <DurationInput + id="tmpl_pickup_duration" + label={t`Pickup or access duration`} + valueUs={value.pickupDuration} + onChangeUs={(v) => set("pickupDuration", v)} + onValidityChange={onValidityChange} + helpText={t`Measured from order creation. Zero expires immediately; leaving this off sets no deadline.`} + /> + )} + </section> + ); +}