commit a5c4d9fcfee5b5d00a12c26242e375cd007033ef
parent 519800af0da61bcda22b44b34c7d6d89d42f45ef
Author: Florian Dold <dold@taler.net>
Date: Wed, 4 Nov 2026 23:54:32 +0100
merchant web UI: add inline rounding and token review components
Edit percentage rounding through subdued sentence controls. Present
earning, reward, validity, and technical settings in a grouped review
with edit links.
Diffstat:
2 files changed, 626 insertions(+), 0 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/PercentageRewardEditor.tsx b/packages/taler-merchant-webui/src/screens/token-setup/PercentageRewardEditor.tsx
@@ -0,0 +1,326 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { ComponentChildren } from "preact";
+import {
+ useCallback,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "preact/hooks";
+import { useTranslation } from "../../context/translation.js";
+import { useOutsideDismiss } from "../../ui/dropdownHooks.js";
+
+/** An inline value whose editor stays attached to the form, out of its layout. */
+function ValuePopover({
+ label,
+ value,
+ open,
+ onOpenChange,
+ children,
+ invalid,
+}: {
+ label: string;
+ value: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: ComponentChildren;
+ invalid?: boolean;
+}) {
+ const id = useId();
+ const wrapper = useRef<HTMLDivElement>(null);
+ const trigger = useRef<HTMLButtonElement>(null);
+ const panel = useRef<HTMLDivElement>(null);
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+ const close = () => onOpenChange(false);
+ const finish = useCallback(() => {
+ trigger.current?.focus({ preventScroll: true });
+ onOpenChange(false);
+ }, [onOpenChange]);
+ const { t } = useTranslation();
+ useOutsideDismiss(open, wrapper, close);
+ useLayoutEffect(() => {
+ if (!open) return;
+ const place = () => {
+ const anchor = trigger.current?.getBoundingClientRect();
+ const bounds = panel.current?.getBoundingClientRect();
+ if (!anchor || !bounds) return;
+ const below = window.innerHeight - anchor.bottom - 16;
+ const above = anchor.top - 16;
+ setPosition({
+ left: Math.max(
+ 12,
+ Math.min(anchor.left, window.innerWidth - bounds.width - 12),
+ ),
+ top: Math.max(
+ 12,
+ Math.min(
+ below < bounds.height && above > below
+ ? anchor.top - bounds.height - 4
+ : anchor.bottom + 4,
+ window.innerHeight - bounds.height - 12,
+ ),
+ ),
+ });
+ };
+ place();
+ (
+ panel.current?.querySelector<HTMLElement>("input:checked") ??
+ panel.current?.querySelector<HTMLElement>("input, button")
+ )?.focus({ preventScroll: true });
+ const observer = new ResizeObserver(place);
+ if (panel.current) observer.observe(panel.current);
+ window.addEventListener("resize", place);
+ window.addEventListener("scroll", place, true);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener("resize", place);
+ window.removeEventListener("scroll", place, true);
+ };
+ }, [open]);
+ // Listen to keyboard events bubbling from the native controls in the editor.
+ useLayoutEffect(() => {
+ if (!open) return;
+ const element = panel.current;
+ const keyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ finish();
+ } else if (
+ event.key === "Enter" &&
+ event.target instanceof HTMLInputElement &&
+ !event.ctrlKey &&
+ !event.isComposing
+ ) {
+ event.preventDefault();
+ finish();
+ }
+ };
+ element?.addEventListener("keydown", keyDown);
+ return () => element?.removeEventListener("keydown", keyDown);
+ }, [open, finish]);
+ return (
+ <div
+ ref={wrapper}
+ role="group"
+ aria-label={label}
+ class="inline-flex"
+ onFocusOut={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null))
+ close();
+ }}
+ >
+ <button
+ ref={trigger}
+ type="button"
+ aria-label={`${label}: ${value}`}
+ aria-describedby={
+ invalid ? "sub_discount_rounding_precision-error" : undefined
+ }
+ aria-expanded={open}
+ aria-haspopup="dialog"
+ aria-controls={id}
+ onClick={() => onOpenChange(!open)}
+ onKeyDown={(event) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ onOpenChange(true);
+ }
+ }}
+ class="group inline-flex items-center gap-1 rounded-sm px-0.5 py-2 font-normal text-gray-700 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
+ >
+ <span class="underline decoration-dotted decoration-gray-400 underline-offset-4 group-hover:decoration-gray-600">
+ {value || "…"}
+ </span>
+ <svg
+ viewBox="0 0 20 20"
+ fill="none"
+ aria-hidden="true"
+ class="h-3.5 w-3.5 shrink-0 text-gray-400 group-hover:text-gray-500"
+ >
+ <path
+ d="m5 7.5 5 5 5-5"
+ stroke="currentColor"
+ stroke-width="1.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </button>
+ {open && (
+ <div
+ ref={panel}
+ id={id}
+ role="dialog"
+ aria-label={label}
+ class="fixed z-40 space-y-3 overflow-auto rounded-xl border border-gray-300 bg-white p-3 text-sm text-gray-800 shadow-lg"
+ style={{
+ ...position,
+ width: "min(18rem, calc(100vw - 24px))",
+ maxHeight: "calc(100dvh - 24px)",
+ }}
+ >
+ {children}
+ <div class="flex justify-end">
+ <button
+ type="button"
+ onClick={finish}
+ class="rounded-lg px-3 py-1.5 font-semibold text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >{t`Done`}</button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function PercentageRewardEditor({
+ percentage,
+ onPercentageChange,
+ mode,
+ onModeChange,
+ precision,
+ onPrecisionChange,
+ currency,
+ precisionOpen,
+ onPrecisionOpenChange,
+ fieldProps,
+ fieldError,
+}: {
+ percentage: string;
+ onPercentageChange: (value: string) => void;
+ mode: "down" | "nearest" | "up";
+ onModeChange: (value: "down" | "nearest" | "up") => void;
+ precision: string;
+ onPrecisionChange: (value: string) => void;
+ currency?: string;
+ precisionOpen: boolean;
+ onPrecisionOpenChange: (open: boolean) => void;
+ fieldProps: (id: string) => {
+ "aria-invalid": boolean | undefined;
+ "aria-describedby": string | undefined;
+ };
+ fieldError: (id: string) => ComponentChildren;
+}) {
+ const { t } = useTranslation();
+ const [modeOpen, setModeOpen] = useState(false);
+ useLayoutEffect(() => {
+ if (precisionOpen) setModeOpen(false);
+ }, [precisionOpen]);
+ const id = useId();
+ const choices = [
+ { value: "down", short: t`down`, label: t`Round down` },
+ { value: "nearest", short: t`to nearest`, label: t`Round to nearest` },
+ { value: "up", short: t`up`, label: t`Round up` },
+ ] as const;
+ return (
+ <div>
+ <label
+ htmlFor="sub_discount_percentage"
+ class="block text-xs font-bold text-gray-700 mb-2"
+ >{t`Discount percentage`}</label>
+ <div
+ class="flex flex-wrap items-center gap-x-2 gap-y-2 text-sm text-gray-600"
+ data-percentage-editor
+ >
+ <div class="inline-flex items-center gap-1.5">
+ <input
+ {...fieldProps("sub_discount_percentage")}
+ id="sub_discount_percentage"
+ type="text"
+ inputMode="decimal"
+ value={percentage}
+ onInput={(event) => onPercentageChange(event.currentTarget.value)}
+ class="w-16 sm:w-20 rounded-lg border border-gray-300 px-2.5 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ <span aria-hidden="true">%</span>
+ </div>
+ <div class="inline-flex items-center gap-2">
+ {t`rounding`}
+ <ValuePopover
+ label={t`Rounding mode`}
+ value={choices.find((choice) => choice.value === mode)!.short}
+ open={modeOpen && !precisionOpen}
+ onOpenChange={(open) => {
+ setModeOpen(open);
+ if (open) onPrecisionOpenChange(false);
+ }}
+ >
+ <fieldset>
+ <legend class="mb-2 text-xs font-bold text-gray-700">{t`Rounding mode`}</legend>
+ {choices.map((choice) => (
+ <label
+ key={choice.value}
+ class="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 hover:bg-gray-50"
+ >
+ <input
+ type="radio"
+ name={`${id}-rounding-mode`}
+ value={choice.value}
+ checked={mode === choice.value}
+ onChange={() => onModeChange(choice.value)}
+ class="accent-blue-600"
+ />
+ {choice.label}
+ </label>
+ ))}
+ </fieldset>
+ </ValuePopover>
+ </div>
+ <div class="inline-flex items-center gap-2">
+ {t`in steps of`}
+ <ValuePopover
+ label={t`Rounding increment`}
+ value={precision}
+ invalid={
+ fieldProps("sub_discount_rounding_precision")["aria-invalid"]
+ }
+ open={precisionOpen}
+ onOpenChange={(open) => {
+ onPrecisionOpenChange(open);
+ if (open) setModeOpen(false);
+ }}
+ >
+ <label
+ htmlFor="sub_discount_rounding_precision"
+ class="block text-xs font-bold text-gray-700"
+ >{t`Rounding increment`}</label>
+ <div class="flex items-center gap-2">
+ <input
+ {...fieldProps("sub_discount_rounding_precision")}
+ aria-describedby={[
+ fieldProps("sub_discount_rounding_precision")[
+ "aria-describedby"
+ ],
+ `${id}-units`,
+ ]
+ .filter(Boolean)
+ .join(" ")}
+ id="sub_discount_rounding_precision"
+ type="text"
+ inputMode="decimal"
+ value={precision}
+ onInput={(event) =>
+ onPrecisionChange(event.currentTarget.value)
+ }
+ class="min-w-0 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ {currency && <span class="text-gray-500">{currency}</span>}
+ </div>
+ <p
+ id={`${id}-units`}
+ class="text-xs text-gray-500"
+ >{t`Currency units, for example 0.01 or 0.05.`}</p>
+ {fieldError("sub_discount_rounding_precision")}
+ </ValuePopover>
+ </div>
+ </div>
+ {fieldError("sub_discount_percentage")}
+ {!precisionOpen && fieldError("sub_discount_rounding_precision")}
+ </div>
+ );
+}
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/TokenSetupReview.tsx b/packages/taler-merchant-webui/src/screens/token-setup/TokenSetupReview.tsx
@@ -0,0 +1,300 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { ComponentChildren } from "preact";
+import { useTranslation } from "../../context/translation.js";
+import type { SubscriptionDraft, TokenSetupStep } from "./model.js";
+import {
+ displayRewardAmount,
+ rewardBenefit,
+ stampsForReward,
+ validityPeriodLabel,
+} from "./copy.js";
+
+function ReviewValue({
+ label,
+ children,
+}: {
+ label: string;
+ children: ComponentChildren;
+}) {
+ return (
+ <div class="grid min-w-0 grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
+ <dt class="font-medium text-gray-600">{label}</dt>
+ <dd class="break-words text-gray-900">{children}</dd>
+ </div>
+ );
+}
+
+function ReviewScope({
+ all,
+ categories,
+ products,
+}: {
+ all: boolean;
+ categories: SubscriptionDraft["selectedCategories"];
+ products: SubscriptionDraft["selectedProducts"];
+}) {
+ const { t } = useTranslation();
+ if (all)
+ return (
+ <dl>
+ <ReviewValue label={t`Applies to`}>{t`All purchases`}</ReviewValue>
+ </dl>
+ );
+ return (
+ <dl class="space-y-3">
+ {[
+ { label: t`Product categories`, items: categories },
+ { label: t`Inventory products`, items: products },
+ ]
+ .filter(({ items }) => items.length > 0)
+ .map(({ label, items }) => (
+ <ReviewValue key={label} label={label}>
+ <ul class="flex flex-wrap gap-2" aria-label={label}>
+ {items.map((item) => (
+ <li
+ key={item.id}
+ class="max-w-full rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs break-words"
+ >
+ {item.name}
+ </li>
+ ))}
+ </ul>
+ </ReviewValue>
+ ))}
+ </dl>
+ );
+}
+
+export function TokenSetupReview({
+ heading,
+ draft: d,
+ primaryCurrency,
+ expandTechnicalDetails,
+ onEdit,
+}: {
+ heading?: ComponentChildren;
+ draft: SubscriptionDraft;
+ primaryCurrency: string;
+ expandTechnicalDetails?: boolean;
+ onEdit: (step: TokenSetupStep, field?: string) => void;
+}) {
+ const { t } = useTranslation();
+ const benefit = rewardBenefit(t, {
+ kind: d.kind,
+ benefitType: d.benefitType,
+ percentage: d.percentage,
+ amounts: d.flatAmounts,
+ priceSelection: d.freeItemPriceSelection,
+ });
+ const hasReward = d.benefitType !== "none";
+ const editButton = (label: string, step: TokenSetupStep, field?: string) => (
+ <button
+ type="button"
+ class="shrink-0 text-sm text-blue-700 underline underline-offset-2"
+ aria-label={t`Edit ${label}`}
+ onClick={() => onEdit(step, field)}
+ >{t`Edit`}</button>
+ );
+ const section = (
+ step: TokenSetupStep,
+ title: string,
+ children: ComponentChildren,
+ ) => (
+ <section
+ id={`token-review-${step}`}
+ aria-labelledby={`token-review-${step}-heading`}
+ class="p-4 sm:p-5 space-y-3 text-sm"
+ >
+ <div class="flex items-center justify-between gap-4">
+ <h3
+ id={`token-review-${step}-heading`}
+ class="font-semibold text-sm text-gray-900"
+ >
+ {title}
+ </h3>
+ {editButton(title, step)}
+ </div>
+ {children}
+ </section>
+ );
+ const rows = "space-y-2";
+ const minimum = d.minimumPurchase
+ ? displayRewardAmount(
+ d.minimumPurchase.includes(":")
+ ? d.minimumPurchase
+ : `${primaryCurrency}:${d.minimumPurchase}`,
+ )
+ : t`No minimum`;
+ return (
+ <div class="space-y-4" aria-label={t`Rule summary`}>
+ <div class="rounded-xl border border-blue-200 bg-blue-50 px-5 py-4 space-y-1">
+ {heading ?? (
+ <h3 class="font-semibold text-gray-900">
+ {d.kind === "discount"
+ ? t`Review your discount stamps`
+ : t`Review your pass`}
+ </h3>
+ )}
+ <p class="text-sm text-gray-800">
+ {benefit}
+ {d.kind === "discount" &&
+ hasReward &&
+ `. ${stampsForReward(t, d.requiredTokens)}.`}
+ </p>
+ </div>
+ <div class="rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
+ {section(
+ "basics",
+ t`Details`,
+ <dl class={rows}>
+ <ReviewValue
+ label={
+ d.kind === "discount" ? t`Discount stamp name` : t`Pass name`
+ }
+ >
+ {d.name}
+ </ReviewValue>
+ <ReviewValue label={t`Customer description`}>
+ {d.description}
+ </ReviewValue>
+ </dl>,
+ )}
+ {d.kind === "discount" &&
+ section(
+ "earning",
+ t`Collecting discount stamps`,
+ <>
+ <p>{t`Customers earn one discount stamp per qualifying paid purchase.`}</p>
+ <div class={rows}>
+ <ReviewScope
+ all={d.issuanceWildcard}
+ categories={d.issuanceCategories}
+ products={d.issuanceProducts}
+ />
+ <dl>
+ <ReviewValue
+ label={
+ d.issuanceWildcard
+ ? t`Minimum purchase total`
+ : t`Minimum spend on the selected products`
+ }
+ >
+ {minimum}
+ {!d.issuanceWildcard && d.minimumPurchase && (
+ <p class="mt-1 text-xs text-gray-500">{t`Other products in the same purchase do not count toward this minimum.`}</p>
+ )}
+ </ReviewValue>
+ </dl>
+ </div>
+ {hasReward && (
+ <p class="text-gray-600">
+ {d.issueOnRedemption
+ ? t`Using the reward also earns a discount stamp if the purchase meets the earning conditions.`
+ : t`Using the reward does not earn another discount stamp.`}
+ </p>
+ )}
+ </>,
+ )}
+ {section(
+ "benefit",
+ d.kind === "discount" ? t`Customer reward` : t`Pass benefit`,
+ <>
+ <dl class={rows}>
+ <ReviewValue label={t`Benefit`}>{benefit}</ReviewValue>
+ {hasReward && d.kind === "discount" && (
+ <ReviewValue label={t`Discount stamps needed`}>
+ {d.requiredTokens}
+ </ReviewValue>
+ )}
+ {d.kind === "subscription" && (
+ <ReviewValue
+ label={t`Using the pass`}
+ >{t`Keep and reuse the pass until it expires`}</ReviewValue>
+ )}
+ </dl>
+ {hasReward && (
+ <div>
+ <ReviewScope
+ all={d.redemptionWildcard}
+ categories={d.selectedCategories}
+ products={d.selectedProducts}
+ />
+ </div>
+ )}
+ </>,
+ )}
+ {section(
+ "timing",
+ t`Validity`,
+ <>
+ <dl class={rows}>
+ <ReviewValue
+ label={
+ d.kind === "discount"
+ ? t`Discount stamp validity period`
+ : t`Pass validity period`
+ }
+ >
+ {t`Up to ${validityPeriodLabel(t, d.duration)}`}
+ </ReviewValue>
+ <ReviewValue label={t`Shared expiry dates`}>
+ {validityPeriodLabel(t, d.stepGranularity)}
+ </ReviewValue>
+ </dl>
+ <dl>
+ <ReviewValue label={t`Start and end dates`}>
+ {!d.useValidFrom && !d.useValidUntil
+ ? t`Starts now, with no end date`
+ : t`Starts ${d.useValidFrom ? d.validFrom : t`now`}; ends ${d.useValidUntil ? d.validUntil : t`no end date`}. Selected dates take effect at 00:00 UTC at the start of the day.`}
+ </ReviewValue>
+ </dl>
+ <p class="text-gray-600">
+ {d.kind === "discount"
+ ? t`Discount stamps issued in the same period expire together, which can shorten their usable time. Individual stamps still expire when no end date is set.`
+ : t`Passes issued in the same period expire together, which can shorten their usable time. Individual passes still expire when no end date is set.`}
+ </p>
+ </>,
+ )}
+ </div>
+ <details
+ open={expandTechnicalDetails}
+ aria-label={t`Technical details`}
+ class="rounded-xl border border-gray-200 bg-white p-5 text-sm"
+ >
+ <summary class="cursor-pointer font-semibold text-gray-700">{t`Technical details`}</summary>
+ <div class="mt-4 space-y-4">
+ <div
+ id="token-review-sub_slug_input"
+ class="flex items-start justify-between gap-4"
+ >
+ <dl class="min-w-0 flex-1">
+ <ReviewValue label={t`Identifier`}>{d.slugId}</ReviewValue>
+ </dl>
+ {editButton(t`Identifier`, "basics", "sub_slug_input")}
+ </div>
+ {d.benefitType === "percentage" && (
+ <div
+ id="token-review-sub_discount_rounding_precision"
+ class="flex items-start justify-between gap-4"
+ >
+ <dl class="min-w-0 flex-1">
+ <ReviewValue
+ label={t`Rounding`}
+ >{t`${d.roundingMode === "down" ? t`Round down` : d.roundingMode === "up" ? t`Round up` : t`Round to nearest`} in steps of ${d.roundingPrecision}`}</ReviewValue>
+ </dl>
+ {editButton(
+ t`Rounding`,
+ "benefit",
+ "sub_discount_rounding_precision",
+ )}
+ </div>
+ )}
+ </div>
+ </details>
+ </div>
+ );
+}