taler-typescript-core

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

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:
Apackages/taler-merchant-webui/contrib/qa/inventory-template-check.mjs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/package.json | 1+
Mpackages/taler-merchant-webui/src/routes/TemplateDetailsRoute.tsx | 9++++++++-
Apackages/taler-merchant-webui/src/screens/InventoryTemplateDetails.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx | 15++++++++++++++-
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>