import { useEffect, useRef, useState } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; import ToggleSwitch from "../../components/ToggleSwitch"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; import { cloneJson, ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor"; import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; const behaviorOptions = ["block", "ask", "drop", "continue", "warn"]; type EditorState = Record; export default function GlobalSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [draft, setDraft] = useState | null>(null); const [editorState, setEditorState] = useState({}); const [dirty, setDirty] = useState(false); const [saveState, setSaveState] = useState("Loaded"); const [localError, setLocalError] = useState(""); const loadedVersionId = useRef(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); const validationPolicy = asRecord(draft?.validation_policy); const attachments = asRecord(draft?.attachments); const delivery = asRecord(draft?.delivery); const rateLimit = asRecord(delivery.rate_limit); const retry = asRecord(delivery.retry); const statusTracking = asRecord(draft?.status_tracking); const optIns = asRecord(editorState.opt_ins); useEffect(() => { if (!version) return; if (loadedVersionId.current === version.id) return; loadedVersionId.current = version.id; setDraft(ensureCampaignDraft(version)); setEditorState(cloneJson(version.editor_state ?? {})); setDirty(false); setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded"); }, [version]); function patch(path: string[], value: unknown) { if (locked) return; setDraft((current) => updateNested(current ?? {}, path, value)); setDirty(true); setLocalError(""); } function patchEditor(path: string[], value: unknown) { if (locked) return; setEditorState((current) => updateNested(current, path, value)); setDirty(true); setLocalError(""); } async function saveDraft(mode: "auto" | "manual" = "manual"): Promise { if (!draft || !version || locked) return false; setSaveState("Saving…"); setError(""); setLocalError(""); try { const saved = await autosaveCampaignVersion(settings, campaignId, version.id, { campaign_json: draft, editor_state: editorState, current_flow: "manual", current_step: "global-settings", workflow_state: "editing", is_complete: false }); setDraft(getCampaignJson(saved)); setEditorState(cloneJson(saved.editor_state ?? editorState)); setDirty(false); setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); await reload(); return true; } catch (err) { const text = err instanceof Error ? err.message : String(err); setLocalError(text); setSaveState("Save failed"); return false; } } useRegisterCampaignUnsavedChanges(dirty && !locked ? { title: "Unsaved global settings", message: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.", onSave: () => saveDraft("manual"), onDiscard: () => setDirty(false) } : null); return (
Global settings

Version {version ? `#${version.version_number}` : "—"} · {saveState}

{error &&
{error}
} {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.
} {draft && ( <>
patch(["validation_policy", "missing_required_attachment"], value)} /> patch(["validation_policy", "missing_optional_attachment"], value)} /> patch(["validation_policy", "ambiguous_attachment_match"], value)} /> patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} /> patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} /> patch(["validation_policy", "ignore_empty_fields"], checked)} />
patch(["attachments", "allow_individual"], checked)} /> patch(["attachments", "send_without_attachments"], checked)} />

The actual global and per-recipient attachment rules live in Files. These settings define the campaign-wide defaults used by validation and review.

patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /> patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /> patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /> patch(["status_tracking", "enabled"], checked)} />
patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} /> patchEditor(["opt_ins", "remember_used_addresses"], checked)} /> patchEditor(["opt_ins", "inline_guidance"], checked)} />

These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.

)}
); } function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) { return ( ); }