first version able to send
This commit is contained in:
@@ -112,6 +112,18 @@ export type CampaignQueuePayload = {
|
||||
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[]> {
|
||||
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>> {
|
||||
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 StatusBadge from "../../components/StatusBadge";
|
||||
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";
|
||||
|
||||
@@ -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 }> = {
|
||||
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." },
|
||||
@@ -144,19 +184,19 @@ function System() {
|
||||
}
|
||||
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
@@ -174,7 +214,7 @@ function ApiKeys() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -208,20 +248,3 @@ function Audit() {
|
||||
</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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
|
||||
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { updateNested } from "./utils/draftEditor";
|
||||
import { AttachmentRulesTable } from "./components/AttachmentRulesOverlay";
|
||||
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||
|
||||
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."
|
||||
});
|
||||
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 globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||
const entries = asRecord(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) })));
|
||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||
|
||||
|
||||
|
||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||
if (locked) return;
|
||||
const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")];
|
||||
const normalized = ensureAttachmentBasePaths(paths);
|
||||
setDraft((current) => {
|
||||
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
|
||||
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
|
||||
@@ -55,7 +54,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
}
|
||||
|
||||
function addBasePath() {
|
||||
patchBasePaths([...basePaths, createBasePath("New attachment source", ".")]);
|
||||
patchBasePaths([...basePaths, createAttachmentBasePath("New attachment source", ".")]);
|
||||
}
|
||||
|
||||
function removeBasePath(index: number) {
|
||||
@@ -64,18 +63,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
function addGlobalAttachmentRule() {
|
||||
if (locked) return;
|
||||
const firstBasePath = basePaths[0]?.path ?? "";
|
||||
patch(["attachments", "global"], [
|
||||
...globalRules,
|
||||
{
|
||||
id: `attachment-${Date.now()}`,
|
||||
label: "",
|
||||
base_dir: firstBasePath,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
include_subdirs: false
|
||||
}
|
||||
]);
|
||||
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "")]);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +153,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<dl className="detail-list">
|
||||
<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>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>
|
||||
</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>
|
||||
@@ -191,14 +179,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
}
|
||||
|
||||
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(
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
|
||||
<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">
|
||||
<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">
|
||||
{paths.map((path) => (
|
||||
{mockAttachmentPathOptions.map((path) => (
|
||||
<Button key={path.path} onClick={() => onSelect(path)}>
|
||||
{path.label}: <code>{path.path}</code>
|
||||
</Button>
|
||||
@@ -224,47 +204,3 @@ function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; on
|
||||
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 { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
|
||||
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
|
||||
|
||||
type FieldDefinition = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
can_override: boolean;
|
||||
};
|
||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||
|
||||
|
||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
@@ -55,7 +46,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function patchFields(nextFields: FieldDefinition[]) {
|
||||
function patchFields(nextFields: CampaignFieldDefinition[]) {
|
||||
patchDraft(["fields"], nextFields.map((field) => ({
|
||||
name: field.name,
|
||||
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);
|
||||
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.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} /></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>)}
|
||||
</select>
|
||||
</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 [];
|
||||
return value.filter(isRecord).map((field) => ({
|
||||
name: getText(field, "name"),
|
||||
label: getText(field, "label"),
|
||||
type: fieldTypeOptions.includes(getText(field, "type")) ? getText(field, "type") : "string",
|
||||
type: normalizeFieldType(getText(field, "type")),
|
||||
required: getBool(field, "required"),
|
||||
can_override: getBool(field, "can_override", true)
|
||||
}));
|
||||
@@ -238,7 +229,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
|
||||
return updateNested(draft, ["fields"], fields);
|
||||
}
|
||||
|
||||
function describeFieldNameProblem(fields: FieldDefinition[]): string {
|
||||
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||
const names = fields.map((field) => field.name.trim());
|
||||
if (names.some((name) => !name)) {
|
||||
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.`;
|
||||
}
|
||||
|
||||
function uniqueFieldName(fields: FieldDefinition[]): string {
|
||||
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
|
||||
const existing = new Set(fields.map((field) => field.name));
|
||||
let counter = fields.length + 1;
|
||||
let name = `field_${counter}`;
|
||||
@@ -265,8 +256,3 @@ function uniqueFieldName(fields: FieldDefinition[]): string {
|
||||
}
|
||||
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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
|
||||
import { getBool, getText } from "./utils/draftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
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";
|
||||
|
||||
type FieldDefinition = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: string;
|
||||
can_override: boolean;
|
||||
};
|
||||
|
||||
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
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 attachmentSection = asRecord(displayDraft.attachments);
|
||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||
const individualAttachmentBasePaths = useMemo(() => {
|
||||
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
|
||||
return enabled.length > 0 ? enabled : attachmentBasePaths;
|
||||
}, [attachmentBasePaths]);
|
||||
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
|
||||
|
||||
|
||||
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 {
|
||||
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;
|
||||
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 type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
@@ -5,30 +6,88 @@ import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import Card from "../../components/Card";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { buildVersion, validateVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue } from "./utils/campaignView";
|
||||
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
|
||||
|
||||
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 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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{actionMessage && <div className="alert info">{actionMessage}</div>}
|
||||
|
||||
<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">
|
||||
<Card title="Validation summary">
|
||||
<div className="summary-grid">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
@@ -5,34 +6,82 @@ import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { sendCampaignNow } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { 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 }) {
|
||||
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 cards = data.summary?.cards;
|
||||
const delivery = getDeliverySection(version);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<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" />
|
||||
@@ -40,6 +89,47 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
</div>
|
||||
|
||||
<Card title="Send campaign" actions={<span className="muted small-note">Small-campaign synchronous send; larger campaigns can use queue workers later.</span>}>
|
||||
<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">
|
||||
<Card title="Delivery rate limit">
|
||||
<dl className="detail-list">
|
||||
|
||||
@@ -9,22 +9,11 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||
|
||||
type BodyMode = "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 }) {
|
||||
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 usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||
const undefinedPlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders
|
||||
.filter((field) => !field.validNamespace || !allAvailableNames.has(field.name))
|
||||
.map((field): UndefinedPlaceholder => ({
|
||||
...field,
|
||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||
}))), [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);
|
||||
const undefinedPlaceholders = useMemo(() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames), [usedPlaceholders, allAvailableNames]);
|
||||
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
|
||||
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
||||
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
||||
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
||||
|
||||
|
||||
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 {
|
||||
const name = valueToPreview(entry.name).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[] {
|
||||
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 ToggleSwitch from "../../../components/ToggleSwitch";
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
source?: string;
|
||||
allow_individual?: boolean;
|
||||
};
|
||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||
|
||||
type AttachmentRulesOverlayProps = {
|
||||
title: string;
|
||||
@@ -146,18 +140,7 @@ export function AttachmentRulesTable({
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
const firstBasePath = basePaths[0]?.path ?? "";
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `attachment-${Date.now()}`,
|
||||
label: "",
|
||||
base_dir: firstBasePath,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
include_subdirs: false
|
||||
}
|
||||
]);
|
||||
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "")]);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const files = [
|
||||
"welcome.pdf",
|
||||
"terms-and-conditions.pdf",
|
||||
"invoice_{{local:invoice_number}}.pdf",
|
||||
"{{local:recipient_id}}/certificate.pdf",
|
||||
"attachments/{{local:email}}/*.pdf"
|
||||
];
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="placeholder-stack attachment-file-browser-list">
|
||||
{files.map((file) => (
|
||||
{mockAttachmentFiles.map((file) => (
|
||||
<Button key={file} onClick={() => onSelect(file)}>
|
||||
<code>{file}</code>
|
||||
</Button>
|
||||
@@ -311,23 +286,3 @@ function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: str
|
||||
</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;
|
||||
value: unknown;
|
||||
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 {
|
||||
if (fieldType === "integer" || fieldType === "double") return "number";
|
||||
if (fieldType === "date") return "date";
|
||||
if (fieldType === "password") return "password";
|
||||
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 type { ApiSettings, WizardStep } from "../../../types";
|
||||
import Stepper from "../../../components/Stepper";
|
||||
import Card from "../../../components/Card";
|
||||
import Button from "../../../components/Button";
|
||||
import FormField from "../../../components/FormField";
|
||||
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 { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
|
||||
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../utils/draftEditor";
|
||||
import { isAuditLockedVersion, versionLockReason } from "../utils/campaignView";
|
||||
import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
||||
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
|
||||
|
||||
const steps: WizardStep[] = [
|
||||
{ id: "basics", label: "Basics", description: "Name and scenario" },
|
||||
@@ -27,8 +22,6 @@ const steps: WizardStep[] = [
|
||||
{ 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 }) {
|
||||
const [activeStep, setActiveStep] = useState("basics");
|
||||
const [localError, setLocalError] = useState("");
|
||||
@@ -55,12 +48,10 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function patchRoot(key: string, value: unknown) {
|
||||
patch([key], value);
|
||||
}
|
||||
|
||||
|
||||
function selectStep(stepId: string) {
|
||||
setActiveStep(stepId);
|
||||
}
|
||||
@@ -84,7 +75,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="wizard-page">
|
||||
@@ -124,7 +114,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{validationMessage && <div className="alert info">{validationMessage}</div>}
|
||||
<Card>
|
||||
|
||||
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
|
||||
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
|
||||
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
|
||||
@@ -145,226 +134,3 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
</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