first version able to send
This commit is contained in:
@@ -112,6 +112,18 @@ export type CampaignQueuePayload = {
|
|||||||
dry_run?: boolean;
|
dry_run?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignSendNowPayload = {
|
||||||
|
version_id?: string | null;
|
||||||
|
include_warnings?: boolean;
|
||||||
|
check_files?: boolean;
|
||||||
|
validate_before_send?: boolean;
|
||||||
|
build_before_send?: boolean;
|
||||||
|
dry_run?: boolean;
|
||||||
|
use_rate_limit?: boolean;
|
||||||
|
enqueue_imap_task?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
||||||
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
||||||
|
|
||||||
@@ -265,6 +277,17 @@ export async function queueCampaign(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendCampaignNow(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
payload: CampaignSendNowPayload = {}
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
|
export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" });
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Button from "../../components/Button";
|
|||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
||||||
|
import AdminPlaceholderTable, { type AdminPlaceholderTableConfig } from "./components/AdminPlaceholderTable";
|
||||||
|
|
||||||
type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit";
|
type AdminSection = "overview" | "system" | "tenants" | "users" | "groups" | "roles" | "api-keys" | "mail-servers" | "settings" | "audit";
|
||||||
|
|
||||||
@@ -32,6 +33,45 @@ const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const adminTableConfigs = {
|
||||||
|
tenants: {
|
||||||
|
title: "Tenants",
|
||||||
|
columns: ["Tenant", "Slug", "Users", "Storage", "Status"],
|
||||||
|
rows: ["Default|default|1|Local dev|Active"],
|
||||||
|
action: "Create tenant",
|
||||||
|
note: "Tenant administration is system-wide. Backend list, create and suspension endpoints can be wired here later."
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
title: "Users",
|
||||||
|
columns: ["User", "Groups", "Roles", "Status", "Last activity"],
|
||||||
|
rows: ["admin@example.local|Default administrators|Owner|Active|Development seed"],
|
||||||
|
action: "Invite user",
|
||||||
|
note: "Tenant users should support invitations, activation state, group membership and role assignments."
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
title: "Groups",
|
||||||
|
columns: ["Group", "Members", "Campaign access", "Default role", "Status"],
|
||||||
|
rows: ["Default administrators|1|All campaigns|Owner|Seed data"],
|
||||||
|
action: "Create group",
|
||||||
|
note: "Groups should later become the main unit for campaign ownership, sharing and storage spaces."
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
title: "Roles",
|
||||||
|
columns: ["Role", "Permissions", "Scope", "Assignable", "Status"],
|
||||||
|
rows: ["Owner|All current permissions|Tenant|No|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Yes|Planned"],
|
||||||
|
action: "Create role",
|
||||||
|
note: "Role definitions are mocked until permission discovery and assignment endpoints are available."
|
||||||
|
},
|
||||||
|
mailServers: {
|
||||||
|
title: "Mail servers",
|
||||||
|
columns: ["Profile", "SMTP", "IMAP", "Default", "Status"],
|
||||||
|
rows: ["Campaign-local settings|Configured per campaign|Optional per campaign|No|Current behavior", "Tenant default SMTP|Planned|Planned|Planned|Mock"],
|
||||||
|
action: "Add mail server",
|
||||||
|
note: "Tenant mail server profiles can later prefill campaign Server settings while campaigns remain self-contained."
|
||||||
|
}
|
||||||
|
} satisfies Record<"tenants" | "users" | "groups" | "roles" | "mailServers", AdminPlaceholderTableConfig>;
|
||||||
|
|
||||||
const sectionTitles: Record<AdminSection, { title: string; description: string }> = {
|
const sectionTitles: Record<AdminSection, { title: string; description: string }> = {
|
||||||
overview: { title: "Overview", description: "Administrative entry point for system-wide and tenant-scoped management." },
|
overview: { title: "Overview", description: "Administrative entry point for system-wide and tenant-scoped management." },
|
||||||
system: { title: "System", description: "Instance-wide health, workers, storage and diagnostics." },
|
system: { title: "System", description: "Instance-wide health, workers, storage and diagnostics." },
|
||||||
@@ -144,19 +184,19 @@ function System() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Tenants() {
|
function Tenants() {
|
||||||
return <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Storage", "Status"]} rows={["Default|default|1|Local dev|Active"]} action="Create tenant" note="Tenant administration is system-wide. Backend list, create and suspension endpoints can be wired here later." />;
|
return <AdminPlaceholderTable {...adminTableConfigs.tenants} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Users() {
|
function Users() {
|
||||||
return <PlaceholderAdminTable title="Users" columns={["User", "Groups", "Roles", "Status", "Last activity"]} rows={["admin@example.local|Default administrators|Owner|Active|Development seed"]} action="Invite user" note="Tenant users should support invitations, activation state, group membership and role assignments." />;
|
return <AdminPlaceholderTable {...adminTableConfigs.users} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Groups() {
|
function Groups() {
|
||||||
return <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Default role", "Status"]} rows={["Default administrators|1|All campaigns|Owner|Seed data"]} action="Create group" note="Groups should later become the main unit for campaign ownership, sharing and storage spaces." />;
|
return <AdminPlaceholderTable {...adminTableConfigs.groups} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Roles() {
|
function Roles() {
|
||||||
return <PlaceholderAdminTable title="Roles" columns={["Role", "Permissions", "Scope", "Assignable", "Status"]} rows={["Owner|All current permissions|Tenant|No|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Yes|Planned"]} action="Create role" note="Role definitions are mocked until permission discovery and assignment endpoints are available." />;
|
return <AdminPlaceholderTable {...adminTableConfigs.roles} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApiKeys() {
|
function ApiKeys() {
|
||||||
@@ -174,7 +214,7 @@ function ApiKeys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MailServers() {
|
function MailServers() {
|
||||||
return <PlaceholderAdminTable title="Mail servers" columns={["Profile", "SMTP", "IMAP", "Default", "Status"]} rows={["Campaign-local settings|Configured per campaign|Optional per campaign|No|Current behavior", "Tenant default SMTP|Planned|Planned|Planned|Mock"]} action="Add mail server" note="Tenant mail server profiles can later prefill campaign Server settings while campaigns remain self-contained." />;
|
return <AdminPlaceholderTable {...adminTableConfigs.mailServers} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TenantSettings() {
|
function TenantSettings() {
|
||||||
@@ -208,20 +248,3 @@ function Audit() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlaceholderAdminTable({ title, columns, rows, action, note }: { title: string; columns: string[]; rows: string[]; action: string; note?: string }) {
|
|
||||||
return (
|
|
||||||
<Card title={title} actions={<Button disabled>{action}</Button>}>
|
|
||||||
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
|
|
||||||
{note && <p className="muted">{note}</p>}
|
|
||||||
<div className="app-table-wrap compact-table-wrap">
|
|
||||||
<table className="app-table module-table">
|
|
||||||
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => <tr key={row}>{row.split("|").map((cell, index) => <td key={`${row}-${index}`}>{cell}</td>)}</tr>)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
27
src/features/admin/components/AdminPlaceholderTable.tsx
Normal file
27
src/features/admin/components/AdminPlaceholderTable.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Button from "../../../components/Button";
|
||||||
|
import Card from "../../../components/Card";
|
||||||
|
|
||||||
|
export type AdminPlaceholderTableConfig = {
|
||||||
|
title: string;
|
||||||
|
columns: string[];
|
||||||
|
rows: string[];
|
||||||
|
action: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminPlaceholderTable({ title, columns, rows, action, note }: AdminPlaceholderTableConfig) {
|
||||||
|
return (
|
||||||
|
<Card title={title} actions={<Button disabled>{action}</Button>}>
|
||||||
|
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
|
||||||
|
{note && <p className="muted">{note}</p>}
|
||||||
|
<div className="app-table-wrap compact-table-wrap">
|
||||||
|
<table className="app-table module-table">
|
||||||
|
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => <tr key={row}>{row.split("|").map((cell, index) => <td key={`${row}-${index}`}>{cell}</td>)}</tr>)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import LoadingFrame from "../../components/LoadingFrame";
|
|||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
|
||||||
|
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||||
|
|
||||||
type PathChooserState = { index: number };
|
type PathChooserState = { index: number };
|
||||||
|
|
||||||
@@ -31,18 +32,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
|
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
|
||||||
});
|
});
|
||||||
const attachments = asRecord(displayDraft.attachments);
|
const attachments = asRecord(displayDraft.attachments);
|
||||||
const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]);
|
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const entries = asRecord(displayDraft.entries);
|
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
|
||||||
const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) })));
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")];
|
const normalized = ensureAttachmentBasePaths(paths);
|
||||||
setDraft((current) => {
|
setDraft((current) => {
|
||||||
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
|
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
|
||||||
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
|
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
|
||||||
@@ -55,7 +54,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addBasePath() {
|
function addBasePath() {
|
||||||
patchBasePaths([...basePaths, createBasePath("New attachment source", ".")]);
|
patchBasePaths([...basePaths, createAttachmentBasePath("New attachment source", ".")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBasePath(index: number) {
|
function removeBasePath(index: number) {
|
||||||
@@ -64,18 +63,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
|
|
||||||
function addGlobalAttachmentRule() {
|
function addGlobalAttachmentRule() {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const firstBasePath = basePaths[0]?.path ?? "";
|
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "")]);
|
||||||
patch(["attachments", "global"], [
|
|
||||||
...globalRules,
|
|
||||||
{
|
|
||||||
id: `attachment-${Date.now()}`,
|
|
||||||
label: "",
|
|
||||||
base_dir: firstBasePath,
|
|
||||||
file_filter: "",
|
|
||||||
required: true,
|
|
||||||
include_subdirs: false
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +153,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
||||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
||||||
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
|
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
|
||||||
<div><dt>Upload support</dt><dd>Planned</dd></div>
|
<div><dt>Upload support</dt><dd>Planned</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<p className="muted small-note">The current storage browser is a mock. The next backend-alignment step can connect these base paths and file rules to campaign, group and tenant storage.</p>
|
<p className="muted small-note">The current storage browser is a mock. The next backend-alignment step can connect these base paths and file rules to campaign, group and tenant storage.</p>
|
||||||
@@ -191,14 +179,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
|
function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
|
||||||
const paths: Array<Partial<AttachmentBasePath> & { label: string }> = [
|
|
||||||
{ label: "Campaign attachments", path: "attachments" },
|
|
||||||
{ label: "Campaign root", path: "." },
|
|
||||||
{ label: "Shared group files", path: "group/shared" },
|
|
||||||
{ label: "Tenant templates", path: "tenant/templates" },
|
|
||||||
{ label: "Personal upload area", path: "user/uploads" }
|
|
||||||
];
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
|
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
|
||||||
<div className="modal-panel attachment-rules-modal">
|
<div className="modal-panel attachment-rules-modal">
|
||||||
@@ -209,7 +189,7 @@ function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; on
|
|||||||
<div className="modal-body attachment-rules-body">
|
<div className="modal-body attachment-rules-body">
|
||||||
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.</p>
|
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.</p>
|
||||||
<div className="placeholder-stack">
|
<div className="placeholder-stack">
|
||||||
{paths.map((path) => (
|
{mockAttachmentPathOptions.map((path) => (
|
||||||
<Button key={path.path} onClick={() => onSelect(path)}>
|
<Button key={path.path} onClick={() => onSelect(path)}>
|
||||||
{path.label}: <code>{path.path}</code>
|
{path.label}: <code>{path.path}</code>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,47 +204,3 @@ function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; on
|
|||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBasePath(name: string, path: string): AttachmentBasePath {
|
|
||||||
return {
|
|
||||||
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
allow_individual: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
|
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
|
||||||
return value.filter(isRecord).map((basePath, index) => ({
|
|
||||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
|
||||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
|
||||||
source: getText(basePath, "source"),
|
|
||||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
|
||||||
allow_individual: getBool(basePath, "allow_individual")
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{
|
|
||||||
id: "base-path-campaign",
|
|
||||||
name: "Campaign files",
|
|
||||||
path: getText(attachments, "base_path", "."),
|
|
||||||
allow_individual: getBool(attachments, "allow_individual")
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter(isRecord).map((rule) => ({
|
|
||||||
id: getText(rule, "id", `global-${Math.random().toString(36).slice(2)}`),
|
|
||||||
type: getText(rule, "type"),
|
|
||||||
label: getText(rule, "label"),
|
|
||||||
base_dir: getText(rule, "base_dir", ""),
|
|
||||||
file_filter: getText(rule, "file_filter"),
|
|
||||||
include_subdirs: getBool(rule, "include_subdirs"),
|
|
||||||
required: getBool(rule, "required", true),
|
|
||||||
missing_behavior: getText(rule, "missing_behavior", "ask"),
|
|
||||||
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
|
|
||||||
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,16 +10,7 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|||||||
import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
|
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||||
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
|
|
||||||
|
|
||||||
type FieldDefinition = {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
required: boolean;
|
|
||||||
can_override: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
@@ -55,7 +46,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchFields(nextFields: FieldDefinition[]) {
|
function patchFields(nextFields: CampaignFieldDefinition[]) {
|
||||||
patchDraft(["fields"], nextFields.map((field) => ({
|
patchDraft(["fields"], nextFields.map((field) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: field.type || "string",
|
type: field.type || "string",
|
||||||
@@ -70,7 +61,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function setField(index: number, patchValue: Partial<FieldDefinition>) {
|
function setField(index: number, patchValue: Partial<CampaignFieldDefinition>) {
|
||||||
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
|
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
|
||||||
patchFields(nextFields);
|
patchFields(nextFields);
|
||||||
}
|
}
|
||||||
@@ -191,7 +182,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
<td><input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} /></td>
|
<td><input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} /></td>
|
||||||
<td><input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} /></td>
|
<td><input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} /></td>
|
||||||
<td>
|
<td>
|
||||||
<select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: event.target.value })}>
|
<select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>
|
||||||
{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -215,12 +206,12 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFields(value: unknown): FieldDefinition[] {
|
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter(isRecord).map((field) => ({
|
return value.filter(isRecord).map((field) => ({
|
||||||
name: getText(field, "name"),
|
name: getText(field, "name"),
|
||||||
label: getText(field, "label"),
|
label: getText(field, "label"),
|
||||||
type: fieldTypeOptions.includes(getText(field, "type")) ? getText(field, "type") : "string",
|
type: normalizeFieldType(getText(field, "type")),
|
||||||
required: getBool(field, "required"),
|
required: getBool(field, "required"),
|
||||||
can_override: getBool(field, "can_override", true)
|
can_override: getBool(field, "can_override", true)
|
||||||
}));
|
}));
|
||||||
@@ -238,7 +229,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
|
|||||||
return updateNested(draft, ["fields"], fields);
|
return updateNested(draft, ["fields"], fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeFieldNameProblem(fields: FieldDefinition[]): string {
|
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||||
const names = fields.map((field) => field.name.trim());
|
const names = fields.map((field) => field.name.trim());
|
||||||
if (names.some((name) => !name)) {
|
if (names.some((name) => !name)) {
|
||||||
return "Field IDs must not be empty before saving.";
|
return "Field IDs must not be empty before saving.";
|
||||||
@@ -255,7 +246,7 @@ function describeFieldNameProblem(fields: FieldDefinition[]): string {
|
|||||||
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
|
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueFieldName(fields: FieldDefinition[]): string {
|
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
|
||||||
const existing = new Set(fields.map((field) => field.name));
|
const existing = new Set(fields.map((field) => field.name));
|
||||||
let counter = fields.length + 1;
|
let counter = fields.length + 1;
|
||||||
let name = `field_${counter}`;
|
let name = `field_${counter}`;
|
||||||
@@ -265,8 +256,3 @@ function uniqueFieldName(fields: FieldDefinition[]): string {
|
|||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeFieldName(name: string): string {
|
|
||||||
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,13 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||||
import { getBool, getText } from "./utils/draftEditor";
|
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||||
|
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||||
|
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, type AttachmentRule } from "./utils/attachments";
|
||||||
import { addressesFromValue } from "../../utils/emailAddresses";
|
import { addressesFromValue } from "../../utils/emailAddresses";
|
||||||
|
|
||||||
type FieldDefinition = {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
can_override: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
@@ -42,10 +37,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
||||||
const attachmentSection = asRecord(displayDraft.attachments);
|
const attachmentSection = asRecord(displayDraft.attachments);
|
||||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||||
const individualAttachmentBasePaths = useMemo(() => {
|
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
|
||||||
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
|
||||||
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
|
||||||
}, [attachmentBasePaths]);
|
|
||||||
|
|
||||||
|
|
||||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||||
@@ -159,22 +151,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDraftFields(draft: Record<string, unknown> | null): FieldDefinition[] {
|
|
||||||
return asArray(draft?.fields)
|
|
||||||
.map((field) => asRecord(field))
|
|
||||||
.map((field) => ({
|
|
||||||
name: getText(field, "name") || getText(field, "id"),
|
|
||||||
label: getText(field, "label"),
|
|
||||||
type: normalizeFieldType(getText(field, "type", "string")),
|
|
||||||
can_override: getBool(field, "can_override", true)
|
|
||||||
}))
|
|
||||||
.filter((field) => Boolean(field.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFieldType(value: string): string {
|
|
||||||
return ["integer", "double", "date", "password"].includes(value) ? value : "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
||||||
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
|
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
|
||||||
}
|
}
|
||||||
@@ -183,27 +159,3 @@ function extraRecipientCount(entry: Record<string, unknown>): number {
|
|||||||
const count = addressesFromValue(entry.to).length;
|
const count = addressesFromValue(entry.to).length;
|
||||||
return Math.max(0, count - 1);
|
return Math.max(0, count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
|
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
|
||||||
return value.filter(isRecord).map((basePath, index) => ({
|
|
||||||
id: getText(basePath, "id", `base-path-${index + 1}`),
|
|
||||||
name: getText(basePath, "name", `Base path ${index + 1}`),
|
|
||||||
source: getText(basePath, "source"),
|
|
||||||
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
|
||||||
allow_individual: getBool(basePath, "allow_individual")
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{
|
|
||||||
id: "base-path-campaign",
|
|
||||||
name: "Campaign files",
|
|
||||||
path: getText(attachments, "base_path", "."),
|
|
||||||
allow_individual: getBool(attachments, "allow_individual", true)
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter(isRecord).map((rule) => ({ ...rule }));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
@@ -5,30 +6,88 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
|
import { buildVersion, validateVersion } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue } from "./utils/campaignView";
|
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
|
||||||
|
|
||||||
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const issues = collectIssues(data.summary?.issues);
|
const issues = collectIssues(data.summary?.issues);
|
||||||
|
const validationSummary = asRecord(version?.validation_summary);
|
||||||
|
const buildSummary = asRecord(version?.build_summary);
|
||||||
|
const validationOk = validationSummary.ok === true;
|
||||||
|
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
|
||||||
|
const [actionMessage, setActionMessage] = useState("");
|
||||||
|
|
||||||
|
async function runValidate() {
|
||||||
|
if (!version || actionBusy) return;
|
||||||
|
setActionBusy("validate");
|
||||||
|
setActionMessage("Validating campaign and locking the version on success…");
|
||||||
|
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.");
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionMessage("");
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setActionBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBuild() {
|
||||||
|
if (!version || actionBusy) return;
|
||||||
|
setActionBusy("build");
|
||||||
|
setActionMessage("Building messages for the locked version…");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await buildVersion(settings, version.id, true);
|
||||||
|
setActionMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionMessage("");
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setActionBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Review</PageTitle>
|
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
|
||||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
|
||||||
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
|
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
{actionMessage && <div className="alert info">{actionMessage}</div>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading review data…">
|
<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>}>
|
||||||
|
<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>
|
||||||
|
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !validationOk}>
|
||||||
|
{actionBusy === "build" ? "Building…" : "Build messages"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<dl className="detail-list compact-detail-list">
|
||||||
|
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
|
||||||
|
<div><dt>Lock</dt><dd>{versionLockReason(version)}</dd></div>
|
||||||
|
<div><dt>Validation</dt><dd>{validationOk ? "Passed" : version?.validation_summary ? "Needs attention" : "Not validated"}</dd></div>
|
||||||
|
<div><dt>Build</dt><dd>{String(buildSummary.built_count ?? buildSummary.ready_count ?? "Not built")}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<Card title="Validation summary">
|
<Card title="Validation summary">
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
@@ -5,34 +6,82 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
|
import { sendCampaignNow } from "../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, formatDateTime, getDeliverySection, getNestedString } from "./utils/campaignView";
|
import { asArray, asRecord, formatDateTime, getDeliverySection, getNestedString, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||||
|
|
||||||
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
const delivery = getDeliverySection(version);
|
const delivery = getDeliverySection(version);
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const retry = asRecord(delivery.retry);
|
const retry = asRecord(delivery.retry);
|
||||||
|
const [sendBusy, setSendBusy] = useState(false);
|
||||||
|
const [sendMessage, setSendMessage] = useState("");
|
||||||
|
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
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;
|
||||||
|
setSendBusy(true);
|
||||||
|
setSendMessage(dryRun ? "Checking what would be sent…" : "Validating, building and sending campaign…");
|
||||||
|
setSendResult(null);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await sendCampaignNow(settings, campaignId, {
|
||||||
|
version_id: version.id,
|
||||||
|
include_warnings: true,
|
||||||
|
check_files: false,
|
||||||
|
validate_before_send: true,
|
||||||
|
build_before_send: true,
|
||||||
|
dry_run: dryRun,
|
||||||
|
use_rate_limit: true,
|
||||||
|
enqueue_imap_task: false
|
||||||
|
});
|
||||||
|
const result = asRecord(response.result ?? response);
|
||||||
|
setSendResult(result);
|
||||||
|
const sent = result.sent_count ?? 0;
|
||||||
|
const failed = result.failed_count ?? 0;
|
||||||
|
setSendMessage(dryRun ? "Dry run finished." : `Send finished. Sent ${String(sent)} message(s), failed ${String(failed)}.`);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setSendMessage("");
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSendBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queuedOrSending || loading || sendBusy) return;
|
||||||
|
const handle = window.setTimeout(() => { void reload(); }, 3000);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
}, [queuedOrSending, loading, reload, sendBusy]);
|
||||||
|
|
||||||
|
const resultRows = asArray(sendResult?.results).map(asRecord);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>Send</PageTitle>
|
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
|
||||||
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
|
||||||
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
|
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
{error && <div className="alert danger">{error}</div>}
|
||||||
|
{sendMessage && <div className="alert info">{sendMessage}</div>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading send data…">
|
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
|
||||||
<div className="metric-grid">
|
<div className="metric-grid">
|
||||||
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
||||||
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
||||||
@@ -40,6 +89,47 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card title="Send campaign" actions={<span className="muted small-note">Small-campaign synchronous send; larger campaigns can use queue workers later.</span>}>
|
||||||
|
<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}>
|
||||||
|
{sendBusy ? "Sending…" : "Send now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<dl className="detail-list compact-detail-list">
|
||||||
|
<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>
|
||||||
|
</dl>
|
||||||
|
{sendResult && (
|
||||||
|
<div className="send-result-panel">
|
||||||
|
<strong>Last send result</strong>
|
||||||
|
<p className="muted small-note">
|
||||||
|
Attempted {String(sendResult.attempted_count ?? "—")}, sent {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, skipped {String(sendResult.skipped_count ?? "—")}.
|
||||||
|
</p>
|
||||||
|
{resultRows.length > 0 && (
|
||||||
|
<div className="app-table-wrap data-table-wrap">
|
||||||
|
<table className="app-table data-table compact-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Status</th><th>Job</th><th>Message</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resultRows.slice(0, 10).map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td><StatusBadge status={String(row.status ?? "info")} /></td>
|
||||||
|
<td>{String(row.job_id ?? row.version_id ?? "—")}</td>
|
||||||
|
<td>{String(row.message ?? stringifyPreview(row, 180))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<Card title="Delivery rate limit">
|
<Card title="Delivery rate limit">
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
|
|||||||
@@ -9,22 +9,11 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
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";
|
||||||
|
|
||||||
type BodyMode = "text" | "html";
|
type BodyMode = "text" | "html";
|
||||||
type EditorTarget = "subject" | "text" | "html";
|
type EditorTarget = "subject" | "text" | "html";
|
||||||
type TemplateNamespace = "global" | "local";
|
|
||||||
|
|
||||||
type TemplatePlaceholder = {
|
|
||||||
raw: string;
|
|
||||||
namespace: string;
|
|
||||||
name: string;
|
|
||||||
validNamespace: boolean;
|
|
||||||
display: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UndefinedPlaceholder = TemplatePlaceholder & {
|
|
||||||
reason: "missing-field" | "invalid-namespace";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
@@ -65,16 +54,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
|
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
|
||||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||||
const undefinedPlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders
|
const undefinedPlaceholders = useMemo(() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames), [usedPlaceholders, allAvailableNames]);
|
||||||
.filter((field) => !field.validNamespace || !allAvailableNames.has(field.name))
|
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
|
||||||
.map((field): UndefinedPlaceholder => ({
|
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
||||||
...field,
|
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
||||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
||||||
}))), [usedPlaceholders, allAvailableNames]);
|
|
||||||
const previewContext = useMemo(() => buildPreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
|
|
||||||
const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
|
||||||
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
|
||||||
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -382,121 +366,6 @@ function TemplatePreviewOverlay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
|
|
||||||
const placeholders = new Map<string, TemplatePlaceholder>();
|
|
||||||
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = pattern.exec(text))) {
|
|
||||||
const raw = match[1].trim();
|
|
||||||
if (!raw || placeholders.has(raw)) continue;
|
|
||||||
const parsed = parseTemplatePlaceholder(raw);
|
|
||||||
placeholders.set(raw, parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
|
|
||||||
const cleaned = raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
|
|
||||||
const separator = cleaned.indexOf(":");
|
|
||||||
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
|
|
||||||
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
|
|
||||||
const validNamespace = namespace === "global" || namespace === "local";
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
namespace,
|
|
||||||
name,
|
|
||||||
validNamespace,
|
|
||||||
display: validNamespace ? `${namespace}:${name}` : raw
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const result: T[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
const key = item.raw;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
result.push(item);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
|
|
||||||
const context: Record<string, string> = {};
|
|
||||||
const globalValues = asRecord(draft?.global_values);
|
|
||||||
const entryFields = asRecord(entry.fields);
|
|
||||||
const overridePolicy = fieldOverridePolicy(draft);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(globalValues)) {
|
|
||||||
addContextValue(context, key, "global", value);
|
|
||||||
addContextValue(context, key, "local", value);
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(entryFields)) {
|
|
||||||
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
|
|
||||||
addContextValue(context, key, "local", value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.name) addContextValue(context, "name", "local", entry.name);
|
|
||||||
if (entry.email) addContextValue(context, "email", "local", entry.email);
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
|
||||||
const policy = new Map<string, boolean>();
|
|
||||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
|
||||||
const name = String(field.name || field.id || "").trim();
|
|
||||||
if (!name) continue;
|
|
||||||
policy.set(name, getBool(field, "can_override", true));
|
|
||||||
}
|
|
||||||
return policy;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
|
|
||||||
if (!policy.has(name)) return true;
|
|
||||||
return policy.get(name) !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
|
|
||||||
const text = valueToPreview(value);
|
|
||||||
context[key] = text;
|
|
||||||
context[`${namespace}:${key}`] = text;
|
|
||||||
context[`${namespace}::${key}`] = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasPreviewOverrideValue(value: unknown): boolean {
|
|
||||||
if (value === undefined || value === null) return false;
|
|
||||||
if (typeof value === "string") return value.trim() !== "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
|
||||||
if (!text) return "";
|
|
||||||
return text
|
|
||||||
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
|
|
||||||
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
|
||||||
const key = normalizePreviewKey(raw);
|
|
||||||
const value = context[key];
|
|
||||||
if (value !== undefined) return value;
|
|
||||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePreviewKey(raw: string): string {
|
|
||||||
return raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueToPreview(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) return "";
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||||
const name = valueToPreview(entry.name).trim();
|
const name = valueToPreview(entry.name).trim();
|
||||||
const email = valueToPreview(entry.email).trim();
|
const email = valueToPreview(entry.email).trim();
|
||||||
@@ -509,17 +378,3 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
|||||||
function uniqueSorted(values: string[]): string[] {
|
function uniqueSorted(values: string[]): string[] {
|
||||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeFieldName(value: string): string {
|
|
||||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePlaceholderFromText(text: string, raw: string): string {
|
|
||||||
if (!text) return text;
|
|
||||||
const escaped = escapeRegExp(raw.trim());
|
|
||||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,15 +3,9 @@ import { createPortal } from "react-dom";
|
|||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
import ToggleSwitch from "../../../components/ToggleSwitch";
|
||||||
import { getBool, getText } from "../utils/draftEditor";
|
import { getBool, getText } from "../utils/draftEditor";
|
||||||
|
import { createAttachmentRule, mockAttachmentFiles, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
|
||||||
|
|
||||||
export type AttachmentRule = Record<string, unknown>;
|
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||||
export type AttachmentBasePath = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
source?: string;
|
|
||||||
allow_individual?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AttachmentRulesOverlayProps = {
|
type AttachmentRulesOverlayProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -146,18 +140,7 @@ export function AttachmentRulesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addRule() {
|
function addRule() {
|
||||||
const firstBasePath = basePaths[0]?.path ?? "";
|
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "")]);
|
||||||
onChange([
|
|
||||||
...rules,
|
|
||||||
{
|
|
||||||
id: `attachment-${Date.now()}`,
|
|
||||||
label: "",
|
|
||||||
base_dir: firstBasePath,
|
|
||||||
file_filter: "",
|
|
||||||
required: true,
|
|
||||||
include_subdirs: false
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRule(index: number) {
|
function removeRule(index: number) {
|
||||||
@@ -287,19 +270,11 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
||||||
const files = [
|
|
||||||
"welcome.pdf",
|
|
||||||
"terms-and-conditions.pdf",
|
|
||||||
"invoice_{{local:invoice_number}}.pdf",
|
|
||||||
"{{local:recipient_id}}/certificate.pdf",
|
|
||||||
"attachments/{{local:email}}/*.pdf"
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attachment-file-browser-content" aria-label="Choose file or pattern">
|
<div className="attachment-file-browser-content" aria-label="Choose file or pattern">
|
||||||
<p className="muted small-note">Mock browser below <code>{basePath || "."}</code>. Later this will browse uploaded files and directories.</p>
|
<p className="muted small-note">Mock browser below <code>{basePath || "."}</code>. Later this will browse uploaded files and directories.</p>
|
||||||
<div className="placeholder-stack attachment-file-browser-list">
|
<div className="placeholder-stack attachment-file-browser-list">
|
||||||
{files.map((file) => (
|
{mockAttachmentFiles.map((file) => (
|
||||||
<Button key={file} onClick={() => onSelect(file)}>
|
<Button key={file} onClick={() => onSelect(file)}>
|
||||||
<code>{file}</code>
|
<code>{file}</code>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -311,23 +286,3 @@ function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: str
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
|
|
||||||
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
|
|
||||||
if (isDirectAttachmentRule(rule)) {
|
|
||||||
summary.direct += 1;
|
|
||||||
} else {
|
|
||||||
summary.rules += 1;
|
|
||||||
}
|
|
||||||
return summary;
|
|
||||||
}, { direct: 0, rules: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
|
||||||
const explicitType = getText(rule, "type");
|
|
||||||
if (explicitType === "direct") return true;
|
|
||||||
if (explicitType === "pattern") return false;
|
|
||||||
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
|
||||||
if (!fileFilter) return false;
|
|
||||||
return !/[{}*?\[\]]/.test(fileFilter);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
type FieldValueInputProps = {
|
import { inputValueToFieldValue, normalizeFieldType, valueToInputText } from "../utils/fieldDefinitions";
|
||||||
|
|
||||||
|
export type FieldValueInputProps = {
|
||||||
fieldType?: string;
|
fieldType?: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -26,34 +28,9 @@ export default function FieldValueInput({ fieldType = "string", value, disabled
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFieldType(fieldType: string): string {
|
|
||||||
return ["integer", "double", "date", "password"].includes(fieldType) ? fieldType : "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputTypeForField(fieldType: string): string {
|
function inputTypeForField(fieldType: string): string {
|
||||||
if (fieldType === "integer" || fieldType === "double") return "number";
|
if (fieldType === "integer" || fieldType === "double") return "number";
|
||||||
if (fieldType === "date") return "date";
|
if (fieldType === "date") return "date";
|
||||||
if (fieldType === "password") return "password";
|
if (fieldType === "password") return "password";
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueToInputText(value: unknown, fieldType: string): string {
|
|
||||||
if (value === undefined || value === null) return "";
|
|
||||||
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputValueToFieldValue(fieldType: string, value: string): unknown {
|
|
||||||
if (value === "") return "";
|
|
||||||
if (fieldType === "integer") {
|
|
||||||
const numberValue = Number.parseInt(value, 10);
|
|
||||||
return Number.isFinite(numberValue) ? numberValue : value;
|
|
||||||
}
|
|
||||||
if (fieldType === "double") {
|
|
||||||
const numberValue = Number(value);
|
|
||||||
return Number.isFinite(numberValue) ? numberValue : value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|||||||
129
src/features/campaigns/utils/attachments.ts
Normal file
129
src/features/campaigns/utils/attachments.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { asArray, asRecord, isRecord } from "./campaignView";
|
||||||
|
import { getBool, getText } from "./draftEditor";
|
||||||
|
|
||||||
|
export type AttachmentRule = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type AttachmentBasePath = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
source?: string;
|
||||||
|
allow_individual?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttachmentSummary = {
|
||||||
|
direct: number;
|
||||||
|
rules: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
||||||
|
{ label: "Campaign attachments", path: "attachments" },
|
||||||
|
{ label: "Campaign root", path: "." },
|
||||||
|
{ label: "Shared group files", path: "group/shared" },
|
||||||
|
{ label: "Tenant templates", path: "tenant/templates" },
|
||||||
|
{ label: "Personal upload area", path: "user/uploads" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockAttachmentFiles = [
|
||||||
|
"welcome.pdf",
|
||||||
|
"terms-and-conditions.pdf",
|
||||||
|
"invoice_{{local:invoice_number}}.pdf",
|
||||||
|
"{{local:recipient_id}}/certificate.pdf",
|
||||||
|
"attachments/{{local:email}}/*.pdf"
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
|
||||||
|
return {
|
||||||
|
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
allow_individual: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAttachmentRule(baseDir = ""): AttachmentRule {
|
||||||
|
return {
|
||||||
|
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
label: "",
|
||||||
|
base_dir: baseDir,
|
||||||
|
file_filter: "",
|
||||||
|
required: true,
|
||||||
|
include_subdirs: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
return value.filter(isRecord).map((basePath, index) => ({
|
||||||
|
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||||
|
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||||
|
source: getText(basePath, "source"),
|
||||||
|
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||||
|
allow_individual: getBool(basePath, "allow_individual")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: "base-path-campaign",
|
||||||
|
name: "Campaign files",
|
||||||
|
path: getText(attachments, "base_path", "."),
|
||||||
|
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
||||||
|
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
||||||
|
const enabled = paths.filter((basePath) => basePath.allow_individual);
|
||||||
|
return enabled.length > 0 ? enabled : paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(isRecord).map((rule) => ({
|
||||||
|
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
|
||||||
|
type: getText(rule, "type"),
|
||||||
|
label: getText(rule, "label"),
|
||||||
|
base_dir: getText(rule, "base_dir", ""),
|
||||||
|
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
|
||||||
|
include_subdirs: getBool(rule, "include_subdirs"),
|
||||||
|
required: getBool(rule, "required", true),
|
||||||
|
missing_behavior: getText(rule, "missing_behavior", "ask"),
|
||||||
|
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
|
||||||
|
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
|
||||||
|
return rules.reduce<AttachmentSummary>((summary, rule) => {
|
||||||
|
if (isDirectAttachmentRule(rule)) {
|
||||||
|
summary.direct += 1;
|
||||||
|
} else {
|
||||||
|
summary.rules += 1;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}, { direct: 0, rules: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countIndividualAttachmentRules(entriesValue: unknown): number {
|
||||||
|
const entries = asRecord(entriesValue);
|
||||||
|
return asArray(entries.inline)
|
||||||
|
.map(asRecord)
|
||||||
|
.flatMap((entry) => asArray(entry.attachments))
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||||
|
const explicitType = getText(rule, "type");
|
||||||
|
if (explicitType === "direct") return true;
|
||||||
|
if (explicitType === "pattern") return false;
|
||||||
|
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
||||||
|
if (!fileFilter) return false;
|
||||||
|
return !/[{}*?\[\]]/.test(fileFilter);
|
||||||
|
}
|
||||||
56
src/features/campaigns/utils/fieldDefinitions.ts
Normal file
56
src/features/campaigns/utils/fieldDefinitions.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { asArray, asRecord } from "./campaignView";
|
||||||
|
import { getBool, getText } from "./draftEditor";
|
||||||
|
|
||||||
|
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
|
||||||
|
export type CampaignFieldType = typeof fieldTypeOptions[number];
|
||||||
|
|
||||||
|
export type CampaignFieldDefinition = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: CampaignFieldType;
|
||||||
|
required: boolean;
|
||||||
|
can_override: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeFieldType(value: string | undefined | null): CampaignFieldType {
|
||||||
|
const normalized = String(value || "string").trim();
|
||||||
|
return fieldTypeOptions.includes(normalized as CampaignFieldType) ? normalized as CampaignFieldType : "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDraftFields(draft: Record<string, unknown> | null | undefined): CampaignFieldDefinition[] {
|
||||||
|
return asArray(draft?.fields)
|
||||||
|
.map(asRecord)
|
||||||
|
.map((field) => ({
|
||||||
|
name: getText(field, "name") || getText(field, "id"),
|
||||||
|
label: getText(field, "label"),
|
||||||
|
type: normalizeFieldType(getText(field, "type", "string")),
|
||||||
|
required: getBool(field, "required"),
|
||||||
|
can_override: getBool(field, "can_override", true)
|
||||||
|
}))
|
||||||
|
.filter((field) => Boolean(field.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanizeFieldName(value: string): string {
|
||||||
|
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function valueToInputText(value: unknown, fieldType: CampaignFieldType): string {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inputValueToFieldValue(fieldType: CampaignFieldType, value: string): unknown {
|
||||||
|
if (value === "") return "";
|
||||||
|
if (fieldType === "integer") {
|
||||||
|
const numberValue = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : value;
|
||||||
|
}
|
||||||
|
if (fieldType === "double") {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
154
src/features/campaigns/utils/templatePlaceholders.ts
Normal file
154
src/features/campaigns/utils/templatePlaceholders.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { asArray, asRecord } from "./campaignView";
|
||||||
|
import { getBool } from "./draftEditor";
|
||||||
|
|
||||||
|
export type TemplateNamespace = "global" | "local";
|
||||||
|
|
||||||
|
export type TemplatePlaceholder = {
|
||||||
|
raw: string;
|
||||||
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
validNamespace: boolean;
|
||||||
|
display: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UndefinedPlaceholder = TemplatePlaceholder & {
|
||||||
|
reason: "missing-field" | "invalid-namespace";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
|
||||||
|
const placeholders = new Map<string, TemplatePlaceholder>();
|
||||||
|
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const raw = match[1].trim();
|
||||||
|
if (!raw || placeholders.has(raw)) continue;
|
||||||
|
placeholders.set(raw, parseTemplatePlaceholder(raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
|
||||||
|
const cleaned = normalizeTemplatePlaceholderKey(raw);
|
||||||
|
const separator = cleaned.indexOf(":");
|
||||||
|
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
|
||||||
|
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
|
||||||
|
const validNamespace = namespace === "global" || namespace === "local";
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
namespace,
|
||||||
|
name,
|
||||||
|
validNamespace,
|
||||||
|
display: validNamespace ? `${namespace}:${name}` : raw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTemplatePlaceholderKey(raw: string): string {
|
||||||
|
return raw.trim()
|
||||||
|
.replace(/^fields\./, "local:")
|
||||||
|
.replace(/^local\./, "local:")
|
||||||
|
.replace(/^global\./, "global:")
|
||||||
|
.replace(/^local::/, "local:")
|
||||||
|
.replace(/^global::/, "global:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: T[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.raw;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUndefinedPlaceholders(placeholders: TemplatePlaceholder[], availableNames: Set<string>): UndefinedPlaceholder[] {
|
||||||
|
return uniquePlaceholders(placeholders
|
||||||
|
.filter((field) => !field.validNamespace || !availableNames.has(field.name))
|
||||||
|
.map((field): UndefinedPlaceholder => ({
|
||||||
|
...field,
|
||||||
|
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTemplatePreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
|
||||||
|
const context: Record<string, string> = {};
|
||||||
|
const globalValues = asRecord(draft?.global_values);
|
||||||
|
const entryFields = asRecord(entry.fields);
|
||||||
|
const overridePolicy = fieldOverridePolicy(draft);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(globalValues)) {
|
||||||
|
addPreviewContextValue(context, key, "global", value);
|
||||||
|
addPreviewContextValue(context, key, "local", value);
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(entryFields)) {
|
||||||
|
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
|
||||||
|
addPreviewContextValue(context, key, "local", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.name) addPreviewContextValue(context, "name", "local", entry.name);
|
||||||
|
if (entry.email) addPreviewContextValue(context, "email", "local", entry.email);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTemplatePreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||||
|
if (!text) return "";
|
||||||
|
return text
|
||||||
|
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
|
||||||
|
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function valueToPreview(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePlaceholderFromText(text: string, raw: string): string {
|
||||||
|
if (!text) return text;
|
||||||
|
const escaped = escapeRegExp(raw.trim());
|
||||||
|
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||||
|
const policy = new Map<string, boolean>();
|
||||||
|
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||||
|
const name = String(field.name || field.id || "").trim();
|
||||||
|
if (!name) continue;
|
||||||
|
policy.set(name, getBool(field, "can_override", true));
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
|
||||||
|
if (!policy.has(name)) return true;
|
||||||
|
return policy.get(name) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPreviewContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
|
||||||
|
const text = valueToPreview(value);
|
||||||
|
context[key] = text;
|
||||||
|
context[`${namespace}:${key}`] = text;
|
||||||
|
context[`${namespace}::${key}`] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPreviewOverrideValue(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null) return false;
|
||||||
|
if (typeof value === "string") return value.trim() !== "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||||
|
const key = normalizeTemplatePlaceholderKey(raw);
|
||||||
|
const value = context[key];
|
||||||
|
if (value !== undefined) return value;
|
||||||
|
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings, WizardStep } from "../../../types";
|
import type { ApiSettings, WizardStep } from "../../../types";
|
||||||
import Stepper from "../../../components/Stepper";
|
import Stepper from "../../../components/Stepper";
|
||||||
import Card from "../../../components/Card";
|
import Card from "../../../components/Card";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import FormField from "../../../components/FormField";
|
|
||||||
import PageTitle from "../../../components/PageTitle";
|
import PageTitle from "../../../components/PageTitle";
|
||||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
|
||||||
import EmailAddressInput from "../../../components/email/EmailAddressInput";
|
|
||||||
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../utils/emailAddresses";
|
|
||||||
import MetricCard from "../../../components/MetricCard";
|
|
||||||
import { validatePartial } from "../../../api/campaigns";
|
import { validatePartial } from "../../../api/campaigns";
|
||||||
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
||||||
import { asArray, asRecord, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
|
import { isAuditLockedVersion, versionLockReason } from "../utils/campaignView";
|
||||||
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../utils/draftEditor";
|
|
||||||
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
||||||
|
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
|
||||||
|
|
||||||
const steps: WizardStep[] = [
|
const steps: WizardStep[] = [
|
||||||
{ id: "basics", label: "Basics", description: "Name and scenario" },
|
{ id: "basics", label: "Basics", description: "Name and scenario" },
|
||||||
@@ -27,8 +22,6 @@ const steps: WizardStep[] = [
|
|||||||
{ id: "send", label: "Send", description: "Test and queue" }
|
{ id: "send", label: "Send", description: "Test and queue" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
|
||||||
|
|
||||||
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const [activeStep, setActiveStep] = useState("basics");
|
const [activeStep, setActiveStep] = useState("basics");
|
||||||
const [localError, setLocalError] = useState("");
|
const [localError, setLocalError] = useState("");
|
||||||
@@ -55,12 +48,10 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function patchRoot(key: string, value: unknown) {
|
function patchRoot(key: string, value: unknown) {
|
||||||
patch([key], value);
|
patch([key], value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function selectStep(stepId: string) {
|
function selectStep(stepId: string) {
|
||||||
setActiveStep(stepId);
|
setActiveStep(stepId);
|
||||||
}
|
}
|
||||||
@@ -84,7 +75,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
return (
|
return (
|
||||||
<div className="wizard-page">
|
<div className="wizard-page">
|
||||||
@@ -124,7 +114,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
|||||||
{localError && <div className="alert danger">{localError}</div>}
|
{localError && <div className="alert danger">{localError}</div>}
|
||||||
{validationMessage && <div className="alert info">{validationMessage}</div>}
|
{validationMessage && <div className="alert info">{validationMessage}</div>}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
||||||
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
|
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
|
||||||
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
|
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
|
||||||
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
|
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
|
||||||
@@ -145,226 +134,3 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BasicsStep({ draft, patch }: StepProps) {
|
|
||||||
const campaign = asRecord(draft.campaign);
|
|
||||||
return (
|
|
||||||
<div className="form-grid">
|
|
||||||
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
|
|
||||||
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Campaign ID" help="Stable technical identifier.">
|
|
||||||
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Mode">
|
|
||||||
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="test">Test</option>
|
|
||||||
<option value="send">Send</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Description">
|
|
||||||
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SenderStep({ draft, patch }: StepProps) {
|
|
||||||
const recipients = asRecord(draft.recipients);
|
|
||||||
const from = asRecord(recipients.from);
|
|
||||||
const suggestions = collectCampaignAddressSuggestions(draft);
|
|
||||||
const globalTo = addressesFromValue(recipients.to);
|
|
||||||
const globalCc = addressesFromValue(recipients.cc);
|
|
||||||
const globalBcc = addressesFromValue(recipients.bcc);
|
|
||||||
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
|
||||||
const server = asRecord(draft.server);
|
|
||||||
const smtp = asRecord(server.smtp);
|
|
||||||
const delivery = asRecord(draft.delivery);
|
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
|
||||||
return (
|
|
||||||
<div className="form-grid">
|
|
||||||
<FormField label="Default From address">
|
|
||||||
<EmailAddressInput
|
|
||||||
value={addressesFromValue(from)}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allowMultiple={false}
|
|
||||||
showAddButton={false}
|
|
||||||
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
|
|
||||||
emptyText="No default sender configured."
|
|
||||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Global recipients">
|
|
||||||
<EmailAddressInput
|
|
||||||
value={globalTo}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allowMultiple
|
|
||||||
addLabel="Add recipient"
|
|
||||||
emptyText="No global recipients configured."
|
|
||||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="CC">
|
|
||||||
<EmailAddressInput
|
|
||||||
value={globalCc}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allowMultiple
|
|
||||||
addLabel="Add CC"
|
|
||||||
emptyText="No global CC recipients configured."
|
|
||||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="BCC">
|
|
||||||
<EmailAddressInput
|
|
||||||
value={globalBcc}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allowMultiple
|
|
||||||
addLabel="Add BCC"
|
|
||||||
emptyText="No global BCC recipients configured."
|
|
||||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Reply-To">
|
|
||||||
<EmailAddressInput
|
|
||||||
value={globalReplyTo.slice(0, 1)}
|
|
||||||
suggestions={suggestions}
|
|
||||||
allowMultiple={false}
|
|
||||||
showAddButton={false}
|
|
||||||
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>
|
|
||||||
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
|
||||||
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
|
|
||||||
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="step-intro">
|
|
||||||
<h2>Campaign fields</h2>
|
|
||||||
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
|
|
||||||
</div>
|
|
||||||
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="step-intro">
|
|
||||||
<h2>Recipients</h2>
|
|
||||||
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
|
|
||||||
</div>
|
|
||||||
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemplateStep({ draft, patch }: StepProps) {
|
|
||||||
const template = asRecord(draft.template);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="step-intro">
|
|
||||||
<h2>Template</h2>
|
|
||||||
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
|
|
||||||
</div>
|
|
||||||
<div className="form-grid">
|
|
||||||
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
|
|
||||||
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
|
|
||||||
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachmentsStep({ draft, patch }: StepProps) {
|
|
||||||
const attachments = asRecord(draft.attachments);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="step-intro">
|
|
||||||
<h2>Attachments</h2>
|
|
||||||
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
|
|
||||||
</div>
|
|
||||||
<div className="form-grid compact responsive-form-grid">
|
|
||||||
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
|
||||||
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
|
||||||
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
|
||||||
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
|
||||||
</div>
|
|
||||||
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
|
|
||||||
const record = asRecord(version);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="step-intro">
|
|
||||||
<h2>Review setup</h2>
|
|
||||||
<p>Validate the campaign definition before building message drafts.</p>
|
|
||||||
</div>
|
|
||||||
<div className="metric-grid inside">
|
|
||||||
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
|
||||||
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
|
||||||
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SendStep({ draft, patch }: StepProps) {
|
|
||||||
const delivery = asRecord(draft.delivery);
|
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="step-intro">
|
|
||||||
<h2>Send preparation</h2>
|
|
||||||
<p>Configure rate limits and prepare the final send workflow.</p>
|
|
||||||
</div>
|
|
||||||
<div className="form-grid compact responsive-form-grid">
|
|
||||||
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
|
||||||
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
|
||||||
</div>
|
|
||||||
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type StepProps = {
|
|
||||||
draft: Record<string, unknown>;
|
|
||||||
patch: (path: string[], value: unknown) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
|
|
||||||
const [text, setText] = useState(stringifyJson(value));
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setText(stringifyJson(value));
|
|
||||||
setError("");
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
function change(nextText: string) {
|
|
||||||
setText(nextText);
|
|
||||||
const parsed = parseJsonTextarea(nextText, value);
|
|
||||||
setError(parsed.error);
|
|
||||||
if (!parsed.error) onValid(parsed.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="json-edit-block">
|
|
||||||
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
|
|
||||||
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
|
|
||||||
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
236
src/features/campaigns/wizard/steps/CreateWizardSteps.tsx
Normal file
236
src/features/campaigns/wizard/steps/CreateWizardSteps.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Button from "../../../../components/Button";
|
||||||
|
import FormField from "../../../../components/FormField";
|
||||||
|
import ToggleSwitch from "../../../../components/ToggleSwitch";
|
||||||
|
import EmailAddressInput from "../../../../components/email/EmailAddressInput";
|
||||||
|
import MetricCard from "../../../../components/MetricCard";
|
||||||
|
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../../utils/emailAddresses";
|
||||||
|
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
|
||||||
|
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";
|
||||||
|
|
||||||
|
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||||
|
|
||||||
|
export type WizardStepPatch = (path: string[], value: unknown) => void;
|
||||||
|
|
||||||
|
export type WizardStepProps = {
|
||||||
|
draft: Record<string, unknown>;
|
||||||
|
patch: WizardStepPatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BasicsStep({ draft, patch }: WizardStepProps) {
|
||||||
|
const campaign = asRecord(draft.campaign);
|
||||||
|
return (
|
||||||
|
<div className="form-grid">
|
||||||
|
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
|
||||||
|
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Campaign ID" help="Stable technical identifier.">
|
||||||
|
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Mode">
|
||||||
|
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="test">Test</option>
|
||||||
|
<option value="send">Send</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description">
|
||||||
|
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SenderStep({ draft, patch }: WizardStepProps) {
|
||||||
|
const recipients = asRecord(draft.recipients);
|
||||||
|
const from = asRecord(recipients.from);
|
||||||
|
const suggestions = collectCampaignAddressSuggestions(draft);
|
||||||
|
const globalTo = addressesFromValue(recipients.to);
|
||||||
|
const globalCc = addressesFromValue(recipients.cc);
|
||||||
|
const globalBcc = addressesFromValue(recipients.bcc);
|
||||||
|
const globalReplyTo = addressesFromValue(recipients.reply_to);
|
||||||
|
const server = asRecord(draft.server);
|
||||||
|
const smtp = asRecord(server.smtp);
|
||||||
|
const delivery = asRecord(draft.delivery);
|
||||||
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
|
return (
|
||||||
|
<div className="form-grid">
|
||||||
|
<FormField label="Default From address">
|
||||||
|
<EmailAddressInput
|
||||||
|
value={addressesFromValue(from)}
|
||||||
|
suggestions={suggestions}
|
||||||
|
allowMultiple={false}
|
||||||
|
showAddButton={false}
|
||||||
|
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
|
||||||
|
emptyText="No default sender configured."
|
||||||
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Global recipients">
|
||||||
|
<EmailAddressInput
|
||||||
|
value={globalTo}
|
||||||
|
suggestions={suggestions}
|
||||||
|
allowMultiple
|
||||||
|
addLabel="Add recipient"
|
||||||
|
emptyText="No global recipients configured."
|
||||||
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="CC">
|
||||||
|
<EmailAddressInput
|
||||||
|
value={globalCc}
|
||||||
|
suggestions={suggestions}
|
||||||
|
allowMultiple
|
||||||
|
addLabel="Add CC"
|
||||||
|
emptyText="No global CC recipients configured."
|
||||||
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="BCC">
|
||||||
|
<EmailAddressInput
|
||||||
|
value={globalBcc}
|
||||||
|
suggestions={suggestions}
|
||||||
|
allowMultiple
|
||||||
|
addLabel="Add BCC"
|
||||||
|
emptyText="No global BCC recipients configured."
|
||||||
|
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Reply-To">
|
||||||
|
<EmailAddressInput
|
||||||
|
value={globalReplyTo.slice(0, 1)}
|
||||||
|
suggestions={suggestions}
|
||||||
|
allowMultiple={false}
|
||||||
|
showAddButton={false}
|
||||||
|
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>
|
||||||
|
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
||||||
|
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
|
||||||
|
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="step-intro">
|
||||||
|
<h2>Campaign fields</h2>
|
||||||
|
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
|
||||||
|
</div>
|
||||||
|
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="step-intro">
|
||||||
|
<h2>Recipients</h2>
|
||||||
|
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
|
||||||
|
</div>
|
||||||
|
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateStep({ draft, patch }: WizardStepProps) {
|
||||||
|
const template = asRecord(draft.template);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="step-intro">
|
||||||
|
<h2>Template</h2>
|
||||||
|
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
|
||||||
|
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
|
||||||
|
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
|
||||||
|
const attachments = asRecord(draft.attachments);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="step-intro">
|
||||||
|
<h2>Attachments</h2>
|
||||||
|
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid compact responsive-form-grid">
|
||||||
|
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
||||||
|
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||||
|
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||||
|
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||||
|
</div>
|
||||||
|
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
|
||||||
|
const record = asRecord(version);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="step-intro">
|
||||||
|
<h2>Review setup</h2>
|
||||||
|
<p>Validate the campaign definition before building message drafts.</p>
|
||||||
|
</div>
|
||||||
|
<div className="metric-grid inside">
|
||||||
|
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
||||||
|
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
||||||
|
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendStep({ draft, patch }: WizardStepProps) {
|
||||||
|
const delivery = asRecord(draft.delivery);
|
||||||
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="step-intro">
|
||||||
|
<h2>Send preparation</h2>
|
||||||
|
<p>Configure rate limits and prepare the final send workflow.</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid compact responsive-form-grid">
|
||||||
|
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||||
|
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
|
||||||
|
const [text, setText] = useState(stringifyJson(value));
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setText(stringifyJson(value));
|
||||||
|
setError("");
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function change(nextText: string) {
|
||||||
|
setText(nextText);
|
||||||
|
const parsed = parseJsonTextarea(nextText, value);
|
||||||
|
setError(parsed.error);
|
||||||
|
if (!parsed.error) onValid(parsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="json-edit-block">
|
||||||
|
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
|
||||||
|
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
|
||||||
|
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user