first wokring prototype
This commit is contained in:
340
src/features/campaigns/CampaignOverviewPage.tsx
Normal file
340
src/features/campaigns/CampaignOverviewPage.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } 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 { createNewCampaign, publishCampaignVersion, updateCampaignVersion } 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";
|
||||
|
||||
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [locking, setLocking] = 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);
|
||||
|
||||
async function copyCampaign() {
|
||||
if (!currentVersion) return;
|
||||
setCopying(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("");
|
||||
setError("");
|
||||
try {
|
||||
await publishCampaignVersion(settings, campaignId, currentVersion.id);
|
||||
setMessage("Campaign version locked as the current audit-safe version.");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLocking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
||||
<p className="mono-small">{campaign?.external_id || campaignId}</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{message && <div className="alert success">{message}</div>}
|
||||
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OverviewFact = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
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 SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="summary-tile">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</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;
|
||||
}
|
||||
Reference in New Issue
Block a user