import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Megaphone, AlertTriangle, Lock } from 'lucide-react'; import { useCampaignServiceContext, type Campaign, type CampaignCtaStyle, type ConditionCatalogueRow, type CampaignCapabilities, type CampaignChannel, type CampaignCommand } from '../../../services/CampaignService'; import { selectedConditionRows, toggleConditionRow } from './campaignConditions'; import { EMPTY_DRAFT, buildSegment, draftFromSegment, validateDraft, describeSegment, GENDERS, LAST_APPT_STATUSES, ANY_STATUS_WITHIN_DAYS_STATUSES, MIN_AGE, MAX_AGE, MAX_DAYS, type SegmentDraft, } from './campaignSegment'; import { CTA_PLACEHOLDER, MAX_CTA_LABEL } from './campaignCta'; import { isEditable, describeCampaign, NOT_CONFIGURED_COPY, campaignSetupDestination, CAMPAIGN_TONE_PILL as TONE_CLASS } from './campaignStatus'; import { useAudienceCount, canSend, canSendNow } from './audienceCount'; import { reasonRows } from './audienceReasons'; import CampaignConfirmDialog, { sendNowConfirmation, scheduleConfirmation, scheduledWhenLabel, resumeConfirmation } from './CampaignConfirmDialog'; import { readRefusal, type CampaignRefusal } from './campaignRefusal'; import { smsChannelAvailability } from './smsChannelAvailability'; import { useBillingContext } from '../../../services/BillingContext'; import { describeTracking, campaignReportRows } from './campaignReport'; import { type PractitionerWithRoles } from '../../../services/PractitionerService'; import { useFhirPractitioners } from '../../../hooks/useFhirPractitioners'; import { useFhirLocations } from '../../../hooks/useFhirLocations'; import { practitionerAxis } from './campaignPractitionerAxis'; import { useLocationServiceContext } from '../../../services/LocationService'; import { Input } from '../../../components/ui/input'; import { Textarea } from '../../../components/ui/textarea'; import { Label } from '../../../components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../components/ui/select'; import { Checkbox } from '../../../components/ui/checkbox'; import { smsSegmentsFor, smsCostPerRecipientUsd, charactersUntilNextSegment, smsWireTextFor, smsEncodingPlan, } from './smsSegments'; import { Card, CardContent } from '../../../components/ui/card'; import { Skeleton } from '../../../components/ui/skeleton'; import { Alert, AlertDescription } from '../../../components/ui/alert'; import { useStaffPermissionServiceContext } from '../../../services'; import { useSaveScope, isSaveRetired, outcomeUnknown } from '../../../utils/saveOperation'; import { onViewerChange, viewerEpoch } from '../../../utils/writeThroughHold'; import { settingsWriteHolder, useUnresolvedSettingsWrite, unresolvedSettingsWrite, holdSettingsWrite, newSettingsOperationId } from '../../../utils/unresolvedSettingsWrite'; import { environment } from '../../../environments/environment'; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Badge } from '../../../components/ui/badge'; // // S4d Phase 2 - the composer's CONTENT and SEGMENT halves and read-only path (task 6), plus // the audience-count panel and the Send/Schedule actions (task 7). // // THE COUNT IS A PRECONDITION FOR SENDING, NOT A PREVIEW. `POST /send` and `POST /schedule` both // refuse without a server-stored preview for THIS EXACT segment - complete, fresh, untruncated - // plus an `acknowledgedCount`. `canSend` (audienceCount.ts) is the client-side mirror of that gate; // any change to the segment draft calls `reset()` so a stale count can never be read as current. // // Diagnoses are chosen as whole rows of the server's condition catalogue. A campaign whose // `segment.conditionCodes` are not whole rows cannot be shown as choices, and editing it would change // its audience unseen, so it renders read-only with an explanation instead. // type Phase = 'loading' | 'loaded' | 'loadFailed'; // Sentinel for Radix Select's "nothing chosen" state - Radix refuses a `SelectItem value=""`, so an // unset optional axis is represented by this string on the wire between the Select and the draft, // never sent anywhere: `buildSegment` only ever sees the draft's own `''` convention. const ANY_VALUE = '__any__'; /** Cosmetic only - capitalises a raw segment value for display. NOT a value-space list: the actual * legal options for each select still come from that select's own imported constant array. */ const humanizeSegmentValue = (value: string): string => { if (value === 'checked-in') return 'Checked in'; if (value === 'noshow') return 'No-show'; return value.charAt(0).toUpperCase() + value.slice(1); }; const practitionerLabel = (entry: PractitionerWithRoles): string => { const id = entry.practitioner.id; const name = entry.practitioner.name?.[0]; if (!name) return id ?? 'Unnamed practitioner'; if (name.text) return name.text; const parts = [name.given?.join(' '), name.family].filter(Boolean); return parts.length > 0 ? parts.join(' ') : (id ?? 'Unnamed practitioner'); }; /** * WHY AN AXIS IS GREYED OUT, said where the reader is looking. * * THE EXPLANATION WAS ALREADY WRITTEN AND COULD NEVER RENDER. Both pickers pass * `placeholder="Could not load practitioners"` to `SelectValue`, and a placeholder only appears when * the select has NO value - while these always carry one, because "any" is a real choice rather than * an empty one. So the control greys out reading "Any practitioner" and the sentence explaining it is * unreachable code. Measured in the browser: the words "Could not load" appear nowhere on the page. * * A greyed control with no reason reads as a broken product to the person looking at it, and the * person looking at it here may be an outside agency with no way to find out. It also says what the * consequence is - the rest of the audience still works - because "something failed" without "and * here is what you can still do" is half an answer. */ const axisUnavailableNote = (axis: string) => `We could not load the ${axis} list, so this filter is unavailable. ` + 'Everything else about the audience still works, and this campaign will simply not be narrowed ' + `by ${axis}.`; // The saved campaign in the shape the composer would save, so the two compare field by field. const savedSendFields = (campaign: Campaign) => { const sms = campaign.channels.includes('sms'); return { name: campaign.name, channels: campaign.channels.filter((channel) => channel === 'email' || channel === 'sms'), subject: campaign.subject, body: campaign.body, smsBody: sms ? (campaign.smsBody ?? '') : null, cta: campaign.cta ? { label: campaign.cta.label, style: campaign.cta.style } : null, segment: buildSegment(draftFromSegment(campaign.segment)), }; }; export default function CampaignComposer() { const { id } = useParams<{ id?: string }>(); const navigate = useNavigate(); const { getCampaign, createCampaign, updateCampaign, previewAudience, sendCampaign, scheduleCampaign, resumeCampaign, listCampaigns, } = useCampaignServiceContext(); const { permissionsSettled, canManage } = useStaffPermissionServiceContext(); const mayManage = permissionsSettled && canManage('campaigns'); const authority = useRef(mayManage); authority.current = mayManage; const epoch = useSyncExternalStore(onViewerChange, viewerEpoch); const ownerKey = `${epoch}|${id ?? 'new'}`; const saves = useSaveScope(mayManage ? ownerKey : undefined); const actionHolder = settingsWriteHolder(`campaign:${id ?? 'new'}`); const heldAction = useUnresolvedSettingsWrite(actionHolder); const [capabilities, setCapabilities] = useState(); const previewId = useRef(); const countedChannels = useRef(); const previewRead = useRef(0); // Practitioner and location options // Read-only from the two app-wide providers (mounted unconditionally in App.tsx) - never a // fetch call here. Both lists initialise empty and are populated by whichever surface fetched // them first, so an empty list is normal and expected, not a fault. // // Called UNCONDITIONALLY and UNGUARDED, deliberately. Both context hooks throw on purpose // when their provider is missing - that is a fail-fast signal, not a bug to route around. An // unset practitioner/location filter does not narrow the audience, it WIDENS it, so silencing // a missing provider here would risk a campaign silently reaching a materially larger // population than intended, with nothing red on screen. Tests mock both services directly // (see campaignTestHelpers.tsx) rather than this component tolerating their absence. // THROUGH THE HOOK, BECAUSE THE CONTEXT ALONE IS NEVER FILLED HERE. Reading `practitionersList` // straight off the service gives whatever some OTHER screen happened to load, and in the Marketing // workspace nothing does - the network panel on a clean load of this page shows a Location search // and no Practitioner search at all. So the axis was not refused, it was never asked for, and it // was permanently dead for the one workspace that owns campaigns. // // `useFhirPractitioners` auto-loads once and reuses the bundle, so a screen that already loaded // them pays nothing. Everything else on this page reads the same list it always did. const { practitionersList, practitionersBundle, loading: practitionersLoading, error: practitionersError, errorIsPermissionDenial: practitionersDenied, } = useFhirPractitioners(); // WHO THIS AXIS MEANS, asked once. It reads "patients who saw this clinician", so it offers // the people a patient could have been seen by rather than everyone holding a `Practitioner` // resource - which at this practice was the office manager, the platform manager and the // marketer alongside the four clinicians. See `campaignPractitionerAxis.ts` for why the // predicate is the one the booking chain already reads and not a filter written for this screen. const axis = practitionerAxis(practitionersList, { loading: practitionersLoading || (!practitionersBundle && !practitionersError), denied: practitionersDenied, }); const practitionerOptions = axis.kind === 'ready' ? axis.options : []; const { locationsList } = useLocationServiceContext(); // Location reads answer three ways, and only a failed one is "could not load". const { loading: locationsLoading, error: locationsError, locationsBundle } = useFhirLocations(); const locationsState: 'ready' | 'loading' | 'failed' | 'none' = locationsList.length > 0 ? 'ready' : locationsError ? 'failed' : (locationsLoading || !locationsBundle) ? 'loading' : 'none'; const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const [loadedOwner, setLoadedOwner] = useState(ownerKey); const [phase, setPhase] = useState(id === undefined ? 'loaded' : 'loading'); const [campaign, setCampaign] = useState(null); const [name, setName] = useState(''); const [subject, setSubject] = useState(''); const [body, setBody] = useState(''); const [draft, setDraft] = useState(EMPTY_DRAFT); const [catalogue, setCatalogue] = useState< { kind: 'loading' } | { kind: 'ready'; rows: ConditionCatalogueRow[] } | { kind: 'failed' } | { kind: 'missing' } >({ kind: 'loading' }); // CTA (login link) - S4e-2 // Label + style ONLY. There is no destination field anywhere in this state: the target is // always the practice's own portal login, computed server-side from the organization. const [ctaEnabled, setCtaEnabled] = useState(false); const [emailSelected, setEmailSelected] = useState(true); const [smsSelected, setSmsSelected] = useState(false); const selectedChannels: CampaignChannel[] = [...(emailSelected ? ['email' as const] : []), ...(smsSelected ? ['sms' as const] : [])]; const previewChannels: CampaignChannel[] = campaign?.execution?.legacyResumeRequired ? ['sms'] : selectedChannels; const channelKey = previewChannels.join('|'); // WHETHER A TEXT CAN BE SENT AT ALL, asked before the control is offered rather than after the // message is written. The old comment here called offering the checkbox "the honest intermediate // state" - it is honest about the intent and silent about the outcome, and silence is what let a // practice price a channel it does not have. const billing = useBillingContext(); const smsChannel = smsChannelAvailability({ // `entitlements` is null until the first fetch resolves, and null must read as NOT KNOWN // rather than as unavailable - which is why this passes undefined and never false. smsPromotionsEnabled: billing.entitlements?.smsPromotionsEnabled, smsPromotionsHeld: billing.addOns?.smsPromotions ?? billing.addOns?.smsReminders, smsRegistrationApproved: billing.smsRegistrationApproved, capabilities, }); // ITS OWN TEXT, never derived from `body`. A text message differs from an email in length, // formatting, delivery-speed expectation and cost, so the server refuses an 'sms' channel that // arrives without one rather than reusing the email body. const [smsBody, setSmsBody] = useState(''); const [ctaLabel, setCtaLabel] = useState(''); const [ctaStyle, setCtaStyle] = useState('button'); const bodyTextareaRef = useRef(null); const smsTextareaRef = useRef(null); const [saving, setSaving] = useState(false); // A field's error shows once somebody has left it, never on first paint. const [touched, setTouched] = useState<{ name?: boolean; subject?: boolean; body?: boolean }>({}); const leave = (field: 'name' | 'subject' | 'body') => () => setTouched((held) => ({ ...held, [field]: true })); const [saveError, setSaveError] = useState(null); // Audience count + send/schedule // `useAudienceCount` is a HOOK - called here, unconditionally, alongside every other hook in // this component, before any early return below. Calling it later (e.g. inside the `!readOnly` // JSX branch) would violate rules-of-hooks the instant a campaign goes read-only. const ownedPreview = useCallback(async (segment: Parameters[0], cursor?: string | null) => { const read = saves.start(); const generation = previewRead.current; try { const result = await read.step(() => previewAudience(segment, cursor, channelKey.split('|').filter(Boolean) as CampaignChannel[], read.transport)); read.admit(); if (generation !== previewRead.current) throw new Error('The audience changed. Count it again.'); previewId.current = result.previewId; countedChannels.current = channelKey; return result; } finally { read.retire(); } }, [previewAudience, saves, channelKey, ownerKey]); const { state, count, reset } = useAudienceCount(ownedPreview); useEffect(() => { previewRead.current += 1; previewId.current = undefined; countedChannels.current = undefined; reset(); }, [channelKey, ownerKey, reset]); const [actionBusy, setActionBusy] = useState<'send' | 'schedule' | 'resume' | null>(null); const [lastAction, setLastAction] = useState<'send' | 'schedule' | 'resume' | null>(null); const [actionRefusal, setActionRefusal] = useState(null); const commandInFlight = useRef(null); const [scheduledForInput, setScheduledForInput] = useState(''); const [confirming, setConfirming] = useState<'send' | 'schedule' | 'resume' | null>(null); useEffect(() => { setSaving(false); setActionBusy(null); setActionRefusal(null); setConfirming(null); commandInFlight.current = null; if (!id) { setCampaign(null); setName(''); setSubject(''); setBody(''); setSmsBody(''); setEmailSelected(true); setSmsSelected(false); setCtaEnabled(false); setCtaLabel(''); setCtaStyle('button'); setDraft(EMPTY_DRAFT); setLoadedOwner(ownerKey); setPhase('loaded'); return; } const read = saves.start(); setPhase('loading'); void (async () => { try { const loaded = await read.step(() => getCampaign(id, read.transport)); read.admit(); setCampaign(loaded); setName(loaded.name); setSubject(loaded.subject); setBody(loaded.body); // - reopening a saved campaign must show the channels it actually has, or // the next save would silently strip SMS back off it. setEmailSelected(loaded.channels.includes('email')); setSmsSelected(loaded.channels.includes('sms')); setCapabilities(loaded.capabilities); setSmsBody(loaded.smsBody ?? ''); setCtaEnabled(loaded.cta !== null); setCtaLabel(loaded.cta?.label ?? ''); setCtaStyle(loaded.cta?.style ?? 'button'); setDraft(draftFromSegment(loaded.segment)); setLoadedOwner(ownerKey); setPhase('loaded'); } catch { if (!read.belongs()) return; // A load failure is NOT "no campaign" - an empty form here would be a claim about // their data. reference-authz-denial-never-renders-as-empty-data setLoadedOwner(ownerKey); setPhase('loadFailed'); } finally { read.retire(); } })(); return () => read.retire(); }, [id, getCampaign, ownerKey, saves, mayManage]); // Only the newest read of the list may answer, and none after unmount. The list is the server's fixed // catalogue, the same at every practice. A failed read can be asked again; a server that sends none is not. const catalogueRead = useRef(0); const readCatalogue = useCallback(async () => { const mine = ++catalogueRead.current; const read = saves.start(); setCatalogue({ kind: 'loading' }); try { const result = await read.step(() => listCampaigns(read.transport)); read.admit(); const rows = result?.conditionCatalogue?.rows; if (!mountedRef.current || mine !== catalogueRead.current) return; setCapabilities(result.capabilities); setCatalogue(Array.isArray(rows) && rows.length > 0 ? { kind: 'ready', rows } : { kind: 'missing' }); } catch { if (read.belongs() && mountedRef.current && mine === catalogueRead.current) setCatalogue({ kind: 'failed' }); } finally { read.retire(); } }, [listCampaigns, saves, ownerKey, mayManage]); useEffect(() => { void readCatalogue(); }, [readCatalogue]); const sendableNow = canSendNow(state, buildSegment(draft), Date.now()); // A recount or segment edit while the send confirmation is open must not leave it confirmable. useEffect(() => { if (confirming === 'send' && !sendableNow) setConfirming(null); }, [confirming, sendableNow]); // Send and Schedule act on the saved campaign, so an unsaved edit would go out as the old text. const unsaved = campaign !== null && JSON.stringify(savedSendFields(campaign)) !== JSON.stringify({ name: name.trim(), channels: selectedChannels, subject: subject.trim(), body: body.trim(), smsBody: smsSelected ? smsBody.trim() : null, cta: ctaEnabled ? { label: ctaLabel.trim(), style: ctaStyle } : null, segment: buildSegment(draft), }); useEffect(() => { if (unsaved) setConfirming(null); }, [unsaved]); if (phase === 'loading' || loadedOwner !== ownerKey) { return (
); } if (phase === 'loadFailed') { return (
We could not load this campaign. Please try again.
); } // phase === 'loaded'. When `id` is set, `campaign` is guaranteed non-null here - the effect // above only reaches setPhase('loaded') after setCampaign(loaded) on the same success path. const segmentHasConditions = campaign !== null && (campaign.segment.conditionCodes?.length ?? 0) > 0; const catalogueRows = catalogue.kind === 'ready' ? catalogue.rows : []; const chosenConditionIds = new Set((selectedConditionRows(draft.conditionCodes, catalogueRows) ?? []).map((row) => row.id)); const conditionsLocked = segmentHasConditions && selectedConditionRows(campaign?.segment.conditionCodes ?? [], catalogueRows) === null; const lockedCampaign = id !== undefined && campaign !== null && (!isEditable(campaign) || conditionsLocked); const sandbox = environment.inSandboxMode; const readOnly = sandbox || lockedCampaign || !mayManage; const savedDraftWithConditions = campaign !== null && campaign.status === 'draft' && segmentHasConditions; const showConditionsBanner = savedDraftWithConditions && conditionsLocked && catalogue.kind === 'ready'; const conditionListPending = savedDraftWithConditions && catalogue.kind !== 'ready'; const status = campaign ? describeCampaign(campaign) : null; const trimmedName = name.trim(); const trimmedSubject = subject.trim(); const trimmedBody = body.trim(); const nameError = trimmedName.length === 0 ? 'Campaign name is required.' : trimmedName.length > 200 ? 'Campaign name must be 200 characters or fewer.' : null; const subjectError = !emailSelected ? null : trimmedSubject.length === 0 ? 'Subject is required.' : trimmedSubject.length > 300 ? 'Subject must be 300 characters or fewer.' : null; // The segment/cost readout under the SMS box. Derived, never stored. // // PRICED ON THE WIRE TEXT, NOT THE TEXTAREA. The server expands the CTA placeholder into a real // URL and appends an opt-out footer, and both are charged while being invisible in the box the // practice is typing into. Pricing the raw textarea would under-report every campaign, in the // direction that costs the practice money. const smsWireText = smsWireTextFor(smsBody); const smsSegments = smsSegmentsFor(smsWireText); const smsCostPerRecipient = smsCostPerRecipientUsd(smsWireText); const charsUntilNextSegment = charactersUntilNextSegment(smsWireText); const trimmedSmsBody = smsBody.trim(); const smsBodyError = !smsSelected ? null : trimmedSmsBody.length === 0 ? 'A text message is required when SMS is selected.' : trimmedSmsBody.length > 1600 ? 'The text message must be 1,600 characters or fewer.' : null; const bodyError = !emailSelected ? null : trimmedBody.length === 0 ? 'Message body is required.' : trimmedBody.length > 20000 ? 'Message body must be 20,000 characters or fewer.' : null; const trimmedCtaLabel = ctaLabel.trim(); const ctaLabelError = ctaEnabled && trimmedCtaLabel.length === 0 ? 'A label is required for the login link.' : ctaEnabled && trimmedCtaLabel.length > MAX_CTA_LABEL ? `The login link label must be ${MAX_CTA_LABEL} characters or fewer.` : null; // A checked CTA whose placeholder never made it into the body would save a `cta` the server // renders nowhere - the practice would believe they added a login link and no such link exists // in the sent email. Blocking save here is the only place this can be caught. const ctaPlaceholderMissing = ctaEnabled && ((emailSelected && !body.includes(CTA_PLACEHOLDER)) || (smsSelected && !smsBody.includes(CTA_PLACEHOLDER))); const smsEncodingError = smsSelected ? smsEncodingPlan(smsWireText).reason : undefined; const segmentErrors = validateDraft(draft); const hasSegmentErrors = Object.keys(segmentErrors).length > 0; // `smsBodyError` is in this list because the SERVER now 400s an 'sms' channel with no text. // Without it the practice fills the form, clicks Create, and meets a validation error that the // composer could have shown them while they were still typing. const canSave = !readOnly && !saving && !nameError && !subjectError && !bodyError && !smsBodyError && !hasSegmentErrors && !ctaLabelError && !ctaPlaceholderMissing && !smsEncodingError && selectedChannels.length > 0; const handleSave = async () => { if (!canSave || sandbox) return; const save = saves.start(); const admit = () => { save.admit(); if (!authority.current) throw new Error('Your current role cannot change campaigns.'); }; const transport = { ...save.transport, admit }; setSaving(true); setSaveError(null); try { const payload = { name: trimmedName, channels: selectedChannels, subject: trimmedSubject, body: trimmedBody, // NULL when SMS is off, deliberately - a retained text would silently return to // service if the channel were re-added later, saying whatever it said at some earlier // date. The server clears it the same way for the same reason. smsBody: smsSelected ? trimmedSmsBody : null, segment: buildSegment(draft), cta: ctaEnabled ? { label: trimmedCtaLabel, style: ctaStyle } : null, }; if (id) { const updated = await save.step(() => updateCampaign(id, payload, transport, campaign?.recordVersion), true); admit(); setCampaign(updated); } else { const created = await save.step(() => createCampaign(payload, transport), true); admit(); navigate(`/dashboard/campaigns/${created.campaignId}`, { replace: true }); } } catch (error) { if (!save.belongs() || isSaveRetired(error)) return; setConfirming(null); setSaveError(outcomeUnknown(error) ? 'The save could not be confirmed. Reopen this campaign to check its current saved draft before making another change.' : 'We could not save this campaign. Your draft is retained.'); } finally { if (save.belongs()) setSaving(false); save.retire(); } }; const insertCtaPlaceholder = () => { const insert = (text: string, textarea: HTMLTextAreaElement | null) => { const start = textarea?.selectionStart ?? text.length; const end = textarea?.selectionEnd ?? text.length; return `${text.slice(0, start)}${CTA_PLACEHOLDER}${text.slice(end)}`; }; if (emailSelected) setBody(insert(body, bodyTextareaRef.current)); if (smsSelected) setSmsBody(insert(smsBody, smsTextareaRef.current)); requestAnimationFrame(() => (emailSelected ? bodyTextareaRef.current : smsTextareaRef.current)?.focus()); }; const setAxis = (key: K, value: SegmentDraft[K]) => { setDraft((prev) => ({ ...prev, [key]: value })); // DISCARD, NOT "STALE". A retained count could be read again by a later click; a // discarded one cannot - `canSend` would need `state.kind === 'counted'` from scratch. previewRead.current += 1; previewId.current = undefined; countedChannels.current = undefined; reset(); setActionRefusal(null); }; // THE GATE. `canSend` is true only when the count is for THIS segment and still fresh - see // audienceCount.ts. Recomputed every render so a segment edit (which calls `reset()` above) // takes effect immediately. const sendable = canSend(state, buildSegment(draft), Date.now()); const noRecipients = sendable && state.kind === 'counted' && state.eligible === 0; const handleCount = () => { setActionRefusal(null); void count(buildSegment(draft)); }; const readinessKnown = capabilities?.protocolVersion === 2 && previewChannels.length > 0 && previewChannels.every((channel) => { const readiness = capabilities.readiness[channel]; return capabilities.implementedChannels.includes(channel) && (readiness?.state === 'ready' || (readiness?.state === 'blocked' && readiness.reason === 'quiet-hours')); }); const commandReady = readinessKnown && countedChannels.current === channelKey && Boolean(previewId.current) && Boolean(campaign?.recordVersion); const readHeld = () => { if (!heldAction?.detail || !['send', 'schedule', 'resume'].includes(heldAction.kind)) return undefined; try { const value = JSON.parse(heldAction.detail) as { command: CampaignCommand; eligible: number; scheduledFor?: string }; return value.command?.requestId === heldAction.sent?.id && typeof value.command.recordVersion === 'string' && typeof value.command.previewId === 'string' && Array.isArray(value.command.channels) && Number.isFinite(value.eligible) ? value : undefined; } catch { return undefined; } }; const runCommand = async (kind: 'send' | 'schedule' | 'resume', retry = false) => { if (sandbox || !authority.current || !campaign || commandInFlight.current || actionBusy !== null) return; const original = retry ? readHeld() : undefined; if (!retry && (heldAction || unsaved || !commandReady || state.kind !== 'counted' || (kind === 'send' && !sendableNow) || !sendable)) return; if (retry && !original) return; const command: CampaignCommand = original?.command ?? { requestId: newSettingsOperationId(), recordVersion: campaign.recordVersion!, previewId: previewId.current!, channels: kind === 'resume' ? ['sms'] : selectedChannels, }; const eligible = original?.eligible ?? (state.kind === 'counted' ? state.eligible : 0); const scheduledDate = kind === 'schedule' && !original ? new Date(scheduledForInput) : undefined; if (scheduledDate && !Number.isFinite(scheduledDate.getTime())) return; const scheduledFor = original?.scheduledFor ?? scheduledDate?.toISOString(); const owner = saves.start(); let sent = false; const settle = () => { const current = unresolvedSettingsWrite(actionHolder)?.sent?.id; if (current === command.requestId) holdSettingsWrite(actionHolder, undefined); }; const transport = { ...owner.transport, admit: () => { owner.admit(); if (!authority.current) throw new Error('Your current role cannot send campaigns.'); const held = unresolvedSettingsWrite(actionHolder); if (held && held.sent?.id !== command.requestId) throw new Error('Another campaign request is still unconfirmed.'); }, onSend: () => { owner.admit(); sent = true; holdSettingsWrite(actionHolder, { kind, state: 'unconfirmed', sent: { id: command.requestId, attempt: 0 }, detail: JSON.stringify({ command, eligible, scheduledFor }) }); }, }; const flight = Symbol(); commandInFlight.current = flight; setLastAction(kind); setActionBusy(kind); setActionRefusal(null); setConfirming(null); try { const request = kind === 'schedule' ? scheduleCampaign(campaign.campaignId, scheduledFor!, eligible, command, transport) : kind === 'resume' ? resumeCampaign(campaign.campaignId, eligible, command, transport) : sendCampaign(campaign.campaignId, eligible, command, transport); const updated = await owner.step(() => request.catch(error => { const status = (error as { status?: number })?.status; if (!retry && typeof status === 'number' && status >= 400 && status < 500) settle(); throw error; }), true); transport.admit(); if (updated.campaignId !== campaign.campaignId || updated.execution?.requestId !== command.requestId) throw new Error('Unconfirmed campaign request'); settle(); setCampaign(updated); } catch (error) { if (!owner.belongs() || isSaveRetired(error)) return; // A first attempt the server answered with a refusal is settled above; its own remedy stands. const status = (error as { status?: number })?.status; if (!retry && typeof status === 'number' && status >= 400 && status < 500) setActionRefusal(readRefusal(error)); else if (!sent) setActionRefusal({ ...readRefusal(error), message: 'This request was not sent. Your campaign is retained.', retryable: false }); else setActionRefusal({ ...readRefusal(error), message: 'The campaign request could not be confirmed. Check its current status before another request.', retryable: false }); } finally { if (owner.belongs()) setActionBusy(null); if (commandInFlight.current === flight) commandInFlight.current = null; owner.retire(); } }; const checkCurrentCampaign = async () => { if (!campaign || actionBusy !== null) return; const read = saves.start(); setActionBusy('send'); try { const current = await read.step(() => getCampaign(campaign.campaignId, read.transport)); read.admit(); if (current.campaignId !== campaign.campaignId) throw new Error('Wrong campaign'); setCampaign(current); setCapabilities(current.capabilities); if (current.execution?.requestId === heldAction?.sent?.id) { holdSettingsWrite(actionHolder, undefined); setActionRefusal(null); } } catch (error) { if (read.belongs()) setActionRefusal({ ...readRefusal(error), message: 'Current campaign status could not be confirmed. The original request remains held.', retryable: false }); } finally { if (read.belongs()) setActionBusy(null); read.retire(); } }; const handleSend = () => runCommand('send'); const handleSchedule = () => runCommand('schedule'); // A settled refusal is retried as a new request, under every current gate; a held one only via its original. const retryLastAction = () => { if (lastAction) void runCommand(lastAction); }; return (

