initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
import ToggleSwitch from "../../../../components/ToggleSwitch";
import EmailAddressInput from "../../../../components/email/EmailAddressInput";
import MetricCard from "../../../../components/MetricCard";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../../utils/emailAddresses";
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
export type WizardStepPatch = (path: string[], value: unknown) => void;
export type WizardStepProps = {
draft: Record<string, unknown>;
patch: WizardStepPatch;
};
export function BasicsStep({ draft, patch }: WizardStepProps) {
const campaign = asRecord(draft.campaign);
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
<option value="draft">Draft</option>
<option value="test">Test</option>
<option value="send">Send</option>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
);
}
export function SenderStep({ draft, patch }: WizardStepProps) {
const recipients = asRecord(draft.recipients);
const from = addressesFromValue(recipients.from);
const suggestions = collectCampaignAddressSuggestions(draft);
const globalTo = addressesFromValue(recipients.to);
const globalCc = addressesFromValue(recipients.cc);
const globalBcc = addressesFromValue(recipients.bcc);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const server = asRecord(draft.server);
const smtp = asRecord(server.smtp);
const delivery = asRecord(draft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
return (
<div className="form-grid">
<FormField label="Default From address">
<EmailAddressInput
value={from}
suggestions={suggestions}
allowMultiple
addLabel="Add From"
emptyText="No global From address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)}
/>
</FormField>
<FormField label="Global recipients">
<EmailAddressInput
value={globalTo}
suggestions={suggestions}
allowMultiple
addLabel="Add recipient"
emptyText="No global recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
/>
</FormField>
<FormField label="CC">
<EmailAddressInput
value={globalCc}
suggestions={suggestions}
allowMultiple
addLabel="Add CC"
emptyText="No global CC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
/>
</FormField>
<FormField label="BCC">
<EmailAddressInput
value={globalBcc}
suggestions={suggestions}
allowMultiple
addLabel="Add BCC"
emptyText="No global BCC recipients configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
/>
</FormField>
<FormField label="Reply-To">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
emptyText="No Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
/>
</FormField>
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
);
}
export function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
return (
<div>
<div className="step-intro">
<h2>Campaign fields</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
</div>
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
</div>
);
}
export function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
</div>
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
</div>
);
}
export function TemplateStep({ draft, patch }: WizardStepProps) {
const template = asRecord(draft.template);
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
</div>
<div className="form-grid">
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
</div>
</div>
);
}
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
const attachments = asRecord(draft.attachments);
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
</div>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div>
);
}
export function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) {
const record = asRecord(version);
return (
<div>
<div className="step-intro">
<h2>Review setup</h2>
<p>Validate the campaign definition before building message drafts.</p>
</div>
<div className="metric-grid inside">
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
</div>
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
</div>
);
}
export function SendStep({ draft, patch }: WizardStepProps) {
const delivery = asRecord(draft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
return (
<div>
<div className="step-intro">
<h2>Send preparation</h2>
<p>Configure rate limits and prepare the final send workflow.</p>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
</div>
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
</div>
);
}
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) {
const [text, setText] = useState(stringifyJson(value));
const [error, setError] = useState("");
useEffect(() => {
setText(stringifyJson(value));
setError("");
}, [value]);
function change(nextText: string) {
setText(nextText);
const parsed = parseJsonTextarea(nextText, value);
setError(parsed.error);
if (!parsed.error) onValid(parsed.value);
}
return (
<div className="json-edit-block">
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>}
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
</div>
);
}