taler-typescript-core

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

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:
Apackages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx | 554+++++++++++++++++++++++++++++++++++++++++++------------------------------------
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>