@@ -214,11 +216,11 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
title="Recipients"
actions={[
Import ,
Add recipient ]}
>
- {draft && inlineEntries.length === 0 && !source.type &&
No recipient data is stored in the current version yet.
}
- {draft && inlineEntries.length === 0 && Boolean(source.type) && (
+ {inlineEntries.length === 0 && !source.type &&
No recipient data is stored in the current version yet.
}
+ {inlineEntries.length === 0 && Boolean(source.type) && (
This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.
)}
- {draft && inlineEntries.length > 0 && (
+ {inlineEntries.length > 0 && (
@@ -328,7 +330,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
saveRecipients("manual")} disabled={!dirty || locked}>Save
>
- )}
+
);
}
diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx
index 05f2a8e..ebacbed 100644
--- a/src/features/campaigns/ReviewDataPage.tsx
+++ b/src/features/campaigns/ReviewDataPage.tsx
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
+import LoadingFrame from "../../components/LoadingFrame";
import Card from "../../components/Card";
import StatusBadge from "../../components/StatusBadge";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -27,6 +28,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
{error && {error}
}
+
@@ -74,6 +76,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
)}
+
);
}
diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx
index e3816dd..ba1fab6 100644
--- a/src/features/campaigns/SendDataPage.tsx
+++ b/src/features/campaigns/SendDataPage.tsx
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
+import LoadingFrame from "../../components/LoadingFrame";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -31,6 +32,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
{error && {error}
}
+
@@ -63,6 +65,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.
+
);
}
diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx
index fa28485..21eae73 100644
--- a/src/features/campaigns/TemplateDataPage.tsx
+++ b/src/features/campaigns/TemplateDataPage.tsx
@@ -5,9 +5,10 @@ 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, getTemplateSection, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
+import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
@@ -38,23 +39,23 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState(null);
- const loadedVersionId = useRef(null);
const subjectRef = useRef(null);
const textRef = useRef(null);
const htmlRef = useRef(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
- const template = draft ? asRecord(draft.template) : getTemplateSection(version);
- const fields = useMemo(() => asArray(draft?.fields).map(asRecord), [draft]);
+ const displayDraft = draft ?? ensureCampaignDraft(null);
+ 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]);
- const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(draft?.global_values))]), [draft?.global_values, localFieldNames]);
+ const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]);
- const entries = asRecord(draft?.entries);
+ const entries = asRecord(displayDraft.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}];
const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {};
- const ignoreEmptyFields = getBool(asRecord(draft?.validation_policy), "ignore_empty_fields", false);
+ const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
@@ -64,15 +65,13 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
...field,
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
}))), [usedPlaceholders, allAvailableNames]);
- const previewContext = useMemo(() => buildPreviewContext(draft, previewEntry), [draft, previewEntry]);
+ const previewContext = useMemo(() => buildPreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
useEffect(() => {
if (!version) return;
- if (loadedVersionId.current === version.id) return;
- loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setPreviewIndex(0);
@@ -196,7 +195,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
{localError && {localError}
}
{locked && This version is read-only. {versionLockReason(version)}
}
- {draft && (
+
<>
setPreviewOpen(true)}>Preview}>
@@ -281,7 +280,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save
>
- )}
+
{previewOpen && (
:not(.loading-frame-overlay) {
+ pointer-events: none;
+ user-select: none;
+}
+
+.loading-frame-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-items: center;
+ min-height: 120px;
+ padding: 1.25rem;
+ border-radius: var(--radius-lg, 18px);
+ background: rgba(255, 255, 255, 0.00);
+ backdrop-filter: blur(1.5px);
+ margin: -10px;
+}
+
+.loading-frame-panel {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12));
+ border-radius: 999px;
+ color: var(--text, #172033);
+ background: rgba(255, 255, 255, 0.86);
+ box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12));
+ font-size: 0.9rem;
+ font-weight: 600;
+}