diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index 5af0e52..1dff887 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -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 { const response = await apiFetch(settings, "/api/v1/campaigns"); @@ -265,6 +277,17 @@ export async function queueCampaign( }); } +export async function sendCampaignNow( + settings: ApiSettings, + campaignId: string, + payload: CampaignSendNowPayload = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/send-now`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" }); } diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx index 543b696..68f3808 100644 --- a/src/features/admin/AdminPage.tsx +++ b/src/features/admin/AdminPage.tsx @@ -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[] = [ } ]; + +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 = { 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 ; + return ; } function Users() { - return ; + return ; } function Groups() { - return ; + return ; } function Roles() { - return ; + return ; } function ApiKeys() { @@ -174,7 +214,7 @@ function ApiKeys() { } function MailServers() { - return ; + return ; } function TenantSettings() { @@ -208,20 +248,3 @@ function Audit() { ); } - -function PlaceholderAdminTable({ title, columns, rows, action, note }: { title: string; columns: string[]; rows: string[]; action: string; note?: string }) { - return ( - {action}}> -
This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.
- {note &&

{note}

} -
- - {columns.map((column) => )} - - {rows.map((row) => {row.split("|").map((cell, index) => )})} - -
{column}
{cell}
-
-
- ); -} diff --git a/src/features/admin/components/AdminPlaceholderTable.tsx b/src/features/admin/components/AdminPlaceholderTable.tsx new file mode 100644 index 0000000..eeecad1 --- /dev/null +++ b/src/features/admin/components/AdminPlaceholderTable.tsx @@ -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 ( + {action}}> +
This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.
+ {note &&

{note}

} +
+ + {columns.map((column) => )} + + {rows.map((row) => {row.split("|").map((cell, index) => )})} + +
{column}
{cell}
+
+
+ ); +} diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index d95e34f..efd80c4 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -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
Base paths
{basePaths.length}
Global attachments
direct: {globalSummary.direct} / rules: {globalSummary.rules}
-
Per-recipient patterns
{individualRules.length}
+
Per-recipient patterns
{individualRulesCount}
Upload support
Planned

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.

@@ -191,14 +179,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings } function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial) => void }) { - const paths: Array & { 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(
@@ -209,7 +189,7 @@ function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; on

Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.

- {paths.map((path) => ( + {mockAttachmentPathOptions.map((path) => ( @@ -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): 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 } : {}) - })); -} diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx index 0923d6b..e0d2728 100644 --- a/src/features/campaigns/CampaignFieldsPage.tsx +++ b/src/features/campaigns/CampaignFieldsPage.tsx @@ -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) { + function setField(index: number, patchValue: Partial) { const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field); patchFields(nextFields); } @@ -191,7 +182,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: renameField(index, event.target.value)} /> setField(index, { label: event.target.value })} /> - setField(index, { type: normalizeFieldType(event.target.value) })}> {fieldTypeOptions.map((option) => )} @@ -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, 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()); -} - diff --git a/src/features/campaigns/RecipientDetailsPage.tsx b/src/features/campaigns/RecipientDetailsPage.tsx index 96ce7f4..087a542 100644 --- a/src/features/campaigns/RecipientDetailsPage.tsx +++ b/src/features/campaigns/RecipientDetailsPage.tsx @@ -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[]) { @@ -159,22 +151,6 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting ); } -function getDraftFields(draft: Record | 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 { return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? ""; } @@ -183,27 +159,3 @@ function extraRecipientCount(entry: Record): number { const count = addressesFromValue(entry.to).length; return Math.max(0, count - 1); } - -function normalizeAttachmentBasePaths(value: unknown, attachments: Record): 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 })); -} diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx index ebacbed..f08c403 100644 --- a/src/features/campaigns/ReviewDataPage.tsx +++ b/src/features/campaigns/ReviewDataPage.tsx @@ -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 (
- Review + Review

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}

- +
{error &&
{error}
} + {actionMessage &&
{actionMessage}
} + Validation locks the exact version that was checked.}> +
+ + +
+
+
Version state
{version?.workflow_state ?? "—"}
+
Lock
{versionLockReason(version)}
+
Validation
{validationOk ? "Passed" : version?.validation_summary ? "Needs attention" : "Not validated"}
+
Build
{String(buildSummary.built_count ?? buildSummary.ready_count ?? "Not built")}
+
+
+
diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index ba1fab6..acbbcac 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -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 | 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 (
- Send + Send

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}

- +
{error &&
{error}
} + {sendMessage &&
{sendMessage}
} - +
@@ -40,6 +89,47 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
+ Small-campaign synchronous send; larger campaigns can use queue workers later.}> +
+ + +
+
+
Campaign status
+
Version state
{version?.workflow_state ?? "—"}
+
Version lock
{versionLockReason(version)}
+
Validation/build
{version?.validation_summary ? "Validation available" : "Will validate before send"} · {version?.build_summary ? "Build available" : "Will build before send"}
+
+ {sendResult && ( +
+ Last send result +

+ Attempted {String(sendResult.attempted_count ?? "—")}, sent {String(sendResult.sent_count ?? "—")}, failed {String(sendResult.failed_count ?? "—")}, skipped {String(sendResult.skipped_count ?? "—")}. +

+ {resultRows.length > 0 && ( +
+ + + + + + {resultRows.slice(0, 10).map((row, index) => ( + + + + + + ))} + +
StatusJobMessage
{String(row.job_id ?? row.version_id ?? "—")}{String(row.message ?? stringifyPreview(row, 180))}
+
+ )} +
+ )} +
+
diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index a598556..6a8685e 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -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(); - 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(items: T[]): T[] { - const seen = new Set(); - 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 | null, entry: Record): Record { - const context: Record = {}; - 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 | null): Map { - const policy = new Map(); - 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, name: string): boolean { - if (!policy.has(name)) return true; - return policy.get(name) !== false; -} - -function addContextValue(context: Record, 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, 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, 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, index: number): string { const name = valueToPreview(entry.name).trim(); const email = valueToPreview(entry.email).trim(); @@ -509,17 +378,3 @@ function recipientLabel(entry: Record, 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, "\\$&"); -} diff --git a/src/features/campaigns/components/AttachmentRulesOverlay.tsx b/src/features/campaigns/components/AttachmentRulesOverlay.tsx index f8e01b7..b7b0b6b 100644 --- a/src/features/campaigns/components/AttachmentRulesOverlay.tsx +++ b/src/features/campaigns/components/AttachmentRulesOverlay.tsx @@ -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; -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 (

Mock browser below {basePath || "."}. Later this will browse uploaded files and directories.

- {files.map((file) => ( + {mockAttachmentFiles.map((file) => ( @@ -311,23 +286,3 @@ function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: str
); } - -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); -} diff --git a/src/features/campaigns/components/FieldValueInput.tsx b/src/features/campaigns/components/FieldValueInput.tsx index 66f4f98..cd42a00 100644 --- a/src/features/campaigns/components/FieldValueInput.tsx +++ b/src/features/campaigns/components/FieldValueInput.tsx @@ -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; -} diff --git a/src/features/campaigns/utils/attachments.ts b/src/features/campaigns/utils/attachments.ts new file mode 100644 index 0000000..f5bef55 --- /dev/null +++ b/src/features/campaigns/utils/attachments.ts @@ -0,0 +1,129 @@ +import { asArray, asRecord, isRecord } from "./campaignView"; +import { getBool, getText } from "./draftEditor"; + +export type AttachmentRule = Record; + +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 & { + 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, 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((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); +} diff --git a/src/features/campaigns/utils/fieldDefinitions.ts b/src/features/campaigns/utils/fieldDefinitions.ts new file mode 100644 index 0000000..65b1404 --- /dev/null +++ b/src/features/campaigns/utils/fieldDefinitions.ts @@ -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 | 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; +} diff --git a/src/features/campaigns/utils/templatePlaceholders.ts b/src/features/campaigns/utils/templatePlaceholders.ts new file mode 100644 index 0000000..9e9c940 --- /dev/null +++ b/src/features/campaigns/utils/templatePlaceholders.ts @@ -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(); + 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(items: T[]): T[] { + const seen = new Set(); + 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): 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 | null, entry: Record): Record { + const context: Record = {}; + 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, 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 | null): Map { + const policy = new Map(); + 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, name: string): boolean { + if (!policy.has(name)) return true; + return policy.get(name) !== false; +} + +function addPreviewContextValue(context: Record, 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, 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, "\\$&"); +} diff --git a/src/features/campaigns/wizard/CreateWizard.tsx b/src/features/campaigns/wizard/CreateWizard.tsx index 63c58bf..d29db98 100644 --- a/src/features/campaigns/wizard/CreateWizard.tsx +++ b/src/features/campaigns/wizard/CreateWizard.tsx @@ -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 (
@@ -124,7 +114,6 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe {localError &&
{localError}
} {validationMessage &&
{validationMessage}
} - {draft && activeStep === "basics" && } {draft && activeStep === "sender" && } {draft && activeStep === "fields" && } @@ -145,226 +134,3 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
); } - -function BasicsStep({ draft, patch }: StepProps) { - const campaign = asRecord(draft.campaign); - return ( -
- - patch(["campaign", "name"], event.target.value)} /> - - - patch(["campaign", "id"], event.target.value)} /> - - - - - -