commit 5dd9792d4882d8e0260c1db44f30402ad95f7df7 parent 4dc54f25b6da2a30ea30b862cd7c28b87a92d0f4 Author: Florian Dold <dold@taler.net> Date: Tue, 3 Nov 2026 21:51:30 +0100 merchant web UI: add settings and dialog footers Keep save errors beside the controls for each settings section and dialog. Diffstat:
10 files changed, 193 insertions(+), 117 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/AddPayoutAccountScreen.tsx b/packages/taler-merchant-webui/src/screens/AddPayoutAccountScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import { @@ -80,7 +81,11 @@ export function AddPayoutAccountScreen({ const [showAdvanced, setShowAdvanced] = useState<boolean>(false); const [userReference, setUserReference] = useState<string>(""); const [isSaving, setIsSaving] = useState(false); - const [saveError, setSaveError] = useState<unknown>(); + const { + rawError: saveError, + setRawError: setSaveError, + feedbackId, + } = useFormFeedback(); useEffect(() => { if (allowedMethods.length > 0 && !allowedMethods.includes(wireMethod)) { @@ -168,12 +173,6 @@ export function AddPayoutAccountScreen({ onSubmit={handleSubmit} class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-5" > - {saveError && ( - <ApiErrorBanner - error={saveError} - title={t`The bank account could not be added`} - /> - )} {!policyReady && policyResource?.error && ( <ReadErrorBanner resource={policyResource} @@ -415,7 +414,12 @@ export function AddPayoutAccountScreen({ </div> {/* Action Buttons */} - <div class="pt-4 flex items-center justify-end space-x-3"> + <FormActionFooter + error={saveError} + feedbackId={feedbackId} + pending={isSaving} + title={t`The bank account could not be added`} + > {onCancel && ( <button type="button" @@ -438,7 +442,7 @@ export function AddPayoutAccountScreen({ > {isSaving ? t`Saving…` : t`Save bank account`} </button> - </div> + </FormActionFooter> </form> </div> ); diff --git a/packages/taler-merchant-webui/src/screens/AdminAccountCredentialsScreen.tsx b/packages/taler-merchant-webui/src/screens/AdminAccountCredentialsScreen.tsx @@ -7,11 +7,11 @@ Foundation; either version 3, or (at your option) any later version. */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { Header } from "../ui/Header.js"; import { Button } from "../ui/Button.js"; -import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { PasswordInput } from "../ui/PasswordInput.js"; import { useTranslation } from "../context/translation.js"; @@ -27,7 +27,11 @@ export function AdminAccountCredentialsScreen({ const { t } = useTranslation(); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); - const [error, setError] = useState<unknown>(); + const { + rawError: error, + setRawError: setError, + feedbackId, + } = useFormFeedback(); const [validation, setValidation] = useState(""); const [pending, setPending] = useState(false); @@ -64,16 +68,7 @@ export function AdminAccountCredentialsScreen({ backLabel={t`Merchant account`} /> <div class="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm text-amber-950">{t`The account’s existing password will stop working. Existing login tokens remain governed by the backend’s token policy.`}</div> - <ApiErrorBanner error={error} title={t`Could not reset password`} /> - {validation && ( - <div - data-error-banner - role="alert" - class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-800" - > - {validation} - </div> - )} + <form onSubmit={(event) => void submit(event)} class="space-y-5 rounded-xl border border-gray-200 bg-white p-5" @@ -98,14 +93,20 @@ export function AdminAccountCredentialsScreen({ showRequiredIndicator={false} minLength={8} /> - <div class="flex justify-end gap-3"> + <FormActionFooter + error={error} + validationError={validation} + feedbackId={feedbackId} + pending={pending} + title={t`Could not reset password`} + > <Button variant="secondary" onClick={onBack} disabled={pending} >{t`Cancel`}</Button> <Button type="submit" isLoading={pending}>{t`Reset password`}</Button> - </div> + </FormActionFooter> </form> </div> ); diff --git a/packages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx b/packages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx @@ -7,6 +7,7 @@ Foundation; either version 3, or (at your option) any later version. */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { @@ -17,7 +18,6 @@ import { } from "@gnu-taler/taler-util"; import { Header } from "../ui/Header.js"; import { Button } from "../ui/Button.js"; -import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { LocationInput } from "../ui/LocationInput.js"; import { ImageUploadInput } from "../ui/ImageUploadInput.js"; import { useImageDataUrlStatus } from "../ui/imageDataUrl.js"; @@ -119,7 +119,11 @@ function AdminAccountFormInner({ const [rounding, setRounding] = useState<string>( initial.default_wire_transfer_rounding_interval || "NONE", ); - const [error, setError] = useState<unknown>(); + const { + rawError: error, + setRawError: setError, + feedbackId, + } = useFormFeedback(); const [validation, setValidation] = useState(""); const [pending, setPending] = useState(false); const logoStatus = useImageDataUrlStatus(value.logo || ""); @@ -221,23 +225,7 @@ function AdminAccountFormInner({ onBack={onBack} backLabel={t`Merchant accounts`} /> - <ApiErrorBanner - error={error} - title={ - mode === "create" - ? t`Could not create merchant account` - : t`Could not update merchant account` - } - /> - {validation && ( - <div - data-error-banner - role="alert" - class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-800" - > - {validation} - </div> - )} + <form onSubmit={(event) => void submit(event)} class="space-y-5"> <section class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-2xs"> <div> @@ -480,7 +468,17 @@ function AdminAccountFormInner({ </section> )} - <div class="flex justify-end gap-3"> + <FormActionFooter + error={error} + validationError={validation} + feedbackId={feedbackId} + pending={pending} + title={ + mode === "create" + ? t`Could not create merchant account` + : t`Could not update merchant account` + } + > <Button variant="secondary" onClick={onBack} @@ -497,7 +495,7 @@ function AdminAccountFormInner({ > {mode === "create" ? t`Create merchant account` : t`Save changes`} </Button> - </div> + </FormActionFooter> </form> </div> ); diff --git a/packages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx b/packages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx @@ -19,6 +19,7 @@ import type { TalerMerchantApi } from "@gnu-taler/taler-util"; import type { PayoutAccount } from "../types/domain.js"; import { useTranslation } from "../context/translation.js"; import { Modal } from "../ui/Modal.js"; +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; interface BankMonitoringDialogProps { @@ -122,7 +123,13 @@ function BankMonitoringForm({ const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [token, setToken] = useState(""); - const [error, setError] = useState<unknown>(); + const { + rawError: error, + setRawError: setError, + errorMsg: validation, + setErrorMsg: setValidation, + feedbackId, + } = useFormFeedback(); const fieldClass = "mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"; return ( @@ -131,7 +138,7 @@ function BankMonitoringForm({ onSubmit={async (event) => { event.preventDefault(); if (saving) return; - setError(undefined); + setValidation(""); let normalizedUrl: string | undefined; if (enabled) { try { @@ -145,10 +152,8 @@ function BankMonitoringForm({ throw new Error(); normalizedUrl = parsed.href; } catch { - setError( - new Error( - t`Enter an HTTP or HTTPS bank history URL without embedded credentials or a fragment.`, - ), + setValidation( + t`Enter an HTTP or HTTPS bank history URL without embedded credentials or a fragment.`, ); return; } @@ -156,10 +161,8 @@ function BankMonitoringForm({ (auth === "basic" && (!username.trim() || !password)) || (auth === "bearer" && !token.trim()) ) { - setError( - new Error( - t`Enter the credentials for the selected authentication method.`, - ), + setValidation( + t`Enter the credentials for the selected authentication method.`, ); return; } @@ -284,11 +287,14 @@ function BankMonitoringForm({ </> )} </fieldset> - <ApiErrorBanner + + <FormActionFooter error={error} + validationError={validation} + feedbackId={feedbackId} + pending={saving} title={t`Bank monitoring settings could not be saved`} - /> - <div class="flex justify-end gap-3"> + > <button type="button" disabled={saving} @@ -302,7 +308,7 @@ function BankMonitoringForm({ > {saving ? t`Saving…` : t`Save`} </button> - </div> + </FormActionFooter> </form> ); } diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx @@ -102,6 +102,18 @@ test("settings serialize section saves, retain other drafts and use the latest c { name: "New", email: "new@example.com" }, ); await act(async () => requests[1]!.reject(new Error("Save failed"))); + const contactForm = field("business-email").closest("form")!; + const failureFooter = contactForm.querySelector( + "[data-form-action-footer]", + )!; + assert.match(failureFooter.textContent!, /Save failed/); + await click( + failureFooter.querySelector<HTMLButtonElement>("[data-dismiss-error]")!, + ); + assert.match(failureFooter.textContent!, /Save not confirmed/); + await click(button("Show details", failureFooter)); + assert.match(failureFooter.textContent!, /Save failed/); + assert.equal(field("business-email").value, "new@example.com"); await act(() => submit(field("business-email"))); await act(async () => requests[2]!.resolve("challenge")); diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx @@ -17,6 +17,7 @@ import { LocationInput } from "../ui/LocationInput.js"; import { ImageUploadInput } from "../ui/ImageUploadInput.js"; import { Button } from "../ui/Button.js"; import { DisclosureChevron } from "../ui/DisclosureChevron.js"; +import { FormActionFooter } from "../ui/FormActionFooter.js"; import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; import { InitialLoadingState } from "../ui/InitialLoadingState.js"; @@ -169,7 +170,7 @@ function DisclosureSection(props: DisclosureSectionProps): VNode { ); return ( - <section class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xs"> + <section class="overflow-clip rounded-xl border border-gray-200 bg-white shadow-2xs"> {props.isOpen ? ( <div class="flex items-start justify-between gap-4 px-4 py-3.5 sm:px-5"> {content} @@ -207,6 +208,8 @@ interface EditorActionsProps { pending: boolean; saveBlocked: boolean; error?: unknown; + validationError?: string; + feedbackId?: number; disabled?: boolean; onCancel: () => void; saveLabel?: string; @@ -216,15 +219,21 @@ function EditorActions({ pending, saveBlocked, error, + validationError, + feedbackId, disabled, onCancel, saveLabel, }: EditorActionsProps): VNode { const { t } = useTranslation(); return ( - <div class="space-y-3 border-t border-gray-200 pt-4"> - <ApiErrorBanner error={error} title={t`Could not save changes`} /> - <div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> + <FormActionFooter + error={error} + validationError={validationError} + pending={pending} + feedbackId={feedbackId} + > + <div class="flex flex-wrap gap-2 sm:justify-end"> <Button type="button" variant="secondary" @@ -240,7 +249,7 @@ function EditorActions({ {saveLabel || t`Save changes`} </Button> </div> - </div> + </FormActionFooter> ); } @@ -301,6 +310,10 @@ export function BusinessSettingsScreen({ const [sectionErrors, setSectionErrors] = useState< Partial<Record<EditorSection, unknown>> >({}); + const [sectionFeedbackIds, setSectionFeedbackIds] = useState< + Partial<Record<EditorSection, number>> + >({}); + const [securityValidation, setSecurityValidation] = useState(""); const [savedSection, setSavedSection] = useState<EditorSection | null>(null); const [currentPassword, setCurrentPassword] = useState(""); @@ -375,6 +388,7 @@ export function BusinessSettingsScreen({ }; const cancelSection = (section: EditorSection) => { + if (section === "security") setSecurityValidation(""); seedSection(section); setSectionErrors((current) => ({ ...current, [section]: undefined })); setEditors((current) => ({ ...current, [section]: false })); @@ -387,6 +401,10 @@ export function BusinessSettingsScreen({ if (pendingSection.value !== null) return; const next: BusinessSettings = { ...committed.value, ...patch }; pendingSection.value = section; + setSectionFeedbackIds((ids) => ({ + ...ids, + [section]: (ids[section] ?? 0) + 1, + })); setSectionErrors((current) => ({ ...current, [section]: undefined })); try { const result = onSave ? await onSave(next) : "saved"; @@ -405,34 +423,27 @@ export function BusinessSettingsScreen({ event.preventDefault(); if (pendingSection.value !== null) return; setSectionErrors((current) => ({ ...current, security: undefined })); + setSecurityValidation(""); + setSectionFeedbackIds((ids) => ({ + ...ids, + security: (ids.security ?? 0) + 1, + })); if (!newPassword) { - setSectionErrors((current) => ({ - ...current, - security: new Error(t`Please enter a new password.`), - })); + setSecurityValidation(t`Please enter a new password.`); return; } if (newPassword.length < 8) { - setSectionErrors((current) => ({ - ...current, - security: new Error( - t`New password must be at least 8 characters long.`, - ), - })); + setSecurityValidation( + t`New password must be at least 8 characters long.`, + ); return; } if (newPassword !== confirmPassword) { - setSectionErrors((current) => ({ - ...current, - security: new Error(t`New passwords do not match.`), - })); + setSecurityValidation(t`New passwords do not match.`); return; } if (!currentPassword) { - setSectionErrors((current) => ({ - ...current, - security: new Error(t`Please enter your current password.`), - })); + setSecurityValidation(t`Please enter your current password.`); return; } pendingSection.value = "security"; @@ -641,6 +652,7 @@ export function BusinessSettingsScreen({ saveBlocked={pendingSection.value !== null} pending={pendingSection.value === "profile"} error={sectionErrors.profile} + feedbackId={sectionFeedbackIds.profile} disabled={ !name.trim() || draftLogoStatus === "checking" || @@ -704,6 +716,7 @@ export function BusinessSettingsScreen({ saveBlocked={pendingSection.value !== null} pending={pendingSection.value === "contact"} error={sectionErrors.contact} + feedbackId={sectionFeedbackIds.contact} onCancel={() => cancelSection("contact")} /> </fieldset> @@ -762,6 +775,7 @@ export function BusinessSettingsScreen({ saveBlocked={pendingSection.value !== null} pending={pendingSection.value === "address"} error={sectionErrors.address} + feedbackId={sectionFeedbackIds.address} onCancel={() => cancelSection("address")} /> </fieldset> @@ -820,6 +834,7 @@ export function BusinessSettingsScreen({ saveBlocked={pendingSection.value !== null} pending={pendingSection.value === "fees"} error={sectionErrors.fees} + feedbackId={sectionFeedbackIds.fees} onCancel={() => cancelSection("fees")} /> </fieldset> @@ -928,6 +943,7 @@ export function BusinessSettingsScreen({ disabled={delayValidity.some((valid) => !valid)} pending={pendingSection.value === "delays"} error={sectionErrors.delays} + feedbackId={sectionFeedbackIds.delays} onCancel={() => cancelSection("delays")} /> </fieldset> @@ -991,6 +1007,7 @@ export function BusinessSettingsScreen({ saveBlocked={pendingSection.value !== null} pending={pendingSection.value === "phone"} error={sectionErrors.phone} + feedbackId={sectionFeedbackIds.phone} disabled={isPhoneInvalid} onCancel={() => cancelSection("phone")} /> @@ -1044,6 +1061,8 @@ export function BusinessSettingsScreen({ saveBlocked={pendingSection.value !== null} pending={pendingSection.value === "security"} error={sectionErrors.security} + feedbackId={sectionFeedbackIds.security} + validationError={securityValidation} onCancel={() => cancelSection("security")} saveLabel={t`Update password`} /> @@ -1097,6 +1116,7 @@ export function BusinessSettingsScreen({ > <div class="space-y-4"> <ApiErrorBanner + compact error={deletionError} title={ deletionMode === "purge" diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx @@ -83,6 +83,16 @@ for (const layout of ["mobile", "desktop"]) { document.querySelector('[role="dialog"]')!.textContent!, /Previous operation failed/, ); + const failureFooter = document.querySelector( + '[role="dialog"] [data-form-action-footer]', + )!; + await click( + failureFooter.querySelector<HTMLButtonElement>("[data-dismiss-error]")!, + ); + assert.match(failureFooter.textContent!, /Save not confirmed/); + assert.equal(field("cat_name_input").value, "New"); + await act(async () => submit(field("cat_name_input"))); + assert.match(failureFooter.textContent!, /Previous operation failed/); await click(button("Cancel")); for (const action of ["create", "rename", "delete"]) { if (action === "create") await click(button("Add a category")); diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { useLocation } from "wouter-preact"; @@ -113,14 +114,22 @@ export function InventoryScreen({ ); const [isSavingCategory, setIsSavingCategory] = useState<boolean>(false); const [isDeletingCategory, setIsDeletingCategory] = useState<boolean>(false); - const [categoryMutationError, setCategoryMutationError] = useState<unknown>(); + const { + rawError: categoryMutationError, + setRawError: setCategoryMutationError, + feedbackId: categoryMutationErrorId, + } = useFormFeedback(); // Quick Price Edit Dialog State const [priceEditCandidate, setPriceEditCandidate] = useState<ProductItem | null>(null); const [quickPrice, setQuickPrice] = useState<string>(""); const [isSavingPrice, setIsSavingPrice] = useState<boolean>(false); - const [quickPriceError, setQuickPriceError] = useState<unknown>(); + const { + rawError: quickPriceError, + setRawError: setQuickPriceError, + feedbackId: quickPriceErrorId, + } = useFormFeedback(); const { searchQuery, @@ -743,11 +752,12 @@ export function InventoryScreen({ /> </div> - <div class="flex items-center justify-end space-x-3 pt-2"> - <ApiErrorBanner - error={categoryMutationError} - title={t`The category could not be saved`} - /> + <FormActionFooter + error={categoryMutationError} + feedbackId={categoryMutationErrorId} + pending={isSavingCategory} + title={t`The category could not be saved`} + > <Button variant="secondary" onClick={() => setIsCategoryModalOpen(false)} @@ -762,7 +772,7 @@ export function InventoryScreen({ > {editingCategory ? t`Save Name` : t`Create Category`} </Button> - </div> + </FormActionFooter> </form> </Modal> @@ -781,7 +791,7 @@ export function InventoryScreen({ error={categoryMutationError} title={t`The category could not be deleted`} /> - <div class="flex items-center justify-end space-x-3 pt-2"> + <div class="flex justify-end space-x-3"> <Button variant="secondary" onClick={() => setDeletingCategory(null)} @@ -845,11 +855,13 @@ export function InventoryScreen({ : [] } /> - <ApiErrorBanner + + <FormActionFooter error={quickPriceError} + pending={isSavingPrice} + feedbackId={quickPriceErrorId} title={t`The price could not be updated`} - /> - <div class="flex items-center justify-end space-x-3 pt-2"> + > <Button variant="secondary" onClick={() => setPriceEditCandidate(null)} @@ -865,7 +877,7 @@ export function InventoryScreen({ > {t`Save Price`} </Button> - </div> + </FormActionFooter> </form> </Modal> diff --git a/packages/taler-merchant-webui/src/screens/PersonalizationScreen.tsx b/packages/taler-merchant-webui/src/screens/PersonalizationScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { Header } from "../ui/Header.js"; @@ -131,7 +132,7 @@ export function PersonalizationScreen(): VNode { </div> {/* Explicit Save Action */} - <div class="pt-4 border-t border-gray-200 flex items-center justify-end gap-3"> + <FormActionFooter> {saveMessage && ( <span role="status" class="text-xs font-semibold text-emerald-700"> {saveMessage} @@ -144,7 +145,7 @@ export function PersonalizationScreen(): VNode { > {t`Save preferences`} </button> - </div> + </FormActionFooter> </form> </div> ); diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { useSignal } from "@preact/signals"; @@ -212,12 +213,18 @@ export function ReportsScreen({ // The signal updates synchronously for guards and also drives the pending UI. const mutationPending = useSignal(false); - const [mutationError, setMutationError] = useState<unknown>(); + const { + errorMsg: validationError, + setErrorMsg: setValidationError, + rawError: mutationError, + setRawError: setMutationError, + feedbackId, + } = useFormFeedback(); const runMutation = async (action: () => Promise<void>) => { if (mutationPending.value) return; mutationPending.value = true; - setMutationError(undefined); + setValidationError(""); try { await action(); } catch (error) { @@ -229,7 +236,7 @@ export function ReportsScreen({ const openDialog = (open: () => void) => { if (mutationPending.value) return; - setMutationError(undefined); + setValidationError(""); open(); }; const closeDialog = (close: () => void) => { @@ -240,7 +247,7 @@ export function ReportsScreen({ const handleOpenAddGroup = () => { if (mutationPending.value) return; - setMutationError(undefined); + setValidationError(""); setEditingGroup(null); setGroupName(""); setGroupDesc(""); @@ -249,7 +256,7 @@ export function ReportsScreen({ const handleOpenEditGroup = (grp: TalerMerchantApi.GroupEntry) => { if (mutationPending.value) return; - setMutationError(undefined); + setValidationError(""); setEditingGroup(grp); setGroupName(grp.group_name); setGroupDesc(grp.description || ""); @@ -262,7 +269,7 @@ export function ReportsScreen({ if (!groupName.trim()) return; const cleanName = toSlug(groupName); if (!isValidMerchantSlug(cleanName)) { - setMutationError(t`Enter a valid product group identifier.`); + setValidationError(t`Enter a valid product group identifier.`); return; } @@ -292,7 +299,7 @@ export function ReportsScreen({ const handleOpenAddPot = () => { if (mutationPending.value) return; - setMutationError(undefined); + setValidationError(""); setEditingPot(null); setPotName(""); setPotDesc(""); @@ -303,7 +310,7 @@ export function ReportsScreen({ pot: TalerMerchantApi.PotEntry & { description?: string }, ) => { if (mutationPending.value) return; - setMutationError(undefined); + setValidationError(""); setEditingPot(pot); setPotName(pot.pot_name); setPotDesc(pot.description ?? ""); @@ -316,7 +323,7 @@ export function ReportsScreen({ if (!potName.trim()) return; const cleanName = toSlug(potName); if (!isValidMerchantSlug(cleanName)) { - setMutationError(t`Enter a valid money pot identifier.`); + setValidationError(t`Enter a valid money pot identifier.`); return; } @@ -854,10 +861,6 @@ export function ReportsScreen({ title={editingGroup ? t`Edit Product Group` : t`Add Product Group`} > <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> - <ApiErrorBanner - error={mutationError} - title={t`Could not save changes`} - /> <form onSubmit={handleSaveGroup} class="space-y-4"> <div> <label @@ -909,7 +912,12 @@ export function ReportsScreen({ /> </div> - <div class="flex items-center justify-end space-x-3 pt-2"> + <FormActionFooter + error={mutationError} + validationError={validationError} + feedbackId={feedbackId} + pending={mutationPending.value} + > <Button variant="secondary" onClick={() => closeDialog(() => setShowGroupModal(false))} @@ -923,7 +931,7 @@ export function ReportsScreen({ > {editingGroup ? t`Save Group` : t`Create Product Group`} </Button> - </div> + </FormActionFooter> </form> </fieldset> </Modal> @@ -934,10 +942,6 @@ export function ReportsScreen({ title={editingPot ? t`Edit Money Pot` : t`Add Money Pot`} > <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> - <ApiErrorBanner - error={mutationError} - title={t`Could not save changes`} - /> <form onSubmit={handleSavePot} class="space-y-4"> <div> <label @@ -987,7 +991,12 @@ export function ReportsScreen({ /> </div> - <div class="flex items-center justify-end space-x-3 pt-2"> + <FormActionFooter + error={mutationError} + validationError={validationError} + feedbackId={feedbackId} + pending={mutationPending.value} + > <Button variant="secondary" onClick={() => closeDialog(() => setShowPotModal(false))} @@ -1001,7 +1010,7 @@ export function ReportsScreen({ > {editingPot ? t`Save Money Pot` : t`Create Money Pot`} </Button> - </div> + </FormActionFooter> </form> </fieldset> </Modal> @@ -1017,6 +1026,7 @@ export function ReportsScreen({ > <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner + compact error={mutationError} title={t`Could not save changes`} /> @@ -1062,6 +1072,7 @@ export function ReportsScreen({ > <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner + compact error={mutationError} title={t`Could not save changes`} /> @@ -1107,6 +1118,7 @@ export function ReportsScreen({ > <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner + compact error={mutationError} title={t`Could not save changes`} />