{campaign ? campaign.name : 'New campaign'}

{status && (
{status.label} {status.detail}
)}
{campaign && campaign.notConfigured && (
{NOT_CONFIGURED_COPY[campaign.notConfigured].title} {NOT_CONFIGURED_COPY[campaign.notConfigured].fix} {campaignSetupDestination(campaign.notConfigured) && Open Marketing setup}
)} {heldAction && An earlier campaign request is unconfirmed. Reopening or editing does not start another send. {actionRefusal &&

{actionRefusal.message}

} {mayManage && readHeld() && }
} {campaign?.execution?.legacyResumeRequired && This saved campaign requires a newly approved SMS continuation. Existing email dispatch and unknown historical sends will not be repeated. {mayManage && !sandbox && <> {state.kind === 'counted' &&

{state.eligible} eligible people in this SMS continuation.

} {state.kind === 'failed' &&

{state.message}

} setConfirming(null)} onConfirm={() => void runCommand('resume')} /> }
} {showConditionsBanner && ( This campaign targets medical conditions outside the list this page offers, so it cannot be edited here. Open it through your practice's support channel to change it. )} {conditionListPending && ( {catalogue.kind === 'loading' ? <>Loading the list of diagnoses… This campaign can be edited once it is read. : catalogue.kind === 'failed' ? <>The list of diagnoses could not be loaded, so this campaign cannot be edited yet. Its saved diagnoses are kept as they are. : <>No list of diagnoses is available here, so this campaign cannot be edited. Its saved diagnoses are kept as they are.} {catalogue.kind === 'failed' && ( )} )} {campaign && !isEditable(campaign) && (

This campaign is no longer a draft and can no longer be edited here.

)}
setName(e.target.value)} onBlur={leave('name')} /> {!readOnly && touched.name && nameError &&

{nameError}

}
setSubject(e.target.value)} onBlur={leave('subject')} /> {!readOnly && touched.subject && subjectError &&

{subjectError}

}
{!readOnly && ( )}
setEmailSelected(checked === true)} />
setSmsSelected(checked === true)} />
{selectedChannels.length === 0 &&

Choose email, text message, or both.

} {smsSelected && smsChannel.available === 'unknown' &&

Text execution readiness could not be confirmed. Your saved text is retained.

} {/* THE SENTENCE, NOT JUST THE GREYED BOX. Disabling a control without saying why sends an administrator looking for a setting they may already have switched on - and the two reasons have different remedies, one of which is to wait. Nothing is said when the entitlement could not be READ: not known is not the same as not available, and asserting the second from the first is the false statement this screen exists to avoid. */} {smsChannel.available === false && (

{smsChannel.reason} {smsChannel.remedy}

)}