diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx index b8b407b..543b696 100644 --- a/src/features/admin/AdminPage.tsx +++ b/src/features/admin/AdminPage.tsx @@ -3,22 +3,33 @@ import Card from "../../components/Card"; import Button from "../../components/Button"; import PageTitle from "../../components/PageTitle"; import StatusBadge from "../../components/StatusBadge"; +import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit"; -const adminSections: { id: AdminSection; label: string }[] = [ - { id: "system", label: "System" }, - { id: "tenants", label: "Tenants" } -]; - -const tenantSections: { id: AdminSection; label: string }[] = [ - { id: "users", label: "Users" }, - { id: "groups", label: "Groups" }, - { id: "roles", label: "Roles" }, - { id: "api-keys", label: "API keys" }, - { id: "mail-servers", label: "Mail servers" }, - { id: "settings", label: "Settings" }, - { id: "audit", label: "Audit" } +const adminSubnav: ModuleSubnavGroup[] = [ + { + items: [{ id: "overview", label: "Overview" }] + }, + { + title: "ADMIN", + items: [ + { id: "system", label: "System" }, + { id: "tenants", label: "Tenants" } + ] + }, + { + title: "TENANT", + items: [ + { id: "users", label: "Users" }, + { id: "groups", label: "Groups" }, + { id: "roles", label: "Roles" }, + { id: "api-keys", label: "API keys" }, + { id: "mail-servers", label: "Mail servers" }, + { id: "settings", label: "Settings" }, + { id: "audit", label: "Audit" } + ] + } ]; const sectionTitles: Record = { @@ -40,25 +51,7 @@ export default function AdminPage() { return (
- +
diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index 9d57a98..d95e34f 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { createPortal } from "react-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; @@ -6,25 +6,30 @@ import Card from "../../components/Card"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; +import { getBool, getText, updateNested } from "./utils/draftEditor"; import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; type PathChooserState = { index: number }; export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const [pathChooser, setPathChooser] = useState(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "files", + unsavedTitle: "Unsaved attachment settings", + unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue." + }); const attachments = asRecord(displayDraft.attachments); const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); @@ -33,21 +38,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings const inlineEntries = asArray(entries.inline).map(asRecord); const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) }))); - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - 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 patchBasePaths(paths: AttachmentBasePath[]) { if (locked) return; const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")]; @@ -55,8 +47,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized); return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || "."); }); - setDirty(true); - setLocalError(""); + markDirty(); } function patchBasePath(index: number, patch: Partial) { @@ -87,38 +78,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings ]); } - 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, - current_flow: "manual", - current_step: "files", - workflow_state: "editing", - is_complete: false - }); - setDraft(getCampaignJson(saved)); - 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 attachment settings", - message: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveDraft("manual"), - onDiscard: () => setDirty(false) - } : null); return (
diff --git a/src/features/campaigns/CampaignDataPage.tsx b/src/features/campaigns/CampaignDataPage.tsx index 23695dd..831763c 100644 --- a/src/features/campaigns/CampaignDataPage.tsx +++ b/src/features/campaigns/CampaignDataPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { Link } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; @@ -9,24 +9,29 @@ import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import EmailAddressInput from "../../components/email/EmailAddressInput"; import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; +import { getBool, getText } from "./utils/draftEditor"; const campaignModeOptions = ["draft", "test", "send"]; export default function CampaignDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "campaign-settings", + unsavedTitle: "Unsaved general campaign data", + unsavedMessage: "General campaign data has unsaved changes. Save it before leaving, or discard it and continue." + }); const campaign = asRecord(displayDraft.campaign); const recipients = asRecord(displayDraft.recipients); const from = asRecord(recipients.from); @@ -34,53 +39,9 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A const globalReplyTo = addressesFromValue(recipients.reply_to); const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]); - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - 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(""); - } - 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, - current_flow: "manual", - current_step: "campaign-settings", - workflow_state: "editing", - is_complete: false - }); - setDraft(getCampaignJson(saved)); - 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 general campaign data", - message: "General campaign data has unsaved changes. Save it before leaving, or discard it and continue.", - onSave: () => saveDraft("manual"), - onDiscard: () => setDirty(false) - } : null); return (
diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx index 89f2abe..0923d6b 100644 --- a/src/features/campaigns/CampaignFieldsPage.tsx +++ b/src/features/campaigns/CampaignFieldsPage.tsx @@ -1,16 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef } 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 LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; +import { getBool, getText, updateNested } from "./utils/draftEditor"; import FieldValueInput from "./components/FieldValueInput"; const fieldTypeOptions = ["string", "integer", "double", "date", "password"]; @@ -26,35 +24,35 @@ type FieldDefinition = { export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const fieldValueKeys = useRef([]); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "campaign-fields", + unsavedTitle: "Unsaved fields", + unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.", + transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)), + onLoaded: (_loadedVersion, loadedDraft) => { + fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name); + } + }); const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]); const globalValues = asRecord(displayDraft.global_values); const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]); const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning; - useEffect(() => { - if (!version) return; - const nextDraft = migrateFieldOverridePolicy(ensureCampaignDraft(version), asRecord(version.editor_state)); - fieldValueKeys.current = normalizeFields(nextDraft.fields).map((field) => field.name); - setDraft(nextDraft); - setDirty(false); - setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded"); - }, [version]); - function patchDraft(path: string[], value: unknown) { if (locked) return; setDraft((current) => updateNested(current ?? {}, path, value)); - setDirty(true); - setLocalError(""); + markDirty(); } function patchFields(nextFields: FieldDefinition[]) { @@ -97,8 +95,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); return updateNested(nextDraft, ["global_values"], nextGlobalValues); }); - setDirty(true); - setLocalError(""); + markDirty(); } function addField() { @@ -110,8 +107,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); return updateNested(nextDraft, ["global_values"], nextGlobalValues); }); - setDirty(true); - setLocalError(""); + markDirty(); } function deleteField(index: number) { @@ -126,8 +122,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); return updateNested(nextDraft, ["global_values"], nextGlobalValues); }); - setDirty(true); - setLocalError(""); + markDirty(); } function setGlobalValue(key: string, value: unknown) { @@ -138,44 +133,16 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: setField(index, { can_override: allowed }); } - async function saveDraft(mode: "auto" | "manual" = "manual"): Promise { - if (!draft || !version || locked) return false; + async function saveFields(): Promise { const fieldProblem = describeFieldNameProblem(fields); if (fieldProblem) { setLocalError(fieldProblem); setSaveState("Save blocked"); return false; } - setSaveState("Saving…"); - setError(""); - setLocalError(""); - try { - const saved = await autosaveCampaignVersion(settings, campaignId, version.id, { - campaign_json: draft, - current_flow: "manual", - current_step: "campaign-fields", - workflow_state: "editing", - is_complete: false - }); - setDraft(getCampaignJson(saved)); - 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; - } + return saveDraft("manual"); } - useRegisterCampaignUnsavedChanges(dirty && !locked ? { - title: "Unsaved fields", - message: "Fields have unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveDraft("manual"), - onDiscard: () => setDirty(false) - } : null); return (
@@ -186,7 +153,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
- +
@@ -240,7 +207,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
- +
diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index 532ef57..ffe3b55 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -6,11 +6,10 @@ import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; 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"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; +import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor"; const behaviorOptions = ["block", "ask", "drop", "continue", "warn"]; @@ -18,15 +17,24 @@ 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 version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "global-settings", + unsavedTitle: "Unsaved global settings", + unsavedMessage: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.", + extraPayload: () => ({ editor_state: editorState }), + onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})), + onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState)) + }); const validationPolicy = asRecord(displayDraft.validation_policy); const attachments = asRecord(displayDraft.attachments); const delivery = asRecord(displayDraft.delivery); @@ -35,63 +43,14 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: const statusTracking = asRecord(displayDraft.status_tracking); const optIns = asRecord(editorState.opt_ins); - useEffect(() => { - if (!version) return; - 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(""); + markDirty(); } - 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 (
diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx index 86bcc5a..e7bd9c0 100644 --- a/src/features/campaigns/MailSettingsPage.tsx +++ b/src/features/campaigns/MailSettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -6,21 +6,16 @@ import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; +import { getBool, getNumber, getText } from "./utils/draftEditor"; const securityOptions = ["plain", "tls", "starttls"]; export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [folderResult, setFolderResult] = useState(null); @@ -28,7 +23,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "mail-settings", + unsavedTitle: "Unsaved server settings", + unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue." + }); const server = asRecord(displayDraft.server); const smtp = asRecord(server.smtp); const imap = asRecord(server.imap); @@ -37,46 +42,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const imapEnabled = getBool(imap, "enabled"); const imapDisabled = locked || !imapEnabled; - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - 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(""); - } - - 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, - current_flow: "manual", - current_step: "mail-settings", - workflow_state: "editing", - is_complete: false - }); - setDraft(getCampaignJson(saved)); - 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; - } - } function toggleImap(enabled: boolean) { patch(["server", "imap", "enabled"], enabled); @@ -85,12 +52,6 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } } - useRegisterCampaignUnsavedChanges(dirty && !locked ? { - title: "Unsaved server settings", - message: "Server settings have unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveDraft("manual"), - onDiscard: () => setDirty(false) - } : null); function emptyToNull(value: string, trim = true): string | null { const normalized = trim ? value.trim() : value; diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx index bed2b77..c341b35 100644 --- a/src/features/campaigns/RecipientDataPage.tsx +++ b/src/features/campaigns/RecipientDataPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -7,11 +7,10 @@ import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import EmailAddressInput from "../../components/email/EmailAddressInput"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; +import { getBool } from "./utils/draftEditor"; import { addressesFromValue, collectCampaignAddressSuggestions, @@ -36,14 +35,20 @@ type EntryAddressColumn = { export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "recipients", + unsavedTitle: "Unsaved recipient changes", + unsavedMessage: "Recipient address data has unsaved changes. Save it before leaving, or discard it and continue." + }); const recipientsSection = asRecord(displayDraft.recipients); const entries = asRecord(displayDraft.entries); const inlineEntries = asArray(entries.inline).map(asRecord); @@ -63,19 +68,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: return columns; }, [recipientsSection]); - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - 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 replaceInlineEntries(nextEntries: Record[]) { patch(["entries", "inline"], nextEntries); @@ -124,37 +116,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index)); } - async function saveRecipients(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, - current_flow: "manual", - current_step: "recipients", - workflow_state: "editing", - is_complete: false - }); - setDraft(ensureCampaignDraft(saved)); - setDirty(false); - setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); - await reload(); - return true; - } catch (err) { - setLocalError(err instanceof Error ? err.message : String(err)); - setSaveState("Save failed"); - return false; - } - } - - useRegisterCampaignUnsavedChanges(dirty && !locked ? { - title: "Unsaved recipient changes", - message: "Recipient addresses or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveRecipients("manual"), - onDiscard: () => setDirty(false) - } : null); return (
@@ -165,7 +126,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
- +
@@ -258,7 +219,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
- +
diff --git a/src/features/campaigns/RecipientDetailsPage.tsx b/src/features/campaigns/RecipientDetailsPage.tsx index b9b7060..96ce7f4 100644 --- a/src/features/campaigns/RecipientDetailsPage.tsx +++ b/src/features/campaigns/RecipientDetailsPage.tsx @@ -1,15 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { Link } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; import PageTitle from "../../components/PageTitle"; import LoadingFrame from "../../components/LoadingFrame"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; +import { getBool, getText } from "./utils/draftEditor"; import FieldValueInput from "./components/FieldValueInput"; import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay"; import { addressesFromValue } from "../../utils/emailAddresses"; @@ -23,14 +22,20 @@ type FieldDefinition = { export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "recipient-data", + unsavedTitle: "Unsaved recipient data changes", + unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue." + }); const entries = asRecord(displayDraft.entries); const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); @@ -42,19 +47,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting return enabled.length > 0 ? enabled : attachmentBasePaths; }, [attachmentBasePaths]); - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - 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 replaceInlineEntries(nextEntries: Record[]) { patch(["entries", "inline"], nextEntries); @@ -79,37 +71,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting updateEntry(index, (entry) => ({ ...entry, attachments })); } - async function saveRecipientData(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, - current_flow: "manual", - current_step: "recipient-data", - workflow_state: "editing", - is_complete: false - }); - setDraft(ensureCampaignDraft(saved)); - setDirty(false); - setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); - await reload(); - return true; - } catch (err) { - setLocalError(err instanceof Error ? err.message : String(err)); - setSaveState("Save failed"); - return false; - } - } - - useRegisterCampaignUnsavedChanges(dirty && !locked ? { - title: "Unsaved recipient data changes", - message: "Recipient field values or attachment rules have unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveRecipientData("manual"), - onDiscard: () => setDirty(false) - } : null); return (
@@ -120,7 +81,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
- +
@@ -190,7 +151,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
- +
diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index 21eae73..a598556 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -1,16 +1,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router-dom"; 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 LoadingFrame from "../../components/LoadingFrame"; -import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; -import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; +import { cloneJson, getBool, getText } from "./utils/draftEditor"; type BodyMode = "text" | "html"; type EditorTarget = "subject" | "text" | "html"; @@ -30,10 +28,6 @@ type UndefinedPlaceholder = TemplatePlaceholder & { export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loaded"); - const [localError, setLocalError] = useState(""); const [bodyMode, setBodyMode] = useState("text"); const [activeEditor, setActiveEditor] = useState("text"); const [previewOpen, setPreviewOpen] = useState(false); @@ -45,7 +39,19 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const displayDraft = draft ?? ensureCampaignDraft(null); + const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "template", + unsavedTitle: "Unsaved template changes", + unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.", + loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded", + onLoaded: () => setPreviewIndex(0) + }); const template = asRecord(displayDraft.template); const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]); const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]); @@ -70,24 +76,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields); const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields); - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - setDirty(false); - setPreviewIndex(0); - setSaveState(version.autosaved_at ? `Loaded autosave ${formatDateTime(version.autosaved_at)}` : "Loaded"); - }, [version]); useEffect(() => { if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1)); }, [previewIndex, previewEntries.length]); - function patch(path: string[], value: unknown) { - if (locked) return; - setDraft((current) => updateNested(current ?? {}, path, value)); - setDirty(true); - setLocalError(""); - } function patchTemplateText(target: EditorTarget, value: string) { patch(["template", target], value); @@ -140,42 +133,10 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A next.template = nextTemplate; return next; }); - setDirty(true); - setLocalError(""); + markDirty(); setUndefinedDialog(null); } - async function saveTemplate(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, - current_flow: "manual", - current_step: "template", - workflow_state: "editing", - is_complete: false - }); - setDraft(ensureCampaignDraft(saved)); - setDirty(false); - setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); - await reload(); - return true; - } catch (err) { - setLocalError(err instanceof Error ? err.message : String(err)); - setSaveState("Save failed"); - return false; - } - } - - useRegisterCampaignUnsavedChanges(dirty && !locked ? { - title: "Unsaved template changes", - message: "The template has unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveTemplate("manual"), - onDiscard: () => setDirty(false) - } : null); return (
@@ -187,7 +148,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
- +
@@ -277,7 +238,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
- +
diff --git a/src/features/campaigns/components/AttachmentRuleCard.tsx b/src/features/campaigns/components/AttachmentRuleCard.tsx deleted file mode 100644 index 4e21add..0000000 --- a/src/features/campaigns/components/AttachmentRuleCard.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import FormField from "../../../components/FormField"; - -export default function AttachmentRuleCard() { - return ( -
-
- Attachment rule - Personalized documents -
-
- - - - - - -
-
- ); -} diff --git a/src/features/campaigns/components/FieldMappingTable.tsx b/src/features/campaigns/components/FieldMappingTable.tsx deleted file mode 100644 index 9c92250..0000000 --- a/src/features/campaigns/components/FieldMappingTable.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export default function FieldMappingTable() { - const rows = [ - ["email", "E-Mail", "required", "person@example.org", "ok"], - ["fields.number", "Dienststelle", "required", "123456", "ok"], - ["fields.password", "Passwort", "optional", "••••••", "ok"] - ]; - - return ( -
-
- Campaign field - Source column - Required - Preview - Status -
- {rows.map((row) => ( -
- {row.map((cell, index) => {cell})} -
- ))} -
- ); -} diff --git a/src/features/campaigns/hooks/useCampaignDraftEditor.ts b/src/features/campaigns/hooks/useCampaignDraftEditor.ts new file mode 100644 index 0000000..4c3c6b7 --- /dev/null +++ b/src/features/campaigns/hooks/useCampaignDraftEditor.ts @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ApiSettings } from "../../../types"; +import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns"; +import { formatDateTime, getCampaignJson } from "../utils/campaignView"; +import { ensureCampaignDraft, updateNested } from "../utils/draftEditor"; +import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext"; + +type StepValue = string | (() => string | null | undefined); + +type UseCampaignDraftEditorOptions = { + settings: ApiSettings; + campaignId: string; + version: CampaignVersionDetail | null; + locked: boolean; + reload: () => Promise; + setError: (message: string) => void; + currentStep: StepValue; + currentFlow?: string; + workflowState?: string; + isComplete?: boolean; + unsavedTitle: string; + unsavedMessage: string; + loadedLabel?: (version: CampaignVersionDetail) => string; + transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record) => Record; + extraPayload?: () => Partial; + onLoaded?: (version: CampaignVersionDetail, draft: Record) => void; + onSaved?: (version: CampaignVersionDetail) => void; +}; + +function resolveStep(value: StepValue): string | null | undefined { + return typeof value === "function" ? value() : value; +} + +function defaultLoadedLabel(version: CampaignVersionDetail): string { + return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded"; +} + +export function useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep, + currentFlow = "manual", + workflowState = "editing", + isComplete = false, + unsavedTitle, + unsavedMessage, + loadedLabel = defaultLoadedLabel, + transformLoadedDraft, + extraPayload, + onLoaded, + onSaved +}: UseCampaignDraftEditorOptions) { + const loadedLabelRef = useRef(loadedLabel); + const transformLoadedDraftRef = useRef(transformLoadedDraft); + const onLoadedRef = useRef(onLoaded); + + useEffect(() => { + loadedLabelRef.current = loadedLabel; + transformLoadedDraftRef.current = transformLoadedDraft; + onLoadedRef.current = onLoaded; + }, [loadedLabel, transformLoadedDraft, onLoaded]); + + const [draft, setDraft] = useState | null>(null); + const [dirty, setDirty] = useState(false); + const [saveState, setSaveState] = useState("Loaded"); + const [localError, setLocalError] = useState(""); + + useEffect(() => { + if (!version) return; + const initialDraft = ensureCampaignDraft(version); + const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft; + setDraft(loadedDraft); + setDirty(false); + setLocalError(""); + setSaveState(loadedLabelRef.current(version)); + onLoadedRef.current?.(version, loadedDraft); + }, [version]); + + const markDirty = useCallback(() => { + setDirty(true); + setLocalError(""); + }, []); + + const patch = useCallback((path: string[], value: unknown) => { + if (locked) return; + setDraft((current) => updateNested(current ?? {}, path, value)); + markDirty(); + }, [locked, markDirty]); + + const saveDraft = useCallback(async (_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, + current_flow: currentFlow, + current_step: resolveStep(currentStep), + workflow_state: workflowState, + is_complete: isComplete, + ...(extraPayload?.() ?? {}) + }); + setDraft(getCampaignJson(saved)); + setDirty(false); + setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); + onSaved?.(saved); + await reload(); + return true; + } catch (err) { + const text = err instanceof Error ? err.message : String(err); + setLocalError(text); + setSaveState("Save failed"); + return false; + } + }, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]); + + useRegisterCampaignUnsavedChanges(dirty && !locked ? { + title: unsavedTitle, + message: unsavedMessage, + onSave: () => saveDraft("manual"), + onDiscard: () => setDirty(false) + } : null); + + return { + draft, + setDraft, + displayDraft: draft ?? ensureCampaignDraft(null), + dirty, + setDirty, + saveState, + setSaveState, + localError, + setLocalError, + patch, + markDirty, + saveDraft + }; +} diff --git a/src/features/campaigns/wizard/CreateWizard.tsx b/src/features/campaigns/wizard/CreateWizard.tsx index bd2f28a..63c58bf 100644 --- a/src/features/campaigns/wizard/CreateWizard.tsx +++ b/src/features/campaigns/wizard/CreateWizard.tsx @@ -10,11 +10,11 @@ import ToggleSwitch from "../../../components/ToggleSwitch"; import EmailAddressInput from "../../../components/email/EmailAddressInput"; import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../utils/emailAddresses"; import MetricCard from "../../../components/MetricCard"; -import { autosaveCampaignVersion, validatePartial } from "../../../api/campaigns"; +import { validatePartial } from "../../../api/campaigns"; import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView"; -import { ensureCampaignDraft, getBool, getNumber, getText, parseJsonTextarea, stringifyJson, updateNested } from "../utils/draftEditor"; -import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext"; +import { asArray, asRecord, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView"; +import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../utils/draftEditor"; +import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor"; const steps: WizardStep[] = [ { id: "basics", label: "Basics", description: "Name and scenario" }, @@ -31,61 +31,35 @@ const behaviorOptions = ["block", "ask", "drop", "continue", "warn"]; export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const [activeStep, setActiveStep] = useState("basics"); - const [draft, setDraft] = useState | null>(null); - const [dirty, setDirty] = useState(false); - const [saveState, setSaveState] = useState("Loading…"); const [localError, setLocalError] = useState(""); const [validationMessage, setValidationMessage] = useState(""); const index = steps.findIndex((s) => s.id === activeStep); const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - - useEffect(() => { - if (!version) return; - setDraft(ensureCampaignDraft(version)); - setDirty(false); - setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded"); - if (version.current_step && steps.some((step) => step.id === version.current_step)) { - setActiveStep(version.current_step); + const { draft, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError: setLocalError, + currentFlow: "create", + currentStep: () => activeStep, + unsavedTitle: "Unsaved wizard changes", + unsavedMessage: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.", + onLoaded: (loadedVersion) => { + if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) { + setActiveStep(loadedVersion.current_step); + } } - }, [version]); + }); - function patch(path: string[], value: unknown) { - if (locked) return; - setDraft((current) => updateNested(current ?? {}, path, value)); - setDirty(true); - setLocalError(""); - } - function patchRoot(key: string, value: unknown) { patch([key], value); } - async function saveDraft(mode: "auto" | "manual" = "manual"): Promise { - if (!draft || !version || locked) return false; - setSaveState("Saving…"); - setLocalError(""); - try { - const saved = await autosaveCampaignVersion(settings, campaignId, version.id, { - campaign_json: draft, - current_flow: "create", - current_step: activeStep, - workflow_state: "editing", - is_complete: false - }); - setDraft(ensureCampaignDraft(saved)); - setDirty(false); - setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`); - await reload(); - return true; - } catch (err) { - setLocalError(err instanceof Error ? err.message : String(err)); - setSaveState("Save failed"); - return false; - } - } function selectStep(stepId: string) { setActiveStep(stepId); @@ -110,12 +84,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe } } - useRegisterCampaignUnsavedChanges(dirty && !locked ? { - title: "Unsaved wizard changes", - message: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.", - onSave: () => saveDraft("manual"), - onDiscard: () => setDirty(false) - } : null); if (locked) { return ( diff --git a/src/features/campaigns/wizard/steps/AttachmentsStep.tsx b/src/features/campaigns/wizard/steps/AttachmentsStep.tsx deleted file mode 100644 index 47d53dd..0000000 --- a/src/features/campaigns/wizard/steps/AttachmentsStep.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import FormField from "../../../../components/FormField"; -import Button from "../../../../components/Button"; -import AttachmentRuleCard from "../../components/AttachmentRuleCard"; - -export default function AttachmentsStep() { - return ( -
-
-

Attachments

-

Configure the campaign base path and one or more attachment matching rules.

-
- - - - -
- - -
-
- ); -} diff --git a/src/features/campaigns/wizard/steps/BasicsStep.tsx b/src/features/campaigns/wizard/steps/BasicsStep.tsx deleted file mode 100644 index 893ae00..0000000 --- a/src/features/campaigns/wizard/steps/BasicsStep.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import FormField from "../../../../components/FormField"; - -export default function BasicsStep() { - return ( -
- - - - - - - - - - -