Mock server workflow - first draft
This commit is contained in:
@@ -131,6 +131,16 @@ export type CampaignSendNowPayload = {
|
||||
enqueue_imap_task?: boolean;
|
||||
};
|
||||
|
||||
export type CampaignMockSendPayload = {
|
||||
version_id?: string | null;
|
||||
send?: boolean;
|
||||
include_warnings?: boolean;
|
||||
include_needs_review?: boolean;
|
||||
append_sent?: boolean;
|
||||
clear_mailbox?: boolean;
|
||||
check_files?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
||||
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
||||
@@ -329,6 +339,18 @@ export async function sendCampaignNow(
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function mockSendCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignMockSendPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -73,3 +73,58 @@ export async function listImapFolders(
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export type MockMailboxMessage = {
|
||||
id: string;
|
||||
kind: "smtp" | "imap_append" | string;
|
||||
created_at: string;
|
||||
envelope_from?: string | null;
|
||||
envelope_recipients?: string[];
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
cc_header?: string | null;
|
||||
bcc_header?: string | null;
|
||||
message_id?: string | null;
|
||||
size_bytes?: number;
|
||||
body_preview?: string | null;
|
||||
attachment_count?: number;
|
||||
folder?: string | null;
|
||||
raw_eml?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
||||
};
|
||||
|
||||
export type MockMailboxListResponse = {
|
||||
messages: MockMailboxMessage[];
|
||||
};
|
||||
|
||||
export type MockMailboxMessageResponse = {
|
||||
message: MockMailboxMessage;
|
||||
};
|
||||
|
||||
export type MockMailboxFailureConfig = {
|
||||
fail_next_smtp?: boolean | null;
|
||||
fail_next_imap?: boolean | null;
|
||||
smtp_reject_recipients_containing?: string | null;
|
||||
};
|
||||
|
||||
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
|
||||
}
|
||||
|
||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function clearMockMailboxMessages(settings: ApiSettings): Promise<{ deleted_count: number }> {
|
||||
return apiFetch<{ deleted_count: number }>(settings, "/api/v1/dev/mailbox/messages", { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateMockMailboxFailures(settings: ApiSettings, payload: MockMailboxFailureConfig): Promise<{ config: MockMailboxFailureConfig }> {
|
||||
return apiFetch<{ config: MockMailboxFailureConfig }>(settings, "/api/v1/dev/mailbox/failures", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type CardProps = {
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
};
|
||||
|
||||
export default function Card({ title, children, actions }: CardProps) {
|
||||
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className="card-body">{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
{(title || actions) && (
|
||||
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
|
||||
{hasHeader && (
|
||||
<header className="card-header">
|
||||
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
|
||||
{actions && <div className="card-actions">{actions}</div>}
|
||||
{(actions || collapsible) && (
|
||||
<div className="card-actions">
|
||||
{actions}
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-label={collapsed ? "Show content" : "Show header only"}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Show content" : "Show header only"}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<div className="card-body">{children}</div>
|
||||
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Individual attachments</th>
|
||||
<th>Unsent warning</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -129,6 +130,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
</div>
|
||||
</td>
|
||||
<td><ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} /></td>
|
||||
<td><ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} /></td>
|
||||
<td className="table-action-cell"><Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -86,7 +86,11 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
|
||||
setDraft((current) => {
|
||||
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
|
||||
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
|
||||
const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues);
|
||||
if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) {
|
||||
return nextWithGlobalValues;
|
||||
}
|
||||
return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName);
|
||||
});
|
||||
markDirty();
|
||||
}
|
||||
@@ -219,6 +223,28 @@ function normalizeFields(value: unknown): CampaignFieldDefinition[] {
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
function migrateEntryFieldValues(draft: Record<string, unknown>, oldName: string, newName: string): Record<string, unknown> {
|
||||
const entries = asRecord(draft.entries);
|
||||
const inlineEntries = Array.isArray(entries.inline) ? entries.inline : [];
|
||||
if (inlineEntries.length === 0) return draft;
|
||||
|
||||
const nextInlineEntries = inlineEntries.map((entry) => {
|
||||
if (!isRecord(entry)) return entry;
|
||||
const fields = asRecord(entry.fields);
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry;
|
||||
|
||||
const nextFields = { ...fields };
|
||||
if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) {
|
||||
nextFields[newName] = nextFields[oldName];
|
||||
}
|
||||
delete nextFields[oldName];
|
||||
return { ...entry, fields: nextFields };
|
||||
});
|
||||
|
||||
return updateNested(draft, ["entries", "inline"], nextInlineEntries);
|
||||
}
|
||||
|
||||
function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState: Record<string, unknown>): Record<string, unknown> {
|
||||
const overridePolicy = asRecord(editorState.field_overrides);
|
||||
if (Object.keys(overridePolicy).length === 0) return draft;
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
|
||||
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
|
||||
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
|
||||
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
|
||||
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
|
||||
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
|
||||
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
|
||||
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
@@ -8,7 +8,7 @@ import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
||||
import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
@@ -21,7 +21,12 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
|
||||
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
|
||||
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
|
||||
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
||||
const [mockError, setMockError] = useState("");
|
||||
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
|
||||
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
@@ -137,6 +142,109 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMockMailSandbox(enabled: boolean) {
|
||||
if (locked) return;
|
||||
setMockSandboxEnabled(enabled);
|
||||
|
||||
if (enabled) {
|
||||
mockSandboxSnapshot.current = {
|
||||
smtp: { ...smtp },
|
||||
imap: { ...imap },
|
||||
imap_append_sent: { ...imapAppend }
|
||||
};
|
||||
patch(["server", "smtp", "host"], "mock.smtp.local");
|
||||
patch(["server", "smtp", "port"], 2525);
|
||||
patch(["server", "smtp", "security"], "plain");
|
||||
patch(["server", "smtp", "username"], "mock");
|
||||
patch(["server", "smtp", "password"], "mock");
|
||||
patch(["server", "imap", "enabled"], true);
|
||||
patch(["server", "imap", "host"], "mock.imap.local");
|
||||
patch(["server", "imap", "port"], 1143);
|
||||
patch(["server", "imap", "security"], "plain");
|
||||
patch(["server", "imap", "username"], "mock");
|
||||
patch(["server", "imap", "password"], "mock");
|
||||
patch(["server", "imap", "sent_folder"], "Sent");
|
||||
patch(["delivery", "imap_append_sent", "enabled"], true);
|
||||
patch(["delivery", "imap_append_sent", "folder"], "Sent");
|
||||
setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = mockSandboxSnapshot.current;
|
||||
if (snapshot) {
|
||||
patch(["server", "smtp"], asRecord(snapshot.smtp));
|
||||
patch(["server", "imap"], asRecord(snapshot.imap));
|
||||
patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent));
|
||||
}
|
||||
mockSandboxSnapshot.current = null;
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
}
|
||||
|
||||
async function loadMockMailbox() {
|
||||
setMailActionState("mock");
|
||||
setMockError("");
|
||||
try {
|
||||
const response = await listMockMailboxMessages(settings);
|
||||
setMockMessages(response.messages);
|
||||
} catch (err) {
|
||||
setMockError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openMockMessage(id: string) {
|
||||
setMailActionState("mock");
|
||||
setMockError("");
|
||||
try {
|
||||
const response = await getMockMailboxMessage(settings, id);
|
||||
setSelectedMockMessage(response.message);
|
||||
} catch (err) {
|
||||
setMockError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearMockMailbox() {
|
||||
setMailActionState("mock");
|
||||
setMockError("");
|
||||
try {
|
||||
await clearMockMailboxMessages(settings);
|
||||
setMockMessages([]);
|
||||
setSelectedMockMessage(null);
|
||||
} catch (err) {
|
||||
setMockError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function failNextMockSmtp() {
|
||||
setMailActionState("mock");
|
||||
setMockError("");
|
||||
try {
|
||||
await updateMockMailboxFailures(settings, { fail_next_smtp: true });
|
||||
} catch (err) {
|
||||
setMockError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function failNextMockImap() {
|
||||
setMailActionState("mock");
|
||||
setMockError("");
|
||||
try {
|
||||
await updateMockMailboxFailures(settings, { fail_next_imap: true });
|
||||
} catch (err) {
|
||||
setMockError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -161,6 +269,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
<section className="form-subsection mail-server-subsection">
|
||||
<div className="subsection-heading split">
|
||||
<h3>SMTP login</h3>
|
||||
<ToggleSwitch label="Mock server settings" checked={mockSandboxEnabled} disabled={locked} onChange={toggleMockMailSandbox} />
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Host"><input value={getText(smtp, "host")} disabled={locked} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
||||
@@ -206,6 +315,67 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Mock mail sandbox">
|
||||
<div className="subsection-heading split">
|
||||
<div>
|
||||
<h3>Captured messages</h3>
|
||||
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
|
||||
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
|
||||
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
|
||||
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mockError && <div className="alert danger">{mockError}</div>}
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Received</th>
|
||||
<th>Subject</th>
|
||||
<th>Envelope</th>
|
||||
<th>Attachments</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockMessages.map((message) => (
|
||||
<tr key={message.id}>
|
||||
<td><span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span></td>
|
||||
<td>{formatMockDate(message.created_at)}</td>
|
||||
<td>{message.subject || "—"}</td>
|
||||
<td>{message.envelope_from || message.folder || "—"} → {(message.envelope_recipients || []).join(", ") || message.folder || "—"}</td>
|
||||
<td>{message.attachment_count ?? 0}</td>
|
||||
<td><Button onClick={() => openMockMessage(message.id)}>Open</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
{mockMessages.length === 0 && (
|
||||
<tr><td colSpan={6}>No mock messages captured yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{selectedMockMessage && (
|
||||
<div className="mock-message-detail">
|
||||
<div className="subsection-heading split">
|
||||
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
|
||||
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
|
||||
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
|
||||
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
|
||||
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
|
||||
</div>
|
||||
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
|
||||
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
@@ -216,6 +386,14 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
||||
}
|
||||
|
||||
|
||||
function formatMockDate(value: string): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
|
||||
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
|
||||
if (!result) return null;
|
||||
const authenticated = result.details?.authenticated;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
@@ -10,20 +10,59 @@ 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, isAuditLockedVersion, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
formatDateTime,
|
||||
isAuditLockedVersion,
|
||||
isFinalLockedVersion,
|
||||
isUserLockedVersion,
|
||||
isVersionReadyForDelivery,
|
||||
stringifyPreview,
|
||||
summaryValue,
|
||||
versionLockReason,
|
||||
} from "./utils/campaignView";
|
||||
|
||||
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
export default function ReviewDataPage({
|
||||
settings,
|
||||
campaignId,
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(
|
||||
settings,
|
||||
campaignId,
|
||||
{ includeSummary: true },
|
||||
);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version);
|
||||
const auditSafe = isUserLockedVersion(version) || isFinalLockedVersion(version);
|
||||
const issues = collectIssues(data.summary?.issues);
|
||||
const auditSafe =
|
||||
isUserLockedVersion(version) || isFinalLockedVersion(version);
|
||||
const validationSummary = asRecord(version?.validation_summary);
|
||||
const buildSummary = asRecord(version?.build_summary);
|
||||
const validationOk = validationSummary.ok === true;
|
||||
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "">("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [lastValidationResult, setLastValidationResult] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLastValidationResult(null);
|
||||
}, [version?.id]);
|
||||
|
||||
const issues = useMemo(
|
||||
() =>
|
||||
collectIssues(
|
||||
lastValidationResult,
|
||||
validationSummary,
|
||||
data.summary?.issues,
|
||||
),
|
||||
[lastValidationResult, validationSummary, data.summary?.issues],
|
||||
);
|
||||
|
||||
async function runValidate() {
|
||||
if (!version || actionBusy) return;
|
||||
@@ -32,7 +71,12 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
setError("");
|
||||
try {
|
||||
const result = await validateVersion(settings, version.id, false);
|
||||
setActionMessage(result.ok ? "Validation passed. This version is now locked but can still be unlocked before sending." : "Validation finished with issues. Fix the campaign and validate again.");
|
||||
setLastValidationResult(result);
|
||||
setActionMessage(
|
||||
result.ok
|
||||
? "Validation passed. This version is now locked but can still be unlocked before sending."
|
||||
: "Validation finished with issues. See the validation issues below, fix the campaign, and validate again.",
|
||||
);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setActionMessage("");
|
||||
@@ -49,7 +93,9 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
setError("");
|
||||
try {
|
||||
const result = await buildVersion(settings, version.id, true);
|
||||
setActionMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`);
|
||||
setActionMessage(
|
||||
`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`,
|
||||
);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setActionMessage("");
|
||||
@@ -64,76 +110,228 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
<VersionLine
|
||||
version={version}
|
||||
versions={data.versions}
|
||||
loadedAt={version?.updated_at}
|
||||
/>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
|
||||
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
|
||||
<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>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message={auditSafe ? "Audit-safe snapshot. Create a copy to continue." : "Validated snapshot. Unlock before sending, or copy to edit."} />}
|
||||
{locked && (
|
||||
<LockedVersionNotice
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
version={version}
|
||||
reload={reload}
|
||||
message={
|
||||
auditSafe
|
||||
? "Audit-safe snapshot. Create a copy to continue."
|
||||
: "Validated snapshot. Unlock before sending, or copy to edit."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading review data…">
|
||||
<Card title="Review actions" actions={<span className="muted small-note">Validation locks this version; unlocking invalidates validation before sending.</span>}>
|
||||
<Card
|
||||
title="Review actions"
|
||||
actions={
|
||||
<span className="muted small-note">
|
||||
Validation locks this version; unlocking invalidates validation
|
||||
before sending.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy) || readyForDelivery || auditSafe}>
|
||||
{actionBusy === "validate" ? "Validating…" : readyForDelivery ? "Validated and locked" : validationOk ? "Validate again" : "Validate and lock"}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={runValidate}
|
||||
disabled={
|
||||
!version ||
|
||||
loading ||
|
||||
Boolean(actionBusy) ||
|
||||
readyForDelivery ||
|
||||
auditSafe
|
||||
}
|
||||
>
|
||||
{actionBusy === "validate"
|
||||
? "Validating…"
|
||||
: readyForDelivery
|
||||
? "Validated and locked"
|
||||
: validationOk
|
||||
? "Validate again"
|
||||
: "Validate and lock"}
|
||||
</Button>
|
||||
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !readyForDelivery}>
|
||||
<Button
|
||||
onClick={runBuild}
|
||||
disabled={
|
||||
!version || loading || Boolean(actionBusy) || !readyForDelivery
|
||||
}
|
||||
>
|
||||
{actionBusy === "build" ? "Building…" : "Build queue"}
|
||||
</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>
|
||||
<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">
|
||||
<SummaryTile label="Errors" value={summaryValue(version?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(version?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="Info" value={summaryValue(version?.validation_summary, ["info_count", "info"])} />
|
||||
<SummaryTile label="Validated" value={formatDateTime(version?.updated_at)} />
|
||||
<SummaryTile
|
||||
label="Errors"
|
||||
value={summaryValue(version?.validation_summary, [
|
||||
"error_count",
|
||||
"errors",
|
||||
"blocked",
|
||||
])}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Warnings"
|
||||
value={summaryValue(version?.validation_summary, [
|
||||
"warning_count",
|
||||
"warnings",
|
||||
])}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Info"
|
||||
value={summaryValue(version?.validation_summary, [
|
||||
"info_count",
|
||||
"info",
|
||||
])}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Validated"
|
||||
value={formatDateTime(version?.updated_at)}
|
||||
/>
|
||||
</div>
|
||||
{!version?.validation_summary && <p className="muted">No validation summary is stored yet.</p>}
|
||||
{!version?.validation_summary && (
|
||||
<p className="muted">No validation summary is stored yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Build summary">
|
||||
<div className="summary-grid">
|
||||
<SummaryTile label="Built" value={summaryValue(version?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="Blocked" value={summaryValue(version?.build_summary, ["blocked_count", "blocked"])} />
|
||||
<SummaryTile label="Needs review" value={summaryValue(version?.build_summary, ["needs_review_count", "needs_review"])} />
|
||||
<SummaryTile label="Warnings" value={summaryValue(version?.build_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile
|
||||
label="Built"
|
||||
value={summaryValue(version?.build_summary, [
|
||||
"built_count",
|
||||
"built",
|
||||
"messages_built",
|
||||
])}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Blocked"
|
||||
value={summaryValue(version?.build_summary, [
|
||||
"blocked_count",
|
||||
"blocked",
|
||||
])}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Needs review"
|
||||
value={summaryValue(version?.build_summary, [
|
||||
"needs_review_count",
|
||||
"needs_review",
|
||||
])}
|
||||
/>
|
||||
<SummaryTile
|
||||
label="Warnings"
|
||||
value={summaryValue(version?.build_summary, [
|
||||
"warning_count",
|
||||
"warnings",
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
{!version?.build_summary && <p className="muted">No build summary is stored yet.</p>}
|
||||
{!version?.build_summary && (
|
||||
<p className="muted">No build summary is stored yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Review issues" actions={<span className="muted small-note">Grouped issue display will be expanded in the next review pass.</span>}>
|
||||
{issues.length === 0 && <p className="muted">No stored issues were returned for this campaign summary.</p>}
|
||||
<Card
|
||||
title="Validation issues"
|
||||
actions={
|
||||
issues.length > 0 ? (
|
||||
<span className="muted small-note">{issues.length} issue(s)</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{issues.length === 0 && (
|
||||
<p className="muted">
|
||||
No validation issues are stored for this version. Run validation
|
||||
to populate this list.
|
||||
</p>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Section</th>
|
||||
<th>Location</th>
|
||||
<th>Code</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{issues.map((issue, index) => (
|
||||
<tr key={index}>
|
||||
<td><StatusBadge status={String(issue.severity || "info")} /></td>
|
||||
<td>{String(issue.section || issue.field || "—")}</td>
|
||||
<td>{String(issue.message || issue.code || stringifyPreview(issue, 180))}</td>
|
||||
<tr key={String(issue.issueKey ?? index)}>
|
||||
<td>
|
||||
<StatusBadge
|
||||
status={String(issue.severity || "info")}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{String(
|
||||
issue.path ||
|
||||
issue.source ||
|
||||
issue.section ||
|
||||
issue.field ||
|
||||
"—",
|
||||
)}
|
||||
</td>
|
||||
<td>{String(issue.code || "—")}</td>
|
||||
<td>
|
||||
{String(issue.message || stringifyPreview(issue, 220))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -146,7 +344,13 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({ label, value }: { label: string; value: string | number }) {
|
||||
function SummaryTile({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className="summary-tile">
|
||||
<span>{label}</span>
|
||||
@@ -155,11 +359,68 @@ function SummaryTile({ label, value }: { label: string; value: string | number }
|
||||
);
|
||||
}
|
||||
|
||||
function collectIssues(raw: unknown): Record<string, unknown>[] {
|
||||
function collectIssues(...sources: unknown[]): Record<string, unknown>[] {
|
||||
const byKey = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const source of sources) {
|
||||
for (const issue of collectIssueSource(source)) {
|
||||
const normalized = normalizeIssue(issue);
|
||||
const key =
|
||||
String(normalized.severity ?? "") +
|
||||
"|" +
|
||||
String(
|
||||
normalized.path ??
|
||||
normalized.source ??
|
||||
normalized.section ??
|
||||
normalized.field ??
|
||||
"",
|
||||
) +
|
||||
"|" +
|
||||
String(normalized.code ?? "") +
|
||||
"|" +
|
||||
String(normalized.message ?? "");
|
||||
byKey.set(key, { ...normalized, issueKey: key });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).sort(
|
||||
(left, right) => severityRank(left.severity) - severityRank(right.severity),
|
||||
);
|
||||
}
|
||||
|
||||
function collectIssueSource(raw: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(raw)) return raw.map(asRecord);
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const record = raw as Record<string, unknown>;
|
||||
const direct = asArray(record.items ?? record.issues ?? record.results);
|
||||
if (direct.length) return direct.map(asRecord);
|
||||
return Object.entries(record).flatMap(([section, value]) => asArray(value).map((item) => ({ section, ...asRecord(item) })));
|
||||
return Object.entries(record).flatMap(([section, value]) =>
|
||||
asArray(value).map((item) => ({ section, ...asRecord(item) })),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIssue(
|
||||
issue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...issue,
|
||||
severity: issue.severity ?? issue.level ?? issue.tone ?? "info",
|
||||
path: issue.path ?? issue.source ?? issue.location ?? issue.field,
|
||||
code: issue.code ?? issue.type,
|
||||
message: issue.message ?? issue.detail ?? issue.description,
|
||||
};
|
||||
}
|
||||
|
||||
function severityRank(value: unknown): number {
|
||||
switch (String(value || "info").toLowerCase()) {
|
||||
case "error":
|
||||
case "danger":
|
||||
case "blocked":
|
||||
return 0;
|
||||
case "warning":
|
||||
case "warn":
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import VersionLine from "./components/VersionLine";
|
||||
import Card from "../../components/Card";
|
||||
import MetricCard from "../../components/MetricCard";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import { sendCampaignNow } from "../../api/campaigns";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import { mockSendCampaign, sendCampaignNow } from "../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVersion, isVersionReadyForDelivery, stringifyPreview, versionLockReason } from "./utils/campaignView";
|
||||
|
||||
@@ -27,6 +29,12 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
const [sendMessage, setSendMessage] = useState("");
|
||||
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
||||
const [mockBusy, setMockBusy] = useState(false);
|
||||
const [mockMessage, setMockMessage] = useState("");
|
||||
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [mockAppendSent, setMockAppendSent] = useState(true);
|
||||
const [mockClearFirst, setMockClearFirst] = useState(true);
|
||||
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
||||
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||
const hasBuild = Boolean(version?.build_summary);
|
||||
|
||||
@@ -61,6 +69,51 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
}
|
||||
}
|
||||
|
||||
async function runMockFlow(send: boolean, includeNeedsReview = false) {
|
||||
if (!version || mockBusy) return;
|
||||
setMockBusy(true);
|
||||
setMockMessage(send ? "Running mock delivery…" : "Validating and building mock delivery plan…");
|
||||
setMockResult(null);
|
||||
setSelectedMockMessage(null);
|
||||
setError("");
|
||||
try {
|
||||
const response = await mockSendCampaign(settings, campaignId, {
|
||||
version_id: version.id,
|
||||
send,
|
||||
include_warnings: true,
|
||||
include_needs_review: includeNeedsReview,
|
||||
append_sent: mockAppendSent,
|
||||
clear_mailbox: send && mockClearFirst,
|
||||
check_files: false
|
||||
});
|
||||
const result = asRecord(response.result ?? response);
|
||||
const sendSection = asRecord(result.send);
|
||||
setMockResult(result);
|
||||
setMockMessage(
|
||||
send
|
||||
? `Mock send finished. Captured ${String(sendSection.sent_count ?? 0)} SMTP message(s), failed ${String(sendSection.failed_count ?? 0)}, skipped ${String(sendSection.skipped_count ?? 0)}.`
|
||||
: "Mock review finished. Inspect the steps and job details below before sending."
|
||||
);
|
||||
} catch (err) {
|
||||
setMockMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMockBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openMockMessage(id: string) {
|
||||
setMockBusy(true);
|
||||
try {
|
||||
const response = await getMockMailboxMessage(settings, id);
|
||||
setSelectedMockMessage(response.message);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setMockBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,22 +123,30 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
}, [queuedOrSending, loading, reload, sendBusy]);
|
||||
|
||||
const resultRows = asArray(sendResult?.results).map(asRecord);
|
||||
const mockSteps = asArray(mockResult?.steps).map(asRecord);
|
||||
const mockBuild = asRecord(mockResult?.build);
|
||||
const mockMessages = asArray(mockBuild.messages).map(asRecord);
|
||||
const mockSend = asRecord(mockResult?.send);
|
||||
const mockRows = asArray(mockSend.results).map(asRecord);
|
||||
const mockMailbox = asRecord(mockResult?.mailbox);
|
||||
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
|
||||
<PageTitle loading={loading || sendBusy || mockBusy}>Send</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
|
||||
<Button onClick={reload} disabled={loading || sendBusy || mockBusy}>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>}
|
||||
{mockMessage && <div className="alert info">{mockMessage}</div>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
|
||||
|
||||
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
|
||||
@@ -96,6 +157,123 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
</div>
|
||||
|
||||
<Card title="Mock delivery test" collapsible actions={<span className="muted small-note">Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.</span>}>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runMockFlow(false, false)} disabled={!version || loading || mockBusy}>{mockBusy ? "Working…" : "Review mock steps"}</Button>
|
||||
<Button variant="primary" onClick={() => void runMockFlow(true, false)} disabled={!version || loading || mockBusy}>Mock-send queueable</Button>
|
||||
<Button onClick={() => void runMockFlow(true, true)} disabled={!version || loading || mockBusy}>Mock-send incl. review</Button>
|
||||
</div>
|
||||
<div className="toggle-row mock-send-options">
|
||||
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={mockBusy} onChange={setMockClearFirst} />
|
||||
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={mockBusy} onChange={setMockAppendSent} />
|
||||
</div>
|
||||
|
||||
{mockResult && (
|
||||
<div className="mock-flow-panel">
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table data-table compact-table">
|
||||
<thead><tr><th>Step</th><th>Status</th><th>Summary</th></tr></thead>
|
||||
<tbody>
|
||||
{mockSteps.map((step) => (
|
||||
<tr key={String(step.key)}>
|
||||
<td>{String(step.label ?? step.key)}</td>
|
||||
<td><StatusBadge status={String(step.status ?? "info")} /></td>
|
||||
<td>{stringifyPreview(asRecord(step.summary), 240)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Built messages</h3>
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table data-table compact-table">
|
||||
<thead><tr><th>#</th><th>Recipient</th><th>Subject</th><th>Validation</th><th>Attachments</th><th>Issues</th></tr></thead>
|
||||
<tbody>
|
||||
{mockMessages.map((message) => {
|
||||
const to = asArray(message.to).map(asRecord);
|
||||
const issues = asArray(message.issues).map(asRecord);
|
||||
return (
|
||||
<tr key={String(message.entry_index ?? message.entry_id)}>
|
||||
<td>{String(message.entry_index ?? "—")}</td>
|
||||
<td>{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}</td>
|
||||
<td>{String(message.subject ?? "—")}</td>
|
||||
<td><StatusBadge status={String(message.validation_status ?? "unknown")} /></td>
|
||||
<td>{String(message.attachment_count ?? 0)}</td>
|
||||
<td>{issues.length === 0 ? "—" : issues.map((issue) => String(issue.message ?? issue.code ?? "issue")).join(" · ")}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{mockMessages.length === 0 && <tr><td colSpan={6}>No messages were built.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{mockRows.length > 0 && (
|
||||
<>
|
||||
<h3>Mock send results</h3>
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table data-table compact-table">
|
||||
<thead><tr><th>#</th><th>Status</th><th>Recipient</th><th>SMTP</th><th>IMAP</th><th>Message</th></tr></thead>
|
||||
<tbody>
|
||||
{mockRows.map((row, index) => {
|
||||
const to = asArray(row.to).map(asRecord);
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{String(row.entry_index ?? index + 1)}</td>
|
||||
<td><StatusBadge status={String(row.status ?? "info")} /></td>
|
||||
<td>{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}</td>
|
||||
<td>{String(row.smtp_message_id ?? row.status ?? "—")}</td>
|
||||
<td>{String(row.imap_message_id ?? row.imap_status ?? "—")}</td>
|
||||
<td>{String(row.message ?? "—")}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3>Mock mailbox</h3>
|
||||
<div className="app-table-wrap data-table-wrap">
|
||||
<table className="app-table data-table compact-table">
|
||||
<thead><tr><th>Kind</th><th>Subject</th><th>Envelope / folder</th><th>Attachments</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{mockMailboxMessages.map((message) => (
|
||||
<tr key={String(message.id)}>
|
||||
<td><span className="status-badge neutral">{String(message.kind ?? "mock")}</span></td>
|
||||
<td>{String(message.subject ?? "—")}</td>
|
||||
<td>{String(message.envelope_from ?? message.folder ?? "—")} → {asArray(message.envelope_recipients).join(", ") || String(message.folder ?? "—")}</td>
|
||||
<td>{String(message.attachment_count ?? 0)}</td>
|
||||
<td><Button onClick={() => void openMockMessage(String(message.id))}>Open</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
{mockMailboxMessages.length === 0 && <tr><td colSpan={5}>No mock messages captured in this run.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedMockMessage && (
|
||||
<div className="mock-message-detail">
|
||||
<div className="subsection-heading split">
|
||||
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
|
||||
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
|
||||
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
|
||||
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
|
||||
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
|
||||
</div>
|
||||
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
|
||||
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Send campaign" actions={<span className="muted small-note">Requires a validated, locked and built version. Sending makes it final.</span>}>
|
||||
{!readyForDelivery && <div className="alert warning compact-alert">Validate and lock this version in Review before dry-run or sending.</div>}
|
||||
{readyForDelivery && !hasBuild && <div className="alert warning compact-alert">Build the queue in Review before dry-run or sending.</div>}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
@@ -11,6 +12,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
||||
import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments";
|
||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||
|
||||
@@ -61,6 +63,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
||||
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
||||
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
||||
const previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,9 +242,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
text={previewText}
|
||||
html={previewHtml}
|
||||
hasRealRecipients={inlineEntries.length > 0}
|
||||
attachments={previewAttachments}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
onFirst={() => setPreviewIndex(0)}
|
||||
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
|
||||
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))}
|
||||
onLast={() => setPreviewIndex(previewEntries.length - 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -319,9 +325,12 @@ function TemplatePreviewOverlay({
|
||||
text,
|
||||
html,
|
||||
hasRealRecipients,
|
||||
attachments,
|
||||
onClose,
|
||||
onFirst,
|
||||
onPrevious,
|
||||
onNext
|
||||
onNext,
|
||||
onLast
|
||||
}: {
|
||||
bodyMode: BodyMode;
|
||||
entry: Record<string, unknown>;
|
||||
@@ -331,9 +340,12 @@ function TemplatePreviewOverlay({
|
||||
text: string;
|
||||
html: string;
|
||||
hasRealRecipients: boolean;
|
||||
attachments: EffectiveAttachmentPreview[];
|
||||
onClose: () => void;
|
||||
onFirst: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onLast: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
|
||||
@@ -348,9 +360,12 @@ function TemplatePreviewOverlay({
|
||||
<strong>{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}</strong>
|
||||
<p className="muted small-note">{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onPrevious} disabled={index <= 0}>Previous</Button>
|
||||
<Button onClick={onNext} disabled={index >= total - 1}>Next</Button>
|
||||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
||||
<button type="button" className="version-arrow" onClick={onFirst} disabled={index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={onPrevious} disabled={index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
||||
<span className="template-preview-count">{index + 1} / {total}</span>
|
||||
<button type="button" className="version-arrow" onClick={onNext} disabled={index >= total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
||||
<button type="button" className="version-arrow" onClick={onLast} disabled={index >= total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-preview-box">
|
||||
@@ -361,6 +376,7 @@ function TemplatePreviewOverlay({
|
||||
<pre>{text || "No plain-text body to preview."}</pre>
|
||||
)}
|
||||
</div>
|
||||
<EffectiveAttachmentPreviewTable attachments={attachments} />
|
||||
</div>
|
||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
||||
</div>
|
||||
@@ -368,6 +384,45 @@ function TemplatePreviewOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) {
|
||||
return (
|
||||
<div className="template-preview-attachments">
|
||||
<h3>Effective attachments</h3>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="muted small-note">No global or recipient attachments are effective for this message.</p>
|
||||
) : (
|
||||
<div className="app-table-wrap">
|
||||
<table className="app-table compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Scope</th>
|
||||
<th>Label</th>
|
||||
<th>Base path</th>
|
||||
<th>File / pattern</th>
|
||||
<th>Options</th>
|
||||
<th>Preview match</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attachments.map((attachment, index) => (
|
||||
<tr key={`${attachment.scope}:${attachment.basePath}:${attachment.fileFilter}:${index}`}>
|
||||
<td>{attachment.scope === "global" ? "Global" : "Recipient"}</td>
|
||||
<td>{attachment.label}</td>
|
||||
<td>{attachment.basePathName}<br /><span className="muted"><code>{attachment.basePath}</code></span></td>
|
||||
<td><code>{attachment.fileFilter || "—"}</code></td>
|
||||
<td>{attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""}</td>
|
||||
<td>{attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
const name = valueToPreview(entry.name).trim();
|
||||
const email = valueToPreview(entry.email).trim();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { asArray, asRecord, isRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
|
||||
|
||||
export type AttachmentRule = Record<string, unknown>;
|
||||
|
||||
@@ -9,6 +10,7 @@ export type AttachmentBasePath = {
|
||||
path: string;
|
||||
source?: string;
|
||||
allow_individual?: boolean;
|
||||
unsent_warning?: boolean;
|
||||
};
|
||||
|
||||
export type AttachmentSummary = {
|
||||
@@ -16,6 +18,17 @@ export type AttachmentSummary = {
|
||||
rules: number;
|
||||
};
|
||||
|
||||
export type EffectiveAttachmentPreview = {
|
||||
scope: "global" | "recipient";
|
||||
label: string;
|
||||
basePathName: string;
|
||||
basePath: string;
|
||||
fileFilter: string;
|
||||
required: boolean;
|
||||
includeSubdirs: boolean;
|
||||
matches: string[];
|
||||
};
|
||||
|
||||
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
||||
label: string;
|
||||
};
|
||||
@@ -41,7 +54,8 @@ export function createAttachmentBasePath(name = "New attachment source", path =
|
||||
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name,
|
||||
path,
|
||||
allow_individual: false
|
||||
allow_individual: false,
|
||||
unsent_warning: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +77,8 @@ export function normalizeAttachmentBasePaths(value: unknown, attachments: Record
|
||||
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")
|
||||
allow_individual: getBool(basePath, "allow_individual"),
|
||||
unsent_warning: getBool(basePath, "unsent_warning")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -71,7 +86,8 @@ export function normalizeAttachmentBasePaths(value: unknown, attachments: Record
|
||||
id: "base-path-campaign",
|
||||
name: "Campaign files",
|
||||
path: getText(attachments, "base_path", "."),
|
||||
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual)
|
||||
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
|
||||
unsent_warning: false
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -88,7 +104,6 @@ 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"),
|
||||
@@ -127,3 +142,67 @@ export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
if (!fileFilter) return false;
|
||||
return !/[{}*?\[\]]/.test(fileFilter);
|
||||
}
|
||||
|
||||
|
||||
export function buildEffectiveAttachmentPreviews(draft: Record<string, unknown> | null, entry: Record<string, unknown>): EffectiveAttachmentPreview[] {
|
||||
const attachments = asRecord(draft?.attachments);
|
||||
const basePaths = normalizeAttachmentBasePaths(attachments.base_paths, attachments);
|
||||
const context = buildTemplatePreviewContext(draft, entry);
|
||||
const includeGlobals = getBool(entry, "combine_attachments", true);
|
||||
const globalRules = includeGlobals ? normalizeAttachmentRules(attachments.global) : [];
|
||||
const entryRules = normalizeAttachmentRules(entry.attachments);
|
||||
const individualPaths = basePaths.filter((basePath) => basePath.allow_individual);
|
||||
const legacyIndividualAllowed = getBool(attachments, "allow_individual", individualPaths.length === 0);
|
||||
|
||||
const items: EffectiveAttachmentPreview[] = [];
|
||||
for (const rule of globalRules) {
|
||||
items.push(ruleToAttachmentPreview("global", rule, basePaths, context));
|
||||
}
|
||||
for (const rule of entryRules) {
|
||||
const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ".");
|
||||
const allowed = individualPaths.length > 0
|
||||
? individualPaths.some((basePath) => basePath.path === basePathValue)
|
||||
: legacyIndividualAllowed;
|
||||
if (allowed) items.push(ruleToAttachmentPreview("recipient", rule, basePaths, context));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function ruleToAttachmentPreview(
|
||||
scope: EffectiveAttachmentPreview["scope"],
|
||||
rule: AttachmentRule,
|
||||
basePaths: AttachmentBasePath[],
|
||||
context: Record<string, string>
|
||||
): EffectiveAttachmentPreview {
|
||||
const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ".");
|
||||
const basePath = basePaths.find((item) => item.path === basePathValue);
|
||||
const fileFilterTemplate = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
||||
const fileFilter = renderTemplatePreviewText(fileFilterTemplate, context, true);
|
||||
return {
|
||||
scope,
|
||||
label: getText(rule, "label") || (scope === "global" ? "Global attachment" : "Recipient attachment"),
|
||||
basePathName: basePath?.name || basePathValue || "Campaign files",
|
||||
basePath: basePath?.path || basePathValue || ".",
|
||||
fileFilter,
|
||||
required: getBool(rule, "required", true),
|
||||
includeSubdirs: getBool(rule, "include_subdirs"),
|
||||
matches: previewFileMatches(fileFilter)
|
||||
};
|
||||
}
|
||||
|
||||
function previewFileMatches(fileFilter: string): string[] {
|
||||
const value = fileFilter.trim();
|
||||
if (!value) return [];
|
||||
if (!/[{}*?\[\]]/.test(value)) {
|
||||
return mockAttachmentFiles.filter((file) => file === value || file.endsWith(`/${value}`));
|
||||
}
|
||||
const pattern = globLikePatternToRegExp(value);
|
||||
return mockAttachmentFiles.filter((file) => pattern.test(file));
|
||||
}
|
||||
|
||||
function globLikePatternToRegExp(value: string): RegExp {
|
||||
const escaped = value.replace(/[.+^${}()|\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
|
||||
...asRecord(raw.attachments)
|
||||
};
|
||||
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
||||
raw.validation_policy = isRecord(raw.validation_policy) ? raw.validation_policy : {};
|
||||
raw.validation_policy = {
|
||||
unsent_attachment_files: "warn",
|
||||
...asRecord(raw.validation_policy)
|
||||
};
|
||||
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
|
||||
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
|
||||
return raw;
|
||||
|
||||
@@ -80,6 +80,13 @@ export function buildTemplatePreviewContext(draft: Record<string, unknown> | nul
|
||||
const entryFields = asRecord(entry.fields);
|
||||
const overridePolicy = fieldOverridePolicy(draft);
|
||||
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
addPreviewContextValue(context, name, "global", "");
|
||||
addPreviewContextValue(context, name, "local", "");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(globalValues)) {
|
||||
addPreviewContextValue(context, key, "global", value);
|
||||
addPreviewContextValue(context, key, "local", value);
|
||||
|
||||
@@ -664,6 +664,36 @@
|
||||
width: min(620px, 100%);
|
||||
}
|
||||
|
||||
|
||||
.template-preview-nav {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.template-preview-count {
|
||||
color: var(--muted);
|
||||
margin: 0 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-preview-attachments {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.template-preview-attachments h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.template-preview-attachments .app-table-wrap {
|
||||
max-height: 220px;
|
||||
overflow: scroll;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.template-preview-attachments code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.template-preview-toolbar {
|
||||
display: grid;
|
||||
@@ -802,7 +832,9 @@
|
||||
.attachment-sources-table th:nth-child(2),
|
||||
.attachment-sources-table td:nth-child(2) { min-width: 200px; }
|
||||
.attachment-sources-table th:nth-child(3),
|
||||
.attachment-sources-table td:nth-child(3) { width: 207px; }
|
||||
.attachment-sources-table td:nth-child(3),
|
||||
.attachment-sources-table th:nth-child(4),
|
||||
.attachment-sources-table td:nth-child(4) { width: 170px; }
|
||||
.attachment-sources-table th:last-child,
|
||||
.attachment-sources-table td:last-child { width: 123px; }
|
||||
|
||||
@@ -1112,7 +1144,7 @@
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.version-line button.version-arrow {
|
||||
.version-arrow:is(button) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
@@ -1120,7 +1152,37 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.version-arrow:is(button):disabled {
|
||||
opacity: 0.24;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Overview version history refinements. */
|
||||
.version-history-table .current-version-row td {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mock-message-detail {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--line-subtle);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.mock-message-preview,
|
||||
.mock-message-raw {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-subtle);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
color: var(--text-primary);
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mock-message-raw {
|
||||
max-height: 420px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -550,3 +550,60 @@
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
/* Collapsible cards */
|
||||
.card-collapsible .card-header {
|
||||
gap: 12px;
|
||||
}
|
||||
.card-collapsible .card-actions {
|
||||
align-items: center;
|
||||
}
|
||||
.card-collapse-toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #c9c3b9;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(#ffffff, #f1efeb);
|
||||
color: #4f4a43;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
|
||||
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.card-collapse-toggle:hover {
|
||||
border-color: #aaa299;
|
||||
background: linear-gradient(#ffffff, #e8e5df);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
|
||||
}
|
||||
.card-collapse-toggle:focus-visible {
|
||||
outline: 3px solid rgba(82, 130, 177, .22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-collapse-toggle svg {
|
||||
transition: transform .22s ease;
|
||||
}
|
||||
.card-collapsible.is-collapsed .card-collapse-toggle svg {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
.card-collapse-region {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows .24s ease, opacity .18s ease;
|
||||
}
|
||||
.card-collapsible.is-collapsed .card-collapse-region {
|
||||
display: none;
|
||||
}
|
||||
.card-collapse-region > .card-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card-collapse-toggle,
|
||||
.card-collapse-toggle svg,
|
||||
.card-collapse-region {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user