commit cb6924c018c0b1a6d2ca8ebbb6f75ddf4969dc45
parent cbe04ebac54c7587ee0624b660d60ce019eb4864
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 20:42:27 +0100
merchant web UI: serialize business settings saves
Diffstat:
2 files changed, 468 insertions(+), 249 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx
@@ -0,0 +1,163 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { render } from "preact";
+import { act } from "preact/test-utils";
+import { BusinessSettingsScreen } from "./BusinessSettingsScreen.js";
+
+function deferred<T>() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise<T>((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+function button(text: string, root: ParentNode = document): HTMLButtonElement {
+ const found = [...root.querySelectorAll("button")].find((b) =>
+ b.textContent?.includes(text),
+ );
+ assert.ok(found, `Missing button: ${text}`);
+ return found;
+}
+function field(id: string): HTMLInputElement {
+ const found = document.querySelector<HTMLInputElement>(`#${id}`);
+ assert.ok(found, `Missing field: ${id}`);
+ return found;
+}
+const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
+const click = (element: HTMLElement) =>
+ act(async () => {
+ element.click();
+ await settle();
+ });
+const input = (element: HTMLInputElement, value: string) =>
+ act(() => {
+ element.value = value;
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+function submit(element: HTMLInputElement) {
+ element
+ .closest("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+}
+function mount() {
+ const root = document.createElement("div");
+ document.body.append(root);
+ return root;
+}
+async function cleanup(root: HTMLElement) {
+ await act(() => render(null, root));
+ root.remove();
+}
+
+test("settings serialize section saves, retain other drafts and use the latest committed values", async () => {
+ const root = mount();
+ const saves: Array<{ name: string; email?: string }> = [];
+ const requests: ReturnType<typeof deferred<"saved" | "challenge">>[] = [];
+ try {
+ await act(() =>
+ render(
+ <BusinessSettingsScreen
+ settings={{ name: "Old", email: "old@example.com" }}
+ onSave={(value) => {
+ saves.push(value);
+ const request = deferred<"saved" | "challenge">();
+ requests.push(request);
+ return request.promise;
+ }}
+ />,
+ root,
+ ),
+ );
+ await click(button("Identity and logo"));
+ await input(field("business-name"), "New");
+ await act(() => {
+ submit(field("business-name"));
+ submit(field("business-name"));
+ });
+ assert.equal(saves.length, 1);
+ assert.equal(field("business-name").closest("fieldset")!.disabled, true);
+ await click(button("Customer contact"));
+ await input(field("business-email"), "new@example.com");
+ assert.equal(
+ button("Save changes", field("business-email").closest("form")!).disabled,
+ true,
+ );
+ await act(() => submit(field("business-email")));
+ assert.equal(saves.length, 1);
+ await act(async () => requests[0]!.resolve("saved"));
+ assert.equal(field("business-email").value, "new@example.com");
+ await act(() => submit(field("business-email")));
+ assert.deepEqual(
+ { name: saves[1]!.name, email: saves[1]!.email },
+ { name: "New", email: "new@example.com" },
+ );
+ await act(async () => requests[1]!.reject(new Error("Save failed")));
+ assert.equal(field("business-email").value, "new@example.com");
+ await act(() => submit(field("business-email")));
+ await act(async () => requests[2]!.resolve("challenge"));
+ await act(() => submit(field("business-email")));
+ assert.equal(saves.length, 4);
+ await act(async () => requests[3]!.resolve("saved"));
+ } finally {
+ await cleanup(root);
+ }
+});
+
+test("password changes share the settings save lock and release it after a challenge", async () => {
+ const root = mount();
+ const password = deferred<{ status: "challenge" }>();
+ let passwordCalls = 0;
+ let settingsCalls = 0;
+ try {
+ await act(() =>
+ render(
+ <BusinessSettingsScreen
+ initialSection="security"
+ onChangePassword={() => {
+ passwordCalls++;
+ return password.promise;
+ }}
+ onSave={async () => {
+ settingsCalls++;
+ return "saved";
+ }}
+ />,
+ root,
+ ),
+ );
+ await input(field("pwd-current"), "old password");
+ await input(field("pwd-new"), "new password");
+ await input(field("pwd-confirm"), "new password");
+ await act(() => {
+ submit(field("pwd-current"));
+ submit(field("pwd-current"));
+ });
+ assert.equal(passwordCalls, 1);
+ await click(button("Customer contact"));
+ await input(field("business-email"), "new@example.com");
+ await act(() => submit(field("business-email")));
+ assert.equal(settingsCalls, 0);
+ await act(async () => {
+ password.resolve({ status: "challenge" });
+ await settle();
+ });
+ assert.equal(field("pwd-current").closest("fieldset")!.disabled, false);
+ await act(async () => {
+ submit(field("business-email"));
+ await settle();
+ });
+ assert.equal(settingsCalls, 1);
+ } finally {
+ await cleanup(root);
+ }
+});
diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx
@@ -204,6 +204,7 @@ function DisclosureSection(props: DisclosureSectionProps): VNode {
interface EditorActionsProps {
pending: boolean;
+ saveBlocked: boolean;
error?: unknown;
disabled?: boolean;
onCancel: () => void;
@@ -212,6 +213,7 @@ interface EditorActionsProps {
function EditorActions({
pending,
+ saveBlocked,
error,
disabled,
onCancel,
@@ -232,7 +234,7 @@ function EditorActions({
type="submit"
variant="primary"
isLoading={pending}
- disabled={disabled || pending}
+ disabled={disabled || pending || saveBlocked}
>
{saveLabel || t`Save changes`}
</Button>
@@ -256,6 +258,8 @@ export function BusinessSettingsScreen({
const [committed, setCommitted] = useState<BusinessSettings>(
incomingSettings || DEFAULT_SETTINGS,
);
+ const committedRef = useRef(committed);
+ const saveLock = useRef(false);
const [editors, setEditors] = useState<Record<EditorSection, boolean>>(() =>
editorState(initialSection),
);
@@ -346,6 +350,7 @@ export function BusinessSettingsScreen({
useEffect(() => {
if (!incomingSettings) return;
const preserveOpenDrafts = hasReceivedSettingsRef.current;
+ committedRef.current = incomingSettings;
setCommitted(incomingSettings);
for (const section of Object.keys(CLOSED_EDITORS) as EditorSection[]) {
if (!preserveOpenDrafts || !editorsRef.current[section])
@@ -378,24 +383,29 @@ export function BusinessSettingsScreen({
section: EditorSection,
patch: Partial<BusinessSettings>,
) => {
- const next: BusinessSettings = { ...committed, ...patch };
+ if (saveLock.current) return;
+ saveLock.current = true;
+ const next: BusinessSettings = { ...committedRef.current, ...patch };
setPendingSection(section);
setSectionErrors((current) => ({ ...current, [section]: undefined }));
try {
const result = onSave ? await onSave(next) : "saved";
if (result === "challenge") return;
+ committedRef.current = next;
setCommitted(next);
setEditors((current) => ({ ...current, [section]: false }));
setSavedSection(section);
} catch (error) {
setSectionErrors((current) => ({ ...current, [section]: error }));
} finally {
+ saveLock.current = false;
setPendingSection(null);
}
};
const savePassword = async (event: Event) => {
event.preventDefault();
+ if (saveLock.current) return;
setSectionErrors((current) => ({ ...current, security: undefined }));
if (!newPassword) {
setSectionErrors((current) => ({
@@ -427,6 +437,7 @@ export function BusinessSettingsScreen({
}));
return;
}
+ saveLock.current = true;
setPendingSection("security");
try {
const result = await onChangePassword?.({
@@ -442,6 +453,7 @@ export function BusinessSettingsScreen({
} catch (error) {
setSectionErrors((current) => ({ ...current, security: error }));
} finally {
+ saveLock.current = false;
setPendingSection(null);
}
};
@@ -592,45 +604,51 @@ export function BusinessSettingsScreen({
}}
class="max-w-2xl space-y-5"
>
- <div class="space-y-1">
- <label
- htmlFor="business-name"
- class="block text-xs font-semibold text-gray-700"
- >
- {t`Business Name`} <span class="text-red-500">*</span>
- </label>
- <input
- id="business-name"
- type="text"
- value={name}
- onInput={(event) => setName(event.currentTarget.value)}
- required
- class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ <fieldset
+ disabled={pendingSection === "profile"}
+ class="min-w-0 space-y-5"
+ >
+ <div class="space-y-1">
+ <label
+ htmlFor="business-name"
+ class="block text-xs font-semibold text-gray-700"
+ >
+ {t`Business Name`} <span class="text-red-500">*</span>
+ </label>
+ <input
+ id="business-name"
+ type="text"
+ value={name}
+ onInput={(event) => setName(event.currentTarget.value)}
+ required
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ </div>
+ <ImageUploadInput
+ id="business-logo"
+ label={t`Logo`}
+ helpText={t`Upload a PNG, JPEG, WebP, or SVG logo to display on customer receipts.`}
+ value={logoUrl}
+ onChange={setLogoUrl}
/>
- </div>
- <ImageUploadInput
- id="business-logo"
- label={t`Logo`}
- helpText={t`Upload a PNG, JPEG, WebP, or SVG logo to display on customer receipts.`}
- value={logoUrl}
- onChange={setLogoUrl}
- />
- {draftLogoStatus === "invalid" && (
- <p
- role="alert"
- class="text-sm font-semibold text-red-700"
- >{t`Remove or replace the logo before saving this section.`}</p>
- )}
- <EditorActions
- pending={pendingSection === "profile"}
- error={sectionErrors.profile}
- disabled={
- !name.trim() ||
- draftLogoStatus === "checking" ||
- draftLogoStatus === "invalid"
- }
- onCancel={() => cancelSection("profile")}
- />
+ {draftLogoStatus === "invalid" && (
+ <p
+ role="alert"
+ class="text-sm font-semibold text-red-700"
+ >{t`Remove or replace the logo before saving this section.`}</p>
+ )}
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ pending={pendingSection === "profile"}
+ error={sectionErrors.profile}
+ disabled={
+ !name.trim() ||
+ draftLogoStatus === "checking" ||
+ draftLogoStatus === "invalid"
+ }
+ onCancel={() => cancelSection("profile")}
+ />
+ </fieldset>
</form>
</DisclosureSection>
@@ -649,40 +667,46 @@ export function BusinessSettingsScreen({
}}
class="max-w-xl space-y-5"
>
- <div class="space-y-1">
- <label
- htmlFor="business-email"
- class="block text-xs font-semibold text-gray-700"
- >{t`Email Address`}</label>
- <input
- id="business-email"
- type="email"
- value={email}
- onInput={(event) => setEmail(event.currentTarget.value)}
- placeholder="contact@example.com"
- class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
- />
- <p class="text-xs text-gray-600">{t`Shown to customers and used for email verification codes.`}</p>
- </div>
- <div class="space-y-1">
- <label
- htmlFor="business-website"
- class="block text-xs font-semibold text-gray-700"
- >{t`Website URL`}</label>
- <input
- id="business-website"
- type="url"
- value={website}
- onInput={(event) => setWebsite(event.currentTarget.value)}
- placeholder="https://example.com"
- class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ <fieldset
+ disabled={pendingSection === "contact"}
+ class="min-w-0 space-y-5"
+ >
+ <div class="space-y-1">
+ <label
+ htmlFor="business-email"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Email Address`}</label>
+ <input
+ id="business-email"
+ type="email"
+ value={email}
+ onInput={(event) => setEmail(event.currentTarget.value)}
+ placeholder="contact@example.com"
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ <p class="text-xs text-gray-600">{t`Shown to customers and used for email verification codes.`}</p>
+ </div>
+ <div class="space-y-1">
+ <label
+ htmlFor="business-website"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Website URL`}</label>
+ <input
+ id="business-website"
+ type="url"
+ value={website}
+ onInput={(event) => setWebsite(event.currentTarget.value)}
+ placeholder="https://example.com"
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ </div>
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ pending={pendingSection === "contact"}
+ error={sectionErrors.contact}
+ onCancel={() => cancelSection("contact")}
/>
- </div>
- <EditorActions
- pending={pendingSection === "contact"}
- error={sectionErrors.contact}
- onCancel={() => cancelSection("contact")}
- />
+ </fieldset>
</form>
</DisclosureSection>
@@ -704,37 +728,43 @@ export function BusinessSettingsScreen({
}}
class="space-y-6"
>
- <LocationInput
- idPrefix="physical-address"
- label={t`Physical business address`}
- subtitle={t`The registered location included in customer contracts.`}
- value={address}
- onChange={setAddress}
- />
- <div class="border-t border-gray-200 pt-5">
- <div class="mb-4 flex flex-col items-start justify-between gap-3 sm:flex-row">
- <div>
- <h4 class="text-sm font-bold text-gray-800">{t`Legal jurisdiction`}</h4>
- <p class="mt-1 text-xs text-gray-600">{t`The location used for legal dispute resolution.`}</p>
+ <fieldset
+ disabled={pendingSection === "address"}
+ class="min-w-0 space-y-6"
+ >
+ <LocationInput
+ idPrefix="physical-address"
+ label={t`Physical business address`}
+ subtitle={t`The registered location included in customer contracts.`}
+ value={address}
+ onChange={setAddress}
+ />
+ <div class="border-t border-gray-200 pt-5">
+ <div class="mb-4 flex flex-col items-start justify-between gap-3 sm:flex-row">
+ <div>
+ <h4 class="text-sm font-bold text-gray-800">{t`Legal jurisdiction`}</h4>
+ <p class="mt-1 text-xs text-gray-600">{t`The location used for legal dispute resolution.`}</p>
+ </div>
+ <Button
+ type="button"
+ variant="secondary"
+ size="sm"
+ onClick={() => setJurisdiction({ ...address })}
+ >{t`Use physical address`}</Button>
</div>
- <Button
- type="button"
- variant="secondary"
- size="sm"
- onClick={() => setJurisdiction({ ...address })}
- >{t`Use physical address`}</Button>
+ <LocationInput
+ idPrefix="legal-jurisdiction"
+ value={jurisdiction}
+ onChange={setJurisdiction}
+ />
</div>
- <LocationInput
- idPrefix="legal-jurisdiction"
- value={jurisdiction}
- onChange={setJurisdiction}
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ pending={pendingSection === "address"}
+ error={sectionErrors.address}
+ onCancel={() => cancelSection("address")}
/>
- </div>
- <EditorActions
- pending={pendingSection === "address"}
- error={sectionErrors.address}
- onCancel={() => cancelSection("address")}
- />
+ </fieldset>
</form>
</DisclosureSection>
</div>
@@ -764,27 +794,35 @@ export function BusinessSettingsScreen({
}}
class="space-y-5"
>
- <div class="flex max-w-2xl items-start gap-3">
- <input
- id="use-stefan"
- type="checkbox"
- checked={useStefan}
- onChange={(event) => setUseStefan(event.currentTarget.checked)}
- class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- />
- <div>
- <label
- htmlFor="use-stefan"
- class="block cursor-pointer text-sm font-bold text-gray-900"
- >{t`Cover transaction fees`}</label>
- <p class="mt-1 text-sm text-gray-600">{t`The business pays the transaction cost instead of adding it to the customer’s payment.`}</p>
+ <fieldset
+ disabled={pendingSection === "fees"}
+ class="min-w-0 space-y-5"
+ >
+ <div class="flex max-w-2xl items-start gap-3">
+ <input
+ id="use-stefan"
+ type="checkbox"
+ checked={useStefan}
+ onChange={(event) =>
+ setUseStefan(event.currentTarget.checked)
+ }
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ />
+ <div>
+ <label
+ htmlFor="use-stefan"
+ class="block cursor-pointer text-sm font-bold text-gray-900"
+ >{t`Cover transaction fees`}</label>
+ <p class="mt-1 text-sm text-gray-600">{t`The business pays the transaction cost instead of adding it to the customer’s payment.`}</p>
+ </div>
</div>
- </div>
- <EditorActions
- pending={pendingSection === "fees"}
- error={sectionErrors.fees}
- onCancel={() => cancelSection("fees")}
- />
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ pending={pendingSection === "fees"}
+ error={sectionErrors.fees}
+ onCancel={() => cancelSection("fees")}
+ />
+ </fieldset>
</form>
</DisclosureSection>
@@ -813,80 +851,86 @@ export function BusinessSettingsScreen({
}}
class="space-y-5"
>
- <div class="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2">
- <DurationInput
- id="pay-delay"
- label={t`Payment window`}
- helpText={t`How long a customer has to pay before an unpaid order expires.`}
- valueUs={payDelayUs}
- onChangeUs={setPayDelayUs}
- onValidityChange={(valid) =>
- setDelayValidity((all) => [valid, all[1]!, all[2]!])
- }
- required
- />
- <div>
+ <fieldset
+ disabled={pendingSection === "delays"}
+ class="min-w-0 space-y-5"
+ >
+ <div class="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2">
<DurationInput
- id="refund-delay"
- label={t`Refund window`}
- helpText={t`How long you can issue a refund after payment.`}
- valueUs={refundDelayUs}
- onChangeUs={setRefundDelayUs}
+ id="pay-delay"
+ label={t`Payment window`}
+ helpText={t`How long a customer has to pay before an unpaid order expires.`}
+ valueUs={payDelayUs}
+ onChangeUs={setPayDelayUs}
onValidityChange={(valid) =>
- setDelayValidity((all) => [all[0]!, valid, all[2]!])
+ setDelayValidity((all) => [valid, all[1]!, all[2]!])
}
required
/>
- {refundDelayUs === 0 && (
- <p
- role="status"
- class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs font-semibold text-amber-900"
- >{t`A zero refund window prevents refunds after payment.`}</p>
- )}
- </div>
- <DurationInput
- id="wire-delay"
- label={t`Payout delay`}
- helpText={t`How long the payment service may wait so it can combine several orders in one transfer.`}
- valueUs={wireDelayUs}
- onChangeUs={setWireDelayUs}
- onValidityChange={(valid) =>
- setDelayValidity((all) => [all[0]!, all[1]!, valid])
- }
- required
- />
- <div class="space-y-1">
- <label
- htmlFor="wire-rounding"
- class="block text-xs font-semibold text-gray-700"
- >{t`Payout deadline rounding`}</label>
- <select
- id="wire-rounding"
- value={roundingInterval}
- onChange={(event) =>
- setRoundingInterval(event.currentTarget.value)
+ <div>
+ <DurationInput
+ id="refund-delay"
+ label={t`Refund window`}
+ helpText={t`How long you can issue a refund after payment.`}
+ valueUs={refundDelayUs}
+ onChangeUs={setRefundDelayUs}
+ onValidityChange={(valid) =>
+ setDelayValidity((all) => [all[0]!, valid, all[2]!])
+ }
+ required
+ />
+ {refundDelayUs === 0 && (
+ <p
+ role="status"
+ class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs font-semibold text-amber-900"
+ >{t`A zero refund window prevents refunds after payment.`}</p>
+ )}
+ </div>
+ <DurationInput
+ id="wire-delay"
+ label={t`Payout delay`}
+ helpText={t`How long the payment service may wait so it can combine several orders in one transfer.`}
+ valueUs={wireDelayUs}
+ onChangeUs={setWireDelayUs}
+ onValidityChange={(valid) =>
+ setDelayValidity((all) => [all[0]!, all[1]!, valid])
}
- class="w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
- >
- <option value="NONE">{t`No rounding (exact time)`}</option>
- <option value="SECOND">{t`Round to nearest second`}</option>
- <option value="MINUTE">{t`Round to nearest minute`}</option>
- <option value="HOUR">{t`Round to nearest hour`}</option>
- <option value="DAY">{t`Round to end of day (midnight)`}</option>
- <option value="WEEK">{t`Round to end of week`}</option>
- <option value="MONTH">{t`Round to end of month`}</option>
- <option value="QUARTER">{t`Round to end of quarter`}</option>
- <option value="YEAR">{t`Round to end of year`}</option>
- </select>
- <p class="text-xs text-gray-600">{t`Aligns payout deadlines to the selected boundary; for example, day rounding uses midnight.`}</p>
+ required
+ />
+ <div class="space-y-1">
+ <label
+ htmlFor="wire-rounding"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Payout deadline rounding`}</label>
+ <select
+ id="wire-rounding"
+ value={roundingInterval}
+ onChange={(event) =>
+ setRoundingInterval(event.currentTarget.value)
+ }
+ class="w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ <option value="NONE">{t`No rounding (exact time)`}</option>
+ <option value="SECOND">{t`Round to nearest second`}</option>
+ <option value="MINUTE">{t`Round to nearest minute`}</option>
+ <option value="HOUR">{t`Round to nearest hour`}</option>
+ <option value="DAY">{t`Round to end of day (midnight)`}</option>
+ <option value="WEEK">{t`Round to end of week`}</option>
+ <option value="MONTH">{t`Round to end of month`}</option>
+ <option value="QUARTER">{t`Round to end of quarter`}</option>
+ <option value="YEAR">{t`Round to end of year`}</option>
+ </select>
+ <p class="text-xs text-gray-600">{t`Aligns payout deadlines to the selected boundary; for example, day rounding uses midnight.`}</p>
+ </div>
</div>
- </div>
- <EditorActions
- disabled={delayValidity.some((valid) => !valid)}
- pending={pendingSection === "delays"}
- error={sectionErrors.delays}
- onCancel={() => cancelSection("delays")}
- />
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ disabled={delayValidity.some((valid) => !valid)}
+ pending={pendingSection === "delays"}
+ error={sectionErrors.delays}
+ onCancel={() => cancelSection("delays")}
+ />
+ </fieldset>
</form>
</DisclosureSection>
</div>
@@ -915,36 +959,42 @@ export function BusinessSettingsScreen({
}}
class="max-w-xl space-y-5"
>
- <div class="space-y-1">
- <label
- htmlFor="business-phone"
- class="block text-xs font-semibold text-gray-700"
- >{t`Mobile Phone Number`}</label>
- <input
- id="business-phone"
- type="tel"
- value={phone}
- onInput={(event) => setPhone(event.currentTarget.value)}
- aria-invalid={isPhoneInvalid}
- aria-describedby="business-phone-help"
- placeholder="+1234567890"
- class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 ${isPhoneInvalid ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"}`}
+ <fieldset
+ disabled={pendingSection === "phone"}
+ class="min-w-0 space-y-5"
+ >
+ <div class="space-y-1">
+ <label
+ htmlFor="business-phone"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Mobile Phone Number`}</label>
+ <input
+ id="business-phone"
+ type="tel"
+ value={phone}
+ onInput={(event) => setPhone(event.currentTarget.value)}
+ aria-invalid={isPhoneInvalid}
+ aria-describedby="business-phone-help"
+ placeholder="+1234567890"
+ class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 ${isPhoneInvalid ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"}`}
+ />
+ <p
+ id="business-phone-help"
+ class={`text-xs ${isPhoneInvalid ? "font-semibold text-red-700" : "text-gray-600"}`}
+ >
+ {isPhoneInvalid
+ ? t`Invalid phone number`
+ : t`Used for administrative SMS verification codes and never shown to customers.`}
+ </p>
+ </div>
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ pending={pendingSection === "phone"}
+ error={sectionErrors.phone}
+ disabled={isPhoneInvalid}
+ onCancel={() => cancelSection("phone")}
/>
- <p
- id="business-phone-help"
- class={`text-xs ${isPhoneInvalid ? "font-semibold text-red-700" : "text-gray-600"}`}
- >
- {isPhoneInvalid
- ? t`Invalid phone number`
- : t`Used for administrative SMS verification codes and never shown to customers.`}
- </p>
- </div>
- <EditorActions
- pending={pendingSection === "phone"}
- error={sectionErrors.phone}
- disabled={isPhoneInvalid}
- onCancel={() => cancelSection("phone")}
- />
+ </fieldset>
</form>
</DisclosureSection>
@@ -960,38 +1010,44 @@ export function BusinessSettingsScreen({
onSubmit={(event) => void savePassword(event)}
class="max-w-xl space-y-5"
>
- <PasswordInput
- id="pwd-current"
- label={t`Current Password`}
- value={currentPassword}
- onInput={setCurrentPassword}
- autoComplete="current-password"
- required
- />
- <PasswordInput
- id="pwd-new"
- label={t`New Password`}
- value={newPassword}
- onInput={setNewPassword}
- autoComplete="new-password"
- required
- minLength={8}
- />
- <PasswordInput
- id="pwd-confirm"
- label={t`Confirm New Password`}
- value={confirmPassword}
- onInput={setConfirmPassword}
- autoComplete="new-password"
- required
- minLength={8}
- />
- <EditorActions
- pending={pendingSection === "security"}
- error={sectionErrors.security}
- onCancel={() => cancelSection("security")}
- saveLabel={t`Update password`}
- />
+ <fieldset
+ disabled={pendingSection === "security"}
+ class="min-w-0 space-y-5"
+ >
+ <PasswordInput
+ id="pwd-current"
+ label={t`Current Password`}
+ value={currentPassword}
+ onInput={setCurrentPassword}
+ autoComplete="current-password"
+ required
+ />
+ <PasswordInput
+ id="pwd-new"
+ label={t`New Password`}
+ value={newPassword}
+ onInput={setNewPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ <PasswordInput
+ id="pwd-confirm"
+ label={t`Confirm New Password`}
+ value={confirmPassword}
+ onInput={setConfirmPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ <EditorActions
+ saveBlocked={pendingSection !== null}
+ pending={pendingSection === "security"}
+ error={sectionErrors.security}
+ onCancel={() => cancelSection("security")}
+ saveLabel={t`Update password`}
+ />
+ </fieldset>
</form>
</DisclosureSection>
</div>