commit cf1b052ac0d1ea640e42a6191b88e7ee708718ea parent 5922d98eb48cfd950164cff449728652a41e3251 Author: Florian Dold <dold@taler.net> Date: Tue, 3 Nov 2026 22:14:02 +0100 merchant web UI: retain inline form errors Associate validation messages with their fields so guidance survives summary dismissal. Revalidate on submission and preserve draft values. Diffstat:
13 files changed, 370 insertions(+), 125 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx @@ -130,20 +130,29 @@ export function CreateAccessScreen({ const setGeneratedToken = onGeneratedTokenChange ?? setLocalToken; const { copied: tokenCopied, error: copyError, copy } = useClipboard(); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); const handleSubmit = async (e: Event) => { e.preventDefault(); if (!usedFor.trim()) { setErrorMsg( t`Please enter a description for what this access is used for.`, + "acc_used_for", ); return; } if (!password.trim()) { setErrorMsg( t`Please enter your current password to confirm your identity.`, + "acc_password", ); return; } @@ -208,6 +217,7 @@ export function CreateAccessScreen({ {t`Used for`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("acc_used_for")} id="acc_used_for" type="text" required @@ -218,6 +228,7 @@ export function CreateAccessScreen({ } class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> + {fieldError("acc_used_for")} <p class="text-xs text-gray-500 mt-1">{t`So you can tell later what would break if you revoked it.`}</p> </div> @@ -301,16 +312,20 @@ export function CreateAccessScreen({ <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> <h3 class="text-base font-bold text-gray-900">{t`3. Identity Confirmation`}</h3> - <PasswordInput - id="acc_password" - label={t`Current Password`} - value={password} - onInput={setPassword} - autoComplete="current-password" - placeholder={t`Enter your current password to confirm identity`} - helpText={t`Confirms it is you before the access is issued.`} - required - /> + <div> + <PasswordInput + {...fieldProps("acc_password")} + id="acc_password" + label={t`Current Password`} + value={password} + onInput={setPassword} + autoComplete="current-password" + placeholder={t`Enter your current password to confirm identity`} + helpText={t`Confirms it is you before the access is issued.`} + required + /> + {fieldError("acc_password")} + </div> <div class="border-t border-gray-100 pt-4"> <div class="flex items-center justify-between"> diff --git a/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx @@ -170,8 +170,15 @@ function CreateDeviceForm({ const [created, setCreated] = useState<CreatedDeviceSetup | null>(null); const submitting = useRef(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); const editingId = editId ?? null; const existingDevice = editingId @@ -221,6 +228,7 @@ function CreateDeviceForm({ if (!isEdit && !cleanKey) { setErrorMsg( t`Secret key must contain exactly 32 Base32 characters (A–Z and 2–7).`, + "auth_secret_custom", ); return; } @@ -439,6 +447,7 @@ function CreateDeviceForm({ {t`Custom Secret Key`} </label> <input + {...fieldProps("auth_secret_custom")} id="auth_secret_custom" type="text" value={secretKey} @@ -448,6 +457,7 @@ function CreateDeviceForm({ placeholder={t`Enter custom secret key`} class="w-full px-3.5 py-2 bg-white border border-gray-300 rounded-lg font-mono text-xs text-gray-900" /> + {fieldError("auth_secret_custom")} </div> ) : ( <div> diff --git a/packages/taler-merchant-webui/src/screens/CreateOrderScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateOrderScreen.tsx @@ -309,8 +309,15 @@ export function CreateOrderScreen({ ruleState === "ready" || ruleState === "stale"; const [submitting, setSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); const normalizedQuickAmount = amountInput.includes(":") ? amountInput.trim() @@ -919,18 +926,25 @@ export function CreateOrderScreen({ } if (!summaryInput.trim()) { - setErrorMsg(t`Please enter an order summary description.`); + setErrorMsg( + t`Please enter an order summary description.`, + "order-summary", + ); return; } if (authoringMode === "itemized" && itemizedLines.length === 0) { - setErrorMsg(t`Add at least one line item to create an itemized order.`); + setErrorMsg( + t`Add at least one line item to create an itemized order.`, + "order-items", + ); return; } if (choicesRequireV1 && !choicesValid) { setAdvancedEditing(true); setErrorMsg( t`Enable at least one choice and correct invalid choice amounts, fees, or token counts.`, + "order-choices", ); return; } @@ -1289,6 +1303,7 @@ export function CreateOrderScreen({ {t`Summary`} * </label> <input + {...fieldProps("order-summary")} id="order-summary" type="text" value={summaryInput} @@ -1299,6 +1314,7 @@ export function CreateOrderScreen({ required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + {fieldError("order-summary")} <span class="text-2xs text-gray-500 mt-1 block">{t`What the customer sees on their receipt.`}</span> </div> </div> @@ -1306,12 +1322,14 @@ export function CreateOrderScreen({ {authoringMode === "itemized" && ( <section + {...fieldProps("order-items")} data-order-line-items class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs" > <div class="flex flex-wrap items-center justify-between gap-2 border-b border-gray-100 px-5 py-3"> <div> <h2 class="text-sm font-bold text-gray-900">{t`Line items`}</h2> + {fieldError("order-items")} <p class="text-xs text-gray-500">{t`Build the customer contract from inventory or custom items.`}</p> </div> <span class="text-xs font-bold text-gray-700"> @@ -1611,6 +1629,7 @@ export function CreateOrderScreen({ {advancedEditing && ( <div class="space-y-4"> + {fieldError("order-choices")} <AdvancedChoiceEditor choices={activeChoices} usesV1={choicesRequireV1} diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx @@ -196,12 +196,30 @@ test("product save failures preserve edits through dismissal, validation, and re await submit(container); assert.match(footer.textContent!, /Enter a valid price/); assert.doesNotMatch(footer.textContent!, /Product save rejected/); - assert.ok(footer.querySelector("[data-dismiss-error]")); + const priceInput = + container.querySelector<HTMLInputElement>("#prod_price_input")!; + assert.equal(priceInput.getAttribute("aria-invalid"), "true"); + const guidance = container.querySelector( + "#" + priceInput.getAttribute("aria-describedby"), + )!; + assert.match(guidance.textContent!, /Enter a valid price/); + await act(() => + footer.querySelector<HTMLButtonElement>("[data-dismiss-error]")!.click(), + ); + assert.equal(footer.querySelector("[role=alert]"), null); + assert.ok(guidance.isConnected, "field guidance survives dismissal"); + assert.equal(priceInput.getAttribute("aria-invalid"), "true"); + assert.equal(patches.length, 2, "dismissal does not submit"); + await submit(container); + assert.match(footer.textContent!, /Enter a valid price/); + assert.equal(patches.length, 2, "retry still validates"); setInput( container.querySelector<HTMLInputElement>("#prod_price_input")!, "4.25", ); await submit(container); + assert.equal(priceInput.getAttribute("aria-invalid"), null); + assert.equal(container.querySelector("#prod_price_input-error"), null); assert.equal(patches.length, 3); assert.equal(patches[2]!.price, "EUR:4.25"); assert.equal(patches[2]!.product_name, "Edited product"); diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx @@ -321,8 +321,15 @@ function CreateProductForm({ const [showAdvanced, setShowAdvanced] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); const [newCategoryName, setNewCategoryName] = useState<string>(""); const [isCreatingCategory, setIsCreatingCategory] = useState<boolean>(false); const [categoryError, setCategoryError] = useState<string>(""); @@ -360,22 +367,28 @@ function CreateProductForm({ return; } if (!name.trim()) { - setErrorMsg(t`Please enter a product name.`); + setErrorMsg(t`Please enter a product name.`, "prod_name_input"); return; } if (imageStatus === "checking" || imageStatus === "invalid") { - setErrorMsg(t`Remove or replace the product image before saving.`); + setErrorMsg( + t`Remove or replace the product image before saving.`, + "prod_image_input", + ); return; } if (!Amounts.checkString(price)) { - setErrorMsg(t`Enter a valid price.`); + setErrorMsg(t`Enter a valid price.`, "prod_price_input"); return; } if ( countStock && (!/^\d+$/.test(totalStock) || !Number.isSafeInteger(Number(totalStock))) ) { - setErrorMsg(t`Enter a non-negative whole stock quantity.`); + setErrorMsg( + t`Enter a non-negative whole stock quantity.`, + "prod_stock_count_input", + ); return; } setErrorMsg(""); @@ -490,6 +503,7 @@ function CreateProductForm({ {t`Product Name`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("prod_name_input")} id="prod_name_input" type="text" required @@ -500,6 +514,7 @@ function CreateProductForm({ } class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + {fieldError("prod_name_input")} <p class="text-xs text-gray-500 mt-1">{t`Product name as customers see it in contracts and receipts.`}</p> </div> @@ -523,13 +538,17 @@ function CreateProductForm({ <p class="text-xs text-gray-500 mt-1">{t`What customers read before completing payment.`}</p> </div> - <ImageUploadInput - id="prod_image_input" - label={t`Product Image`} - value={image} - onChange={setImage} - helpText={t`Upload a product image (PNG, JPEG, WebP, max 1 MB). Shown to customers in Web POS and digital order contracts.`} - /> + <div> + <ImageUploadInput + {...fieldProps("prod_image_input")} + id="prod_image_input" + label={t`Product Image`} + value={image} + onChange={setImage} + helpText={t`Upload a product image (PNG, JPEG, WebP, max 1 MB). Shown to customers in Web POS and digital order contracts.`} + /> + {fieldError("prod_image_input")} + </div> </div> </div> @@ -541,18 +560,22 @@ function CreateProductForm({ offered three invented VAT rates, was never submitted, and never reached a customer's receipt. */} <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> - <AmountInput - id="prod_price_input" - label={t`Price per unit`} - required - value={price} - preserveCurrencyWhenEmpty - onChange={(val) => setPrice(val)} - primaryCurrency={currency} - payoutCurrencies={payoutCurrencies} - currencies={currencies} - helpText={t`What one of these costs, including any tax.`} - /> + <div> + <AmountInput + {...fieldProps("prod_price_input")} + id="prod_price_input" + label={t`Price per unit`} + required + value={price} + preserveCurrencyWhenEmpty + onChange={(val) => setPrice(val)} + primaryCurrency={currency} + payoutCurrencies={payoutCurrencies} + currencies={currencies} + helpText={t`What one of these costs, including any tax.`} + /> + {fieldError("prod_price_input")} + </div> </div> <div> @@ -630,6 +653,7 @@ function CreateProductForm({ {t`Units in Stock`} </label> <input + {...fieldProps("prod_stock_count_input")} id="prod_stock_count_input" type="text" value={totalStock} @@ -638,6 +662,7 @@ function CreateProductForm({ } class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono" /> + {fieldError("prod_stock_count_input")} </div> <div> diff --git a/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx @@ -464,8 +464,15 @@ function CreateSubscriptionForm({ ); const [manualId, setManualId] = useState<boolean>(initialDraft.manualId); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); useEffect(() => { if (roundingPrecisionIsDefault.current) { @@ -528,29 +535,36 @@ function CreateSubscriptionForm({ const slug = (slugId || `token_${slugify(trimmedName)}`).trim(); if (!trimmedName) { - setErrorMsg(t`Please enter a name for this discount or pass.`); + setErrorMsg( + t`Please enter a name for this discount or pass.`, + "sub_name_input", + ); return; } if (!trimmedDesc) { - setErrorMsg(t`Please enter a description for this discount or pass.`); + setErrorMsg( + t`Please enter a description for this discount or pass.`, + "sub_desc_input", + ); return; } if (!/^[a-zA-Z0-9_-]+$/.test(slug)) { setErrorMsg( t`The identifier can only contain letters, numbers, underscores, and hyphens (no spaces or special characters).`, + "sub_slug_input", ); return; } if (useValidFrom && !validFrom) { - setErrorMsg(t`Please choose a "Valid From" date.`); + setErrorMsg(t`Please choose a "Valid From" date.`, "sub_valid_from"); return; } if (useValidUntil && !validUntil) { - setErrorMsg(t`Please choose a "Valid Until" date.`); + setErrorMsg(t`Please choose a "Valid Until" date.`, "sub_valid_until"); return; } @@ -561,11 +575,14 @@ function CreateSubscriptionForm({ fromMs = parseStrictDate(validFrom, "validFrom").getTime(); untilMs = parseStrictDate(validUntil, "validUntil").getTime(); } catch { - setErrorMsg(t`Enter valid calendar dates.`); + setErrorMsg(t`Enter valid calendar dates.`, "sub_valid_until"); return; } if (untilMs <= fromMs) { - setErrorMsg(t`"Valid Until" date must be after "Valid From" date.`); + setErrorMsg( + t`"Valid Until" date must be after "Valid From" date.`, + "sub_valid_until", + ); return; } } @@ -580,7 +597,10 @@ function CreateSubscriptionForm({ (!isEditing || validUntil !== authoritativeValidUntil) ) { if (!isFutureDateInput(validUntil)) { - setErrorMsg(t`"Valid Until" date must be in the future.`); + setErrorMsg( + t`"Valid Until" date must be in the future.`, + "sub_valid_until", + ); return; } } @@ -589,6 +609,7 @@ function CreateSubscriptionForm({ if (!allowedGranularities.includes(stepGranularity)) { setErrorMsg( t`Validity granularity must be 1 minute, 1 hour, 1 day, 7 days, 30 days, 90 days, or 365 days.`, + "sub_granularity", ); return; } @@ -601,6 +622,7 @@ function CreateSubscriptionForm({ ) { setErrorMsg( t`Select at least one product category or inventory product.`, + "redemption-products", ); return; } @@ -611,7 +633,10 @@ function CreateSubscriptionForm({ ({ id }) => !displayCategories.some((category) => category.id === id), ) ) { - setErrorMsg(t`Remove unavailable categories before saving this rule.`); + setErrorMsg( + t`Remove unavailable categories before saving this rule.`, + "redemption-products", + ); return; } if ( @@ -621,7 +646,10 @@ function CreateSubscriptionForm({ ({ id }) => !displayProducts.some((product) => product.id === id), ) ) { - setErrorMsg(t`Remove unavailable products before saving this rule.`); + setErrorMsg( + t`Remove unavailable products before saving this rule.`, + "redemption-products", + ); return; } @@ -670,12 +698,14 @@ function CreateSubscriptionForm({ if (!normalized) { setErrorMsg( t`Enter a percentage greater than 0 and no more than 100, with up to eight decimal places.`, + "sub_discount_percentage", ); return undefined; } if (!normalizedPrecision || normalizedPrecision === "0") { setErrorMsg( t`Enter a positive rounding precision with up to eight decimal places.`, + "sub_discount_rounding_precision", ); return undefined; } @@ -686,7 +716,7 @@ function CreateSubscriptionForm({ } as const; } if (flatAmounts.length === 0) { - setErrorMsg(t`Add at least one currency cap.`); + setErrorMsg(t`Add at least one currency cap.`, "currency-caps"); return undefined; } const normalizedAmounts: AmountString[] = []; @@ -697,18 +727,22 @@ function CreateSubscriptionForm({ !Amounts.checkString(normalizedAmount) || !Amounts.isNonZero(normalizedAmount) ) { - setErrorMsg(t`Enter a positive amount for every currency cap.`); + setErrorMsg( + t`Enter a positive amount for every currency cap.`, + "currency-caps", + ); return undefined; } const currency = Amounts.currencyOf(normalizedAmount).toUpperCase(); if (!supportedCurrencies.includes(currency)) { setErrorMsg( t`Remove or change currency caps that are no longer supported by the merchant.`, + "currency-caps", ); return undefined; } if (seenCurrencies.has(currency)) { - setErrorMsg(t`Use each currency only once.`); + setErrorMsg(t`Use each currency only once.`, "currency-caps"); return undefined; } seenCurrencies.add(currency); @@ -721,7 +755,10 @@ function CreateSubscriptionForm({ let semantics: TokenSemantics; if (kind === "subscription") { if (benefit.benefitType === "free_item") { - setErrorMsg(t`Free-item benefits are only available for discounts.`); + setErrorMsg( + t`Free-item benefits are only available for discounts.`, + "benefit-type", + ); return; } semantics = { @@ -741,7 +778,10 @@ function CreateSubscriptionForm({ !Number.isSafeInteger(required) || required <= 0) ) { - setErrorMsg(t`Enter a positive whole-number redemption threshold.`); + setErrorMsg( + t`Enter a positive whole-number redemption threshold.`, + "sub_required_tokens", + ); return; } if ( @@ -751,6 +791,7 @@ function CreateSubscriptionForm({ ) { setErrorMsg( t`Select at least one issuance category or inventory product, or choose all merchant purchases.`, + "issuance-products", ); return; } @@ -760,7 +801,10 @@ function CreateSubscriptionForm({ ({ id }) => !displayCategories.some((category) => category.id === id), ) ) { - setErrorMsg(t`Remove unavailable categories before saving this rule.`); + setErrorMsg( + t`Remove unavailable categories before saving this rule.`, + "issuance-products", + ); return; } if ( @@ -769,7 +813,10 @@ function CreateSubscriptionForm({ ({ id }) => !displayProducts.some((product) => product.id === id), ) ) { - setErrorMsg(t`Remove unavailable products before saving this rule.`); + setErrorMsg( + t`Remove unavailable products before saving this rule.`, + "issuance-products", + ); return; } let normalizedMinimum: AmountString | undefined; @@ -784,6 +831,7 @@ function CreateSubscriptionForm({ ) { setErrorMsg( t`Enter a positive minimum purchase in a supported merchant currency.`, + "sub_minimum_purchase", ); return; } @@ -1133,6 +1181,7 @@ function CreateSubscriptionForm({ {t`Name`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("sub_name_input")} id="sub_name_input" type="text" required @@ -1147,6 +1196,7 @@ function CreateSubscriptionForm({ } class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + {fieldError("sub_name_input")} <p class="text-xs text-gray-500 mt-1"> {kind === "subscription" ? t`What pass holders see in their wallets and contract receipts.` @@ -1162,6 +1212,7 @@ function CreateSubscriptionForm({ {t`Description`} </label> <textarea + {...fieldProps("sub_desc_input")} id="sub_desc_input" rows={2} placeholder={ @@ -1175,6 +1226,7 @@ function CreateSubscriptionForm({ } class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + {fieldError("sub_desc_input")} <p class="text-xs text-gray-500 mt-1">{t`Detailed terms or redemption rules shown to customers.`}</p> </div> </div> @@ -1222,6 +1274,7 @@ function CreateSubscriptionForm({ <fieldset class="space-y-3"> <legend class="block text-xs font-bold text-gray-700">{t`Benefit calculation`}</legend> + {fieldError("benefit-type")} <div class={`grid grid-cols-1 ${kind === "discount" ? "sm:grid-cols-4" : "sm:grid-cols-3"} gap-3`} > @@ -1293,6 +1346,7 @@ function CreateSubscriptionForm({ >{t`Percentage`}</label> <div class="flex items-center gap-2"> <input + {...fieldProps("sub_discount_percentage")} id="sub_discount_percentage" type="text" inputMode="decimal" @@ -1304,6 +1358,7 @@ function CreateSubscriptionForm({ /> <span class="text-sm text-gray-600">%</span> </div> + {fieldError("sub_discount_percentage")} </div> <div class="rounded-lg border border-gray-200"> <button @@ -1357,6 +1412,7 @@ function CreateSubscriptionForm({ class="block text-xs font-bold text-gray-700 mb-1" >{t`Rounding precision`}</label> <input + {...fieldProps("sub_discount_rounding_precision")} id="sub_discount_rounding_precision" type="text" inputMode="decimal" @@ -1369,6 +1425,7 @@ function CreateSubscriptionForm({ }} class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono" /> + {fieldError("sub_discount_rounding_precision")} <p class="text-2xs text-gray-500 mt-1">{t`Currency units, for example 0.01 or 0.05.`}</p> </div> </div> @@ -1376,8 +1433,14 @@ function CreateSubscriptionForm({ </div> </div> ) : benefitType === "flat" ? ( - <div class="space-y-2"> + <div + role="group" + aria-label={t`Maximum benefit amounts`} + {...fieldProps("currency-caps")} + class="space-y-2" + > <span class="block text-xs font-bold text-gray-700">{t`Maximum benefit amounts`}</span> + {fieldError("currency-caps")} {flatAmounts.map((amount, index) => { const currency = amount.includes(":") ? amount.slice(0, amount.indexOf(":")).toUpperCase() @@ -1516,6 +1579,7 @@ function CreateSubscriptionForm({ class="block text-xs font-bold text-gray-700 mb-1" >{t`Discounts required to redeem`}</label> <input + {...fieldProps("sub_required_tokens")} id="sub_required_tokens" type="number" min="1" @@ -1526,14 +1590,19 @@ function CreateSubscriptionForm({ } class="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg" /> + {fieldError("sub_required_tokens")} </div> )} {benefitType !== "none" && ( - <fieldset class="space-y-3"> + <fieldset + {...fieldProps("redemption-products")} + class="space-y-3" + > <legend class="block text-xs font-bold text-gray-700"> {t`Products where the benefit applies`} </legend> + {fieldError("redemption-products")} <label class={`flex items-start gap-2 rounded-lg border p-3 cursor-pointer ${redemptionWildcard ? "border-blue-500 bg-blue-50" : "border-gray-200"}`} @@ -1596,10 +1665,11 @@ function CreateSubscriptionForm({ >{t`Earning discounts`}</h3> <p class="text-xs text-gray-500 mt-1">{t`Each qualifying paid order earns exactly one discount.`}</p> </div> - <fieldset class="space-y-3"> + <fieldset {...fieldProps("issuance-products")} class="space-y-3"> <legend class="block text-xs font-bold text-gray-700"> {t`Products where discounts are earned`} </legend> + {fieldError("issuance-products")} <label class={`flex items-start gap-2 rounded-lg border p-3 cursor-pointer ${issuanceWildcard ? "border-blue-500 bg-blue-50" : "border-gray-200"}`} > @@ -1642,23 +1712,27 @@ function CreateSubscriptionForm({ )} </fieldset> <div> - <AmountInput - id="sub_minimum_purchase" - value={minimumPurchase} - label={t`Minimum qualifying purchase (optional)`} - primaryCurrency={primaryCurrency} - currencies={supportedCurrencies} - step={currencyFractionalStep( - minimumPurchase.includes(":") - ? minimumPurchase.slice(0, minimumPurchase.indexOf(":")) - : primaryCurrency, - currencySpecifications, - )} - preserveCurrencyWhenEmpty - preserveExplicitCurrency - onChange={setMinimumPurchase} - class="w-64" - /> + <div> + <AmountInput + {...fieldProps("sub_minimum_purchase")} + id="sub_minimum_purchase" + value={minimumPurchase} + label={t`Minimum qualifying purchase (optional)`} + primaryCurrency={primaryCurrency} + currencies={supportedCurrencies} + step={currencyFractionalStep( + minimumPurchase.includes(":") + ? minimumPurchase.slice(0, minimumPurchase.indexOf(":")) + : primaryCurrency, + currencySpecifications, + )} + preserveCurrencyWhenEmpty + preserveExplicitCurrency + onChange={setMinimumPurchase} + class="w-64" + /> + {fieldError("sub_minimum_purchase")} + </div> </div> <label class="flex items-start gap-2 text-sm text-gray-800"> <input @@ -1731,6 +1805,7 @@ function CreateSubscriptionForm({ : t`Group discount expiry times by`} </label> <select + {...fieldProps("sub_granularity")} id="sub_granularity" disabled={isEditing} value={stepGranularity} @@ -1747,6 +1822,7 @@ function CreateSubscriptionForm({ <option value="90d">{t`90 days`}</option> <option value="365d">{t`365 days`}</option> </select> + {fieldError("sub_granularity")} <p class="text-xs text-gray-600 leading-relaxed bg-gray-50 p-3 rounded-lg border border-gray-200"> {kind === "subscription" ? ( <span> @@ -1824,6 +1900,7 @@ function CreateSubscriptionForm({ {t`First valid date`} </label> <input + {...fieldProps("sub_valid_from")} id="sub_valid_from" type="date" required @@ -1833,6 +1910,7 @@ function CreateSubscriptionForm({ } class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> + {fieldError("sub_valid_from")} <p class="text-2xs text-gray-500 mt-1"> {kind === "subscription" ? t`First date this pass can be issued or used.` @@ -1867,6 +1945,7 @@ function CreateSubscriptionForm({ {t`Last valid date`} </label> <input + {...fieldProps("sub_valid_until")} id="sub_valid_until" type="date" required @@ -1876,6 +1955,7 @@ function CreateSubscriptionForm({ } class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> + {fieldError("sub_valid_until")} <p class="text-2xs text-gray-500 mt-1"> {kind === "subscription" ? t`Cut-off date after which no new passes can start.` @@ -1906,6 +1986,7 @@ function CreateSubscriptionForm({ )} </label> <input + {...fieldProps("sub_slug_input")} id="sub_slug_input" type="text" disabled={isEditing} @@ -1915,6 +1996,7 @@ function CreateSubscriptionForm({ } class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono disabled:bg-gray-100 disabled:text-gray-500" /> + {fieldError("sub_slug_input")} <p class="text-xs text-gray-500 mt-1"> {t`Unique identifier in backend contracts. Cannot be changed later.`} </p> diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx @@ -215,8 +215,15 @@ function CreateTemplateForm({ const submitting = useRef(false); const idInput = useRef<HTMLInputElement>(null); const [idConflict, setIdConflict] = useState(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); useLayoutEffect(() => { if (idConflict && showAdvanced && !isSubmitting) idInput.current?.focus(); @@ -266,11 +273,11 @@ function CreateTemplateForm({ return; } if (!payDurationValid) { - setErrorMsg(t`Enter a valid payment duration.`); + setErrorMsg(t`Enter a valid payment duration.`, "tmpl_time_pay_input"); return; } if (!name.trim()) { - setErrorMsg(t`Please enter a template name.`); + setErrorMsg(t`Please enter a template name.`, "tmpl_name_input"); return; } setErrorMsg(""); @@ -295,7 +302,10 @@ function CreateTemplateForm({ (!Amounts.checkString(formattedAmount) || Amounts.currencyOf(formattedAmount) !== currency) ) { - setErrorMsg(t`Enter a valid fixed amount in the selected currency.`); + setErrorMsg( + t`Enter a valid fixed amount in the selected currency.`, + "tmpl_amount_input", + ); return; } if ( @@ -303,7 +313,10 @@ function CreateTemplateForm({ minimumAge < 0 || minimumAge > 200 ) { - setErrorMsg(t`Enter a valid minimum age between 0 and 200.`); + setErrorMsg( + t`Enter a valid minimum age between 0 and 200.`, + "tmpl_min_age_input", + ); return; } @@ -487,6 +500,7 @@ function CreateTemplateForm({ {t`Template Name`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("tmpl_name_input")} id="tmpl_name_input" type="text" required @@ -497,6 +511,7 @@ function CreateTemplateForm({ } class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + {fieldError("tmpl_name_input")} <p class="text-xs text-gray-500 mt-1">{t`What this template is for in your portal dashboard so you can identify it later.`}</p> </div> @@ -521,20 +536,24 @@ function CreateTemplateForm({ </div> {canChooseWhatItSells && selType === "fixed" && ( - <AmountInput - id="tmpl_amount_input" - label={t`Fixed Amount`} - required - value={amount} - onChange={(val) => { - setAmount(val); - setCurrency(val.split(":")[0] || startingCurrency); - }} - primaryCurrency={startingCurrency} - payoutCurrencies={payoutCurrencies} - currencies={availableCurrencies} - helpText={t`Select currency and enter the fixed price charged for every order.`} - /> + <div> + <AmountInput + {...fieldProps("tmpl_amount_input")} + id="tmpl_amount_input" + label={t`Fixed Amount`} + required + value={amount} + onChange={(val) => { + setAmount(val); + setCurrency(val.split(":")[0] || startingCurrency); + }} + primaryCurrency={startingCurrency} + payoutCurrencies={payoutCurrencies} + currencies={availableCurrencies} + helpText={t`Select currency and enter the fixed price charged for every order.`} + /> + {fieldError("tmpl_amount_input")} + </div> )} </div> </div> @@ -666,21 +685,25 @@ function CreateTemplateForm({ </div> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> - <DurationInput - 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> + <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> <div> <label @@ -690,6 +713,7 @@ function CreateTemplateForm({ {t`Minimum Age Requirement`} </label> <input + {...fieldProps("tmpl_min_age_input")} id="tmpl_min_age_input" type="number" min={0} @@ -704,6 +728,7 @@ function CreateTemplateForm({ } class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" /> + {fieldError("tmpl_min_age_input")} <p class="text-xs text-gray-500 mt-1">{t`Restricts who can pay. Leave at 0 for no restriction.`}</p> </div> </div> diff --git a/packages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx @@ -330,8 +330,15 @@ function CreateWebhookForm({ ); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); const insertVariable = (varName: string) => { const placeholder = `{{${varName}}}`; @@ -359,11 +366,11 @@ function CreateWebhookForm({ const handleSubmit = async (e: Event) => { e.preventDefault(); if (!isEditing && !webhookId.trim()) { - setErrorMsg(t`Enter a webhook identifier.`); + setErrorMsg(t`Enter a webhook identifier.`, "wh_id_input"); return; } if (!isHttpWebhookUrl(url.trim())) { - setErrorMsg(t`Enter a valid HTTP or HTTPS callback URL.`); + setErrorMsg(t`Enter a valid HTTP or HTTPS callback URL.`, "wh_url_input"); return; } setErrorMsg(""); @@ -422,6 +429,7 @@ function CreateWebhookForm({ <span>{t`Webhook Identifier (ID)`}</span> </label> <input + {...fieldProps("wh_id_input")} id="wh_id_input" type="text" disabled={isEditing} @@ -432,6 +440,7 @@ function CreateWebhookForm({ placeholder={t`e.g. wh_order_fulfillment`} class="w-full px-3.5 py-2.5 text-sm font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500" /> + {fieldError("wh_id_input")} <p class="text-xs text-gray-500 mt-1">{t`Unique webhook identifier. Derived automatically from the name unless overridden.`}</p> </div> @@ -466,6 +475,7 @@ function CreateWebhookForm({ {t`Call this address (URL)`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("wh_url_input")} id="wh_url_input" type="text" required @@ -474,6 +484,7 @@ function CreateWebhookForm({ onInput={(e) => setUrl((e.target as HTMLInputElement).value)} class="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono" /> + {fieldError("wh_url_input")} <p class="text-xs text-gray-500 mt-1"> {t`Where your server sends the notification. Your systems receive it; no customer is involved.`} </p> diff --git a/packages/taler-merchant-webui/src/screens/ScheduleReportScreen.tsx b/packages/taler-merchant-webui/src/screens/ScheduleReportScreen.tsx @@ -75,8 +75,15 @@ export function ScheduleReportScreen({ generatorEntries[0]?.[0] ?? "", ); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } = - useFormFeedback(); + const { + errorMsg, + setErrorMsg, + rawError, + setRawError, + feedbackId, + fieldProps, + fieldError, + } = useFormFeedback(); useEffect(() => { if (!reportSenderProgram && generatorEntries[0]) { @@ -89,11 +96,14 @@ export function ScheduleReportScreen({ const handleSubmit = async (e: Event) => { e.preventDefault(); if (!reportDesc.trim()) { - setErrorMsg(t`Please describe what this report is for.`); + setErrorMsg(t`Please describe what this report is for.`, "rep_desc_in"); return; } if (!reportTarget.trim()) { - setErrorMsg(t`Please enter the destination for this report.`); + setErrorMsg( + t`Please enter the destination for this report.`, + "rep_target_in", + ); return; } if (!reportSenderProgram) { @@ -162,6 +172,7 @@ export function ScheduleReportScreen({ {t`Description`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("rep_desc_in")} id="rep_desc_in" type="text" placeholder={t`e.g. Weekly sales summary`} @@ -172,6 +183,7 @@ export function ScheduleReportScreen({ } class={field} /> + {fieldError("rep_desc_in")} </div> {/* Only the sales summary has a handler on the server. The other @@ -267,6 +279,7 @@ export function ScheduleReportScreen({ {t`Destination`} <span class="text-red-500">*</span> </label> <input + {...fieldProps("rep_target_in")} id="rep_target_in" type="text" required @@ -277,6 +290,7 @@ export function ScheduleReportScreen({ } class={field} /> + {fieldError("rep_target_in")} <p class="text-2xs text-gray-500 mt-1"> {t`The configured delivery program decides what kind of destination this must be.`} </p> diff --git a/packages/taler-merchant-webui/src/ui/AmountInput.tsx b/packages/taler-merchant-webui/src/ui/AmountInput.tsx @@ -18,6 +18,8 @@ import type { VNode } from "preact"; import { useTranslation } from "../context/translation.js"; export interface AmountInputProps { + "aria-invalid"?: boolean; + "aria-describedby"?: string; id?: string; value?: string; // e.g. "EUR:10.50" or "10.50" onChange?: (value: string) => void; @@ -132,6 +134,8 @@ export function resolveCurrencyPriority(params: { } export function AmountInput({ + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, id = "amount-input", value = "", onChange, @@ -213,6 +217,8 @@ export function AmountInput({ {/* Numeric Value Field */} <input id={id} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} type="number" step={step} min="0" diff --git a/packages/taler-merchant-webui/src/ui/DurationInput.tsx b/packages/taler-merchant-webui/src/ui/DurationInput.tsx @@ -29,6 +29,8 @@ export type DurationUnit = export type DurationValue = number | "forever"; export interface DurationInputProps { + "aria-invalid"?: boolean; + "aria-describedby"?: string; /** * Fixed-unit selection is a value edit: changing 2 hours to days keeps the * number 2 and emits 2 days. Selecting custom mode instead preserves the @@ -93,6 +95,8 @@ function formatCustomText(us: DurationValue): string { } export function DurationInput({ + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, id = "duration-input", label, helpText, @@ -293,6 +297,8 @@ export function DurationInput({ <input ref={inputRef} id={id} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} type="number" min="0" value={numValue} @@ -306,6 +312,8 @@ export function DurationInput({ <input ref={inputRef} id={id} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} type="text" size={15} value={customText} diff --git a/packages/taler-merchant-webui/src/ui/ImageUploadInput.tsx b/packages/taler-merchant-webui/src/ui/ImageUploadInput.tsx @@ -29,6 +29,8 @@ import { } from "./imageDataUrl.js"; export interface ImageUploadInputProps { + "aria-invalid"?: boolean; + "aria-describedby"?: string; id?: string; label?: string; helpText?: string; @@ -39,6 +41,8 @@ export interface ImageUploadInputProps { } export function ImageUploadInput({ + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, id = "logo-upload-input", label: propLabel, helpText: propHelpText, @@ -170,6 +174,8 @@ export function ImageUploadInput({ <input ref={fileInputRef} id={id} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" onChange={handleFileSelect} diff --git a/packages/taler-merchant-webui/src/ui/PasswordInput.tsx b/packages/taler-merchant-webui/src/ui/PasswordInput.tsx @@ -12,6 +12,8 @@ import { useState } from "preact/hooks"; import { useTranslation } from "../context/translation.js"; export interface PasswordInputProps { + "aria-invalid"?: boolean; + "aria-describedby"?: string; id: string; label: string; value: string; @@ -28,6 +30,8 @@ export interface PasswordInputProps { /** The single password-entry pattern used throughout the merchant web UI. */ export function PasswordInput({ + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, id, label, value, @@ -63,6 +67,8 @@ export function PasswordInput({ <div class="flex items-center overflow-hidden rounded-lg border border-gray-300 bg-white shadow-2xs focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500"> <input id={id} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedBy} aria-label={accessibleLabel} type={shown ? "text" : "password"} value={value}