taler-typescript-core

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

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:
Apackages/taler-merchant-webui/src/screens/token-setup/PercentageRewardEditor.tsx | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/screens/token-setup/TokenSetupReview.tsx | 300+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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> + ); +}