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:
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>
+ );
+}