commit 1cb1cadb20bb3e18439c1d651ed07a1241146cd3
parent 12c0a8d824c473b140906dd3e0baf0c468675d89
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 22:34:51 +0100
merchant web UI: display inventory template selections and rules
Diffstat:
5 files changed, 284 insertions(+), 2 deletions(-)
diff --git a/packages/taler-merchant-webui/contrib/qa/inventory-template-check.mjs b/packages/taler-merchant-webui/contrib/qa/inventory-template-check.mjs
@@ -0,0 +1,176 @@
+/*
+ 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/>
+ */
+// Run from the package root after building. Exercise the actual browser controls.
+import assert from "node:assert/strict";
+import { createServer } from "node:http";
+import { readFileSync } from "node:fs";
+import { build } from "esbuild";
+import puppeteer from "puppeteer-core";
+import { buildOptions } from "../../esbuild.config.mjs";
+const bundle = await build({
+ ...buildOptions({ outdir: "unused", dev: true }),
+ entryPoints: undefined,
+ stdin: {
+ resolveDir: process.cwd(),
+ loader: "tsx",
+ contents: `
+ import { render } from 'preact';
+ import { CreateTemplateScreen } from './src/screens/CreateTemplateScreen.tsx';
+ import { TemplateDetailsScreen } from './src/screens/TemplateDetailsScreen.tsx';
+ import { updatePreference } from './src/stores/preferences.ts';
+ const catalog = { products: [{ id: 'coffee', name: 'Coffee', price: 'CHF:5' }], categories: [{ id: 7, name: 'Drinks', productCount: 1 }] };
+ window.writes = [];
+ window.advanced = (value) => updatePreference('advancedTools', value);
+ window.mount = (mode = 'create') => {
+ render(null, document.getElementById('app'));
+ const template = window.saved;
+ render(mode === 'details' ? <TemplateDetailsScreen templateId={template.id} initialTemplate={template} {...catalog} payUri="taler://pay-template/example.test/drinks" /> :
+ <CreateTemplateScreen {...catalog} defaultCurrency="CHF" configuredCurrencies={['CHF', 'EUR']}
+ editId={mode === 'edit' ? template.id : undefined} initialTemplate={mode === 'edit' ? template : undefined}
+ onCreateTemplate={async (id, name, contract) => { window.writes.push(contract); return id; }}
+ onUpdateTemplate={async (id, name, contract) => { window.writes.push(contract); }}
+ onSave={(value) => { window.saved = value; }} />, document.getElementById('app'));
+ };
+ window.advanced(false);
+ window.mount();
+ `,
+ },
+ splitting: false,
+ sourcemap: false,
+ write: false,
+});
+const script = bundle.outputFiles.find((f) => f.path.endsWith(".js")).text;
+const style = readFileSync("dist/prod/style.css");
+const server = createServer((req, res) => {
+ if (req.url === "/app.js") {
+ res.setHeader("Content-Type", "text/javascript");
+ res.end(script);
+ } else if (req.url === "/style.css") {
+ res.setHeader("Content-Type", "text/css");
+ res.end(style);
+ } else {
+ res.setHeader("Content-Type", "text/html");
+ res.end(
+ '<html><head><link rel="stylesheet" href="/style.css"></head><body><div id="app" class="p-6"></div><script type="module" src="/app.js"></script></body></html>',
+ );
+ }
+});
+await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+let browser;
+try {
+ browser = await puppeteer.launch({
+ executablePath: process.env.CHROME_BIN || "/usr/bin/google-chrome",
+ headless: true,
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
+ });
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1100, height: 1000 });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(`http://127.0.0.1:${server.address().port}`);
+ await page.waitForSelector("#tmpl_name_input");
+ const inventoryButton = () =>
+ page.evaluate(() =>
+ [...document.querySelectorAll("button")].some((b) =>
+ b.textContent.includes("Inventory products"),
+ ),
+ );
+ assert.equal(await inventoryButton(), false);
+ await page.evaluate(() => window.advanced(true));
+ await page.waitForFunction(() =>
+ [...document.querySelectorAll("button")].some((b) =>
+ b.textContent.includes("Inventory products"),
+ ),
+ );
+ await page.evaluate(() =>
+ [...document.querySelectorAll("button")]
+ .find((b) => b.textContent.includes("Inventory products"))
+ .click(),
+ );
+ await page.type("#tmpl_name_input", "Drinks");
+ await page.type("#tmpl_summary_input", "Your drinks");
+ await page.select("#tmpl_inventory_scope", "selected");
+ const selects = await page.$$('[role="combobox"]');
+ await selects[0].type("Coffee");
+ await page.waitForSelector('[role="option"]');
+ await page.click('[role="option"]');
+ await selects[1].type("Drinks");
+ await page.waitForSelector('[role="option"]');
+ await page.click('[role="option"]');
+ await page.click("#tmpl_choose_one");
+ await page.click("#tmpl_request_tip");
+ await page.type("#tmpl_min_amount", "1");
+ await page.type("#tmpl_max_amount", "20");
+ await page.click("#tmpl_pickup_enabled");
+ await page.click('button[type="submit"]');
+ await page.waitForFunction(() => window.writes.length === 1);
+ const contract = await page.evaluate(() => window.writes[0]);
+ assert.deepEqual(contract, {
+ template_type: "inventory-cart",
+ summary: "Your drinks",
+ selected_products: ["coffee"],
+ selected_categories: [7],
+ choose_one: true,
+ request_tip: true,
+ currency: "CHF",
+ min_amount: "CHF:1",
+ max_amount: "CHF:20",
+ max_pickup_duration: { d_us: 3600000000 },
+ });
+ await page.evaluate(() => {
+ window.advanced(false);
+ window.mount("details");
+ });
+ await page.waitForSelector('section[aria-label="Inventory products"]');
+ const details = await page.$eval(
+ 'section[aria-label="Inventory products"]',
+ (e) => e.textContent,
+ );
+ assert.match(details, /Coffee/);
+ assert.match(details, /Drinks/);
+ assert.match(details, /Maximum cart total/);
+ await page.evaluate(() => window.mount("edit"));
+ await page.waitForSelector("#tmpl_request_tip");
+ await page.click("#tmpl_request_tip");
+ await page.click('button[type="submit"]');
+ await page.waitForFunction(() => window.writes.length === 2);
+ assert.deepEqual(await page.evaluate(() => window.writes[1]), {
+ ...contract,
+ request_tip: false,
+ });
+ await page.screenshot({
+ path: "/tmp/inventory-template-desktop.png",
+ fullPage: true,
+ });
+ await page.setViewport({ width: 390, height: 844 });
+ assert.equal(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ true,
+ );
+ await page.screenshot({
+ path: "/tmp/inventory-template-mobile.png",
+ fullPage: true,
+ });
+ assert.deepEqual(errors, []);
+ console.log(
+ "Inventory template browser check passed: advanced creation, filters, normal-mode details/editing, mobile layout.",
+ );
+} finally {
+ await browser?.close();
+ await new Promise((resolve) => server.close(resolve));
+}
diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json
@@ -13,6 +13,7 @@
"dev": "./dev.mjs",
"test": "./test.mjs",
"test:save-feedback-browser": "node contrib/qa/save-feedback-check.mjs",
+ "test:inventory-template-browser": "node contrib/qa/inventory-template-check.mjs",
"test:exchange-status-browser": "node contrib/qa/exchange-status-check.mjs",
"test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant",
"lint": "../qa-tooling/bin/eslint.mjs .",
diff --git a/packages/taler-merchant-webui/src/routes/TemplateDetailsRoute.tsx b/packages/taler-merchant-webui/src/routes/TemplateDetailsRoute.tsx
@@ -17,12 +17,14 @@
import type { VNode } from "preact";
import {
useBusinessSettings,
+ useInventory,
+ useCategories,
useTemplateDetails,
useTemplates,
useTemplatePayUri,
} from "../api/hooks.js";
import { TemplateDetailsScreen } from "../screens/TemplateDetailsScreen.js";
-import { Duration } from "@gnu-taler/taler-util";
+import { Duration, TemplateType } from "@gnu-taler/taler-util";
export function TemplateDetailsRoute({
templateId,
@@ -32,10 +34,15 @@ export function TemplateDetailsRoute({
const { template, resource, isLoading } = useTemplateDetails(templateId);
const { deleteTemplate, createOrderFromTemplate } = useTemplates();
const { settings } = useBusinessSettings();
+ const enabled = template?.templateType === TemplateType.INVENTORY_CART;
+ const { products } = useInventory({ enabled });
+ const { categories } = useCategories({ enabled });
const payUri = useTemplatePayUri()(templateId);
return (
<TemplateDetailsScreen
+ products={products}
+ categories={categories}
templateId={templateId}
template={template}
templateResource={resource}
diff --git a/packages/taler-merchant-webui/src/screens/InventoryTemplateDetails.tsx b/packages/taler-merchant-webui/src/screens/InventoryTemplateDetails.tsx
@@ -0,0 +1,85 @@
+/*
+ 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 { Amounts, Duration } from "@gnu-taler/taler-util";
+import { useTranslation } from "../context/translation.js";
+import { inventoryTemplateDraft } from "../utils/inventoryTemplates.js";
+import type { InventoryTemplateCatalogProps } from "./InventoryTemplateFields.js";
+
+export function InventoryTemplateDetails({
+ contract,
+ products = [],
+ categories = [],
+}: InventoryTemplateCatalogProps & { contract: Record<string, unknown> }) {
+ const { t } = useTranslation();
+ const d = inventoryTemplateDraft(contract);
+ const rows = [
+ [
+ t`Available products`,
+ d.all ? t`All inventory` : t`Selected products and categories`,
+ ],
+ ...(!d.all
+ ? [
+ [
+ t`Products`,
+ d.products
+ .map((id) => products.find((p) => p.id === id)?.name ?? id)
+ .join(", ") || "—",
+ ],
+ [
+ t`Categories`,
+ d.categories
+ .map(
+ (id) => categories.find((c) => c.id === id)?.name ?? String(id),
+ )
+ .join(", ") || "—",
+ ],
+ ]
+ : []),
+ [t`Require one selection entry`, d.chooseOne ? t`Yes` : t`No`],
+ [t`Request a tip`, d.requestTip ? t`Yes` : t`No`],
+ [t`Currency`, d.currency || t`Any supported currency`],
+ [
+ t`Minimum cart total`,
+ d.minAmount ? Amounts.formatAmount(d.minAmount) : "—",
+ ],
+ [
+ t`Maximum cart total`,
+ d.maxAmount ? Amounts.formatAmount(d.maxAmount) : "—",
+ ],
+ [
+ t`Pickup or access duration`,
+ d.pickupDuration === undefined || d.pickupDuration === "forever"
+ ? t`No deadline`
+ : Duration.formatShort({ d_us: d.pickupDuration }),
+ ],
+ ];
+ return (
+ <section
+ class="bg-white border rounded-xl p-5 space-y-4"
+ aria-label={t`Inventory products`}
+ >
+ <h3 class="font-bold">{t`Inventory products`}</h3>
+ <dl class="grid sm:grid-cols-2 gap-4">
+ {rows.map(([label, value]) => (
+ <div key={label}>
+ <dt class="text-xs font-bold text-gray-600">{label}</dt>
+ <dd class="text-sm break-words">{value}</dd>
+ </div>
+ ))}
+ </dl>
+ </section>
+ );
+}
diff --git a/packages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx b/packages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx
@@ -15,6 +15,8 @@
*/
import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
+import { InventoryTemplateDetails } from "./InventoryTemplateDetails.js";
+import type { InventoryTemplateCatalogProps } from "./InventoryTemplateFields.js";
import type { VNode } from "preact";
import { useRef, useState, useEffect } from "preact/hooks";
import { useLocation } from "wouter-preact";
@@ -40,7 +42,7 @@ import { ReadErrorBanner } from "../ui/ReadErrorBanner.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { resourcePath } from "../routing/resourcePath.js";
-export interface TemplateDetailsScreenProps {
+export interface TemplateDetailsScreenProps extends InventoryTemplateCatalogProps {
templateId: string;
initialTemplate?: TemplateItem;
onDeleteTemplate?: (id: string) => Promise<void>;
@@ -110,6 +112,8 @@ export function TemplateDetailsScreen({
payUri = "",
merchantDefaultPayDeadline,
onCreateOrder,
+ products,
+ categories,
}: TemplateDetailsScreenProps): VNode {
const { t } = useTranslation();
const [, setLocation] = useLocation();
@@ -407,6 +411,15 @@ export function TemplateDetailsScreen({
/>
</div>
+ {(template.templateType === TemplateType.INVENTORY_CART ||
+ template.type === "catalogue") && (
+ <InventoryTemplateDetails
+ contract={template.rawContract ?? {}}
+ products={products}
+ categories={categories}
+ />
+ )}
+
{/* Section 3: Advanced Settings */}
<div class="bg-gray-50 p-5 rounded-xl border border-gray-200 space-y-4">
<h4 class="text-xs font-bold text-gray-700">{t`3. Contract Deadlines & Rules`}</h4>