Reload
diff --git a/src/features/campaigns/CampaignWorkspace.tsx b/src/features/campaigns/CampaignWorkspace.tsx
index 1d1f1b0..cbc864e 100644
--- a/src/features/campaigns/CampaignWorkspace.tsx
+++ b/src/features/campaigns/CampaignWorkspace.tsx
@@ -76,7 +76,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
} />
} />
} />
-
} />
+
} />
} />
} />
} />
diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx
index a974760..08526f8 100644
--- a/src/features/campaigns/RecipientDataPage.tsx
+++ b/src/features/campaigns/RecipientDataPage.tsx
@@ -12,7 +12,7 @@ import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, vers
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
-import AttachmentRulesOverlay, { type AttachmentRule } from "./components/AttachmentRulesOverlay";
+import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -46,6 +46,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
+ const attachmentSection = asRecord(draft?.attachments);
+ const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [draft?.attachments]);
+ const individualAttachmentBasePaths = useMemo(() => {
+ const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
+ return enabled.length > 0 ? enabled : attachmentBasePaths;
+ }, [attachmentBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
const globalRecipientValues: Record
= {
to: addressesFromValue(recipientsSection.to),
@@ -292,6 +298,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
disabled={locked}
+ basePaths={individualAttachmentBasePaths}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
@@ -347,6 +354,25 @@ function fallbackRecipientAddress(entry: Record): MailboxAddres
return direct?.email ? [direct] : [];
}
+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 e7e3d1b..05f2a8e 100644
--- a/src/features/campaigns/ReviewDataPage.tsx
+++ b/src/features/campaigns/ReviewDataPage.tsx
@@ -17,6 +17,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
Review
+
Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}
Reload
diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx
index e40c580..e3816dd 100644
--- a/src/features/campaigns/SendDataPage.tsx
+++ b/src/features/campaigns/SendDataPage.tsx
@@ -5,12 +5,13 @@ import PageTitle from "../../components/PageTitle";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
-import { asRecord, getDeliverySection, getNestedString } from "./utils/campaignView";
+import { asRecord, formatDateTime, getDeliverySection, getNestedString } from "./utils/campaignView";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
+ const version = data.currentVersion;
const cards = data.summary?.cards;
- const delivery = getDeliverySection(data.currentVersion);
+ const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const retry = asRecord(delivery.retry);
@@ -20,6 +21,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
Send
+
Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}
Reload
diff --git a/src/features/campaigns/components/AttachmentRulesOverlay.tsx b/src/features/campaigns/components/AttachmentRulesOverlay.tsx
index 25df9a9..f8e01b7 100644
--- a/src/features/campaigns/components/AttachmentRulesOverlay.tsx
+++ b/src/features/campaigns/components/AttachmentRulesOverlay.tsx
@@ -29,6 +29,8 @@ type AttachmentRulesTableProps = {
emptyText?: string;
basePaths?: AttachmentBasePath[];
showAddButton?: boolean;
+ activeChooserRuleIndex?: number | null;
+ onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -47,28 +49,70 @@ export default function AttachmentRulesOverlay({
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
+ const [fileChooser, setFileChooser] = useState
(null);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
+ function patchRule(index: number, patch: Partial) {
+ onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
+ }
+
+ function openFileChooser(ruleIndex: number) {
+ const rule = rules[ruleIndex] ?? {};
+ setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
+ }
+
+ function selectFileFilter(fileFilter: string) {
+ if (!fileChooser) return;
+ patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
+ setFileChooser(null);
+ }
+
+ function closeOverlay() {
+ setFileChooser(null);
+ setOpen(false);
+ }
+
+ const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
+ const activeBasePath = fileChooser
+ ? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
+ : "";
+
const dialog = open ? createPortal(
- {title}
- setOpen(false)}>×
+ {fileChooser ? "Choose file or pattern" : title}
+ ×
-
Use direct files for fixed attachments and rules/patterns for files resolved during build.
-
+ {fileChooser ? (
+
setFileChooser(null)}
+ />
+ ) : (
+ <>
+ Use direct files for fixed attachments and rules/patterns for files resolved during build.
+
+ >
+ )}
- setOpen(false)}>Close
+ {fileChooser ? (
+ setFileChooser(null)}>Back to rules
+ ) : (
+ Close
+ )}
,
@@ -91,6 +135,8 @@ export function AttachmentRulesTable({
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
showAddButton = true,
+ activeChooserRuleIndex = null,
+ onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState(null);
@@ -115,90 +161,112 @@ export function AttachmentRulesTable({
}
function removeRule(index: number) {
+ setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function openFileChooser(ruleIndex: number) {
+ if (onOpenFileChooser) {
+ onOpenFileChooser(ruleIndex);
+ return;
+ }
const rule = rules[ruleIndex] ?? {};
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
}
+ function selectFileFilter(fileFilter: string) {
+ if (!fileChooser) return;
+ patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
+ setFileChooser(null);
+ }
+
+ const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
+ const activeBasePath = fileChooser
+ ? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
+ : "";
+
return (
- <>
- {rules.length === 0 ? (
- {emptyText}
- ) : (
-
- )}
- {showAddButton && (
-
- Add file
-
- )}
+
+
+ {rules.length === 0 ? (
+
{emptyText}
+ ) : (
+
+ )}
+ {showAddButton && (
+
+ Add file
+
+ )}
+
{fileChooser && (
{
- patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
- setFileChooser(null);
- }}
+ basePath={activeBasePath}
+ onSelect={selectFileFilter}
onClose={() => setFileChooser(null)}
/>
)}
- >
+
);
}
function MockFileChooserOverlay({ 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 createPortal(
@@ -207,14 +275,7 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
×
-
Mock chooser for now. Later this will browse uploaded files below {basePath || "."}.
-
- {files.map((file) => (
- onSelect(file)}>
- {file}
-
- ))}
-
+