import { useEffect, useState } from "react"; import { OverlayActions } from "../ui/overlay-actions"; import { useDirtyReport } from "../../hooks/useDirtyReport"; import { useAuthServiceContext } from "../../services/AuthService"; import { useCapabilityServiceContext } from "../../services/CapabilityService"; import { useOrganizationServiceContext } from "../../services/OrganizationService"; import { usePracticeAddressApi } from "../../services/practiceAddressApi"; import { usePracticeAddressCheck } from "../../hooks/usePracticeAddressCheck"; import { ADDRESS_SUFFIX, isAddressReady, normalizeAddressLabel } from "../../utils/practiceAddress"; import { relayServerFailure } from "../../utils/fetchFailureMessage"; import { isSaveRetired, useSaveScope } from "../../utils/saveOperation"; import { holdSettingsWrite, nextSettingsAttempt, recordSettingsAttempt, settingsWriteHolder, useUnresolvedSettingsWrite, watchSettingsWrite, } from "../../utils/unresolvedSettingsWrite"; import { DismissibleAlert } from "../ui/dismissible-alert"; import { Spinner } from "../ui/spinner"; import PracticeAddressField from "./PracticeAddressField"; import PracticeAddressConfirm from "./PracticeAddressConfirm"; import "./PracticeAddress.css"; import { PracticeAddressHeld } from "./PracticeAddressHeld"; type PracticeAddressPanelProps = { onSaved: (hostname: string) => void; // Lets a host sheet title each view as the deck does. onViewChange?: (view: View) => void; onContinue?: () => void; onDirtyChange?: (dirty: boolean) => void; }; export type View = "choose" | "confirm" | "saved"; const unconfirmedClaim = (label: string) => `We could not tell whether ${label}${ADDRESS_SUFFIX} was claimed. Claim the same address again to finish.`; const alreadyHeld = (label: string) => `This practice already holds the address ${label}. It is permanent, so no other address can be claimed.`; // A practice founded before addresses existed claims one here, before the five setup steps. The // claim is atomic on the server, and claiming the same address again is idempotent: a practice keeps the first address // it holds, so a claim whose answer was lost is settled by claiming that exact address again, never by choosing // another. A conflict returns to editing. const PracticeAddressPanel = ({ onSaved, onViewChange, onContinue, onDirtyChange }: PracticeAddressPanelProps) => { const { currentOrganization } = useAuthServiceContext(); const { mayCall } = useCapabilityServiceContext(); const { claimPracticeHostname, fetchMyPracticeHostname } = useOrganizationServiceContext(); const { recommendAddress } = usePracticeAddressApi(); const [view, setView] = useState("choose"); const [value, setValue] = useState(""); const [suggestion, setSuggestion] = useState(); const [hostname, setHostname] = useState(); const [busy, setBusy] = useState(false); const [error, setError] = useState(); const { check, retry } = usePracticeAddressCheck(value, view === "choose"); useEffect(() => onViewChange?.(view), [view, onViewChange]); // A choice, claim and its readback belong to one practice, viewer (AuthService's key names the practice too) and // right to claim. When any of them changes, or the panel closes, a claim started before sends nothing more and // shows or reports nothing; the next practice starts from its own choice. const owner = `${currentOrganization?.id ?? ""}|${mayCall("POST", "/practice-hostname") === false ? "refused" : "may"}`; const scope = useSaveScope(owner, () => { setView("choose"); setValue(""); setSuggestion(undefined); setHostname(undefined); setBusy(false); setError(undefined); }); // A claim sent and never answered: held for its sender, with the exact address, until it is settled. const holder = settingsWriteHolder("practice-address"); const unresolved = useUnresolvedSettingsWrite(holder); const heldLabel = unresolved?.state === "unconfirmed" && unresolved.kind === "address" ? unresolved.detail : undefined; // The server named the address this practice already holds; it stays shown until its own read can show it in full. const existingLabel = unresolved?.state === "unconfirmed" && unresolved.kind === "address-existing" ? unresolved.detail : undefined; useEffect(() => { let cancelled = false; void recommendAddress(currentOrganization?.name ?? "").then((label) => { if (cancelled || !label) return; setSuggestion(label); setValue((current) => current || label); }); return () => { cancelled = true; }; }, [currentOrganization?.name, recommendAddress]); const dirty = view !== "saved" && normalizeAddressLabel(value) !== (suggestion ?? ""); useDirtyReport(dirty, onDirtyChange); const saved = (name: string) => { setHostname(name); setView("saved"); onSaved(name); }; const claim = async (label = normalizeAddressLabel(value)) => { const operation = scope.start(); const heldFor = holder; const startedWith = unresolved; // Not sent: the server needs no ordering here. It names this attempt to the hold, so a retired one never replaces // a newer claim or revives a settled one. const attempt = nextSettingsAttempt(startedWith); const write = watchSettingsWrite((onSend) => claimPracticeHostname(label, { ...operation.transport, onSend })); const unknown = { kind: "address", state: "unconfirmed" as const, detail: label }; // An address held is final, and while a claim is held only its own address can be claimed, so an answer that settles // this claim settles the hold, even one a retired attempt has touched since this one started. const settle = () => holdSettingsWrite(heldFor, undefined); setBusy(true); setError(undefined); try { const result = await operation.step(write.run, true); settle(); saved(result.hostname); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) { recordSettingsAttempt(heldFor, attempt, startedWith, write.unconfirmed() ? unknown : undefined); return; } const status = (err as { status?: unknown } | null)?.status; // The server's own answer that this practice already holds another address: final, whatever a read says next, so // the choice stays closed. Held before anything else is awaited, so neither a retired read nor a close or reload // can lose it. const existing = (err as { body?: { existing?: unknown } } | null)?.body?.existing; const holdsExisting = status === 409 && typeof existing === "string" && existing !== ""; if (holdsExisting) { holdSettingsWrite(heldFor, { kind: "address-existing", state: "unconfirmed", detail: existing as string, sent: attempt }); } // What the practice holds now. An address held is final: whatever is still in flight can no longer change it. // Holding none is not proof by itself, since a claim still running may yet land. let mine: Awaited> | undefined; try { mine = await operation.step(() => fetchMyPracticeHostname(operation.transport)); } catch (readErr) { if (isSaveRetired(readErr) || !operation.belongs()) { recordSettingsAttempt(heldFor, attempt, startedWith, write.unconfirmed() ? unknown : undefined); return; } } if (mine?.claimed && mine.hostname) { settle(); saved(mine.hostname); return; } if (holdsExisting) return; // Shown by the held view, which carries its own explanation. if (write.unconfirmed()) { recordSettingsAttempt(heldFor, attempt, startedWith, unknown); return; } // Answered. Taken means another practice holds this address, and invalid or reserved means it can never be // claimed: either way no claim of it, this one or an earlier one, can land, so the choice is open again. if (status === 409 || status === 400) { settle(); setView("choose"); setError(relayServerFailure(err, "That address is no longer available. Choose another name.")); retry(); return; } // Any other refusal wrote nothing, but says nothing about an earlier claim still held. recordSettingsAttempt(heldFor, attempt, startedWith, undefined); setError(relayServerFailure(err, "The address could not be claimed just now. Try again in a minute.")); } finally { if (operation.belongs()) setBusy(false); operation.retire(); } }; // Shows the address the server said this practice holds: its own read gives the full address it is built from. const showExisting = async () => { const operation = scope.start(); const heldFor = holder; setBusy(true); setError(undefined); try { const mine = await operation.step(() => fetchMyPracticeHostname(operation.transport)); if (mine.claimed && mine.hostname) { holdSettingsWrite(heldFor, undefined); saved(mine.hostname); return; } setError("This practice's address could not be shown just now. Check again in a minute."); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; setError(relayServerFailure(err, "This practice's address could not be shown just now. Check again in a minute.")); } finally { if (operation.belongs()) setBusy(false); operation.retire(); } }; if (view === "saved" && hostname) { return (
{onContinue && ( <>

Your address is saved. Continue to set up your practice.

)}
); } if (existingLabel) { return (
{error && ( setError(undefined)}> {error} )}
{alreadyHeld(existingLabel)}
); } // A claim whose answer was lost: its exact address, read-only, and the one action that settles it. if (heldLabel) { return (
{error && ( setError(undefined)}> {error} )}
{unconfirmedClaim(heldLabel)}
{heldLabel}{ADDRESS_SUFFIX}
); } return (
{error && ( setError(undefined)}> {error} )} {view === "confirm" ? ( setView("choose")} onSave={() => void claim()} /> ) : ( <> {/* Read-only here: the name is set in Identity; the deck draws it as a field above the address. */}
{currentOrganization?.name ? ( ) : ( )}
)}
); }; export default PracticeAddressPanel;