first wokring prototype
This commit is contained in:
155
src/features/campaigns/utils/campaignView.ts
Normal file
155
src/features/campaigns/utils/campaignView.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { CampaignListItem } from "../../../types";
|
||||
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
|
||||
export type CampaignWorkspaceData = {
|
||||
campaign: CampaignListItem | null;
|
||||
versions: CampaignVersionListItem[];
|
||||
currentVersion: CampaignVersionDetail | null;
|
||||
summary: CampaignSummary | null;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return version?.raw_json ?? version?.campaign_json ?? {};
|
||||
}
|
||||
|
||||
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).campaign);
|
||||
}
|
||||
|
||||
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).recipients);
|
||||
}
|
||||
|
||||
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).template);
|
||||
}
|
||||
|
||||
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).delivery);
|
||||
}
|
||||
|
||||
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).entries);
|
||||
}
|
||||
|
||||
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).attachments);
|
||||
}
|
||||
|
||||
export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
return asArray(getCampaignJson(version).fields);
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
if (version.locked_at || version.published_at) return true;
|
||||
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
|
||||
}
|
||||
|
||||
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`;
|
||||
if (version.published_at) return `Published at ${formatDateTime(version.published_at)}.`;
|
||||
if (["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "")) {
|
||||
return `Workflow state is ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
}
|
||||
return "Editable working version.";
|
||||
}
|
||||
|
||||
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "—";
|
||||
const flow = version.current_flow || "manual";
|
||||
const step = version.current_step || "not set";
|
||||
return `${humanize(flow)} / ${humanize(step)}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
export function humanize(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
|
||||
if (!summary) return "—";
|
||||
for (const key of keys) {
|
||||
const value = summary[key];
|
||||
if (typeof value === "number" || typeof value === "string") return value;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
||||
let current: unknown = record;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) return fallback;
|
||||
current = current[part];
|
||||
}
|
||||
if (typeof current === "string" && current.trim()) return current;
|
||||
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
||||
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
|
||||
if (isRecord(current)) return stringifyPreview(current, 120);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function stringifyPreview(value: unknown, maxLength = 220): string {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
}
|
||||
|
||||
export function cloneCampaignJsonForCopy(
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string
|
||||
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
|
||||
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
|
||||
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
|
||||
const description = String(campaignSection.description || campaign?.description || "");
|
||||
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
|
||||
const name = `${baseName} (copy)`;
|
||||
|
||||
rawJson.campaign = {
|
||||
...campaignSection,
|
||||
id: externalId,
|
||||
name,
|
||||
description
|
||||
};
|
||||
|
||||
return { externalId, name, description, rawJson };
|
||||
}
|
||||
|
||||
export function timestampSlug(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||
}
|
||||
107
src/features/campaigns/utils/draftEditor.ts
Normal file
107
src/features/campaigns/utils/draftEditor.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
|
||||
|
||||
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
export function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? {})) as T;
|
||||
}
|
||||
|
||||
export function ensureCampaignDraft(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
const raw = cloneJson(getCampaignJson(version));
|
||||
raw.version = typeof raw.version === "string" ? raw.version : "1";
|
||||
raw.campaign = {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
mode: "draft",
|
||||
...asRecord(raw.campaign)
|
||||
};
|
||||
raw.fields = Array.isArray(raw.fields) ? raw.fields : [];
|
||||
raw.global_values = isRecord(raw.global_values) ? raw.global_values : {};
|
||||
raw.server = isRecord(raw.server) ? raw.server : {};
|
||||
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
|
||||
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
|
||||
raw.attachments = {
|
||||
base_path: ".",
|
||||
allow_individual: false,
|
||||
send_without_attachments: true,
|
||||
global: [],
|
||||
missing_behavior: "ask",
|
||||
ambiguous_behavior: "ask",
|
||||
...asRecord(raw.attachments)
|
||||
};
|
||||
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
||||
raw.validation_policy = isRecord(raw.validation_policy) ? raw.validation_policy : {};
|
||||
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
|
||||
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function updateNested(
|
||||
draft: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
const next = cloneJson(draft);
|
||||
let current: Record<string, unknown> = next;
|
||||
path.forEach((segment, index) => {
|
||||
if (index === path.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
}
|
||||
const existing = current[segment];
|
||||
if (!isRecord(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseJsonTextarea<T>(text: string, fallback: T): { value: T; error: string } {
|
||||
if (!text.trim()) return { value: fallback, error: "" };
|
||||
try {
|
||||
return { value: JSON.parse(text) as T, error: "" };
|
||||
} catch (error) {
|
||||
return { value: fallback, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
export function getBool(record: Record<string, unknown>, key: string, fallback = false): boolean {
|
||||
const value = record[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function getNumber(record: Record<string, unknown>, key: string, fallback = 0): number {
|
||||
const value = record[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
export function getText(record: Record<string, unknown>, key: string, fallback = ""): string {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function downloadJson(filename: string, data: Record<string, unknown>) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function safeFileStem(value?: string | null): string {
|
||||
const stem = (value || "campaign").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return stem || "campaign";
|
||||
}
|
||||
Reference in New Issue
Block a user