Rework of campaign structure; locking
This commit is contained in:
@@ -9,6 +9,14 @@ export type CampaignListResponse =
|
||||
results?: CampaignListItem[];
|
||||
};
|
||||
|
||||
|
||||
export type CampaignUpdatePayload = {
|
||||
external_id?: string | null;
|
||||
name?: string | null;
|
||||
status?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignCreateMinimalPayload = {
|
||||
external_id?: string;
|
||||
name?: string;
|
||||
@@ -138,6 +146,17 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
||||
}
|
||||
|
||||
export async function updateCampaignMetadata(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignUpdatePayload
|
||||
): Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNewCampaign(
|
||||
settings: ApiSettings,
|
||||
overrides: CampaignCreateMinimalPayload = {}
|
||||
@@ -177,6 +196,16 @@ export async function getCampaignVersion(
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
export async function unlockCampaignVersionValidation(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -189,6 +218,18 @@ export async function updateCampaignVersion(
|
||||
});
|
||||
}
|
||||
|
||||
export async function forkCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload = {}
|
||||
): Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function autosaveCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
44
src/components/ConfirmDialog.tsx
Normal file
44
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Button from "./Button";
|
||||
|
||||
export type ConfirmDialogTone = "default" | "danger";
|
||||
|
||||
export type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
tone?: ConfirmDialogTone;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
tone = "default",
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="confirm-backdrop" role="presentation" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !busy) onCancel();
|
||||
}}>
|
||||
<section className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-message">
|
||||
<h2 id="confirm-dialog-title">{title}</h2>
|
||||
<p id="confirm-dialog-message">{message}</p>
|
||||
<div className="button-row compact-actions confirm-dialog-actions">
|
||||
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
|
||||
<Button variant={tone === "danger" ? "danger" : "primary"} onClick={onConfirm} disabled={busy}>{busy ? "Working…" : confirmLabel}</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
|
||||
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||
@@ -73,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Manage files</Button>
|
||||
@@ -84,7 +86,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing attachments." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { formatDateTime } from "./utils/campaignView";
|
||||
|
||||
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||
@@ -15,7 +15,7 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Audit log</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
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 FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
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 version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
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);
|
||||
const defaultFrom = addressesFromValue(from);
|
||||
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>General</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</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>}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
<div className="campaign-settings-stack">
|
||||
<Card title="Campaign identity">
|
||||
<div className="form-grid campaign-identity-grid">
|
||||
<FormField label="Campaign ID">
|
||||
<input value={getText(campaign, "id")} disabled={locked} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<select value={getText(campaign, "mode", "draft")} disabled={locked} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
|
||||
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<input value={getText(campaign, "name")} disabled={locked} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<textarea rows={4} value={getText(campaign, "description")} disabled={locked} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Campaign sender">
|
||||
<div className="campaign-header-stack">
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Default From address">
|
||||
<EmailAddressInput
|
||||
value={defaultFrom}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
showAddButton={false}
|
||||
disabled={locked}
|
||||
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
|
||||
emptyText="No default sender configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label="Allow individual senders"
|
||||
checked={getBool(recipients, "allow_individual_from")}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Global Reply-To address">
|
||||
<EmailAddressInput
|
||||
value={globalReplyTo.slice(0, 1)}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
showAddButton={false}
|
||||
disabled={locked}
|
||||
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
|
||||
emptyText="No Reply-To address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label="Allow individual Reply-To"
|
||||
checked={getBool(recipients, "allow_individual_reply_to")}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Related campaign areas">
|
||||
<div className="related-link-grid">
|
||||
<Link to="../recipients" className="related-link-card">
|
||||
<strong>Recipients</strong>
|
||||
<span>Recipient address profiles, global recipient headers and header overrides.</span>
|
||||
</Link>
|
||||
<Link to="../recipient-data" className="related-link-card">
|
||||
<strong>Recipient data</strong>
|
||||
<span>Maintain recipient-specific field values and attachment rules.</span>
|
||||
</Link>
|
||||
<Link to="../global-settings" className="related-link-card">
|
||||
<strong>Global settings</strong>
|
||||
<span>Policies, attachment defaults, delivery defaults and opt-ins.</span>
|
||||
</Link>
|
||||
<Link to="../fields" className="related-link-card">
|
||||
<strong>Fields</strong>
|
||||
<span>Define fields, global values and recipient override behavior.</span>
|
||||
</Link>
|
||||
<Link to="../files" className="related-link-card">
|
||||
<strong>Attachments</strong>
|
||||
<span>Configure global attachments and per-recipient file patterns.</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
|
||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||
@@ -140,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Fields</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
@@ -151,7 +153,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</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 && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing fields." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { ApiSettings } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
|
||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||
|
||||
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
@@ -19,7 +20,7 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>JSON</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
|
||||
@@ -109,7 +109,9 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}
|
||||
</td>
|
||||
<td className="updated-cell">{formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at)}</td>
|
||||
<td><Link className="table-action-link" to={`/campaigns/${campaign.id}`}>View</Link></td>
|
||||
<td>
|
||||
<Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,91 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useMemo, 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 MetricCard from "../../components/MetricCard";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { publishCampaignVersion, updateCampaignMetadata, type CampaignVersionListItem } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
cloneCampaignJsonForCopy,
|
||||
getCampaignJson,
|
||||
getString,
|
||||
isAuditLockedVersion,
|
||||
summaryValue,
|
||||
timestampSlug,
|
||||
versionLockReason
|
||||
} from "./utils/campaignView";
|
||||
import { addressesFromValue } from "../../utils/emailAddresses";
|
||||
import { canUnlockValidationVersion, formatDateTime, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, summaryValue } from "./utils/campaignView";
|
||||
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
|
||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [locking, setLocking] = useState(false);
|
||||
const campaign = data.campaign;
|
||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
||||
const [identityDirty, setIdentityDirty] = useState(false);
|
||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||
const [lockingVersion, setLockingVersion] = useState<CampaignVersionListItem | null>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const campaign = data.campaign;
|
||||
const currentVersion = data.currentVersion;
|
||||
const campaignJson = getCampaignJson(currentVersion);
|
||||
const locked = isAuditLockedVersion(currentVersion);
|
||||
const cards = data.summary?.cards;
|
||||
const overviewFacts = getOverviewFacts(campaignJson, campaign);
|
||||
useEffect(() => {
|
||||
if (!campaign || identityDirty) return;
|
||||
setIdentity({
|
||||
external_id: campaign.external_id ?? "",
|
||||
name: campaign.name ?? "",
|
||||
status: campaign.status ?? "",
|
||||
description: campaign.description ?? ""
|
||||
});
|
||||
}, [campaign, identityDirty]);
|
||||
|
||||
async function copyCampaign() {
|
||||
if (!currentVersion) return;
|
||||
setCopying(true);
|
||||
function patchIdentity(key: keyof typeof identity, value: string) {
|
||||
setIdentity((current) => ({ ...current, [key]: value }));
|
||||
setIdentityDirty(true);
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
const copy = cloneCampaignJsonForCopy(campaignJson, campaign, timestampSlug());
|
||||
const created = await createNewCampaign(settings, {
|
||||
external_id: copy.externalId,
|
||||
name: copy.name,
|
||||
description: copy.description,
|
||||
current_flow: "manual",
|
||||
current_step: "copied"
|
||||
});
|
||||
await updateCampaignVersion(settings, created.campaign.id, created.version.id, {
|
||||
campaign_json: copy.rawJson,
|
||||
current_flow: "manual",
|
||||
current_step: null,
|
||||
workflow_state: "editing",
|
||||
is_complete: false,
|
||||
editor_state: {
|
||||
copied_from_campaign_id: campaignId,
|
||||
copied_from_version_id: currentVersion.id
|
||||
}
|
||||
});
|
||||
navigate(`/campaigns/${created.campaign.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function lockCampaign() {
|
||||
if (!currentVersion || locked) return;
|
||||
const confirmed = window.confirm(
|
||||
"Lock this campaign version for audit-safe use? The current version should no longer be edited afterwards; create a copy if you need a new working version."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setLocking(true);
|
||||
setMessage("");
|
||||
async function saveIdentity() {
|
||||
if (!campaign || savingIdentity || !identityDirty) return;
|
||||
setSavingIdentity(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await publishCampaignVersion(settings, campaignId, currentVersion.id);
|
||||
setMessage("Campaign version locked as the current audit-safe version.");
|
||||
await updateCampaignMetadata(settings, campaign.id, {
|
||||
external_id: identity.external_id,
|
||||
name: identity.name,
|
||||
status: identity.status,
|
||||
description: identity.description
|
||||
});
|
||||
setIdentityDirty(false);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLocking(false);
|
||||
setSavingIdentity(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function lockAuditSnapshot() {
|
||||
const version = lockingVersion;
|
||||
if (!version || lockBusy) return;
|
||||
setLockBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await publishCampaignVersion(settings, campaignId, version.id);
|
||||
setMessage(`Version #${version.version_number} locked as audit-safe snapshot.`);
|
||||
setLockingVersion(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLockBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,14 +85,12 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
||||
<p className="mono-small">{campaign?.external_id || campaignId}</p>
|
||||
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
|
||||
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
|
||||
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
|
||||
</Button>
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
|
||||
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,133 +98,113 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
{message && <div className="alert success">{message}</div>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading campaign overview…">
|
||||
<Card title="Campaign identity">
|
||||
<div className="form-grid campaign-identity-grid">
|
||||
<FormField label="Campaign ID">
|
||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
||||
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{locked && (
|
||||
<div className="alert info">
|
||||
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
|
||||
</div>
|
||||
)}
|
||||
<Card title="Version history">
|
||||
<div className="app-table-wrap">
|
||||
<table className="app-table version-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Lock</th>
|
||||
<th>Validation</th>
|
||||
<th>Build</th>
|
||||
<th>Updated</th>
|
||||
<th aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted">No versions found.</td></tr>
|
||||
)}
|
||||
{versions.map((version) => {
|
||||
const isCurrentVersion = version.id === data.currentVersion?.id;
|
||||
return (
|
||||
<tr key={version.id} className={isCurrentVersion ? "current-version-row" : undefined}>
|
||||
<td>#{version.version_number}</td>
|
||||
<td><StatusBadge status={version.workflow_state ?? "editing"} /></td>
|
||||
<td>{versionLockLabel(version)}</td>
|
||||
<td>{validationLabel(version)}</td>
|
||||
<td>{buildLabel(version)}</td>
|
||||
<td>{formatDateTime(version.updated_at)}</td>
|
||||
<td className="table-action-cell">
|
||||
<div className="button-row compact-actions">
|
||||
<Link to={`review?version=${version.id}`}><Button variant="primary">Open</Button></Link>
|
||||
<Button
|
||||
onClick={() => setLockingVersion(version)}
|
||||
disabled={isUserLockedVersion(version) || isFinalLockedVersion(version) || canUnlockValidationVersion(version)}
|
||||
>Lock</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
|
||||
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
|
||||
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
</div>
|
||||
|
||||
<Card title="Guided actions" actions={<span className="muted small-note">Wizards change or advance the campaign; data pages display and edit the current working draft.</span>}>
|
||||
<div className="wizard-action-grid">
|
||||
<WizardAction
|
||||
title={locked ? "Create a new working copy" : "Edit campaign structure"}
|
||||
description={locked ? "This version is locked. Copy the campaign before editing structural data." : "Open the structured create/edit wizard for overview, recipients, template and attachments."}
|
||||
to="wizard/create"
|
||||
label={locked ? "Open wizard read-only" : "Open Create Campaign"}
|
||||
/>
|
||||
<WizardAction
|
||||
title="Resolve review issues"
|
||||
description="Use a guided flow for validation issues, missing recipients or attachment decisions."
|
||||
to="wizard/review"
|
||||
label="Open Review Wizard"
|
||||
/>
|
||||
<WizardAction
|
||||
title="Prepare sending"
|
||||
description="Use the sending wizard for dry runs, rate limits, test sending and queue preparation."
|
||||
to="wizard/send"
|
||||
label="Open Send Wizard"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="overview-config-grid">
|
||||
<ConfigShortcutCard
|
||||
title="General"
|
||||
description="Name, sender and global recipients."
|
||||
facts={overviewFacts.campaignSettings}
|
||||
actions={[{ to: "data", label: "General" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Global settings"
|
||||
description="Policies, opt-ins and delivery defaults."
|
||||
facts={overviewFacts.globalSettings}
|
||||
actions={[{ to: "global-settings", label: "Global settings" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Fields"
|
||||
description="Field definitions and global values."
|
||||
facts={overviewFacts.fields}
|
||||
actions={[{ to: "fields", label: "Fields" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Recipients"
|
||||
description="Recipient list and per-recipient values."
|
||||
facts={overviewFacts.recipients}
|
||||
actions={[{ to: "recipients", label: "Recipients" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Template"
|
||||
description="Message content, preview and field usage."
|
||||
facts={overviewFacts.template}
|
||||
actions={[{ to: "template", label: "Template" }]}
|
||||
/>
|
||||
<ConfigShortcutCard
|
||||
title="Attachments"
|
||||
description="Global attachments and per-recipient rules."
|
||||
facts={overviewFacts.files}
|
||||
actions={[{ to: "files", label: "Attachments" }]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card title="Validation and build state">
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Built messages" value={summaryValue(currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Jobs total" value={cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
|
||||
</Card>
|
||||
<Card title="Current version state">
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(lockingVersion)}
|
||||
title="Lock audit-safe snapshot?"
|
||||
message="This is a user-requested final lock. The version cannot be validated, unlocked, built, dry-run or sent afterwards. Create an editable copy for future changes."
|
||||
confirmLabel="Lock snapshot"
|
||||
tone="danger"
|
||||
busy={lockBusy}
|
||||
onCancel={() => setLockingVersion(null)}
|
||||
onConfirm={() => void lockAuditSnapshot()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OverviewFact = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
function versionLockLabel(version: CampaignVersionListItem): string {
|
||||
if (isUserLockedVersion(version)) return "User locked";
|
||||
if (isFinalLockedVersion(version)) return "Final";
|
||||
if (canUnlockValidationVersion(version)) return "Validation lock";
|
||||
if (version.locked_at) return "Locked";
|
||||
return "Editable";
|
||||
}
|
||||
|
||||
function ConfigShortcutCard({
|
||||
title,
|
||||
description,
|
||||
facts,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
facts: OverviewFact[];
|
||||
actions: Array<{ to: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<section className="overview-config-card">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<dl className="overview-config-facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="overview-config-actions">
|
||||
{actions.map((action) => (
|
||||
<Link key={action.to} to={action.to}>
|
||||
<Button>{action.label}</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
function validationLabel(version: CampaignVersionListItem): string {
|
||||
const validation = version.validation_summary ?? {};
|
||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
||||
if (validation.ok === false) return "Needs attention";
|
||||
if (validation.ok === true) return "Invalid for delivery";
|
||||
return "Not validated";
|
||||
}
|
||||
|
||||
function buildLabel(version: CampaignVersionListItem): string {
|
||||
const build = version.build_summary ?? {};
|
||||
return String(build.built_count ?? build.ready_count ?? "Not built");
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||
@@ -246,98 +215,3 @@ function SummaryTile({ label, value }: { label: string; value: string | number }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardAction({ title, description, to, label }: { title: string; description: string; to: string; label: string }) {
|
||||
return (
|
||||
<section className="wizard-action-card">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
<Link to={to}><Button>{label}</Button></Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getOverviewFacts(rawJson: Record<string, unknown>, campaign: { name?: string; external_id?: string; id?: string; status?: string } | null) {
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const recipients = asRecord(rawJson.recipients);
|
||||
const attachments = asRecord(rawJson.attachments);
|
||||
const template = asRecord(rawJson.template);
|
||||
const entries = asRecord(rawJson.entries);
|
||||
const validationPolicy = asRecord(rawJson.validation_policy);
|
||||
const delivery = asRecord(rawJson.delivery);
|
||||
const fields = asArray(rawJson.fields).map(asRecord);
|
||||
const globalValues = asRecord(rawJson.global_values);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const entrySource = asRecord(entries.source);
|
||||
const globalAttachmentRules = asArray(attachments.global).map(asRecord);
|
||||
const individualAttachmentRules = inlineEntries.reduce((count, entry) => count + asArray(entry.attachments).length, 0);
|
||||
const globalRecipients = ["to", "cc", "bcc"].reduce((count, key) => count + addressesFromValue(recipients[key]).length, 0);
|
||||
|
||||
return {
|
||||
campaignSettings: [
|
||||
{ label: "Name", value: getString(campaignSection, "name", campaign?.name || "—") },
|
||||
{ label: "Campaign ID", value: getString(campaignSection, "id", campaign?.external_id || campaign?.id || "—") },
|
||||
{ label: "Sender", value: formatMailbox(recipients.from) }
|
||||
],
|
||||
globalSettings: [
|
||||
{ label: "Mode", value: getString(campaignSection, "mode", campaign?.status || "draft") },
|
||||
{ label: "Attachment policy", value: `${getString(attachments, "missing_behavior", "ask")} / ${getString(attachments, "ambiguous_behavior", "ask")}` },
|
||||
{ label: "Delivery", value: getString(delivery, "mode", getString(validationPolicy, "send_without_attachments", "standard")) }
|
||||
],
|
||||
fields: [
|
||||
{ label: "Fields", value: fields.length },
|
||||
{ label: "Global values", value: Object.keys(globalValues).length },
|
||||
{ label: "Required", value: fields.filter((field) => field.required === true).length }
|
||||
],
|
||||
recipients: [
|
||||
{ label: "Recipients", value: recipientSummary(inlineEntries, entrySource) },
|
||||
{ label: "Global recipients", value: globalRecipients },
|
||||
{ label: "Source", value: sourceSummary(entrySource) }
|
||||
],
|
||||
template: [
|
||||
{ label: "Subject", value: getString(template, "subject", "Not configured") },
|
||||
{ label: "Source", value: templateSourceSummary(template) },
|
||||
{ label: "Placeholders", value: countTemplatePlaceholders(template) }
|
||||
],
|
||||
files: [
|
||||
{ label: "Base path", value: getString(attachments, "base_path", ".") },
|
||||
{ label: "Global files", value: globalAttachmentRules.length },
|
||||
{ label: "Individual rules", value: individualAttachmentRules }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function formatMailbox(value: unknown): string {
|
||||
const [address] = addressesFromValue(value);
|
||||
if (!address) return "Not configured";
|
||||
return address.name ? `${address.name} <${address.email}>` : address.email;
|
||||
}
|
||||
|
||||
function recipientSummary(inlineEntries: Record<string, unknown>[], source: Record<string, unknown>): string {
|
||||
if (inlineEntries.length) return `${inlineEntries.length} inline`;
|
||||
if (Object.keys(source).length) return "External source";
|
||||
return "Not configured";
|
||||
}
|
||||
|
||||
function sourceSummary(source: Record<string, unknown>): string {
|
||||
if (!Object.keys(source).length) return "Inline / manual";
|
||||
return getString(source, "type", getString(source, "path", "External"));
|
||||
}
|
||||
|
||||
function templateSourceSummary(template: Record<string, unknown>): string {
|
||||
const libraryId = getString(template, "library_id", "");
|
||||
const templateId = getString(template, "template_id", "");
|
||||
const source = getString(template, "source", "");
|
||||
if (libraryId) return `Library: ${libraryId}`;
|
||||
if (templateId) return `Library: ${templateId}`;
|
||||
if (source) return source;
|
||||
return "Inline campaign template";
|
||||
}
|
||||
|
||||
function countTemplatePlaceholders(template: Record<string, unknown>): number {
|
||||
const text = `${getString(template, "subject", "")}
|
||||
${getString(template, "text", "")}
|
||||
${getString(template, "html", "")}`;
|
||||
const matches = text.match(/\{\{\s*[\w.-]+\s*\}\}/g) ?? [];
|
||||
return new Set(matches.map((item) => item.replace(/[{}\s]/g, ""))).size;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { formatDateTime } from "./utils/campaignView";
|
||||
@@ -16,7 +17,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Report</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at ?? data.summary?.generated_at)}</p>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "re
|
||||
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
|
||||
import SectionSidebar from "../../layout/SectionSidebar";
|
||||
import CampaignOverviewPage from "./CampaignOverviewPage";
|
||||
import CampaignDataPage from "./CampaignDataPage";
|
||||
import CampaignFieldsPage from "./CampaignFieldsPage";
|
||||
import GlobalSettingsPage from "./GlobalSettingsPage";
|
||||
import RecipientDataPage from "./RecipientDataPage";
|
||||
@@ -22,7 +21,7 @@ import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./con
|
||||
|
||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
overview: "",
|
||||
campaign: "data",
|
||||
campaign: "recipients",
|
||||
"global-settings": "global-settings",
|
||||
fields: "fields",
|
||||
recipients: "recipients",
|
||||
@@ -65,7 +64,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
|
||||
<section className="workspace-content">
|
||||
<Routes>
|
||||
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="data" element={<CampaignDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
||||
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
@@ -99,7 +98,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
const section = segments[2];
|
||||
|
||||
if (!section || section === "wizard" || section === "create") return "overview";
|
||||
if (section === "data" || section === "campaign") return "campaign";
|
||||
if (section === "data" || section === "campaign") return "recipients";
|
||||
if (section === "global-settings" || section === "settings") return "global-settings";
|
||||
if (section === "fields") return "fields";
|
||||
if (section === "recipients") return "recipients";
|
||||
|
||||
@@ -5,10 +5,12 @@ import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
|
||||
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
@@ -57,7 +59,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Global settings</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
@@ -67,7 +69,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
|
||||
{error && <div className="alert danger">{error}</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 && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing global settings." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
@@ -116,7 +118,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
</div>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
@@ -5,11 +5,13 @@ import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { getBool, getNumber, getText } from "./utils/draftEditor";
|
||||
|
||||
const securityOptions = ["plain", "tls", "starttls"];
|
||||
@@ -140,7 +142,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Server settings</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
@@ -150,7 +152,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
|
||||
{error && <div className="alert danger">{error}</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 && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing server settings." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
|
||||
@@ -5,11 +5,13 @@ import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import EmailAddressInput from "../../components/email/EmailAddressInput";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { getBool } from "./utils/draftEditor";
|
||||
import {
|
||||
addressesFromValue,
|
||||
@@ -54,6 +56,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
|
||||
const defaultFrom = addressesFromValue(recipientsSection.from);
|
||||
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
||||
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
||||
to: addressesFromValue(recipientsSection.to),
|
||||
cc: addressesFromValue(recipientsSection.cc),
|
||||
@@ -121,8 +125,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Recipients</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
<PageTitle loading={loading}>Sender & Recipients</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
@@ -132,10 +136,60 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing sender or recipient profiles." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
<Card title="Campaign sender">
|
||||
<div className="campaign-header-stack">
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Default From address">
|
||||
<EmailAddressInput
|
||||
value={defaultFrom}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
showAddButton={false}
|
||||
disabled={locked}
|
||||
addLabel={defaultFrom.length ? "Replace" : "Add sender"}
|
||||
emptyText="No default sender configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label="Allow individual senders"
|
||||
checked={getBool(recipientsSection, "allow_individual_from")}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid">
|
||||
<FormField label="Global Reply-To address">
|
||||
<EmailAddressInput
|
||||
value={globalReplyTo.slice(0, 1)}
|
||||
suggestions={addressSuggestions}
|
||||
allowMultiple={false}
|
||||
showAddButton={false}
|
||||
disabled={locked}
|
||||
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
|
||||
emptyText="No Reply-To address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-header-toggle">
|
||||
<ToggleSwitch
|
||||
label="Allow individual Reply-To"
|
||||
checked={getBool(recipientsSection, "allow_individual_reply_to")}
|
||||
disabled={locked}
|
||||
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Global recipient headers">
|
||||
<div className="campaign-header-stack">
|
||||
{recipientHeaderRows.map((row) => (
|
||||
|
||||
@@ -5,9 +5,11 @@ import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
@@ -69,7 +71,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Recipient data</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
@@ -79,7 +81,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing recipient data." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
|
||||
@@ -4,19 +4,24 @@ import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import Card from "../../components/Card";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { buildVersion, validateVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
|
||||
|
||||
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const auditSafe = isUserLockedVersion(version) || isFinalLockedVersion(version);
|
||||
const issues = collectIssues(data.summary?.issues);
|
||||
const validationSummary = asRecord(version?.validation_summary);
|
||||
const buildSummary = asRecord(version?.build_summary);
|
||||
const validationOk = validationSummary.ok === true;
|
||||
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
|
||||
@@ -27,7 +32,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
setError("");
|
||||
try {
|
||||
const result = await validateVersion(settings, version.id, false);
|
||||
setActionMessage(result.ok ? "Validation passed. This version is now locked." : "Validation finished with issues. Fix the campaign and validate again.");
|
||||
setActionMessage(result.ok ? "Validation passed. This version is now locked but can still be unlocked before sending." : "Validation finished with issues. Fix the campaign and validate again.");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setActionMessage("");
|
||||
@@ -40,7 +45,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
async function runBuild() {
|
||||
if (!version || actionBusy) return;
|
||||
setActionBusy("build");
|
||||
setActionMessage("Building messages for the locked version…");
|
||||
setActionMessage("Building the queue for the locked, validated version…");
|
||||
setError("");
|
||||
try {
|
||||
const result = await buildVersion(settings, version.id, true);
|
||||
@@ -59,7 +64,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
|
||||
@@ -69,15 +74,16 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{actionMessage && <div className="alert info">{actionMessage}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message={auditSafe ? "Audit-safe snapshot. Create a copy to continue." : "Validated snapshot. Unlock before sending, or copy to edit."} />}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading review data…">
|
||||
<Card title="Review actions" actions={<span className="muted small-note">Validation locks the exact version that was checked.</span>}>
|
||||
<Card title="Review actions" actions={<span className="muted small-note">Validation locks this version; unlocking invalidates validation before sending.</span>}>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy)}>
|
||||
{actionBusy === "validate" ? "Validating…" : validationOk ? "Validate again" : "Validate and lock"}
|
||||
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy) || readyForDelivery || auditSafe}>
|
||||
{actionBusy === "validate" ? "Validating…" : readyForDelivery ? "Validated and locked" : validationOk ? "Validate again" : "Validate and lock"}
|
||||
</Button>
|
||||
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !validationOk}>
|
||||
{actionBusy === "build" ? "Building…" : "Build messages"}
|
||||
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !readyForDelivery}>
|
||||
{actionBusy === "build" ? "Building…" : "Build queue"}
|
||||
</Button>
|
||||
</div>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
|
||||
@@ -2,18 +2,22 @@ import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { sendCampaignNow } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, getDeliverySection, getNestedString, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVersion, isVersionReadyForDelivery, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
|
||||
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = getDeliverySection(version);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
@@ -22,12 +26,14 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
const [sendBusy, setSendBusy] = useState(false);
|
||||
const [sendMessage, setSendMessage] = useState("");
|
||||
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
||||
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||
const hasBuild = Boolean(version?.build_summary);
|
||||
|
||||
async function runSendNow(dryRun = false) {
|
||||
if (!version || sendBusy) return;
|
||||
if (!dryRun && !window.confirm("Send this campaign version now? The validated version will remain locked as the sent audit snapshot.")) return;
|
||||
if (!version || sendBusy || !readyForDelivery || !hasBuild) return;
|
||||
setSendBusy(true);
|
||||
setSendMessage(dryRun ? "Checking what would be sent…" : "Validating, building and sending campaign…");
|
||||
setSendMessage(dryRun ? "Checking the built queue without sending…" : "Sending the locked campaign version…");
|
||||
setSendResult(null);
|
||||
setError("");
|
||||
try {
|
||||
@@ -35,8 +41,8 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
version_id: version.id,
|
||||
include_warnings: true,
|
||||
check_files: false,
|
||||
validate_before_send: true,
|
||||
build_before_send: true,
|
||||
validate_before_send: false,
|
||||
build_before_send: false,
|
||||
dry_run: dryRun,
|
||||
use_rate_limit: true,
|
||||
enqueue_imap_task: false
|
||||
@@ -70,7 +76,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
|
||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
|
||||
@@ -80,6 +86,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{sendMessage && <div className="alert info">{sendMessage}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
|
||||
|
||||
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
|
||||
<div className="metric-grid">
|
||||
@@ -89,10 +96,12 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
</div>
|
||||
|
||||
<Card title="Send campaign" actions={<span className="muted small-note">Small-campaign synchronous send; larger campaigns can use queue workers later.</span>}>
|
||||
<Card title="Send campaign" actions={<span className="muted small-note">Requires a validated, locked and built version. Sending makes it final.</span>}>
|
||||
{!readyForDelivery && <div className="alert warning compact-alert">Validate and lock this version in Review before dry-run or sending.</div>}
|
||||
{readyForDelivery && !hasBuild && <div className="alert warning compact-alert">Build the queue in Review before dry-run or sending.</div>}
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy}>Dry run</Button>
|
||||
<Button variant="primary" onClick={() => void runSendNow(false)} disabled={!version || loading || sendBusy}>
|
||||
<Button onClick={() => void runSendNow(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>Dry run</Button>
|
||||
<Button variant="primary" onClick={() => setSendConfirmOpen(true)} disabled={!version || loading || sendBusy || !readyForDelivery || !hasBuild}>
|
||||
{sendBusy ? "Sending…" : "Send now"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -100,7 +109,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
<div><dt>Campaign status</dt><dd><StatusBadge status={data.campaign?.status ?? "unknown"} /></dd></div>
|
||||
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
|
||||
<div><dt>Version lock</dt><dd>{versionLockReason(version)}</dd></div>
|
||||
<div><dt>Validation/build</dt><dd>{version?.validation_summary ? "Validation available" : "Will validate before send"} · {version?.build_summary ? "Build available" : "Will build before send"}</dd></div>
|
||||
<div><dt>Validation/build</dt><dd>{readyForDelivery ? "Validated and locked" : "Not ready"} · {hasBuild ? "Build available" : "Not built"}</dd></div>
|
||||
</dl>
|
||||
{sendResult && (
|
||||
<div className="send-result-panel">
|
||||
@@ -156,6 +165,19 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
</p>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
<ConfirmDialog
|
||||
open={sendConfirmOpen}
|
||||
title="Send this version now?"
|
||||
message="This sends the built queue and keeps this version as the final audit snapshot. Further changes require a new editable copy."
|
||||
confirmLabel="Send now"
|
||||
tone="danger"
|
||||
busy={sendBusy}
|
||||
onCancel={() => setSendConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setSendConfirmOpen(false);
|
||||
void runSendNow(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||
@@ -127,7 +129,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Template</PageTitle>
|
||||
<p className="mono-small">{saveState}</p>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Manage templates</Button>
|
||||
@@ -138,7 +140,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
|
||||
101
src/features/campaigns/components/LockedVersionNotice.tsx
Normal file
101
src/features/campaigns/components/LockedVersionNotice.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import Button from "../../../components/Button";
|
||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
||||
import { forkCampaignVersion, unlockCampaignVersionValidation, type CampaignVersionDetail, type CampaignVersionListItem } from "../../../api/campaigns";
|
||||
import { canUnlockValidationVersion, formatDateTime } from "../utils/campaignView";
|
||||
|
||||
type LockedVersionNoticeProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null;
|
||||
reload: () => Promise<void>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export default function LockedVersionNotice({ settings, campaignId, version, reload, message }: LockedVersionNoticeProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [localMessage, setLocalMessage] = useState("");
|
||||
const [unlockConfirmOpen, setUnlockConfirmOpen] = useState(false);
|
||||
|
||||
const canUnlock = canUnlockValidationVersion(version);
|
||||
const noticeText = message ?? (canUnlock ? "Validated version. Unlock to edit, or create a copy." : "Final snapshot. Create a copy to edit.");
|
||||
const lockInfo = getCompactLockInfo(version, canUnlock);
|
||||
|
||||
async function unlockValidation() {
|
||||
if (!version || busy) return;
|
||||
setBusy(true);
|
||||
setLocalError("");
|
||||
setLocalMessage("");
|
||||
try {
|
||||
await unlockCampaignVersionValidation(settings, campaignId, version.id);
|
||||
setLocalMessage("Validation invalidated; version is editable again.");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createEditableCopy() {
|
||||
if (!version || busy) return;
|
||||
setBusy(true);
|
||||
setLocalError("");
|
||||
setLocalMessage("");
|
||||
try {
|
||||
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
||||
current_flow: "manual",
|
||||
current_step: version.current_step ?? null
|
||||
});
|
||||
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert info locked-version-notice">
|
||||
<div className="locked-version-copy">
|
||||
<strong>Locked version.</strong>{" "}
|
||||
<span>{noticeText}</span>
|
||||
{lockInfo && <span className="locked-version-reason"> {lockInfo}</span>}
|
||||
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
|
||||
{localError && <span className="locked-version-error"> {localError}</span>}
|
||||
</div>
|
||||
<div className="button-row compact-actions locked-version-actions">
|
||||
{canUnlock && (
|
||||
<Button onClick={() => setUnlockConfirmOpen(true)} disabled={!version || busy}>
|
||||
{busy ? "Working…" : "Unlock validation"}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
|
||||
{busy ? "Creating copy…" : "Create editable copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={unlockConfirmOpen}
|
||||
title="Unlock validation?"
|
||||
message="This unlocks the version for editing and clears validation/build results and generated jobs for this version."
|
||||
confirmLabel="Unlock validation"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setUnlockConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setUnlockConfirmOpen(false);
|
||||
void unlockValidation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCompactLockInfo(version: CampaignVersionDetail | CampaignVersionListItem | null, canUnlock: boolean): string {
|
||||
if (!version?.locked_at) return "";
|
||||
const label = canUnlock ? "Validated" : "Locked";
|
||||
return `${label} ${formatDateTime(version.locked_at)}.`;
|
||||
}
|
||||
127
src/features/campaigns/components/VersionLine.tsx
Normal file
127
src/features/campaigns/components/VersionLine.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
|
||||
import { formatDateTime } from "../utils/campaignView";
|
||||
|
||||
type VersionLineProps = {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null;
|
||||
versions?: CampaignVersionListItem[];
|
||||
status?: string;
|
||||
loadedAt?: string | null;
|
||||
};
|
||||
|
||||
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
||||
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
|
||||
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
|
||||
const first = currentIndex > 0 ? sorted[0] : null;
|
||||
const previous = currentIndex > 0 ? sorted[currentIndex - 1] : null;
|
||||
const next = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[currentIndex + 1] : null;
|
||||
const latest = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[sorted.length - 1] : null;
|
||||
const suffix = status ?? `Loaded ${formatDateTime(loadedAt ?? version?.updated_at)}`;
|
||||
|
||||
function openVersion(target: CampaignVersionListItem) {
|
||||
const targetUrl = versionTarget(location.pathname, location.search, target.id);
|
||||
requestNavigation(() => navigate(targetUrl));
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="mono-small version-line">
|
||||
<VersionArrow
|
||||
icon="first"
|
||||
target={first}
|
||||
fallbackLabel="Already at first version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
<VersionArrow
|
||||
icon="previous"
|
||||
target={previous}
|
||||
fallbackLabel="No older version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
<span>Version {version ? `#${version.version_number}` : "—"}</span>
|
||||
<VersionArrow
|
||||
icon="next"
|
||||
target={next}
|
||||
fallbackLabel="No newer version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
<VersionArrow
|
||||
icon="latest"
|
||||
target={latest}
|
||||
fallbackLabel="Already at latest version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
<span className="version-line-separator">·</span>
|
||||
<span>{suffix}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type VersionArrowProps = {
|
||||
icon: "first" | "previous" | "next" | "latest";
|
||||
target: CampaignVersionListItem | null;
|
||||
fallbackLabel: string;
|
||||
onOpen: (target: CampaignVersionListItem) => void;
|
||||
};
|
||||
|
||||
function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps) {
|
||||
const Icon = getVersionIcon(icon);
|
||||
const actionLabel = getVersionActionLabel(icon, target);
|
||||
|
||||
if (!target) {
|
||||
return (
|
||||
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="version-arrow"
|
||||
onClick={() => onOpen(target)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getVersionIcon(icon: VersionArrowProps["icon"]) {
|
||||
switch (icon) {
|
||||
case "first":
|
||||
return ArrowBigLeftDash;
|
||||
case "previous":
|
||||
return ArrowBigLeft;
|
||||
case "next":
|
||||
return ArrowBigRight;
|
||||
case "latest":
|
||||
return ArrowBigRightDash;
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionActionLabel(icon: VersionArrowProps["icon"], target: CampaignVersionListItem | null): string {
|
||||
const versionLabel = target ? `version #${target.version_number}` : "version";
|
||||
switch (icon) {
|
||||
case "first":
|
||||
return `Open first ${versionLabel}`;
|
||||
case "previous":
|
||||
return `Open previous ${versionLabel}`;
|
||||
case "next":
|
||||
return `Open next ${versionLabel}`;
|
||||
case "latest":
|
||||
return `Open latest ${versionLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
function versionTarget(pathname: string, search: string, versionId: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set("version", versionId);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
getCampaign,
|
||||
@@ -6,8 +7,7 @@ import {
|
||||
getCampaignVersion,
|
||||
listCampaignVersions,
|
||||
type CampaignSummary,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem
|
||||
type CampaignVersionDetail
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
||||
|
||||
@@ -34,6 +34,8 @@ export function useCampaignWorkspaceData(
|
||||
includeSummary = false,
|
||||
includeVersions = false
|
||||
} = options;
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedVersionId = searchParams.get("version");
|
||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -44,24 +46,20 @@ export function useCampaignWorkspaceData(
|
||||
setError("");
|
||||
try {
|
||||
const needsCampaign = includeCurrentVersion || includeVersions;
|
||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||
const summaryPromise = includeSummary ? getCampaignSummary(settings, campaignId) : Promise.resolve(null);
|
||||
const campaign = needsCampaign ? await getCampaign(settings, campaignId) : null;
|
||||
let versions: CampaignVersionListItem[] = [];
|
||||
let selectedVersionId = campaign?.current_version_id;
|
||||
|
||||
if (includeVersions || (includeCurrentVersion && !selectedVersionId)) {
|
||||
versions = await listCampaignVersions(settings, campaignId);
|
||||
selectedVersionId = selectedVersionId ?? versions[0]?.id;
|
||||
}
|
||||
const versions = shouldLoadVersions ? await listCampaignVersions(settings, campaignId) : [];
|
||||
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
|
||||
|
||||
const [versionResult, summaryResult] = await Promise.allSettled([
|
||||
includeCurrentVersion && selectedVersionId ? getCampaignVersion(settings, campaignId, selectedVersionId) : Promise.resolve(null),
|
||||
includeCurrentVersion && wantedVersionId ? getCampaignVersion(settings, campaignId, wantedVersionId) : Promise.resolve(null),
|
||||
summaryPromise
|
||||
]);
|
||||
|
||||
setData({
|
||||
campaign,
|
||||
versions: includeVersions ? versions : [],
|
||||
versions,
|
||||
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
|
||||
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
|
||||
});
|
||||
@@ -71,7 +69,7 @@ export function useCampaignWorkspaceData(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions]);
|
||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
|
||||
@@ -52,19 +52,41 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
return asArray(getCampaignJson(version).fields);
|
||||
}
|
||||
|
||||
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
return Boolean(version?.published_at);
|
||||
}
|
||||
|
||||
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
|
||||
}
|
||||
|
||||
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version?.locked_at || isUserLockedVersion(version)) return false;
|
||||
const validation = asRecord(version.validation_summary);
|
||||
return validation.ok === true;
|
||||
}
|
||||
|
||||
export function canUnlockValidationVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
if (isUserLockedVersion(version)) return false;
|
||||
if (!isVersionReadyForDelivery(version)) return false;
|
||||
if (isFinalLockedVersion(version)) return false;
|
||||
return ["approved", "built"].includes(version.workflow_state ?? "");
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
if (version.locked_at || version.published_at) return true;
|
||||
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
|
||||
return isFinalLockedVersion(version);
|
||||
}
|
||||
|
||||
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
if (canUnlockValidationVersion(version)) return `Validation locked at ${formatDateTime(version.locked_at)}. Unlocking will invalidate validation and build state.`;
|
||||
if (isUserLockedVersion(version)) return `User locked at ${formatDateTime(version.published_at)}. Audit-safe; copy only.`;
|
||||
if (isFinalLockedVersion(version)) return `Final state: ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`;
|
||||
if (version.published_at) return `Published at ${formatDateTime(version.published_at)}.`;
|
||||
if (["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "")) {
|
||||
return `Workflow state is ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
}
|
||||
return "Editable working version.";
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import Stepper from "../../../components/Stepper";
|
||||
import Card from "../../../components/Card";
|
||||
import Button from "../../../components/Button";
|
||||
import PageTitle from "../../../components/PageTitle";
|
||||
import LockedVersionNotice from "../components/LockedVersionNotice";
|
||||
import { validatePartial } from "../../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
||||
import { isAuditLockedVersion, versionLockReason } from "../utils/campaignView";
|
||||
import { isAuditLockedVersion } from "../utils/campaignView";
|
||||
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
||||
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
|
||||
|
||||
@@ -87,11 +88,15 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
<div className="save-state">Locked</div>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="alert info">
|
||||
{versionLockReason(data.currentVersion)} Create or copy a working version before editing campaign data, recipients, template or attachment rules.
|
||||
</div>
|
||||
<LockedVersionNotice
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
version={data.currentVersion}
|
||||
reload={reload}
|
||||
message="Create an editable copy before continuing the creation wizard."
|
||||
/>
|
||||
<div className="button-row">
|
||||
<Link to="../.."><Button variant="primary">Back to overview</Button></Link>
|
||||
<Link to="../.."><Button>Back to overview</Button></Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function BreadcrumbBar({ pathname }: { pathname: string }) {
|
||||
}
|
||||
|
||||
const campaignRouteLabels: Record<string, string> = {
|
||||
data: "General",
|
||||
campaign: "General",
|
||||
data: "Sender & Recipients",
|
||||
campaign: "Sender & Recipients",
|
||||
settings: "Global settings",
|
||||
"global-settings": "Global settings",
|
||||
fields: "Fields",
|
||||
recipients: "Recipients",
|
||||
recipients: "Sender & Recipients",
|
||||
"recipient-data": "Recipient data",
|
||||
template: "Template",
|
||||
files: "Attachments",
|
||||
|
||||
@@ -8,10 +8,9 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
{
|
||||
title: "CAMPAIGN",
|
||||
items: [
|
||||
{ id: "campaign", label: "General" },
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "files", label: "Attachments" },
|
||||
{ id: "recipients", label: "Recipients" },
|
||||
{ id: "recipients", label: "Sender & Recipients" },
|
||||
{ id: "recipient-data", label: "Recipient data" },
|
||||
{ id: "template", label: "Template" }
|
||||
]
|
||||
|
||||
@@ -1031,3 +1031,96 @@
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.locked-version-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.locked-version-copy {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.locked-version-reason {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.locked-version-feedback {
|
||||
color: var(--success, #157347);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.locked-version-error {
|
||||
color: var(--danger, #b42318);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.locked-version-notice {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Version navigation ---------------------------------------------------- */
|
||||
.version-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.version-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
color: var(--text, #243247);
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.version-arrow svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.version-arrow:hover {
|
||||
background: var(--surface-subtle, rgba(15, 23, 42, 0.08));
|
||||
color: var(--text, #111827);
|
||||
}
|
||||
|
||||
.version-arrow.disabled {
|
||||
opacity: 0.24;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.version-line-separator {
|
||||
opacity: 0.55;
|
||||
margin-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.locked-version-actions {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.version-line button.version-arrow {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Overview version history refinements. */
|
||||
.version-history-table .current-version-row td {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -511,3 +511,42 @@
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Reusable confirm dialog ------------------------------------------------ */
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 12000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: min(460px, 100%);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--line, rgba(15, 23, 42, 0.14));
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text, #172033);
|
||||
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
|
||||
}
|
||||
|
||||
.confirm-dialog h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
color: var(--text-strong, #111827);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
margin: 0;
|
||||
color: var(--muted, #5f6b7a);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-dialog-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
.campaign-table th:nth-child(4),
|
||||
.campaign-table td:nth-child(4) { width: 230px; }
|
||||
.campaign-table th:nth-child(5),
|
||||
.campaign-table td:nth-child(5) { width: 86px; text-align: right; }
|
||||
.campaign-table td:nth-child(5) { width: 105px; text-align: right; }
|
||||
.campaign-table td.updated-cell {
|
||||
color: #696660;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,7 +11,7 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
|
||||
template: { id: "campaign.template", title: "Template" },
|
||||
files: { id: "campaign.attachments", title: "Attachments" },
|
||||
attachments: { id: "campaign.attachments", title: "Attachments" },
|
||||
recipients: { id: "campaign.recipients", title: "Recipients" },
|
||||
recipients: { id: "campaign.recipients", title: "Sender & Recipients" },
|
||||
"recipient-data": { id: "campaign.recipient-data", title: "Recipient data" },
|
||||
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
|
||||
|
||||
Reference in New Issue
Block a user