Reloading redesign
This commit is contained in:
26
src/components/LoadingFrame.tsx
Normal file
26
src/components/LoadingFrame.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import LoadingIndicator from "./LoadingIndicator";
|
||||||
|
|
||||||
|
type LoadingFrameProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) {
|
||||||
|
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames} aria-busy={loading || undefined}>
|
||||||
|
{children}
|
||||||
|
{loading && (
|
||||||
|
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||||
|
<div className="loading-frame-panel">
|
||||||
|
<LoadingIndicator label={label} size="md" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -23,11 +24,12 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const attachments = asRecord(draft?.attachments);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
|
const attachments = asRecord(displayDraft.attachments);
|
||||||
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
|
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const entries = asRecord(draft?.entries);
|
const entries = asRecord(displayDraft.entries);
|
||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
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) })));
|
const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) })));
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
title="Attachment sources"
|
title="Attachment sources"
|
||||||
@@ -213,7 +215,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
|
|
||||||
{pathChooser && (
|
{pathChooser && (
|
||||||
<MockPathChooserOverlay
|
<MockPathChooserOverlay
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { formatDateTime } from "./utils/campaignView";
|
import { formatDateTime } from "./utils/campaignView";
|
||||||
|
|
||||||
@@ -23,9 +24,11 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading} label="Loading audit data…">
|
||||||
<Card title="Recent audit events">
|
<Card title="Recent audit events">
|
||||||
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||||
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses";
|
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses";
|
||||||
@@ -25,12 +26,13 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const campaign = asRecord(draft?.campaign);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
const recipients = asRecord(draft?.recipients);
|
const campaign = asRecord(displayDraft.campaign);
|
||||||
|
const recipients = asRecord(displayDraft.recipients);
|
||||||
const from = asRecord(recipients.from);
|
const from = asRecord(recipients.from);
|
||||||
const defaultFrom = addressesFromValue(from);
|
const defaultFrom = addressesFromValue(from);
|
||||||
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
||||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version) return;
|
if (!version) return;
|
||||||
@@ -97,7 +99,7 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing general campaign data.</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing general campaign data.</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<div className="campaign-settings-stack">
|
<div className="campaign-settings-stack">
|
||||||
<Card title="Campaign identity">
|
<Card title="Campaign identity">
|
||||||
@@ -195,7 +197,7 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
|
|||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -33,8 +34,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const fields = useMemo(() => normalizeFields(draft?.fields), [draft?.fields]);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
const globalValues = asRecord(draft?.global_values);
|
const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]);
|
||||||
|
const globalValues = asRecord(displayDraft.global_values);
|
||||||
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
|
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
|
||||||
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
|
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</div>}
|
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
title="Fields and global values"
|
title="Fields and global values"
|
||||||
@@ -241,7 +243,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
|
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
|
||||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||||
@@ -26,9 +27,11 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
<LoadingFrame loading={loading} label="Loading JSON…">
|
||||||
<Card>
|
<Card>
|
||||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||||
</Card>
|
</Card>
|
||||||
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Card from "../../components/Card";
|
|||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
||||||
import type { CampaignListItem } from "../../types";
|
import type { CampaignListItem } from "../../types";
|
||||||
|
|
||||||
@@ -72,7 +73,8 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
{!loading && campaigns.length === 0 && (
|
<LoadingFrame loading={loading} label="Loading campaigns…">
|
||||||
|
{campaigns.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h2>No campaigns yet</h2>
|
<h2>No campaigns yet</h2>
|
||||||
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
|
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
|
||||||
@@ -81,7 +83,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && campaigns.length > 0 && (
|
{campaigns.length > 0 && (
|
||||||
<div className="app-table-wrap campaign-table-wrap">
|
<div className="app-table-wrap campaign-table-wrap">
|
||||||
<table className="app-table campaign-table">
|
<table className="app-table campaign-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -114,6 +116,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</LoadingFrame>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
|
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import {
|
import {
|
||||||
@@ -106,7 +107,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
{message && <div className="alert success">{message}</div>}
|
{message && <div className="alert success">{message}</div>}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||||
|
|
||||||
{locked && (
|
{locked && (
|
||||||
<div className="alert info">
|
<div className="alert info">
|
||||||
@@ -192,6 +194,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
|||||||
</div>
|
</div>
|
||||||
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
|
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { formatDateTime } from "./utils/campaignView";
|
import { formatDateTime } from "./utils/campaignView";
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
<LoadingFrame loading={loading} label="Loading report data…">
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<Card title="Report summary">
|
<Card title="Report summary">
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
@@ -35,6 +37,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
|||||||
<p className="muted">CSV export and report-emailing buttons will be added once the report section is reviewed.</p>
|
<p className="muted">CSV export and report-emailing buttons will be added once the report section is reviewed.</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -25,12 +26,13 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const validationPolicy = asRecord(draft?.validation_policy);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
const attachments = asRecord(draft?.attachments);
|
const validationPolicy = asRecord(displayDraft.validation_policy);
|
||||||
const delivery = asRecord(draft?.delivery);
|
const attachments = asRecord(displayDraft.attachments);
|
||||||
|
const delivery = asRecord(displayDraft.delivery);
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
const retry = asRecord(delivery.retry);
|
const retry = asRecord(delivery.retry);
|
||||||
const statusTracking = asRecord(draft?.status_tracking);
|
const statusTracking = asRecord(displayDraft.status_tracking);
|
||||||
const optIns = asRecord(editorState.opt_ins);
|
const optIns = asRecord(editorState.opt_ins);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,7 +110,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<Card title="Validation policy">
|
<Card title="Validation policy">
|
||||||
@@ -158,7 +160,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
|||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||||
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
||||||
@@ -27,10 +28,11 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const server = asRecord(draft?.server);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
|
const server = asRecord(displayDraft.server);
|
||||||
const smtp = asRecord(server.smtp);
|
const smtp = asRecord(server.smtp);
|
||||||
const imap = asRecord(server.imap);
|
const imap = asRecord(server.imap);
|
||||||
const delivery = asRecord(draft?.delivery);
|
const delivery = asRecord(displayDraft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const imapEnabled = getBool(imap, "enabled");
|
const imapEnabled = getBool(imap, "enabled");
|
||||||
const imapDisabled = locked || !imapEnabled;
|
const imapDisabled = locked || !imapEnabled;
|
||||||
@@ -189,7 +191,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing server settings.</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing server settings.</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<Card title="Mail server settings">
|
<Card title="Mail server settings">
|
||||||
<div className="mail-server-settings-grid">
|
<div className="mail-server-settings-grid">
|
||||||
@@ -245,7 +247,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||||
@@ -41,18 +42,19 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const recipientsSection = asRecord(draft?.recipients);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
const entries = asRecord(draft?.entries);
|
const recipientsSection = asRecord(displayDraft.recipients);
|
||||||
|
const entries = asRecord(displayDraft.entries);
|
||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||||
const source = asRecord(entries.source);
|
const source = asRecord(entries.source);
|
||||||
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
|
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
||||||
const attachmentSection = asRecord(draft?.attachments);
|
const attachmentSection = asRecord(displayDraft.attachments);
|
||||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [draft?.attachments]);
|
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||||
const individualAttachmentBasePaths = useMemo(() => {
|
const individualAttachmentBasePaths = useMemo(() => {
|
||||||
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
||||||
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
||||||
}, [attachmentBasePaths]);
|
}, [attachmentBasePaths]);
|
||||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
|
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||||
to: addressesFromValue(recipientsSection.to),
|
to: addressesFromValue(recipientsSection.to),
|
||||||
cc: addressesFromValue(recipientsSection.cc),
|
cc: addressesFromValue(recipientsSection.cc),
|
||||||
@@ -180,7 +182,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<Card title="Global recipient headers">
|
<Card title="Global recipient headers">
|
||||||
<div className="campaign-header-stack">
|
<div className="campaign-header-stack">
|
||||||
@@ -214,11 +216,11 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
title="Recipients"
|
title="Recipients"
|
||||||
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
|
actions={[<Button disabled={true}>Import</Button>, <Button variant="primary" onClick={addRecipient} disabled={locked}>Add recipient</Button>]}
|
||||||
>
|
>
|
||||||
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
|
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
|
||||||
{draft && inlineEntries.length === 0 && Boolean(source.type) && (
|
{inlineEntries.length === 0 && Boolean(source.type) && (
|
||||||
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
|
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
|
||||||
)}
|
)}
|
||||||
{draft && inlineEntries.length > 0 && (
|
{inlineEntries.length > 0 && (
|
||||||
<div className="app-table-wrap recipient-table-wrap">
|
<div className="app-table-wrap recipient-table-wrap">
|
||||||
<table className="app-table recipient-table recipient-editor-table">
|
<table className="app-table recipient-table recipient-editor-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -328,7 +330,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
|||||||
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked}>Save</Button>
|
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -27,6 +28,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading} label="Loading review data…">
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<Card title="Validation summary">
|
<Card title="Validation summary">
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
@@ -74,6 +76,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -31,6 +32,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading} label="Loading send data…">
|
||||||
<div className="metric-grid">
|
<div className="metric-grid">
|
||||||
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
||||||
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
||||||
@@ -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.
|
SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
</LoadingFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import Button from "../../components/Button";
|
|||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
import FormField from "../../components/FormField";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { autosaveCampaignVersion } from "../../api/campaigns";
|
import { autosaveCampaignVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
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 { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||||
|
|
||||||
@@ -38,23 +39,23 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const [previewIndex, setPreviewIndex] = useState(0);
|
const [previewIndex, setPreviewIndex] = useState(0);
|
||||||
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
||||||
const loadedVersionId = useRef<string | null>(null);
|
|
||||||
const subjectRef = useRef<HTMLInputElement | null>(null);
|
const subjectRef = useRef<HTMLInputElement | null>(null);
|
||||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
|
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const template = draft ? asRecord(draft.template) : getTemplateSection(version);
|
const displayDraft = draft ?? ensureCampaignDraft(null);
|
||||||
const fields = useMemo(() => asArray(draft?.fields).map(asRecord), [draft]);
|
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 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 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 inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
|
||||||
const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}];
|
const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}];
|
||||||
const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {};
|
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 templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
|
||||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||||
@@ -64,15 +65,13 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
...field,
|
...field,
|
||||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||||
}))), [usedPlaceholders, allAvailableNames]);
|
}))), [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 previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
||||||
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
||||||
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version) return;
|
if (!version) return;
|
||||||
if (loadedVersionId.current === version.id) return;
|
|
||||||
loadedVersionId.current = version.id;
|
|
||||||
setDraft(ensureCampaignDraft(version));
|
setDraft(ensureCampaignDraft(version));
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setPreviewIndex(0);
|
setPreviewIndex(0);
|
||||||
@@ -196,7 +195,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||||
|
|
||||||
{draft && (
|
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||||
<>
|
<>
|
||||||
<div className="dashboard-grid template-editor-grid">
|
<div className="dashboard-grid template-editor-grid">
|
||||||
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
|
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
|
||||||
@@ -281,7 +280,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
</LoadingFrame>
|
||||||
|
|
||||||
{previewOpen && (
|
{previewOpen && (
|
||||||
<TemplatePreviewOverlay
|
<TemplatePreviewOverlay
|
||||||
|
|||||||
@@ -337,7 +337,7 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.campaigns-page .card-header {
|
.campaigns-page .card-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|||||||
@@ -470,4 +470,44 @@
|
|||||||
.field-with-action .button {
|
.field-with-action .button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.loading-frame {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-frame.is-loading {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-frame.is-loading > :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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user