first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8000

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8000

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

125
README.md
View File

@@ -1,2 +1,125 @@
# multi-seal-mail-webui
# MultiMailer WebUI
Standalone React/Vite WebUI for MultiMailer.
## Layout
This shell implements the requested layout:
```text
+--------+----------------------------------------------------------------------------------------+
| MSM | Titlebar with tenant selector ? Help Account selector |
| +----------------------------------------------------------------------------------------+
| Icon | Breadcrumb |
| +---------------+-----------------------------------------------------------------------+
| Icon | Submenu | Content |
| | | |
```
## Run locally
Start the backend first:
```bash
cd multimailer-server/server
source .venv/bin/activate
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
Start the WebUI:
```bash
cd multimailer-webui
npm install
npm run dev
```
Open:
```text
http://127.0.0.1:5173
```
Use the development API key:
```text
dev-multimailer-api-key
```
## Current UI scope
Included now:
- screenshot-inspired app shell
- dark left icon rail
- top titlebar with tenant selector, help and account area
- breadcrumb/action bar
- module submenu
- dashboard cards
- campaign workspace
- Create / Review / Send wizard skeletons
- structured editor placeholders
- settings/admin placeholders
- local API key storage
Next steps:
1. Wire campaign list and version detail to backend.
2. Wire version autosave endpoints.
3. Implement Create Wizard data model.
4. Implement fields editor.
5. Implement recipient mapping table.
6. Implement attachment rule editor.
7. Implement review wizard around missing/ambiguous attachments.
## Troubleshooting: campaigns response does not render
The server returns campaign lists as:
```json
{
"campaigns": []
}
```
The WebUI client normalizes this to an array internally. If the page still shows
"No campaigns loaded yet", check:
```bash
curl -H "X-API-Key: dev-multimailer-api-key" \
http://127.0.0.1:8000/api/v1/campaigns
```
During Vite development, either:
1. leave the WebUI API base URL empty and use the Vite `/api` proxy, or
2. configure FastAPI CORS and use `http://127.0.0.1:8000` as API base URL.
To reset the saved API base URL:
```js
localStorage.removeItem("multimailer.apiSettings.baseUrl")
```
## Login and help shell update
The top line no longer contains the API URL, API key field or Blog link. The URL/API key remain under Settings for development and automation access.
The Help menu includes:
- context-sensitive Help, opened by the Help menu or `F1`
- User docs placeholder
- Admin docs placeholder
- GitLab link
- About modal
The account selector now opens a login flow. Development login requires the backend auth-session update:
```text
Email: admin@example.local
Password: dev-admin
```
The tenant selector is currently informational. It becomes actionable once users can have memberships in multiple tenants.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MultiMailer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
nginx.conf Normal file
View File

@@ -0,0 +1,7 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ /index.html; }
}

1779
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "multimailer-webui",
"version": "0.1.0",
"private": true,
"description": "Standalone MultiMailer WebUI",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.6",
"typescript": "^5.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"lucide-react": "^0.555.0"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2"
}
}

113
src/App.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { useEffect, useState } from "react";
import { loadApiSettings, saveApiSettings } from "./api/client";
import { fetchMe } from "./api/auth";
import type { ApiSettings, AuthInfo, LoginResponse } from "./types";
import AppShell from "./layout/AppShell";
import DashboardPage from "./features/dashboard/DashboardPage";
import CampaignListPage from "./features/campaigns/CampaignListPage";
import CampaignWorkspace from "./features/campaigns/CampaignWorkspace";
import SettingsPage from "./features/settings/SettingsPage";
import AdminPage from "./features/admin/AdminPage";
import TemplatesPage from "./features/templates/TemplatesPage";
import FilesPage from "./features/files/FilesPage";
import PlaceholderPage from "./features/PlaceholderPage";
import PublicLandingPage from "./features/auth/PublicLandingPage";
export default function App() {
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
function updateSettings(next: ApiSettings) {
setSettings(next);
saveApiSettings(next);
}
function updateAuth(next: AuthInfo | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next);
if (accessToken !== undefined) {
setSettings(nextSettings);
saveApiSettings(nextSettings);
}
}
function handlePublicLogin(response: LoginResponse) {
const active = response.active_tenant ?? response.tenant;
updateAuth(
{
user: response.user,
tenant: active,
active_tenant: active,
tenants: response.tenants ?? [active],
scopes: response.scopes,
roles: response.roles,
groups: response.groups
},
response.access_token
);
}
useEffect(() => {
if (!settings.accessToken) {
setAuth(null);
setCheckingSession(false);
return;
}
let cancelled = false;
setCheckingSession(true);
fetchMe(settings)
.then((me) => { if (!cancelled) setAuth(me); })
.catch(() => {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
}
})
.finally(() => {
if (!cancelled) setCheckingSession(false);
});
return () => { cancelled = true; };
}, [settings.accessToken, settings.apiBaseUrl]);
if (checkingSession) {
return (
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode>
<div className="public-landing">
<section className="public-card">
<div className="public-kicker">MultiMailer</div>
<h1>Checking session</h1>
<p>Please wait while the local session is verified.</p>
</section>
</div>
</AppShell>
);
}
if (!auth) {
return (
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode>
<PublicLandingPage settings={settings} onLogin={handlePublicLogin} />
</AppShell>
);
}
return (
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth}>
<Routes>
<Route path="/" element={<Navigate to="/campaigns" />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/campaigns" element={<CampaignListPage settings={settings} />} />
<Route path="/campaigns/:campaignId/*" element={<CampaignWorkspace settings={settings} />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
<Route path="/settings" element={<SettingsPage settings={settings} onSettingsChange={updateSettings} />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</AppShell>
);
}

20
src/api/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { ApiSettings, AuthInfo, LoginResponse } from "../types";
import { apiFetch } from "./client";
export async function login(
settings: ApiSettings,
payload: { email: string; password: string }
): Promise<LoginResponse> {
return apiFetch<LoginResponse>({ ...settings, accessToken: "", apiKey: "" }, "/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
}
export async function logout(settings: ApiSettings): Promise<void> {
await apiFetch(settings, "/api/v1/auth/logout", { method: "POST" });
}

289
src/api/campaigns.ts Normal file
View File

@@ -0,0 +1,289 @@
import type { ApiSettings, CampaignListItem } from "../types";
import { apiFetch } from "./client";
export type CampaignListResponse =
| CampaignListItem[]
| {
campaigns?: CampaignListItem[];
items?: CampaignListItem[];
results?: CampaignListItem[];
};
export type CampaignCreateMinimalPayload = {
external_id?: string;
name?: string;
description?: string | null;
current_flow?: string;
current_step?: string;
};
export type CampaignCreateResponse = {
campaign: CampaignListItem & {
current_version_id?: string | null;
};
version: CampaignVersionListItem;
};
export type CampaignVersionListItem = {
id: string;
campaign_id: string;
version_number: number;
schema_version?: string;
source_filename?: string | null;
source_base_path?: string | null;
workflow_state?: string;
current_flow?: string;
current_step?: string | null;
is_complete?: boolean;
editor_state?: Record<string, unknown>;
autosaved_at?: string | null;
published_at?: string | null;
locked_at?: string | null;
locked_by_user_id?: string | null;
created_at?: string;
updated_at?: string;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
};
export type CampaignVersionDetail = CampaignVersionListItem & {
raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>;
};
export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
current_step?: string | null;
workflow_state?: string | null;
is_complete?: boolean | null;
editor_state?: Record<string, unknown> | null;
source_filename?: string | null;
source_base_path?: string | null;
};
export type CampaignPartialValidationPayload = {
campaign_json?: Record<string, unknown> | null;
section?: string | null;
};
export type CampaignPartialValidationResponse = {
ok: boolean;
section?: string | null;
error_count: number;
warning_count: number;
info_count: number;
issues: Record<string, unknown>[];
};
export type CampaignSummary = {
generated_at?: string;
campaign?: CampaignListItem;
current_version?: {
id: string;
version_number?: number;
schema_version?: string;
source_filename?: string | null;
created_at?: string | null;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
} | null;
cards?: {
jobs_total?: number;
queueable?: number;
needs_attention?: number;
sent?: number;
failed?: number;
imap_appended?: number;
imap_failed?: number;
};
status_counts?: Record<string, Record<string, number>>;
issues?: Record<string, unknown>;
attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>;
delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[];
};
export type CampaignQueuePayload = {
version_id?: string | null;
include_warnings?: boolean;
enqueue_celery?: boolean;
dry_run?: boolean;
};
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
if (Array.isArray(response)) {
return response;
}
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}
export async function createNewCampaign(
settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {}
): Promise<CampaignCreateResponse> {
const now = new Date();
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const payload = {
external_id: overrides.external_id ?? `new-campaign-${stamp}`,
name: overrides.name ?? "New Campaign",
description: overrides.description ?? "",
current_flow: overrides.current_flow ?? "create",
current_step: overrides.current_step ?? "basics"
};
return apiFetch<CampaignCreateResponse>(settings, "/api/v1/campaigns/new", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function getCampaignSchema(settings: ApiSettings): Promise<unknown> {
return apiFetch(settings, "/api/v1/schemas/campaign");
}
export async function listCampaignVersions(
settings: ApiSettings,
campaignId: string
): Promise<CampaignVersionListItem[]> {
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
}
export async function getCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
}
export async function updateCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function autosaveCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function setCampaignVersionStep(
settings: ApiSettings,
campaignId: string,
versionId: string,
currentStep: string,
currentFlow?: string | null
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
method: "POST",
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
});
}
export async function validatePartial(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignPartialValidationPayload = {}
): Promise<CampaignPartialValidationResponse> {
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function publishCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
method: "POST"
});
}
export async function validateVersion(
settings: ApiSettings,
versionId: string,
checkFiles = false
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST",
body: JSON.stringify({ check_files: checkFiles })
});
}
export async function buildVersion(
settings: ApiSettings,
versionId: string,
writeEml = true
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
method: "POST",
body: JSON.stringify({ write_eml: writeEml })
});
}
export async function getCampaignSummary(settings: ApiSettings, campaignId: string): Promise<CampaignSummary> {
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`);
}
export async function getCampaignJobs(settings: ApiSettings, campaignId: string): Promise<{ jobs: Record<string, unknown>[] }> {
return apiFetch<{ jobs: Record<string, unknown>[] }>(settings, `/api/v1/campaigns/${campaignId}/jobs`);
}
export async function queueCampaign(
settings: ApiSettings,
campaignId: string,
payload: CampaignQueuePayload = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
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" });
}
export async function resumeCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/resume`, { method: "POST" });
}
export async function cancelCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/cancel`, { method: "POST" });
}
export async function appendSent(
settings: ApiSettings,
campaignId: string,
dryRun = false
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST",
body: JSON.stringify({ dry_run: dryRun })
});
}

62
src/api/client.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings";
export function loadApiSettings(): ApiSettings {
const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`);
return {
// Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI.
apiBaseUrl:
storedBaseUrl !== null
? storedBaseUrl
: import.meta.env.VITE_API_BASE_URL ?? "",
apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "",
accessToken: localStorage.getItem(`${STORAGE_KEY}.accessToken`) || ""
};
}
export function saveApiSettings(settings: ApiSettings): void {
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, settings.apiBaseUrl);
localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey);
localStorage.setItem(`${STORAGE_KEY}.accessToken`, settings.accessToken);
}
export function clearAccessToken(): void {
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
}
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
const baseUrl = settings.apiBaseUrl.trim().replace(/\/$/, "");
const url = baseUrl ? `${baseUrl}${path}` : path;
const headers = new Headers(init?.headers || {});
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (settings.accessToken) {
headers.set("Authorization", `Bearer ${settings.accessToken}`);
} else if (settings.apiKey) {
headers.set("X-API-Key", settings.apiKey);
}
const response = await fetch(url, { ...init, headers });
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${text}`);
}
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return (await response.text()) as T;
}
return (await response.json()) as T;
}

75
src/api/mail.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
};
export type MailImapTestPayload = MailSmtpTestPayload & {
enabled?: boolean;
sent_folder?: string | null;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export async function testSmtpSettings(
settings: ApiSettings,
payload: MailSmtpTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function testImapSettings(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listImapFolders(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
}

View File

@@ -0,0 +1,5 @@
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
return <button className={`btn btn-${variant} ${className}`} {...props} />;
}

19
src/components/Card.tsx Normal file
View File

@@ -0,0 +1,19 @@
type CardProps = {
title?: React.ReactNode;
children: React.ReactNode;
actions?: React.ReactNode;
};
export default function Card({ title, children, actions }: CardProps) {
return (
<section className="card">
{(title || actions) && (
<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>}
</header>
)}
<div className="card-body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) {
return (
<label className="form-field">
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
{children}
</label>
);
}

View File

@@ -0,0 +1,12 @@
type LoadingIndicatorProps = {
label?: string;
size?: "sm" | "md";
};
export default function LoadingIndicator({ label = "Loading", size = "sm" }: LoadingIndicatorProps) {
return (
<span className={`loading-indicator loading-indicator-${size}`} role="status" aria-label={label} title={label}>
<span className="loading-envelope" aria-hidden="true" />
</span>
);
}

View File

@@ -0,0 +1,9 @@
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
return (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{label}</div>
<div className="metric-value">{value}</div>
{detail && <div className="metric-detail">{detail}</div>}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import LoadingIndicator from "./LoadingIndicator";
type PageTitleProps = {
children: React.ReactNode;
loading?: boolean;
};
export default function PageTitle({ children, loading = false }: PageTitleProps) {
return (
<h1 className="page-title-with-loader">
<span>{children}</span>
{loading && <LoadingIndicator label="Loading page data" />}
</h1>
);
}

View File

@@ -0,0 +1,3 @@
export default function StatusBadge({ status }: { status: string }) {
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{status}</span>;
}

View File

@@ -0,0 +1,19 @@
import type { WizardStep } from "../types";
export default function Stepper({ steps, activeStep, onSelect }: { steps: WizardStep[]; activeStep: string; onSelect: (id: string) => void }) {
return (
<ol className="stepper">
{steps.map((step, index) => (
<li key={step.id} className={`step ${activeStep === step.id ? "active" : ""} step-${step.status || "todo"}`}>
<button onClick={() => onSelect(step.id)}>
<span className="step-number">{index + 1}</span>
<span>
<strong>{step.label}</strong>
{step.description && <small>{step.description}</small>}
</span>
</button>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
type ToggleSwitchProps = {
label: ReactNode;
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
help?: ReactNode;
};
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<input
className="toggle-switch-input"
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange?.(event.target.checked)}
/>
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
<span className="toggle-switch-copy">
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{label}</FieldLabel>
</span>
</label>
);
}

View File

@@ -0,0 +1,246 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import Button from "../Button";
import {
addressDisplayName,
dedupeAddresses,
isValidEmailAddress,
normalizeEmailAddress,
parseMailboxAddressText,
type MailboxAddress
} from "../../utils/emailAddresses";
type EmailAddressInputProps = {
value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void;
suggestions?: MailboxAddress[];
allowMultiple?: boolean;
clearOnAdd?: boolean;
disabled?: boolean;
addLabel?: string;
namePlaceholder?: string;
emailPlaceholder?: string;
emptyText?: string;
compact?: boolean;
showAddButton?: boolean;
};
export default function EmailAddressInput({
value,
onChange,
onAddressAdded,
suggestions = [],
allowMultiple = true,
clearOnAdd = false,
disabled = false,
addLabel = "Add",
namePlaceholder = "Name",
emailPlaceholder = "email@example.org",
emptyText = "No address added yet.",
compact = false,
showAddButton
}: EmailAddressInputProps) {
const inputId = useId();
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
const [entryText, setEntryText] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogName, setDialogName] = useState("");
const [dialogEmail, setDialogEmail] = useState("");
const [error, setError] = useState("");
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const addButtonRef = useRef<HTMLButtonElement | null>(null);
const canUseAddButton = showAddButton ?? allowMultiple;
const filteredSuggestions = useMemo(() => {
const query = entryText.trim().toLowerCase();
if (!query) return normalizedSuggestions.slice(0, 6);
return normalizedSuggestions
.filter((item) => `${item.name ?? ""} ${item.email}`.toLowerCase().includes(query))
.slice(0, 6);
}, [entryText, normalizedSuggestions]);
useEffect(() => {
if (!dialogOpen) return;
function updatePopoverPosition() {
const trigger = addButtonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportPadding = 16;
const width = Math.min(360, Math.max(280, window.innerWidth - viewportPadding * 2));
const estimatedHeight = 250;
const left = Math.min(Math.max(viewportPadding, rect.right - width), window.innerWidth - width - viewportPadding);
const belowTop = rect.bottom + 8;
const aboveTop = rect.top - estimatedHeight - 8;
const top = belowTop + estimatedHeight > window.innerHeight && aboveTop > viewportPadding ? aboveTop : belowTop;
setPopoverStyle({
position: "fixed",
top,
left,
width,
zIndex: 10000
});
}
updatePopoverPosition();
window.addEventListener("resize", updatePopoverPosition);
window.addEventListener("scroll", updatePopoverPosition, true);
return () => {
window.removeEventListener("resize", updatePopoverPosition);
window.removeEventListener("scroll", updatePopoverPosition, true);
};
}, [dialogOpen]);
function removeAddress(emailToRemove: string) {
if (disabled) return;
onChange?.(normalizedValue.filter((address) => address.email !== emailToRemove));
setError("");
}
function commitAddress(candidate: MailboxAddress | null, sourceText = "") {
if (disabled) return false;
if (!candidate?.email) {
setError(sourceText ? "Use a valid address such as Name <email@example.org>." : "Enter an email address first.");
return false;
}
const normalized = normalizeEmailAddress(candidate);
if (!isValidEmailAddress(normalized.email)) {
setError("Enter a valid email address.");
return false;
}
if (allowMultiple && normalizedValue.some((address) => address.email === normalized.email)) {
setError("This address is already listed.");
return false;
}
onAddressAdded?.(normalized);
if (!clearOnAdd) {
const nextValue = allowMultiple ? dedupeAddresses([...normalizedValue, normalized]) : [normalized];
onChange?.(nextValue);
}
setEntryText("");
setDialogName("");
setDialogEmail("");
setDialogOpen(false);
setError("");
return true;
}
function commitTypedAddress() {
const text = entryText.trim();
if (!text) return;
commitAddress(parseMailboxAddressText(text), text);
}
function commitDialogAddress() {
commitAddress({ name: dialogName, email: dialogEmail }, `${dialogName} ${dialogEmail}`);
}
function handleTextKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
commitTypedAddress();
return;
}
if (event.key === "Backspace" && !entryText && normalizedValue.length > 0 && !disabled) {
event.preventDefault();
const last = normalizedValue[normalizedValue.length - 1];
removeAddress(last.email);
}
}
function applySuggestion(address: MailboxAddress) {
commitAddress(address);
}
const addressDialog = dialogOpen && canUseAddButton && !disabled ? createPortal(
<div
className="email-address-popover"
style={popoverStyle}
role="dialog"
aria-modal="false"
aria-labelledby={`${inputId}-dialog-title`}
onKeyDown={(event) => {
if (event.key === "Escape") setDialogOpen(false);
}}
>
<h4 id={`${inputId}-dialog-title`}>Add address</h4>
<label>
<span>Name</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
</label>
<label>
<span>Email address</span>
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
</label>
<div className="button-row compact-actions">
<Button type="button" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
</div>
</div>,
document.body
) : null;
return (
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>}
{normalizedValue.map((address) => {
const valid = isValidEmailAddress(address.email);
return (
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "Invalid email address"}>
<span className="email-chip-main">{addressDisplayName(address)}</span>
{address.name && <span className="email-chip-address">{address.email}</span>}
{!disabled && (
<button type="button" className="email-chip-remove" aria-label={`Remove ${address.email}`} onClick={() => removeAddress(address.email)}>
×
</button>
)}
</span>
);
})}
</div>
{!disabled && (
<>
<textarea
id={inputId}
className="email-address-textarea"
rows={1}
value={entryText}
onChange={(event) => {
setEntryText(event.target.value);
setError("");
}}
onKeyDown={handleTextKeyDown}
placeholder={`${namePlaceholder} <${emailPlaceholder}>`}
aria-label="Type a name and email address, then press Enter"
/>
{canUseAddButton && (
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="Open address form" title="Add address with form" onClick={() => setDialogOpen((open) => !open)}>
+
</button>
)}
</>
)}
</div>
{!disabled && filteredSuggestions.length > 0 && entryText.trim() && (
<div className="email-address-suggestions" role="listbox" aria-label="Address suggestions">
{filteredSuggestions.map((item) => (
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
<span>{addressDisplayName(item)}</span>
{item.name && <small>{item.email}</small>}
</button>
))}
</div>
)}
{error && <p className="form-help danger-text">{error}</p>}
{addressDialog}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import InlineHelp from "./InlineHelp";
type FieldLabelProps = {
children: ReactNode;
help?: ReactNode;
className?: string;
};
export default function FieldLabel({ children, help, className = "" }: FieldLabelProps) {
return (
<span className={`field-label ${className}`.trim()}>
<span className="field-label-text">{children}</span>
{help && <InlineHelp>{help}</InlineHelp>}
</span>
);
}

View File

@@ -0,0 +1,186 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
type InlineHelpProps = {
children: ReactNode;
className?: string;
};
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 10000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove
? topCandidate
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid var(--line-dark)",
borderTop: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid var(--line-dark)",
borderBottom: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={`inline-help ${className}`.trim()}
tabIndex={0}
aria-label="Show field help"
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}
>
<span className="inline-help-mark" aria-hidden="true">?</span>
</span>
{isOpen && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,15 @@
import Card from "../components/Card";
export default function PlaceholderPage({ title }: { title: string }) {
return (
<div className="content-pad">
<div className="page-heading">
<h1>{title}</h1>
<p>This module is prepared but not implemented yet.</p>
</div>
<Card>
<p className="muted">Next passes will add functionality here.</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
type AdminSection = "overview" | "users" | "groups" | "roles" | "tenants" | "api-keys" | "audit" | "system";
const sections: { id: AdminSection; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "users", label: "Users" },
{ id: "groups", label: "Groups" },
{ id: "roles", label: "Roles" },
{ id: "tenants", label: "Tenants" },
{ id: "api-keys", label: "API keys" },
{ id: "audit", label: "Audit" },
{ id: "system", label: "System" }
];
export default function AdminPage() {
const [active, setActive] = useState<AdminSection>("overview");
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<div className="section-title">ADMIN</div>
{sections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label}
</button>
))}
</aside>
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Administration</PageTitle>
<p>Tenant, user, role and operational administration surfaces.</p>
</div>
<div className="button-row compact-actions"><Button disabled>Refresh</Button></div>
</div>
{active === "overview" && <Overview />}
{active === "users" && <PlaceholderAdminTable title="Users" columns={["User", "Tenant admin", "Status", "Last activity"]} rows={["admin@example.local|Yes|Active|Development seed"]} action="Create user" />}
{active === "groups" && <PlaceholderAdminTable title="Groups" columns={["Group", "Members", "Campaign access", "Status"]} rows={["Default administrators|1|All campaigns|Seed data"]} action="Create group" />}
{active === "roles" && <PlaceholderAdminTable title="Roles and permissions" columns={["Role", "Permissions", "Scope", "Status"]} rows={["Owner|All current permissions|Tenant|Seed data", "Campaign operator|View/edit/review/send planned|Campaign/group|Planned"]} action="Create role" />}
{active === "tenants" && <PlaceholderAdminTable title="Tenants" columns={["Tenant", "Slug", "Users", "Status"]} rows={["Default|default|1|Active"]} action="Create tenant" />}
{active === "api-keys" && <ApiKeys />}
{active === "audit" && <Audit />}
{active === "system" && <System />}
</div>
</section>
</div>
);
}
function Overview() {
return (
<>
<div className="metric-grid">
<Card title="Users"><strong className="module-big-number"></strong><p className="muted">Backend list endpoint pending.</p></Card>
<Card title="Groups"><strong className="module-big-number"></strong><p className="muted">Backend list endpoint pending.</p></Card>
<Card title="API keys"><strong className="module-big-number">Create-only</strong><p className="muted">Creation endpoint exists; listing/revocation UI pending.</p></Card>
<Card title="Audit"><strong className="module-big-number">Available</strong><p className="muted">Audit search can be wired next.</p></Card>
</div>
<Card title="Administration roadmap">
<div className="placeholder-stack">
<span>User and invitation management</span>
<span>Group and campaign sharing permissions</span>
<span>Role assignment and tenant administration</span>
<span>API key lifecycle: create, label, revoke, rotate</span>
</div>
</Card>
</>
);
}
function PlaceholderAdminTable({ title, columns, rows, action }: { title: string; columns: string[]; rows: string[]; action: string }) {
return (
<Card title={title} actions={<Button disabled>{action}</Button>}>
<div className="alert info">This view is laid out for production use, but the corresponding backend list/write endpoints still need to be added.</div>
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
<tbody>
{rows.map((row) => <tr key={row}>{row.split("|").map((cell, index) => <td key={`${row}-${index}`}>{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
</Card>
);
}
function ApiKeys() {
return (
<Card title="API keys" actions={<Button disabled>Create API key</Button>}>
<p className="muted">The backend has API-key support, but a complete key lifecycle UI needs list, revoke and rotate endpoints before this can be safely exposed.</p>
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Name</th><th>Scope</th><th>Status</th><th>Last used</th></tr></thead>
<tbody><tr><td>Development key</td><td>Automation</td><td><StatusBadge status="dev" /></td><td>Local only</td></tr></tbody>
</table>
</div>
</Card>
);
}
function Audit() {
return (
<Card title="Administrative audit">
<p className="muted">Administrative audit filtering will reuse the audit backend. Campaign-specific audit remains inside each campaign workspace.</p>
<div className="placeholder-stack"><span>User changes</span><span>Role changes</span><span>API key lifecycle</span><span>Tenant settings</span></div>
</Card>
);
}
function System() {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="System health">
<dl className="detail-list compact-detail-list">
<div><dt>Backend</dt><dd>Check via Settings Connection</dd></div>
<div><dt>Queue</dt><dd>Planned</dd></div>
<div><dt>Storage</dt><dd>Planned</dd></div>
<div><dt>Mail tests</dt><dd>Planned</dd></div>
</dl>
</Card>
<Card title="Operational actions"><div className="button-row compact-actions stacked-actions"><Button disabled>Run health check</Button><Button disabled>View worker status</Button><Button disabled>Export diagnostics</Button></div></Card>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types";
import { login } from "../../api/auth";
import Button from "../../components/Button";
import FormField from "../../components/FormField";
export default function LoginModal({
settings,
onClose,
onLogin
}: {
settings: ApiSettings;
onClose: () => void;
onLogin: (response: LoginResponse) => void;
}) {
const [email, setEmail] = useState("admin@example.local");
const [password, setPassword] = useState("dev-admin");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: React.FormEvent) {
event.preventDefault();
setError("");
setBusy(true);
try {
const response = await login(settings, { email, password });
onLogin(response);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<form className="modal-panel" onSubmit={submit}>
<header className="modal-header">
<h2>Sign in</h2>
<button className="modal-close" type="button" onClick={onClose}>×</button>
</header>
<div className="modal-body form-grid">
<div className="login-hint">Development default: user <strong>admin@example.local</strong>, password <strong>dev-admin</strong>.</div>
{error && <div className="alert danger">{error}</div>}
<FormField label="Email">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</FormField>
<FormField label="Password">
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</FormField>
</div>
<footer className="modal-footer">
<Button type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" variant="primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</Button>
</footer>
</form>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types";
import Button from "../../components/Button";
import LoginModal from "./LoginModal";
export default function PublicLandingPage({
settings,
onLogin
}: {
settings: ApiSettings;
onLogin: (response: LoginResponse) => void;
}) {
const [loginOpen, setLoginOpen] = useState(false);
return (
<div className="public-landing">
<section className="public-card">
<div className="public-kicker">MultiMailer</div>
<h1>Controlled bulk mail campaigns with guided review.</h1>
<p>
Build structured campaigns, validate recipient data and attachments,
review issues, and send messages through controlled queues with reporting.
</p>
<div className="public-actions">
<Button variant="primary" onClick={() => setLoginOpen(true)}>Sign in</Button>
</div>
<div className="public-footnote">
Access is restricted. Sign in to open campaigns, templates, reports and administration.
</div>
</section>
{loginOpen && (
<LoginModal
settings={settings}
onClose={() => setLoginOpen(false)}
onLogin={(response) => {
onLogin(response);
setLoginOpen(false);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, stringifyPreview, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type AttachmentRule = Record<string, unknown>;
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const attachments = asRecord(draft?.attachments);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const entries = asRecord(draft?.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) })));
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchGlobalAttachment(index: number, patchValue: Partial<AttachmentRule>) {
const nextRules = globalRules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patchValue } : rule);
patch(["attachments", "global"], nextRules);
}
function addGlobalAttachment() {
const nextRules: AttachmentRule[] = [
...globalRules,
{
id: `global-${Date.now()}`,
label: "",
base_dir: "",
file_filter: "",
required: true,
include_subdirs: false,
allow_multiple: false,
missing_behavior: getText(attachments, "missing_behavior", "ask"),
ambiguous_behavior: getText(attachments, "ambiguous_behavior", "ask")
}
];
patch(["attachments", "global"], nextRules);
}
function removeGlobalAttachment(index: number) {
patch(["attachments", "global"], globalRules.filter((_, currentIndex) => currentIndex !== index));
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "files",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved attachment settings",
message: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Manage files</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{draft && (
<>
<div className="dashboard-grid">
<Card title="Attachment area">
<dl className="detail-list">
<div><dt>Attachment base path</dt><dd>{String(attachments.base_path || ".")}</dd></div>
<div><dt>Global files</dt><dd>{globalRules.length}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRules.length}</dd></div>
<div><dt>Upload support</dt><dd>Planned</dd></div>
</dl>
</Card>
<Card title="Campaign file storage">
<p className="muted">This section will become the Garage/S3-backed file picker for tenant, group and campaign attachment areas.</p>
<div className="placeholder-stack">
<span>Upload campaign files</span>
<span>Pick files for global attachment rules</span>
<span>Resolve missing or ambiguous individual matches</span>
</div>
</Card>
</div>
<Card title="Global attachment files" actions={<Button onClick={addGlobalAttachment} disabled={locked}>Add file</Button>}>
<div className="attachment-base-stack">
<div className="attachment-base-grid">
<FormField label="Attachment base path"><input value={getText(attachments, "base_path", ".")} disabled={locked} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<div className="attachment-base-toggle"><ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} /></div>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Default missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Default ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
</div>
</div>
{globalRules.length === 0 ? (
<p className="muted small-note">No global files selected. Add files here only if every message should include them.</p>
) : (
<div className="app-table-wrap compact-table-wrap">
<table className="app-table direct-attachment-table">
<thead>
<tr>
<th>Label</th>
<th>Base dir</th>
<th>Selected file / pattern</th>
<th>Required</th>
<th>Include subdirs</th>
<th></th>
</tr>
</thead>
<tbody>
{globalRules.map((rule, index) => (
<tr key={String(rule.id ?? index)}>
<td><input value={getText(rule, "label")} disabled={locked} placeholder="Attachment label" onChange={(event) => patchGlobalAttachment(index, { label: event.target.value })} /></td>
<td><input value={getText(rule, "base_dir")} disabled={locked} placeholder="optional/folder" onChange={(event) => patchGlobalAttachment(index, { base_dir: event.target.value })} /></td>
<td><input value={getText(rule, "file_filter")} disabled={locked} placeholder="file.pdf or {{field}}.pdf" onChange={(event) => patchGlobalAttachment(index, { file_filter: event.target.value })} /></td>
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { required: checked })} /></td>
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={locked} onChange={(checked) => patchGlobalAttachment(index, { include_subdirs: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeGlobalAttachment(index)} disabled={locked}>Remove</Button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<Card title="Per-recipient file patterns" actions={<Link to="../recipients"><Button>Open recipients</Button></Link>}>
<p className="muted small-note">Individual file patterns can be edited on each recipient row. They are summarized here because file matching and upload review also belong to the Attachments workflow.</p>
<div className="app-table-wrap data-table-wrap">
<table className="app-table files-table">
<thead>
<tr>
<th>Recipient / entry</th>
<th>Label</th>
<th>Base dir</th>
<th>Filter</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{individualRules.length === 0 && (
<tr><td colSpan={5} className="muted">No per-recipient file patterns are configured yet.</td></tr>
)}
{individualRules.map((rule, index) => <RuleRow key={`individual-${index}`} scope={`Entry ${rule.entry}`} rule={rule} />)}
</tbody>
</table>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}
function RuleRow({ scope, rule }: { scope: string; rule: Record<string, unknown> }) {
return (
<tr>
<td>{scope}</td>
<td>{String(rule.label || rule.id || "—")}</td>
<td><code>{String(rule.base_dir || "—")}</code></td>
<td><code>{String(rule.file_filter || "—")}</code></td>
<td><code>{stringifyPreview({ required: rule.required, allow_multiple: rule.allow_multiple, zip: rule.zip }, 120)}</code></td>
</tr>
);
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `global-${Math.random().toString(36).slice(2)}`),
label: getText(rule, "label"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter"),
include_subdirs: getBool(rule, "include_subdirs"),
required: getBool(rule, "required", true),
allow_multiple: getBool(rule, "allow_multiple"),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}

View File

@@ -0,0 +1,14 @@
import Card from "../../components/Card";
export default function CampaignAuditPage() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading">
<h1>Audit log</h1>
</div>
<Card title="Recent audit events">
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const campaignModeOptions = ["draft", "test", "send"];
export default function CampaignDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const campaign = asRecord(draft?.campaign);
const recipients = asRecord(draft?.recipients);
const from = asRecord(recipients.from);
const defaultFrom = addressesFromValue(from);
const globalReplyTo = addressesFromValue(recipients.reply_to);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "campaign-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved general campaign data",
message: "General campaign data has unsaved changes. Save it before leaving, or discard it and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>General</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing general campaign data.</div>}
{draft && (
<>
<div className="campaign-settings-stack">
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={getText(campaign, "id")} disabled={locked} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={getText(campaign, "mode", "draft")} disabled={locked} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<FormField label="Name">
<input value={getText(campaign, "name")} disabled={locked} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField>
<FormField label="Description">
<textarea rows={4} value={getText(campaign, "description")} disabled={locked} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField>
</div>
</Card>
<Card title="Campaign sender">
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual senders"
checked={getBool(recipients, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
/>
</div>
</div>
<div className="campaign-header-grid">
<FormField label="Global Reply-To address">
<EmailAddressInput
value={globalReplyTo.slice(0, 1)}
suggestions={addressSuggestions}
allowMultiple={false}
showAddButton={false}
disabled={locked}
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>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual Reply-To"
checked={getBool(recipients, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
/>
</div>
</div>
</div>
</Card>
<Card title="Related campaign areas">
<div className="related-link-grid">
<Link to="../recipients" className="related-link-card">
<strong>Recipients</strong>
<span>Recipient rows, global recipient headers and recipient-specific header overrides.</span>
</Link>
<Link to="../global-settings" className="related-link-card">
<strong>Global settings</strong>
<span>Policies, attachment defaults, delivery defaults and opt-ins.</span>
</Link>
<Link to="../fields" className="related-link-card">
<strong>Fields</strong>
<span>Define fields, global values and recipient override behavior.</span>
</Link>
<Link to="../files" className="related-link-card">
<strong>Attachments</strong>
<span>Configure global attachments and per-recipient file patterns.</span>
</Link>
</div>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const fieldTypeOptions = ["string", "integer", "double", "date", "password"];
type FieldDefinition = {
name: string;
label: string;
type: string;
required: boolean;
can_override: boolean;
};
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const fields = useMemo(() => normalizeFields(draft?.fields), [draft?.fields]);
const globalValues = asRecord(draft?.global_values);
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
useEffect(() => {
if (!version) return;
const nextDraft = migrateFieldOverridePolicy(ensureCampaignDraft(version), asRecord(version.editor_state));
fieldValueKeys.current = normalizeFields(nextDraft.fields).map((field) => field.name);
setDraft(nextDraft);
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patchDraft(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchFields(nextFields: FieldDefinition[]) {
patchDraft(["fields"], nextFields.map((field) => ({
name: field.name,
type: field.type || "string",
label: field.label,
required: field.required,
can_override: field.can_override
})));
}
function patchGlobalValues(nextValues: Record<string, unknown>) {
patchDraft(["global_values"], nextValues);
}
function setField(index: number, patchValue: Partial<FieldDefinition>) {
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
patchFields(nextFields);
}
function renameField(index: number, nextName: string) {
const oldName = fields[index]?.name ?? "";
const cleanedName = nextName.trim();
const duplicate = Boolean(cleanedName) && fields.some((field, currentIndex) => currentIndex !== index && field.name === cleanedName);
const valueKey = fieldValueKeys.current[index] ?? oldName;
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, name: cleanedName } : field);
const nextGlobalValues = { ...globalValues };
if (!duplicate && cleanedName) {
if (valueKey && valueKey !== cleanedName && Object.prototype.hasOwnProperty.call(nextGlobalValues, valueKey)) {
nextGlobalValues[cleanedName] = nextGlobalValues[valueKey];
delete nextGlobalValues[valueKey];
}
fieldValueKeys.current[index] = cleanedName;
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function addField() {
const name = uniqueFieldName(fields);
const nextFields = [...fields, { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }];
fieldValueKeys.current = [...fieldValueKeys.current, name];
const nextGlobalValues = { ...globalValues, [name]: "" };
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function deleteField(index: number) {
const field = fields[index];
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
fieldValueKeys.current = fieldValueKeys.current.filter((_, currentIndex) => currentIndex !== index);
const nextGlobalValues = { ...globalValues };
if (field?.name) {
delete nextGlobalValues[field.name];
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
setDirty(true);
setLocalError("");
}
function setGlobalValue(key: string, value: string) {
patchGlobalValues({ ...globalValues, [key]: value });
}
function setOverrideAllowed(index: number, allowed: boolean) {
setField(index, { can_override: allowed });
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
return false;
}
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "campaign-fields",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved fields",
message: "Fields have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{fieldNameWarning && <div className="alert warning">{fieldNameWarning}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.</div>}
{draft && (
<>
<Card
title="Fields and global values"
actions={<Button variant="primary" onClick={addField} disabled={locked}>Add field</Button>}
>
<div className="app-table-wrap field-editor-table-wrap">
<table className="app-table field-editor-table">
<thead>
<tr>
<th>Field ID</th>
<th>Label</th>
<th>Type</th>
<th>Required</th>
<th>Global value</th>
<th>Recipient override</th>
<th></th>
</tr>
</thead>
<tbody>
{fields.length === 0 ? (
<tr>
<td colSpan={7} className="empty-table-cell">No campaign fields configured yet.</td>
</tr>
) : fields.map((field, index) => (
<tr key={`field-row-${index}`}>
<td><input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} /></td>
<td><input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} /></td>
<td>
<select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: event.target.value })}>
{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</td>
<td><ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} /></td>
<td><input value={valueToText(globalValues[field.name])} disabled={locked || !field.name} placeholder="Optional default" onChange={(event) => setGlobalValue(field.name, event.target.value)} /></td>
<td><ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} /></td>
<td className="table-action-cell"><Button variant="danger" disabled={locked} onClick={() => deleteField(index)}>Delete</Button></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
)}
</div>
);
}
function normalizeFields(value: unknown): FieldDefinition[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((field) => ({
name: getText(field, "name"),
label: getText(field, "label"),
type: fieldTypeOptions.includes(getText(field, "type")) ? getText(field, "type") : "string",
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}));
}
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;
const fields = normalizeFields(draft.fields).map((field) => {
if (!field.name || !Object.prototype.hasOwnProperty.call(overridePolicy, field.name)) return field;
return { ...field, can_override: getBool(overridePolicy, field.name, true) };
});
return updateNested(draft, ["fields"], fields);
}
function describeFieldNameProblem(fields: FieldDefinition[]): string {
const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving.";
}
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
}
function uniqueFieldName(fields: FieldDefinition[]): string {
const existing = new Set(fields.map((field) => field.name));
let counter = fields.length + 1;
let name = `field_${counter}`;
while (existing.has(name)) {
counter += 1;
name = `field_${counter}`;
}
return name;
}
function humanizeFieldName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function valueToText(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}

View File

@@ -0,0 +1,32 @@
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const campaignJson = getCampaignJson(data.currentVersion);
const campaign = asRecord(campaignJson.campaign);
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!data.currentVersion}>Download JSON</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<Card>
{!loading || data.currentVersion ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import StatusBadge from "../../components/StatusBadge";
import LoadingIndicator from "../../components/LoadingIndicator";
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
const hasAuth = Boolean(settings.accessToken || settings.apiKey);
async function load() {
if (!hasAuth) return;
setLoading(true);
setError("");
try {
const data = await listCampaigns(settings);
setCampaigns(data);
setLastLoadedAt(formatLoadedAt(new Date()));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function create() {
setCreating(true);
setError("");
try {
const created = await createNewCampaign(settings);
navigate(`/campaigns/${created.campaign.id}/wizard/create`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreating(false);
}
}
useEffect(() => {
load();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
return (
<div className="content-pad campaigns-page">
{!hasAuth && (
<div className="alert warning">Sign in with your user account or configure an automation API key under Settings to load campaigns.</div>
)}
{error && <div className="alert danger">{error}</div>}
<Card
title={<span className="card-heading-with-loader">All campaigns {loading && <LoadingIndicator label="Loading campaigns" />}</span>}
actions={
<div className="campaign-card-actions">
{lastLoadedAt && <span className="last-loaded">Last loaded: {lastLoadedAt}</span>}
<div className="button-row compact-actions">
<Button onClick={load} disabled={!hasAuth || loading}>Reload</Button>
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
{creating ? "Creating…" : "New campaign"}
</Button>
</div>
</div>
}
>
{!loading && campaigns.length === 0 && (
<div className="empty-state">
<h2>No campaigns yet</h2>
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
Create first campaign
</Button>
</div>
)}
{!loading && campaigns.length > 0 && (
<div className="app-table-wrap campaign-table-wrap">
<table className="app-table campaign-table">
<thead>
<tr>
<th>Campaign</th>
<th>Status</th>
<th>Current version</th>
<th>Updated</th>
<th aria-label="Open"></th>
</tr>
</thead>
<tbody>
{campaigns.map((campaign) => (
<tr key={campaign.id}>
<td>
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
{campaign.name || campaign.external_id || campaign.id}
</Link>
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
</td>
<td><StatusBadge status={campaign.status || "draft"} /></td>
<td className="version-cell mono-small" title={campaign.current_version_id || undefined}>
{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}
</td>
<td className="updated-cell">{formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at)}</td>
<td><Link className="table-action-link" to={`/campaigns/${campaign.id}`}>View</Link></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function shortId(value: string): string {
if (value.length <= 20) return value;
return `${value.slice(0, 12)}${value.slice(-6)}`;
}
function formatDateTime(value?: string): 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"
});
}
function formatLoadedAt(value: Date): string {
return value.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}

View File

@@ -0,0 +1,340 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
cloneCampaignJsonForCopy,
getCampaignJson,
getString,
isAuditLockedVersion,
summaryValue,
timestampSlug,
versionLockReason
} from "./utils/campaignView";
import { addressesFromValue } from "../../utils/emailAddresses";
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [copying, setCopying] = useState(false);
const [locking, setLocking] = useState(false);
const [message, setMessage] = useState("");
const campaign = data.campaign;
const currentVersion = data.currentVersion;
const campaignJson = getCampaignJson(currentVersion);
const locked = isAuditLockedVersion(currentVersion);
const cards = data.summary?.cards;
const overviewFacts = getOverviewFacts(campaignJson, campaign);
async function copyCampaign() {
if (!currentVersion) return;
setCopying(true);
setMessage("");
setError("");
try {
const copy = cloneCampaignJsonForCopy(campaignJson, campaign, timestampSlug());
const created = await createNewCampaign(settings, {
external_id: copy.externalId,
name: copy.name,
description: copy.description,
current_flow: "manual",
current_step: "copied"
});
await updateCampaignVersion(settings, created.campaign.id, created.version.id, {
campaign_json: copy.rawJson,
current_flow: "manual",
current_step: null,
workflow_state: "editing",
is_complete: false,
editor_state: {
copied_from_campaign_id: campaignId,
copied_from_version_id: currentVersion.id
}
});
navigate(`/campaigns/${created.campaign.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCopying(false);
}
}
async function lockCampaign() {
if (!currentVersion || locked) return;
const confirmed = window.confirm(
"Lock this campaign version for audit-safe use? The current version should no longer be edited afterwards; create a copy if you need a new working version."
);
if (!confirmed) return;
setLocking(true);
setMessage("");
setError("");
try {
await publishCampaignVersion(settings, campaignId, currentVersion.id);
setMessage("Campaign version locked as the current audit-safe version.");
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLocking(false);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
<p className="mono-small">{campaign?.external_id || campaignId}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={copyCampaign} disabled={!currentVersion || copying}>{copying ? "Copying…" : "Copy campaign"}</Button>
<Button variant="primary" onClick={lockCampaign} disabled={!currentVersion || locked || locking}>
{locking ? "Locking…" : locked ? "Locked" : "Lock campaign"}
</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{message && <div className="alert success">{message}</div>}
{locked && (
<div className="alert info">
This version is audit-safe and should be treated as read-only. {versionLockReason(currentVersion)} Only workflow state should change from here.
</div>
)}
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Built and ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review before sending" />
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Guided actions" actions={<span className="muted small-note">Wizards change or advance the campaign; data pages display and edit the current working draft.</span>}>
<div className="wizard-action-grid">
<WizardAction
title={locked ? "Create a new working copy" : "Edit campaign structure"}
description={locked ? "This version is locked. Copy the campaign before editing structural data." : "Open the structured create/edit wizard for overview, recipients, template and attachments."}
to="wizard/create"
label={locked ? "Open wizard read-only" : "Open Create Campaign"}
/>
<WizardAction
title="Resolve review issues"
description="Use a guided flow for validation issues, missing recipients or attachment decisions."
to="wizard/review"
label="Open Review Wizard"
/>
<WizardAction
title="Prepare sending"
description="Use the sending wizard for dry runs, rate limits, test sending and queue preparation."
to="wizard/send"
label="Open Send Wizard"
/>
</div>
</Card>
<div className="overview-config-grid">
<ConfigShortcutCard
title="General"
description="Name, sender and global recipients."
facts={overviewFacts.campaignSettings}
actions={[{ to: "data", label: "General" }]}
/>
<ConfigShortcutCard
title="Global settings"
description="Policies, opt-ins and delivery defaults."
facts={overviewFacts.globalSettings}
actions={[{ to: "global-settings", label: "Global settings" }]}
/>
<ConfigShortcutCard
title="Fields"
description="Field definitions and global values."
facts={overviewFacts.fields}
actions={[{ to: "fields", label: "Fields" }]}
/>
<ConfigShortcutCard
title="Recipients"
description="Recipient list and per-recipient values."
facts={overviewFacts.recipients}
actions={[{ to: "recipients", label: "Recipients" }]}
/>
<ConfigShortcutCard
title="Template"
description="Message content, preview and field usage."
facts={overviewFacts.template}
actions={[{ to: "template", label: "Template" }]}
/>
<ConfigShortcutCard
title="Attachments"
description="Global attachments and per-recipient rules."
facts={overviewFacts.files}
actions={[{ to: "files", label: "Attachments" }]}
/>
</div>
<Card title="Validation and build state">
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={cards?.jobs_total ?? "—"} />
</div>
<p className="muted">Review and sending are displayed on their own data pages; use the guided buttons above to change state.</p>
</Card>
</div>
);
}
type OverviewFact = {
label: string;
value: string | number;
};
function ConfigShortcutCard({
title,
description,
facts,
actions
}: {
title: string;
description: string;
facts: OverviewFact[];
actions: Array<{ to: string; label: string }>;
}) {
return (
<section className="overview-config-card">
<h3>{title}</h3>
<p>{description}</p>
<dl className="overview-config-facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<div className="overview-config-actions">
{actions.map((action) => (
<Link key={action.to} to={action.to}>
<Button>{action.label}</Button>
</Link>
))}
</div>
</section>
);
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function WizardAction({ title, description, to, label }: { title: string; description: string; to: string; label: string }) {
return (
<section className="wizard-action-card">
<h3>{title}</h3>
<p>{description}</p>
<Link to={to}><Button>{label}</Button></Link>
</section>
);
}
function getOverviewFacts(rawJson: Record<string, unknown>, campaign: { name?: string; external_id?: string; id?: string; status?: string } | null) {
const campaignSection = asRecord(rawJson.campaign);
const recipients = asRecord(rawJson.recipients);
const attachments = asRecord(rawJson.attachments);
const template = asRecord(rawJson.template);
const entries = asRecord(rawJson.entries);
const validationPolicy = asRecord(rawJson.validation_policy);
const delivery = asRecord(rawJson.delivery);
const fields = asArray(rawJson.fields).map(asRecord);
const globalValues = asRecord(rawJson.global_values);
const inlineEntries = asArray(entries.inline).map(asRecord);
const entrySource = asRecord(entries.source);
const globalAttachmentRules = asArray(attachments.global).map(asRecord);
const individualAttachmentRules = inlineEntries.reduce((count, entry) => count + asArray(entry.attachments).length, 0);
const globalRecipients = ["to", "cc", "bcc"].reduce((count, key) => count + addressesFromValue(recipients[key]).length, 0);
return {
campaignSettings: [
{ label: "Name", value: getString(campaignSection, "name", campaign?.name || "—") },
{ label: "Campaign ID", value: getString(campaignSection, "id", campaign?.external_id || campaign?.id || "—") },
{ label: "Sender", value: formatMailbox(recipients.from) }
],
globalSettings: [
{ label: "Mode", value: getString(campaignSection, "mode", campaign?.status || "draft") },
{ label: "Attachment policy", value: `${getString(attachments, "missing_behavior", "ask")} / ${getString(attachments, "ambiguous_behavior", "ask")}` },
{ label: "Delivery", value: getString(delivery, "mode", getString(validationPolicy, "send_without_attachments", "standard")) }
],
fields: [
{ label: "Fields", value: fields.length },
{ label: "Global values", value: Object.keys(globalValues).length },
{ label: "Required", value: fields.filter((field) => field.required === true).length }
],
recipients: [
{ label: "Recipients", value: recipientSummary(inlineEntries, entrySource) },
{ label: "Global recipients", value: globalRecipients },
{ label: "Source", value: sourceSummary(entrySource) }
],
template: [
{ label: "Subject", value: getString(template, "subject", "Not configured") },
{ label: "Source", value: templateSourceSummary(template) },
{ label: "Placeholders", value: countTemplatePlaceholders(template) }
],
files: [
{ label: "Base path", value: getString(attachments, "base_path", ".") },
{ label: "Global files", value: globalAttachmentRules.length },
{ label: "Individual rules", value: individualAttachmentRules }
]
};
}
function formatMailbox(value: unknown): string {
const [address] = addressesFromValue(value);
if (!address) return "Not configured";
return address.name ? `${address.name} <${address.email}>` : address.email;
}
function recipientSummary(inlineEntries: Record<string, unknown>[], source: Record<string, unknown>): string {
if (inlineEntries.length) return `${inlineEntries.length} inline`;
if (Object.keys(source).length) return "External source";
return "Not configured";
}
function sourceSummary(source: Record<string, unknown>): string {
if (!Object.keys(source).length) return "Inline / manual";
return getString(source, "type", getString(source, "path", "External"));
}
function templateSourceSummary(template: Record<string, unknown>): string {
const libraryId = getString(template, "library_id", "");
const templateId = getString(template, "template_id", "");
const source = getString(template, "source", "");
if (libraryId) return `Library: ${libraryId}`;
if (templateId) return `Library: ${templateId}`;
if (source) return source;
return "Inline campaign template";
}
function countTemplatePlaceholders(template: Record<string, unknown>): number {
const text = `${getString(template, "subject", "")}
${getString(template, "text", "")}
${getString(template, "html", "")}`;
const matches = text.match(/\{\{\s*[\w.-]+\s*\}\}/g) ?? [];
return new Set(matches.map((item) => item.replace(/[{}\s]/g, ""))).size;
}

View File

@@ -0,0 +1,38 @@
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { formatDateTime } from "./utils/campaignView";
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const cards = data.summary?.cards;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<div className="dashboard-grid">
<Card title="Report summary">
<dl className="detail-list">
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>Sent</dt><dd>{cards?.sent ?? "—"}</dd></div>
<div><dt>Failed</dt><dd>{cards?.failed ?? "—"}</dd></div>
</dl>
</Card>
<Card title="Exports">
<p className="muted">CSV export and report-emailing buttons will be added once the report section is reviewed.</p>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type { ApiSettings, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignDataPage from "./CampaignDataPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import ReviewDataPage from "./ReviewDataPage";
import SendDataPage from "./SendDataPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
campaign: "data",
"global-settings": "global-settings",
fields: "fields",
recipients: "recipients",
template: "template",
files: "files",
"mail-settings": "mail-settings",
review: "review",
send: "send",
report: "report",
audit: "audit",
json: "json"
};
export default function CampaignWorkspace({ settings }: { settings: ApiSettings }) {
return (
<CampaignUnsavedChangesProvider>
<CampaignWorkspaceInner settings={settings} />
</CampaignUnsavedChangesProvider>
);
}
function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
const { campaignId } = useParams();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const location = useLocation();
const active = sectionFromPath(location.pathname);
function select(section: CampaignWorkspaceSection) {
const path = sectionPaths[section];
const target = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
if (location.pathname === target) return;
requestNavigation(() => navigate(target));
}
return (
<div className="workspace">
<SectionSidebar active={active} onSelect={select} />
<section className="workspace-content">
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="data" element={<CampaignDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="review" element={<ReviewDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} />
<Route path="wizard/send" element={<SendWizard />} />
<Route path="create" element={<Navigate to="../wizard/create" replace />} />
<Route path="campaign" element={<Navigate to="../data" replace />} />
<Route path="mail" element={<Navigate to="../mail-settings" replace />} />
<Route path="settings" element={<Navigate to="../global-settings" replace />} />
</Routes>
</section>
</div>
);
}
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
const segments = pathname.split("/").filter(Boolean);
const section = segments[2];
if (!section || section === "wizard" || section === "create") return "overview";
if (section === "data" || section === "campaign") return "campaign";
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";
if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
if (section === "review") return "review";
if (section === "send") return "send";
if (section === "report" || section === "reports") return "report";
if (section === "audit") return "audit";
if (section === "json") return "json";
return "overview";
}

View File

@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type EditorState = Record<string, unknown>;
export default function GlobalSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [editorState, setEditorState] = useState<EditorState>({});
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const validationPolicy = asRecord(draft?.validation_policy);
const attachments = asRecord(draft?.attachments);
const delivery = asRecord(draft?.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry);
const statusTracking = asRecord(draft?.status_tracking);
const optIns = asRecord(editorState.opt_ins);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setEditorState(cloneJson(version.editor_state ?? {}));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
editor_state: editorState,
current_flow: "manual",
current_step: "global-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setEditorState(cloneJson(saved.editor_state ?? editorState));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved global settings",
message: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Global settings</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.</div>}
{draft && (
<>
<div className="dashboard-grid">
<Card title="Validation policy">
<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="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)} />
</Card>
<Card title="Attachment defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Missing behavior">
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} 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")} disabled={locked} 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")} disabled={locked} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
</div>
<p className="muted small-note">The actual global and per-recipient attachment rules live in Files. These settings define the campaign-wide defaults used by validation and review.</p>
</Card>
</div>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults">
<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)} disabled={locked} 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)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<Card title="Opt-ins and local assistance">
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save current draft</Button>
</div>
</>
)}
</div>
);
}
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
);
}

View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
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 loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const server = asRecord(draft?.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const delivery = asRecord(draft?.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapEnabled = getBool(imap, "enabled");
const imapDisabled = locked || !imapEnabled;
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "mail-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
if (!enabled) {
patch(["delivery", "imap_append_sent", "enabled"], false);
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved server settings",
message: "Server settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function smtpPayload() {
return {
host: emptyToNull(getText(smtp, "host")),
port: getNumber(smtp, "port", 587),
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false),
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
};
}
function imapPayload() {
return {
enabled: true,
host: emptyToNull(getText(imap, "host")),
port: getNumber(imap, "port", 993),
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false),
security: readSecurity(getText(imap, "security", "tls"), "tls"),
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
};
}
async function runSmtpTest() {
if (locked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(await testSmtpSettings(settings, smtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(await testImapSettings(settings, imapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (imapDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(await listImapFolders(settings, imapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || imapDisabled) return;
patch(["server", "imap", "sent_folder"], folder);
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
patch(["delivery", "imap_append_sent", "folder"], folder);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing server settings.</div>}
{draft && (
<>
<Card title="Mail server settings">
<div className="mail-server-settings-grid">
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
</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>
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={locked} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(smtp, "username")} disabled={locked} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={locked} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={locked} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={locked} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : "Test SMTP login"}</Button>
</div>
<MailActionResult result={smtpTestResult} />
</section>
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>IMAP sent-folder append</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked} onChange={toggleImap} />
</div>
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : "Test IMAP login"}</Button>
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
</div>
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
<MailActionResult result={imapTestResult} />
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
</section>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<div className={`alert ${result.ok ? "success" : "danger"}`}>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</div>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <div className="alert danger">{result.message}</div>;
}
return (
<div className="alert success">
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{result.folders.length > 0 && (
<div className="field-chip-list">
{result.folders.slice(0, 12).map((folder) => (
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,305 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, stringifyPreview, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
type MailboxAddress
} from "../../utils/emailAddresses";
const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
];
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const recipientsSection = asRecord(draft?.recipients);
const entries = asRecord(draft?.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function appendRecipient(address: MailboxAddress) {
const nextEntry = {
id: `recipient-${inlineEntries.length + 1}`,
active: true,
to: [address],
name: address.name ?? "",
email: address.email,
fields: {},
attachments: []
};
replaceInlineEntries([...inlineEntries, nextEntry]);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryRecipient(index: number, addresses: MailboxAddress[]) {
const address = addresses[0] ?? { name: "", email: "" };
updateEntry(index, (entry) => ({
...entry,
to: address.email ? [address] : [],
name: address.name ?? "",
email: address.email
}));
}
function updateEntryField(index: number, field: string, value: string) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, text: string) {
const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments));
if (parsed.error) {
setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`);
return;
}
updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value }));
}
function removeEntry(index: number) {
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
}
async function saveRecipients(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "recipient-data",
workflow_state: "editing",
is_complete: false
});
setDraft(ensureCampaignDraft(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved recipient changes",
message: "Recipients or recipient header settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveRecipients("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipients</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{draft && (
<>
<Card title="Global recipient headers">
<div className="campaign-header-stack">
{recipientHeaderRows.map((row) => (
<div className="campaign-header-grid" key={row.key}>
<FormField label={row.label}>
<EmailAddressInput
value={globalRecipientValues[row.key] ?? []}
suggestions={addressSuggestions}
allowMultiple
disabled={locked}
addLabel={row.addLabel}
emptyText={row.emptyText}
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label={row.toggleLabel}
checked={getBool(recipientsSection, row.toggleKey)}
disabled={locked}
onChange={(checked) => patch(["recipients", row.toggleKey], checked)}
/>
</div>
</div>
))}
</div>
</Card>
<Card title="Recipients" actions={<span className="muted small-note">Editable inline recipients with mail-style address chips, field values and individual attachment config.</span>}>
{draft && inlineEntries.length === 0 && !source.type && <p className="muted">No recipient data is stored in the current version yet.</p>}
{draft && inlineEntries.length === 0 && Boolean(source.type) && (
<div className="alert info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</div>
)}
{draft && (
<div className="app-table-wrap recipient-table-wrap">
<table className="app-table recipient-table recipient-editor-table">
<thead>
<tr>
<th>#</th>
<th>Recipient</th>
<th>Status</th>
{fieldNames.map((field) => <th key={field}>{field}</th>)}
<th>Individual attachments</th>
<th aria-label="Actions"></th>
</tr>
</thead>
<tbody>
<tr className="recipient-add-row">
<td className="mono-small">+</td>
<td colSpan={Math.max(2, fieldNames.length + 3)}>
<EmailAddressInput
value={[]}
suggestions={addressSuggestions}
clearOnAdd
disabled={locked || !draft}
addLabel="Add recipient"
emptyText="Add a new inline recipient."
onAddressAdded={appendRecipient}
/>
</td>
<td></td>
</tr>
{inlineEntries.slice(0, 100).map((entry, index) => {
const recipient = primaryRecipient(entry);
const fields = asRecord(entry.fields);
const attachments = asArray(entry.attachments);
return (
<tr key={String(entry.id || index)}>
<td className="mono-small">{index + 1}</td>
<td>
<EmailAddressInput
value={recipient.email ? [recipient] : []}
suggestions={addressSuggestions}
allowMultiple={false}
compact
disabled={locked}
addLabel={recipient.email ? "Replace" : "Add"}
emptyText="No recipient address."
onChange={(addresses) => updateEntryRecipient(index, addresses)}
/>
</td>
<td><StatusBadge status={String(entry.active === false ? "inactive" : "active")} /></td>
{fieldNames.map((field) => (
<td key={field}>
<input
className="recipient-field-input"
value={String(fields[field] ?? "")}
disabled={locked}
onChange={(event) => updateEntryField(index, field, event.target.value)}
/>
</td>
))}
<td>
<textarea
className="recipient-attachments-input"
rows={2}
value={attachments.length ? stringifyJson(attachments) : "[]"}
disabled={locked}
title={attachments.length ? stringifyPreview(attachments, 180) : undefined}
onChange={(event) => updateEntryAttachments(index, event.target.value)}
/>
</td>
<td><Button variant="danger" onClick={() => removeEntry(index)} disabled={locked}>Remove</Button></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveRecipients("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}
function getDraftFieldNames(draft: Record<string, unknown> | null): string[] {
return asArray(draft?.fields)
.map((field) => asRecord(field))
.map((field) => String(field.name || field.id || ""))
.filter(Boolean);
}
function primaryRecipient(entry: Record<string, unknown>): MailboxAddress {
const to = addressesFromValue(entry.to)[0];
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return to ?? direct ?? { name: "", email: "" };
}

View File

@@ -0,0 +1,96 @@
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import Card from "../../components/Card";
import StatusBadge from "../../components/StatusBadge";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, stringifyPreview, summaryValue } from "./utils/campaignView";
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const issues = collectIssues(data.summary?.issues);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Review</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<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)} />
</div>
{!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"])} />
</div>
{!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>}
{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>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>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function collectIssues(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) })));
}

View File

@@ -0,0 +1,66 @@
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
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";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const cards = data.summary?.cards;
const delivery = getDeliverySection(data.currentVersion);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const retry = asRecord(delivery.retry);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Send</PageTitle>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<div className="metric-grid">
<MetricCard label="Queueable" value={cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
<MetricCard label="Sent" value={cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<div className="dashboard-grid">
<Card title="Delivery rate limit">
<dl className="detail-list">
<div><dt>Messages/minute</dt><dd>{String(rateLimit.messages_per_minute ?? "—")}</dd></div>
<div><dt>Concurrency</dt><dd>{String(rateLimit.concurrency ?? "—")}</dd></div>
<div><dt>Max attempts</dt><dd>{String(retry.max_attempts ?? "—")}</dd></div>
<div><dt>Backoff</dt><dd>{getNestedString(delivery, ["retry", "backoff_seconds"])}</dd></div>
</dl>
</Card>
<Card title="Sent-folder append">
<dl className="detail-list">
<div><dt>Enabled</dt><dd>{String(Boolean(imapAppend.enabled))}</dd></div>
<div><dt>Folder</dt><dd>{String(imapAppend.folder || "auto")}</dd></div>
<div><dt>Appended</dt><dd>{cards?.imap_appended ?? "—"}</dd></div>
<div><dt>Append failed</dt><dd>{cards?.imap_failed ?? "—"}</dd></div>
</dl>
</Card>
</div>
<Card title="Sending rule">
<p className="muted">
SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.
</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,558 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getTemplateSection, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
type BodyMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
type TemplateNamespace = "global" | "local";
type TemplatePlaceholder = {
raw: string;
namespace: string;
name: string;
validNamespace: boolean;
display: string;
};
type UndefinedPlaceholder = TemplatePlaceholder & {
reason: "missing-field" | "invalid-namespace";
};
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const loadedVersionId = useRef<string | null>(null);
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const template = draft ? asRecord(draft.template) : getTemplateSection(version);
const fields = useMemo(() => asArray(draft?.fields).map(asRecord), [draft]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(draft?.global_values))]), [draft?.global_values, localFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]);
const entries = asRecord(draft?.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}];
const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {};
const ignoreEmptyFields = getBool(asRecord(draft?.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders
.filter((field) => !field.validNamespace || !allAvailableNames.has(field.name))
.map((field): UndefinedPlaceholder => ({
...field,
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
}))), [usedPlaceholders, allAvailableNames]);
const previewContext = useMemo(() => buildPreviewContext(draft, previewEntry), [draft, previewEntry]);
const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setPreviewIndex(0);
setSaveState(version.autosaved_at ? `Loaded autosave ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`;
patchTemplateText(target, nextText);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + token.length;
element?.setSelectionRange(cursor, cursor);
});
}
function addUndefinedField(field: UndefinedPlaceholder) {
if (!draft || locked || !field.name) return;
const existingFields = asArray(draft.fields).map(asRecord);
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
if (!alreadyDefined) {
patch(["fields"], [
...existingFields,
{
name: field.name,
label: humanizeFieldName(field.name),
type: "string",
required: false,
can_override: true
}
]);
}
setUndefinedDialog(null);
}
function removePlaceholder(field: UndefinedPlaceholder) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw);
nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw);
nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw);
next.template = nextTemplate;
return next;
});
setDirty(true);
setLocalError("");
setUndefinedDialog(null);
}
async function saveTemplate(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "template",
workflow_state: "editing",
is_complete: false
});
setDraft(ensureCampaignDraft(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
setSaveState("Save failed");
return false;
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved template changes",
message: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveTemplate("manual"),
onDiscard: () => setDirty(false)
} : null);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</PageTitle>
<p className="mono-small">{saveState}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Select template</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
{draft && (
<>
<div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
<div className="form-grid">
<FormField label="Subject">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)}
/>
</FormField>
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
</div>
{bodyMode === "text" && (
<FormField label="Plain text body">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => setActiveEditor("text")}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
</FormField>
)}
{bodyMode === "html" && (
<FormField label="HTML body">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => setActiveEditor("html")}
onChange={(event) => patchTemplateText("html", event.target.value)}
/>
</FormField>
)}
<div className="button-row template-editor-actions">
<Button disabled>Save to library</Button>
</div>
</div>
</Card>
<div className="template-side-stack">
<Card title="Fields">
{invalidNamespacePlaceholders.length > 0 && (
<div className="alert warning">Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</div>
)}
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
<h3 className="section-mini-heading">Global fields</h3>
<TemplateFieldChipList
namespace="global"
names={globalFieldNames}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Local fields</h3>
<TemplateFieldChipList
namespace="local"
names={localFieldNames}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used in template, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
)}
{previewOpen && (
<TemplatePreviewOverlay
bodyMode={bodyMode}
entry={previewEntry}
index={Math.min(previewIndex, previewEntries.length - 1)}
total={previewEntries.length}
subject={previewSubject}
text={previewText}
html={previewHtml}
hasRealRecipients={inlineEntries.length > 0}
onClose={() => setPreviewOpen(false)}
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))}
/>
)}
{undefinedDialog && (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="undefined-template-field-title">
<div className="modal-panel template-action-dialog">
<header className="modal-header">
<h2 id="undefined-template-field-title">Undefined template field</h2>
<button className="modal-close" onClick={() => setUndefinedDialog(null)}>×</button>
</header>
<div className="modal-body">
<p>The template uses <code>{`{{${undefinedDialog.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{undefinedDialog.reason === "invalid-namespace" && <div className="alert warning">Use the namespace <code>global:</code> or <code>local:</code>.</div>}
{undefinedDialog.reason === "missing-field" && <p className="muted">You can add the name <strong>{undefinedDialog.name}</strong> as a campaign field, or remove this placeholder from subject, plain text and HTML.</p>}
</div>
<footer className="modal-footer">
<Button onClick={() => setUndefinedDialog(null)}>Cancel</Button>
<Button onClick={() => removePlaceholder(undefinedDialog)}>Remove from template</Button>
<Button variant="primary" onClick={() => addUndefinedField(undefinedDialog)} disabled={!undefinedDialog.name}>Add field</Button>
</footer>
</div>
</div>
)}
</div>
);
}
function TemplateFieldChipList({
namespace,
names,
usedPlaceholders,
empty,
onInsert
}: {
namespace: TemplateNamespace;
names: string[];
usedPlaceholders: TemplatePlaceholder[];
empty: string;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
if (names.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{names.map((name) => {
const used = usedPlaceholders.some((field) => field.validNamespace && field.namespace === namespace && field.name === name);
return (
<button type="button" className={`field-chip field-chip-button ${used ? "used" : ""}`} key={`${namespace}:${name}`} onClick={() => onInsert(namespace, name)}>
<span className="field-chip-namespace">{namespace}</span>{name}
</button>
);
})}
</div>
);
}
function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceholder[]; onSelect: (item: UndefinedPlaceholder) => void }) {
if (items.length === 0) return <p className="muted">No undefined placeholders detected.</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button type="button" className="field-chip field-chip-button undefined" key={`${item.raw}:${item.reason}`} onClick={() => onSelect(item)}>
{item.display}
</button>
))}
</div>
);
}
function TemplatePreviewOverlay({
bodyMode,
entry,
index,
total,
subject,
text,
html,
hasRealRecipients,
onClose,
onPrevious,
onNext
}: {
bodyMode: BodyMode;
entry: Record<string, unknown>;
index: number;
total: number;
subject: string;
text: string;
html: string;
hasRealRecipients: boolean;
onClose: () => void;
onPrevious: () => void;
onNext: () => void;
}) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
<div className="modal-panel template-preview-modal">
<header className="modal-header">
<h2 id="template-preview-title">Template preview</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="template-preview-toolbar">
<div>
<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>
</div>
<div className="template-preview-box">
<h3>{subject || "No subject"}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={html || "<p>No HTML body to preview.</p>"} />
) : (
<pre>{text || "No plain-text body to preview."}</pre>
)}
</div>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
const placeholders = new Map<string, TemplatePlaceholder>();
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
for (const pattern of patterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
const raw = match[1].trim();
if (!raw || placeholders.has(raw)) continue;
const parsed = parseTemplatePlaceholder(raw);
placeholders.set(raw, parsed);
}
}
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
}
function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
const cleaned = raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
const separator = cleaned.indexOf(":");
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
const validNamespace = namespace === "global" || namespace === "local";
return {
raw,
namespace,
name,
validNamespace,
display: validNamespace ? `${namespace}:${name}` : raw
};
}
function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const key = item.raw;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function buildPreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
const context: Record<string, string> = {};
const globalValues = asRecord(draft?.global_values);
const entryFields = asRecord(entry.fields);
const overridePolicy = fieldOverridePolicy(draft);
for (const [key, value] of Object.entries(globalValues)) {
addContextValue(context, key, "global", value);
addContextValue(context, key, "local", value);
}
for (const [key, value] of Object.entries(entryFields)) {
if (canOverrideField(overridePolicy, key)) {
addContextValue(context, key, "local", value);
}
}
if (entry.name) addContextValue(context, "name", "local", entry.name);
if (entry.email) addContextValue(context, "email", "local", entry.email);
return context;
}
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
const policy = new Map<string, boolean>();
for (const field of asArray(draft?.fields).map(asRecord)) {
const name = String(field.name || field.id || "").trim();
if (!name) continue;
policy.set(name, getBool(field, "can_override", true));
}
return policy;
}
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
if (!policy.has(name)) return true;
return policy.get(name) !== false;
}
function addContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
const text = valueToPreview(value);
context[key] = text;
context[`${namespace}:${key}`] = text;
context[`${namespace}::${key}`] = text;
}
function renderPreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
if (!text) return "";
return text
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
}
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
const key = normalizePreviewKey(raw);
const value = context[key];
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function normalizePreviewKey(raw: string): string {
return raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
}
function valueToPreview(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return `Recipient ${index + 1}`;
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function removePlaceholderFromText(text: string, raw: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,20 @@
import FormField from "../../../components/FormField";
export default function AttachmentRuleCard() {
return (
<div className="attachment-rule-card">
<div className="attachment-rule-header">
<strong>Attachment rule</strong>
<span className="muted">Personalized documents</span>
</div>
<div className="form-grid compact">
<FormField label="Base directory"><input placeholder="xls/" /></FormField>
<FormField label="File filter"><input placeholder="ab????-${local::number}-*.XLSX" /></FormField>
<FormField label="Include subdirectories"><select><option>No</option><option>Yes</option></select></FormField>
<FormField label="Allow multiple matches"><select><option>Yes</option><option>No</option></select></FormField>
<FormField label="Missing behavior"><select><option>Ask</option><option>Block</option><option>Warn</option><option>Drop</option><option>Continue</option></select></FormField>
<FormField label="Ambiguous behavior"><select><option>Ask</option><option>Block</option><option>Warn</option><option>Drop</option><option>Continue</option></select></FormField>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export default function FieldMappingTable() {
const rows = [
["email", "E-Mail", "required", "person@example.org", "ok"],
["fields.number", "Dienststelle", "required", "123456", "ok"],
["fields.password", "Passwort", "optional", "••••••", "ok"]
];
return (
<div className="mapping-table">
<div className="mapping-header">
<span>Campaign field</span>
<span>Source column</span>
<span>Required</span>
<span>Preview</span>
<span>Status</span>
</div>
{rows.map((row) => (
<div className="mapping-row" key={row[0]}>
{row.map((cell, index) => <span key={index}>{cell}</span>)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "../../../components/Button";
type NavigationAction = () => void;
type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: NavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function CampaignUnsavedChangesProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
const [pendingAction, setPendingAction] = useState<NavigationAction | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
useEffect(() => {
registrationRef.current = registration;
}, [registration]);
const hasUnsavedChanges = Boolean(registration);
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
setRegistration(next);
return () => {
setRegistration((current) => current === next ? null : current);
};
}, []);
const proceed = useCallback((action: NavigationAction) => {
setPendingAction(null);
setSaveError("");
action();
}, []);
const requestNavigation = useCallback((action: NavigationAction) => {
const active = registrationRef.current;
if (!active) {
action();
return;
}
setSaveError("");
setPendingAction(() => action);
}, []);
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!registrationRef.current) return;
event.preventDefault();
event.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onDocumentClick(event: MouseEvent) {
if (!registrationRef.current) return;
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const target = event.target as Element | null;
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!anchor) return;
if (anchor.target && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
if (anchor.getAttribute("href")?.startsWith("#")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.href === current.href) return;
event.preventDefault();
event.stopPropagation();
requestNavigation(() => {
if (destination.origin === current.origin) {
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
} else {
window.location.assign(destination.href);
}
});
}
document.addEventListener("click", onDocumentClick, true);
return () => document.removeEventListener("click", onDocumentClick, true);
}, [navigate, requestNavigation]);
async function handleSaveAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action || !active) return;
setSaving(true);
setSaveError("");
try {
const ok = await active.onSave();
if (!ok) {
setSaveError("The changes could not be saved. Please review the page message and try again.");
return;
}
proceed(action);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function handleDiscardAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action) return;
active?.onDiscard?.();
proceed(action);
}
const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges,
registerUnsavedChanges,
requestNavigation
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved campaign changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>×</button>
</header>
<div className="modal-body">
<p>{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <div className="alert danger">{saveError}</div>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={handleSaveAndLeave} disabled={saving}>{saving ? "Saving…" : "Save and leave"}</Button>
</footer>
</div>
</div>
)}
</UnsavedChangesContext.Provider>
);
}
export function useCampaignUnsavedChanges() {
const context = useContext(UnsavedChangesContext);
if (!context) {
throw new Error("useCampaignUnsavedChanges must be used inside CampaignUnsavedChangesProvider");
}
return context;
}
export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useCampaignUnsavedChanges();
useEffect(() => {
return registerUnsavedChanges(registration);
}, [registerUnsavedChanges, registration]);
}

View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from "react";
import type { ApiSettings } from "../../../types";
import {
getCampaign,
getCampaignSummary,
getCampaignVersion,
listCampaignVersions,
type CampaignSummary,
type CampaignVersionDetail,
type CampaignVersionListItem
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
const initialData: CampaignWorkspaceData = {
campaign: null,
versions: [],
currentVersion: null,
summary: null
};
export function useCampaignWorkspaceData(settings: ApiSettings, campaignId: string) {
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const reload = useCallback(async () => {
if (!campaignId) return;
setLoading(true);
setError("");
try {
const [campaign, versions] = await Promise.all([
getCampaign(settings, campaignId),
listCampaignVersions(settings, campaignId)
]);
const selectedVersionId = campaign.current_version_id ?? versions[0]?.id;
const [versionResult, summaryResult] = await Promise.allSettled([
selectedVersionId ? getCampaignVersion(settings, campaignId, selectedVersionId) : Promise.resolve(null),
getCampaignSummary(settings, campaignId)
]);
setData({
campaign,
versions,
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
});
} catch (err) {
setData(initialData);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [settings, campaignId]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload, setError };
}

View 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, "");
}

View 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";
}

View File

@@ -0,0 +1,405 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings, WizardStep } from "../../../types";
import Stepper from "../../../components/Stepper";
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import FormField from "../../../components/FormField";
import PageTitle from "../../../components/PageTitle";
import ToggleSwitch from "../../../components/ToggleSwitch";
import EmailAddressInput from "../../../components/email/EmailAddressInput";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../utils/emailAddresses";
import MetricCard from "../../../components/MetricCard";
import { autosaveCampaignVersion, validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, stringifyPreview, summaryValue, versionLockReason } from "../utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, parseJsonTextarea, stringifyJson, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" },
{ id: "sender", label: "Sender", description: "Mail account and headers" },
{ id: "fields", label: "Fields", description: "Define campaign data" },
{ id: "recipients", label: "Recipients", description: "Import and map source data" },
{ id: "template", label: "Template", description: "Subject and body" },
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" },
{ id: "review", label: "Review", description: "Validate before build" },
{ id: "send", label: "Send", description: "Test and queue" }
];
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const [activeStep, setActiveStep] = useState("basics");
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loading…");
const [localError, setLocalError] = useState("");
const [validationMessage, setValidationMessage] = useState("");
const loadedVersionId = useRef<string | null>(null);
const index = steps.findIndex((s) => s.id === activeStep);
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
if (version.current_step && steps.some((step) => step.id === version.current_step)) {
setActiveStep(version.current_step);
}
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
function patchRoot(key: string, value: unknown) {
patch([key], value);
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "create",
current_step: activeStep,
workflow_state: "editing",
is_complete: false
});
setDraft(ensureCampaignDraft(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
setSaveState("Save failed");
return false;
}
}
function selectStep(stepId: string) {
setActiveStep(stepId);
}
function nextStep() {
setActiveStep(steps[Math.min(steps.length - 1, index + 1)].id);
}
function previousStep() {
setActiveStep(steps[Math.max(0, index - 1)].id);
}
async function validateCurrentStep() {
if (!version || !draft) return;
setValidationMessage("Validating…");
try {
const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep });
setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`);
} catch (err) {
setValidationMessage(err instanceof Error ? err.message : String(err));
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved wizard changes",
message: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
if (locked) {
return (
<div className="wizard-page">
<div className="wizard-card locked-wizard-card">
<div className="wizard-body standalone-wizard-body">
<div className="wizard-heading">
<div>
<PageTitle>Create campaign</PageTitle>
</div>
<div className="save-state">Locked</div>
</div>
<Card>
<div className="alert info">
{versionLockReason(data.currentVersion)} Create or copy a working version before editing campaign data, recipients, template or attachment rules.
</div>
<div className="button-row">
<Link to="../.."><Button variant="primary">Back to overview</Button></Link>
</div>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="wizard-page">
<div className="wizard-card">
<Stepper steps={steps} activeStep={activeStep} onSelect={selectStep} />
<div className="wizard-body">
<div className="wizard-heading">
<div>
<PageTitle loading={loading}>Create campaign</PageTitle>
</div>
<div className="save-state">{saveState}</div>
</div>
{localError && <div className="alert danger">{localError}</div>}
{validationMessage && <div className="alert info">{validationMessage}</div>}
<Card>
{draft && activeStep === "basics" && <BasicsStep draft={draft} patch={patch} />}
{draft && activeStep === "sender" && <SenderStep draft={draft} patch={patch} />}
{draft && activeStep === "fields" && <FieldsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "recipients" && <RecipientsStep draft={draft} patchRoot={patchRoot} />}
{draft && activeStep === "template" && <TemplateStep draft={draft} patch={patch} />}
{draft && activeStep === "attachments" && <AttachmentsStep draft={draft} patch={patch} />}
{draft && activeStep === "review" && <ReviewStep version={version} onValidate={validateCurrentStep} />}
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
</Card>
<div className="wizard-footer">
<Button onClick={previousStep}>Back</Button>
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button>
<Button onClick={validateCurrentStep}>Validate step</Button>
<Button variant="primary" onClick={nextStep}>Continue</Button>
</div>
</div>
</div>
</div>
);
}
function BasicsStep({ draft, patch }: StepProps) {
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>
);
}
function SenderStep({ draft, patch }: StepProps) {
const recipients = asRecord(draft.recipients);
const from = asRecord(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={addressesFromValue(from)}
suggestions={suggestions}
allowMultiple={false}
showAddButton={false}
addLabel={getText(from, "email") ? "Replace" : "Add sender"}
emptyText="No default sender configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses[0] ?? { name: "", email: "" })}
/>
</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>
);
}
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>
);
}
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>
);
}
function TemplateStep({ draft, patch }: StepProps) {
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>
);
}
function AttachmentsStep({ draft, patch }: StepProps) {
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>
);
}
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>
);
}
function SendStep({ draft, patch }: StepProps) {
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>
);
}
type StepProps = {
draft: Record<string, unknown>;
patch: (path: string[], value: unknown) => void;
};
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>
);
}

View File

@@ -0,0 +1,23 @@
import Card from "../../../components/Card";
import MetricCard from "../../../components/MetricCard";
import Button from "../../../components/Button";
export default function ReviewWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Review Wizard</h1>
</div>
<div className="metric-grid">
<MetricCard label="Needs review" value="—" tone="warning" />
<MetricCard label="Missing attachments" value="—" tone="warning" />
<MetricCard label="Ambiguous matches" value="—" tone="info" />
<MetricCard label="Blocked" value="—" tone="danger" />
</div>
<Card title="Resolution workflow">
<p className="muted">This wizard will guide users through issues one class at a time.</p>
<Button variant="primary">Start review</Button>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Card from "../../../components/Card";
import Button from "../../../components/Button";
export default function SendWizard() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Send Wizard</h1>
</div>
<div className="dashboard-grid">
<Card title="Test send">
<p className="muted">Send one generated message to a test address.</p>
<Button>Open test-send dialog</Button>
</Card>
<Card title="Queue estimate">
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p>
<Button variant="primary">Queue dry run</Button>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
import AttachmentRuleCard from "../../components/AttachmentRuleCard";
export default function AttachmentsStep() {
return (
<div>
<div className="step-intro">
<h2>Attachments</h2>
<p>Configure the campaign base path and one or more attachment matching rules.</p>
</div>
<FormField label="Campaign attachment base path">
<input placeholder="./data/attachments" />
</FormField>
<AttachmentRuleCard />
<div className="button-row">
<Button>Add attachment rule</Button>
<Button variant="primary">Resolve attachments</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import FormField from "../../../../components/FormField";
export default function BasicsStep() {
return (
<div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports.">
<input placeholder="Rechnungslegung 2026-05" />
</FormField>
<FormField label="Campaign ID" help="Stable technical identifier.">
<input placeholder="rechnungslegung-2026-05" />
</FormField>
<FormField label="Scenario">
<select>
<option>Personalized documents with attachments</option>
<option>Simple bulk message</option>
<option>Recurring monthly campaign</option>
</select>
</FormField>
<FormField label="Description">
<textarea rows={5} placeholder="Describe the purpose of this campaign…" />
</FormField>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import FieldMappingTable from "../../components/FieldMappingTable";
import Button from "../../../../components/Button";
export default function FieldsStep() {
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>
<div className="button-row">
<Button variant="primary">Add field wizard</Button>
<Button>Add manually</Button>
</div>
<FieldMappingTable />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import FieldMappingTable from "../../components/FieldMappingTable";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
export default function RecipientsStep() {
return (
<div>
<div className="step-intro">
<h2>Recipients</h2>
<p>Upload or reference a recipient source, then map source columns to campaign fields.</p>
</div>
<div className="form-grid compact">
<FormField label="Source type">
<select>
<option>CSV file</option>
<option>Inline recipients</option>
<option>JSON file</option>
</select>
</FormField>
<FormField label="Source path"><input placeholder="./data/recipients.csv" /></FormField>
</div>
<div className="button-row">
<Button>Preview source</Button>
<Button variant="primary">Auto-map columns</Button>
</div>
<FieldMappingTable />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import MetricCard from "../../../../components/MetricCard";
import Button from "../../../../components/Button";
export default function ReviewStep() {
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="Ready" value="—" tone="good" />
<MetricCard label="Warnings" value="—" tone="warning" />
<MetricCard label="Needs review" value="—" tone="info" />
</div>
<Button variant="primary">Validate campaign</Button>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
export default function SendStep() {
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">
<FormField label="Messages per minute"><input type="number" defaultValue={5} min={1} /></FormField>
<FormField label="Concurrency"><input type="number" defaultValue={1} min={1} /></FormField>
</div>
<div className="button-row">
<Button>Send test</Button>
<Button>Queue dry run</Button>
<Button variant="primary">Open Send Wizard</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import FormField from "../../../../components/FormField";
export default function SenderStep() {
return (
<div className="form-grid">
<FormField label="From name"><input placeholder="Office" /></FormField>
<FormField label="From email"><input placeholder="office@example.org" /></FormField>
<FormField label="Reply-To"><input placeholder="reply@example.org" /></FormField>
<FormField label="IMAP append to Sent">
<select>
<option>Enabled, auto-detect Sent folder</option>
<option>Disabled</option>
<option>Enabled, manual folder</option>
</select>
</FormField>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import FormField from "../../../../components/FormField";
import Button from "../../../../components/Button";
export default function TemplateStep() {
return (
<div>
<div className="step-intro">
<h2>Template</h2>
<p>Compose the subject and body. Merge fields can be inserted from the field picker.</p>
</div>
<div className="button-row">
<Button>Insert merge field</Button>
<Button>Preview recipient</Button>
</div>
<div className="form-grid">
<FormField label="Subject"><input placeholder="Ihre Unterlagen für ${global::monthyear}" /></FormField>
<FormField label="Plain text body"><textarea rows={12} placeholder="Sehr geehrte/r ${local::name}, …" /></FormField>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
export default function DashboardPage() {
return (
<div className="content-pad">
<div className="page-heading">
<h1>Dashboard</h1>
</div>
<div className="metric-grid">
<MetricCard label="Campaigns" value="0" detail="Connect the API to load data" />
<MetricCard label="Queued" value="0" tone="info" />
<MetricCard label="Needs review" value="0" tone="warning" />
<MetricCard label="Failed" value="0" tone="danger" />
</div>
<div className="dashboard-grid">
<Card title="Recommended next action"><p className="muted">Create or open a campaign to continue.</p></Card>
<Card title="System status"><p className="muted">API health and queue metrics will appear here.</p></Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { useMemo, useState } from "react";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
type StorageRecord = {
id: string;
name: string;
description: string;
type: string;
scope: string;
status: string;
files: number;
used: string;
retention: string;
updatedAt: string;
};
type StorageSection = "browse" | "upload" | "settings" | "retention" | "bulk" | "activity";
const storages: StorageRecord[] = [
{
id: "campaign-files",
name: "Campaign files",
description: "Files uploaded or referenced for campaign attachments.",
type: "Local path / planned object storage",
scope: "Campaigns",
status: "available",
files: 124,
used: "2.4 GB",
retention: "Keep sent evidence",
updatedAt: "2026-06-08 15:36"
},
{
id: "template-assets",
name: "Template assets",
description: "Images and reusable assets for message templates.",
type: "Planned object storage",
scope: "Templates",
status: "planned",
files: 8,
used: "34 MB",
retention: "Manual cleanup",
updatedAt: "2026-06-06 10:12"
},
{
id: "shared-library",
name: "Shared library",
description: "Tenant or group-wide files available to multiple campaigns.",
type: "Planned object storage",
scope: "Tenant / groups",
status: "planned",
files: 0,
used: "0 MB",
retention: "Policy pending",
updatedAt: "Not connected"
}
];
const storageSections: { id: StorageSection; label: string }[] = [
{ id: "browse", label: "Browse" },
{ id: "upload", label: "Upload" },
{ id: "settings", label: "Settings" },
{ id: "retention", label: "Retention" },
{ id: "bulk", label: "Bulk actions" },
{ id: "activity", label: "Activity" }
];
const demoFiles = [
{ name: "statement_1001.pdf", path: "/2026/05/statement_1001.pdf", size: "142 KB", updatedAt: "2026-06-08 15:36", status: "ready" },
{ name: "statement_1002.pdf", path: "/2026/05/statement_1002.pdf", size: "148 KB", updatedAt: "2026-06-08 15:36", status: "ready" },
{ name: "global_notice.pdf", path: "/shared/global_notice.pdf", size: "81 KB", updatedAt: "2026-06-06 09:44", status: "ready" }
];
export default function FilesPage() {
const [selectedStorageId, setSelectedStorageId] = useState<string | null>(null);
const [active, setActive] = useState<StorageSection>("browse");
const selectedStorage = useMemo(
() => storages.find((storage) => storage.id === selectedStorageId) ?? null,
[selectedStorageId]
);
function openStorage(storageId: string) {
setSelectedStorageId(storageId);
setActive("browse");
}
if (selectedStorage) {
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<button className="section-link section-link-primary" onClick={() => setSelectedStorageId(null)}>
File storages
</button>
<div className="section-title section-title-lower">STORAGE</div>
{storageSections.map((section) => (
<button
key={section.id}
className={`section-link ${active === section.id ? "active" : ""}`}
onClick={() => setActive(section.id)}
>
{section.label}
</button>
))}
</aside>
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>{selectedStorage.name}</PageTitle>
<p>{selectedStorage.description}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Upload</Button>
<Button disabled>Download</Button>
<Button variant="danger" disabled>Delete</Button>
</div>
</div>
{active === "browse" && <StorageBrowse storage={selectedStorage} />}
{active === "upload" && <StorageUpload storage={selectedStorage} />}
{active === "settings" && <StorageSettings storage={selectedStorage} />}
{active === "retention" && <StorageRetention storage={selectedStorage} />}
{active === "bulk" && <StorageBulkActions />}
{active === "activity" && <StorageActivity />}
</div>
</section>
</div>
);
}
return (
<div className="content-pad workspace-data-page module-entry-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Files</PageTitle>
<p>Manage file storages first. Open a storage to browse content, upload files and configure retention.</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Refresh</Button>
<Button variant="primary" disabled>Add storage</Button>
</div>
</div>
<Card
title={
<div className="module-card-heading">
<h2>File storages</h2>
<span>Storage endpoints are placeholders until the backend model is added</span>
</div>
}
>
<div className="app-table-wrap compact-table-wrap module-table-wrap">
<table className="app-table module-table module-entry-table">
<thead>
<tr>
<th>Storage</th>
<th>Type</th>
<th>Scope</th>
<th>Files</th>
<th>Used</th>
<th>Retention</th>
<th>Updated</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{storages.map((storage) => (
<tr key={storage.id}>
<td>
<div className="module-title-cell">
<strong>{storage.name}</strong>
<span>{storage.description}</span>
</div>
</td>
<td>{storage.type}</td>
<td>{storage.scope}</td>
<td>{storage.files}</td>
<td>{storage.used}</td>
<td>{storage.retention}</td>
<td><span className="muted small-text">{storage.updatedAt}</span></td>
<td className="table-action-cell"><Button onClick={() => openStorage(storage.id)}>Open</Button></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function StorageBrowse({ storage }: { storage: StorageRecord }) {
return (
<Card
title="Browse content"
actions={<div className="button-row compact-actions"><Button disabled>Upload</Button><Button disabled>Download selected</Button><Button variant="danger" disabled>Delete selected</Button></div>}
>
<div className="app-table-wrap compact-table-wrap module-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Updated</th><th>Status</th><th></th></tr></thead>
<tbody>
{(storage.id === "campaign-files" ? demoFiles : []).map((file) => (
<tr key={file.path}>
<td>{file.name}</td>
<td><code>{file.path}</code></td>
<td>{file.size}</td>
<td><span className="muted small-text">{file.updatedAt}</span></td>
<td><StatusBadge status={file.status} /></td>
<td className="table-action-cell"><Button disabled>Download</Button></td>
</tr>
))}
{storage.id !== "campaign-files" && <tr><td colSpan={6} className="muted">Files will appear here when this storage is connected.</td></tr>}
</tbody>
</table>
</div>
</Card>
);
}
function StorageUpload({ storage }: { storage: StorageRecord }) {
return (
<Card title="Upload files" actions={<Button variant="primary" disabled>Select files</Button>}>
<p className="muted">Upload will target <strong>{storage.name}</strong>. The backend will later provide chunked upload, duplicate handling and progress state.</p>
<div className="placeholder-stack">
<span>Drag and drop upload area</span>
<span>Duplicate handling: ask, replace, keep both</span>
<span>Optional file tagging after upload</span>
</div>
</Card>
);
}
function StorageSettings({ storage }: { storage: StorageRecord }) {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Storage settings">
<dl className="detail-list compact-detail-list">
<div><dt>Type</dt><dd>{storage.type}</dd></div>
<div><dt>Scope</dt><dd>{storage.scope}</dd></div>
<div><dt>Status</dt><dd><StatusBadge status={storage.status} /></dd></div>
</dl>
</Card>
<Card title="Backend requirements">
<p className="muted">This view is prepared for local path, Garage/S3 and tenant/group/user storage settings.</p>
<div className="placeholder-stack"><span>Storage backend</span><span>Access policy</span><span>Quota</span><span>Encryption / lifecycle</span></div>
</Card>
</div>
);
}
function StorageRetention({ storage }: { storage: StorageRecord }) {
return (
<Card title="Retention policy" actions={<Button disabled>Save policy</Button>}>
<p className="muted">Current policy: {storage.retention}. Retention must respect audit-safe campaigns and sent attachments.</p>
<div className="placeholder-stack">
<span>Keep files for sent campaigns</span>
<span>Prune unused draft uploads after a configurable period</span>
<span>Export manifest before destructive cleanup</span>
</div>
</Card>
);
}
function StorageBulkActions() {
return (
<Card title="Bulk actions">
<p className="muted">Bulk download and delete should be available from the Browse view as well as from a dedicated filtered action view.</p>
<div className="button-row page-bottom-actions"><Button disabled>Download filtered files</Button><Button variant="danger" disabled>Delete filtered files</Button></div>
</Card>
);
}
function StorageActivity() {
return (
<Card title="Activity">
<p className="muted">Storage activity will show uploads, downloads, deletions and retention cleanup runs once backend audit events are available.</p>
<div className="placeholder-stack"><span>Last upload</span><span>Last bulk delete</span><span>Retention cleanup result</span></div>
</Card>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from "react";
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client";
type SettingsSection = "connection" | "mail-accounts" | "address-book" | "storage" | "retention" | "notifications";
const sections: { id: SettingsSection; label: string }[] = [
{ id: "connection", label: "Connection" },
{ id: "mail-accounts", label: "Mail accounts" },
{ id: "address-book", label: "Address book" },
{ id: "storage", label: "Storage" },
{ id: "retention", label: "Retention" },
{ id: "notifications", label: "Notifications" }
];
export default function SettingsPage({ settings, onSettingsChange }: { settings: ApiSettings; onSettingsChange: (settings: ApiSettings) => void }) {
const [active, setActive] = useState<SettingsSection>("connection");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
const [rememberAddresses, setRememberAddresses] = useState(false);
const [addressBookSync, setAddressBookSync] = useState(false);
async function testConnection() {
setTesting(true);
setTestResult("");
try {
await apiFetch<unknown>(settings, "/health");
setTestResult("Connection successful. The backend health endpoint responded.");
} catch (err) {
setTestResult(err instanceof Error ? err.message : String(err));
} finally {
setTesting(false);
}
}
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<div className="section-title">SETTINGS</div>
{sections.map((section) => (
<button key={section.id} className={`section-link ${active === section.id ? "active" : ""}`} onClick={() => setActive(section.id)}>
{section.label}
</button>
))}
</aside>
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Settings</PageTitle>
<p>Personal, local and tenant-level settings for the WebUI.</p>
</div>
</div>
{active === "connection" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="API connection / automation key">
<div className="form-grid">
<FormField label="API base URL" help="Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.">
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
</FormField>
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
<input type="password" value={settings.apiKey} onChange={(e) => onSettingsChange({ ...settings, apiKey: e.target.value })} />
</FormField>
<div className="button-row compact-actions">
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
</div>
{testResult && <div className={`alert ${testResult.startsWith("Connection successful") ? "success" : "warning"}`}>{testResult}</div>}
</div>
</Card>
<Card title="Session state">
<dl className="detail-list compact-detail-list">
<div><dt>Browser token</dt><dd>{settings.accessToken ? "Stored" : "Not stored"}</dd></div>
<div><dt>Automation key</dt><dd>{settings.apiKey ? "Configured" : "Not configured"}</dd></div>
<div><dt>Backend mode</dt><dd>{settings.apiBaseUrl ? "Explicit API URL" : "Same-origin / proxied"}</dd></div>
</dl>
<p className="muted small-note">Logout and tenant switching are handled in the title bar. More session-management controls can be added when backend endpoints exist.</p>
</Card>
</div>
)}
{active === "mail-accounts" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Reusable mail accounts">
<p className="muted">Campaigns can currently keep SMTP/IMAP data in their working draft. Later, reusable encrypted mail accounts should live here and be shared per user, group or tenant.</p>
<div className="placeholder-stack">
<span>Personal SMTP/IMAP accounts</span>
<span>Group sender identities</span>
<span>Tenant-wide defaults</span>
</div>
</Card>
<Card title="Planned account actions">
<div className="button-row compact-actions stacked-actions">
<Button disabled>Add mail account</Button>
<Button disabled>Test selected account</Button>
<Button disabled>Share with group</Button>
</div>
</Card>
</div>
)}
{active === "address-book" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book and suggestions">
<div className="form-grid">
<ToggleSwitch
label="Remember previously used addresses"
help="Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only."
checked={rememberAddresses}
onChange={setRememberAddresses}
/>
<ToggleSwitch
label="External address-book sync"
help="Placeholder for CardDAV/Google/LDAP-style address sources. No external address book is connected yet."
checked={addressBookSync}
onChange={setAddressBookSync}
/>
</div>
</Card>
<Card title="Address sources">
<div className="app-table-wrap compact-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Source</th><th>Status</th><th>Scope</th></tr></thead>
<tbody>
<tr><td>Campaign-local addresses</td><td>Active</td><td>Current campaign</td></tr>
<tr><td>Previously used addresses</td><td>Planned</td><td>User</td></tr>
<tr><td>External address books</td><td>Planned</td><td>User / tenant</td></tr>
</tbody>
</table>
</div>
</Card>
</div>
)}
{active === "storage" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Storage backends">
<p className="muted">Campaign files are still represented by paths and draft JSON. This area is prepared for Garage/S3-backed tenant, group and campaign storage.</p>
<div className="placeholder-stack">
<span>Local development storage</span>
<span>Garage/S3 tenant bucket</span>
<span>Per-campaign file area</span>
</div>
</Card>
<Card title="Quotas and cleanup">
<dl className="detail-list compact-detail-list">
<div><dt>User quota</dt><dd>Planned</dd></div>
<div><dt>Campaign file quota</dt><dd>Planned</dd></div>
<div><dt>Orphan cleanup</dt><dd>Planned</dd></div>
</dl>
</Card>
</div>
)}
{active === "retention" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Draft and version retention">
<p className="muted">Old editable versions can later be pruned unless they have been sent, partially sent or locked for audit. This needs backend support before destructive actions are exposed.</p>
<div className="placeholder-stack">
<span>Prune unsent autosave drafts</span>
<span>Keep locked/sent versions</span>
<span>Export audit-safe campaign package</span>
</div>
</Card>
<Card title="Backup hooks"><p className="muted">Backup settings for campaign JSON, reports and audit data will be added once storage and retention backends are implemented.</p></Card>
</div>
)}
{active === "notifications" && (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Notification preferences">
<p className="muted">Prepared for later background notifications: queue complete, send failures, IMAP append failures and report delivery.</p>
<div className="placeholder-stack">
<span>In-app notifications</span>
<span>Email summary after campaign completion</span>
<span>Failure alerts</span>
</div>
</Card>
</div>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { useMemo, useState } from "react";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import FieldLabel from "../../components/help/FieldLabel";
import StatusBadge from "../../components/StatusBadge";
type TemplateRecord = {
id: string;
name: string;
description: string;
type: string;
status: string;
fields: string[];
updatedAt: string;
usedBy: string;
subject: string;
body: string;
versions: number;
};
type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "usage" | "versions";
const templateRecords: TemplateRecord[] = [
{
id: "monthly-statement",
name: "Monthly statement",
description: "Reusable subject and body for monthly statement mailings.",
type: "Plain text",
status: "ready",
fields: ["recipient_name", "period", "amount"],
updatedAt: "2026-06-08 16:42",
usedBy: "2 campaigns",
subject: "Your statement for {{period}}",
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.",
versions: 4
},
{
id: "deadline-reminder",
name: "Deadline reminder",
description: "Short reminder template with one deadline field.",
type: "Plain text",
status: "draft",
fields: ["recipient_name", "deadline"],
updatedAt: "2026-06-07 11:18",
usedBy: "Not used yet",
subject: "Reminder: {{deadline}}",
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.",
versions: 1
},
{
id: "attachment-notice",
name: "Attachment notice",
description: "Generic note for campaigns where every recipient receives a file.",
type: "HTML-ready",
status: "ready",
fields: ["recipient_name", "file_label", "contact_email"],
updatedAt: "2026-06-05 09:05",
usedBy: "1 campaign",
subject: "Documents for {{recipient_name}}",
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.",
versions: 3
}
];
const templateSections: { id: TemplateDetailSection; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "content", label: "Content" },
{ id: "fields", label: "Fields" },
{ id: "preview", label: "Preview" },
{ id: "usage", label: "Usage" },
{ id: "versions", label: "Versions" }
];
export default function TemplatesPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [active, setActive] = useState<TemplateDetailSection>("overview");
const selectedTemplate = useMemo(
() => templateRecords.find((template) => template.id === selectedId) ?? null,
[selectedId]
);
function openTemplate(templateId: string) {
setSelectedId(templateId);
setActive("overview");
}
if (selectedTemplate) {
return (
<div className="workspace module-workspace">
<aside className="section-sidebar">
<button className="section-link section-link-primary" onClick={() => setSelectedId(null)}>
Template library
</button>
<div className="section-title section-title-lower">TEMPLATE</div>
{templateSections.map((section) => (
<button
key={section.id}
className={`section-link ${active === section.id ? "active" : ""}`}
onClick={() => setActive(section.id)}
>
{section.label}
</button>
))}
</aside>
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>{selectedTemplate.name}</PageTitle>
<p>{selectedTemplate.description}</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Duplicate</Button>
<Button variant="primary" disabled>Use in campaign</Button>
</div>
</div>
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
{active === "content" && <TemplateContent template={selectedTemplate} />}
{active === "fields" && <TemplateFields template={selectedTemplate} />}
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
</div>
</section>
</div>
);
}
return (
<div className="content-pad workspace-data-page module-entry-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Templates</PageTitle>
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Export</Button>
</div>
</div>
<Card
title={
<div className="module-card-heading">
<h2>All templates</h2>
<span>Last loaded: local demo data</span>
</div>
}
actions={<Button disabled>Refresh</Button>}
>
<div className="app-table-wrap compact-table-wrap module-table-wrap">
<table className="app-table module-table module-entry-table">
<thead>
<tr>
<th>Template</th>
<th>Type</th>
<th>Fields</th>
<th>Updated</th>
<th>Status</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{templateRecords.map((template) => (
<tr key={template.id}>
<td>
<div className="module-title-cell">
<strong>{template.name}</strong>
<span>{template.description}</span>
</div>
</td>
<td>{template.type}</td>
<td>
<div className="chip-row compact-chip-row">
{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}
</div>
</td>
<td><span className="muted small-text">{template.updatedAt}</span></td>
<td><StatusBadge status={template.status} /></td>
<td className="table-action-cell"><Button onClick={() => openTemplate(template.id)}>Open</Button></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function TemplateOverview({ template }: { template: TemplateRecord }) {
return (
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Template status">
<dl className="detail-list compact-detail-list">
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div>
<div><dt>Type</dt><dd>{template.type}</dd></div>
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div>
<div><dt>Versions</dt><dd>{template.versions}</dd></div>
</dl>
</Card>
<Card title="Compatibility notes">
<p className="muted">Campaigns can reuse this template, but each campaign still needs a field matching check because campaign fields and template placeholders can drift.</p>
<div className="placeholder-stack">
<span>Subject placeholders are detected</span>
<span>Body placeholders are compared with campaign fields</span>
<span>A mapping wizard can be added later</span>
</div>
</Card>
</div>
);
}
function TemplateContent({ template }: { template: TemplateRecord }) {
return (
<Card title="Template content" actions={<Button disabled>Save changes</Button>}>
<div className="form-grid compact responsive-form-grid">
<label className="form-field"><FieldLabel className="form-label" help="Read-only subject stored in this reusable template record.">Subject</FieldLabel><input value={template.subject} readOnly /></label>
<label className="form-field full-span"><FieldLabel className="form-label" help="Read-only body stored in this reusable template record.">Body</FieldLabel><textarea rows={10} value={template.body} readOnly /></label>
</div>
</Card>
);
}
function TemplateFields({ template }: { template: TemplateRecord }) {
return (
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}>
<div className="app-table-wrap compact-table-wrap module-table-wrap">
<table className="app-table module-table">
<thead><tr><th>Field</th><th>Source</th><th>Required</th><th></th></tr></thead>
<tbody>
{template.fields.map((field) => (
<tr key={field}><td><code>{field}</code></td><td>Detected placeholder</td><td>Yes</td><td><Button disabled>Configure</Button></td></tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
function TemplatePreview({ template }: { template: TemplateRecord }) {
const preview = template.body
.replace(/\{\{recipient_name\}\}/g, "Jane Example")
.replace(/\{\{period\}\}/g, "May 2026")
.replace(/\{\{amount\}\}/g, "123.45 EUR")
.replace(/\{\{deadline\}\}/g, "30 June 2026")
.replace(/\{\{file_label\}\}/g, "statement")
.replace(/\{\{contact_email\}\}/g, "support@example.org");
return (
<Card title="Preview" actions={<Button disabled>Change sample data</Button>}>
<div className="message-preview">
<strong>{template.subject.replace(/\{\{period\}\}/g, "May 2026").replace(/\{\{deadline\}\}/g, "30 June 2026").replace(/\{\{recipient_name\}\}/g, "Jane Example")}</strong>
<pre>{preview}</pre>
</div>
</Card>
);
}
function TemplateUsage({ template }: { template: TemplateRecord }) {
return (
<Card title="Usage">
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p>
<dl className="detail-list compact-detail-list">
<div><dt>Currently used by</dt><dd>{template.usedBy}</dd></div>
<div><dt>Safe to edit</dt><dd>Requires versioning once templates are shared</dd></div>
</dl>
</Card>
);
}
function TemplateVersions({ template }: { template: TemplateRecord }) {
return (
<Card title="Versions and import/export" actions={<div className="button-row compact-actions"><Button disabled>Import</Button><Button disabled>Export</Button></div>}>
<div className="placeholder-stack">
<span>{template.versions} local versions in the planned model</span>
<span>Sent campaigns should keep a fixed template snapshot</span>
<span>Draft campaigns can update to a newer template version later</span>
</div>
</Card>
);
}

48
src/layout/AppShell.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useLocation } from "react-router-dom";
import type { ApiSettings, AuthInfo } from "../types";
import IconRail from "./IconRail";
import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar";
type Props = {
children: React.ReactNode;
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
publicMode?: boolean;
};
export default function AppShell({
children,
settings,
auth,
onSettingsChange,
onAuthChange,
publicMode = false
}: Props) {
const location = useLocation();
if (publicMode) {
return (
<div className="app-shell public-shell">
<IconRail compact />
<div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<main className="public-content">{children}</main>
</div>
</div>
);
}
return (
<div className="app-shell">
<IconRail />
<div className="app-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} />
<BreadcrumbBar pathname={location.pathname} />
<main className="app-content">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
export default function BreadcrumbBar({ pathname }: { pathname: string }) {
const parts = pathname.split("/").filter(Boolean);
const labels = parts.length ? parts : ["campaigns"];
return (
<div className="breadcrumb-bar">
<nav className="breadcrumbs" aria-label="Breadcrumb">
{labels.map((part, index) => {
const href = `/${labels.slice(0, index + 1).join("/")}`;
return (
<span className="crumb" key={`${part}-${index}`}>
<Link className="crumb-link" to={href}>{labelFor(part, labels, index)}</Link>
{index < labels.length - 1 && <ChevronRight size={16} aria-hidden="true" />}
</span>
);
})}
</nav>
</div>
);
}
const campaignRouteLabels: Record<string, string> = {
data: "General",
campaign: "General",
settings: "Global settings",
"global-settings": "Global settings",
fields: "Fields",
recipients: "Recipients",
template: "Template",
files: "Attachments",
attachments: "Attachments",
mail: "Server settings",
"mail-settings": "Server settings",
"server-settings": "Server settings",
review: "Review",
send: "Send",
report: "Report",
reports: "Report",
audit: "Audit log",
json: "JSON",
wizard: "Wizard",
create: "Create",
};
const topLevelRouteLabels: Record<string, string> = {
campaigns: "Campaigns",
dashboard: "Dashboard",
templates: "Templates",
files: "Files",
reports: "Reports",
settings: "Settings",
admin: "Admin",
};
function labelFor(value: string, parts: string[], index: number): string {
if (parts[0] === "campaigns" && index === 1) return "Campaign";
if (parts[0] === "campaigns" && index >= 2) {
const mapped = campaignRouteLabels[value];
if (mapped) return mapped;
}
const mapped = topLevelRouteLabels[value];
if (mapped) return mapped;
if (value.length > 18) return "Campaign";
return value.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

122
src/layout/HelpMenu.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import Button from "../components/Button";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
export default function HelpMenu() {
const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [contextOpen, setContextOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const helpContext = helpContextForPathname(location.pathname);
useEffect(() => {
function openContextHelp(event: KeyboardEvent) {
if (event.key !== "F1") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
setOpen(false);
setContextOpen(true);
}
function onPointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
window.addEventListener("keydown", openContextHelp, true);
window.addEventListener("mousedown", onPointerDown);
return () => {
window.removeEventListener("keydown", openContextHelp, true);
window.removeEventListener("mousedown", onPointerDown);
};
}, []);
function openHelp() {
setOpen(false);
setContextOpen(true);
}
return (
<div className="context-menu-wrap" ref={wrapRef}>
<button
className="titlebar-link"
onClick={() => setOpen(!open)}
onKeyDown={(event) => {
if (event.key === "F1") {
event.preventDefault();
event.stopPropagation();
openHelp();
}
}}
title="Help (F1)"
>
<HelpCircle size={17} /> Help
</button>
{open && (
<div className="dropdown-menu">
<button className="dropdown-item" onClick={openHelp}>
<HelpCircle size={16} /> Help <small>F1</small>
</button>
<hr />
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> User docs</a>
<a className="dropdown-item" href="#" onClick={(e) => e.preventDefault()}><BookOpen size={16} /> Admin docs</a>
<hr />
<a className="dropdown-item" href="https://git.add-ideas.de/add-ideas" target="_blank" rel="noreferrer"><GitBranch size={16} /> GitLab</a>
<button className="dropdown-item" onClick={() => { setAboutOpen(true); setOpen(false); }}><Info size={16} /> About</button>
</div>
)}
{contextOpen && <ContextHelpModal context={helpContext} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal onClose={() => setAboutOpen(false)} />}
</div>
);
}
function ContextHelpModal({ context, onClose }: { context: HelpContext; onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" data-help-context={context.id}>
<div className="modal-panel">
<header className="modal-header">
<h2>Context help</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="help-panel-section">
<h3>{context.title}</h3>
<p className="mono-small">Help context: {context.id}</p>
<p className="muted">This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter <span className="kbd">{helpQueryForContext(context)}</span>, to open the right page or section.</p>
</div>
<div className="help-panel-section">
<h3>Next actions</h3>
<p className="muted">The first guided help content can cover campaign creation, review, attachment resolution and sending preparation.</p>
</div>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function AboutModal({ onClose }: { onClose: () => void }) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel">
<header className="modal-header">
<h2>About MultiMailer</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="about-logo" />
<p><strong>MultiMailer WebUI</strong></p>
<p className="muted">Version 0.1.0 early development build.</p>
<p>MultiMailer is a local-first / server-assisted campaign mailer for structured, personalized bulk messages with attachment resolution, review workflows and auditable sending.</p>
<p><a href="https://add-ideas.de" target="_blank" rel="noreferrer">add-ideas.de</a></p>
<p className="muted">License: project license pending / to be finalized. Backend components are currently development prototypes.</p>
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}

35
src/layout/IconRail.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Form, FileText, Folder, LayoutDashboard, MailCheck, Settings, Shield, Users } from "lucide-react";
import { NavLink } from "react-router-dom";
const items = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/campaigns", label: "Campaigns", icon: MailCheck },
{ to: "/templates", label: "Templates", icon: Form },
{ to: "/files", label: "Files", icon: Folder },
{ to: "/reports", label: "Reports", icon: FileText },
{ to: "/settings", label: "Settings", icon: Settings },
{ to: "/admin", label: "Admin", icon: Shield }
];
export default function IconRail({ compact = false }: { compact?: boolean }) {
return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
<div className="brand-mark" title="MultiMailer">MSM</div>
{!compact && (
<>
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={label}>
<Icon size={20} />
</NavLink>
))}
</nav>
<div className="icon-rail-bottom">
<Users size={18} />
</div>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,49 @@
import type { CampaignWorkspaceSection } from "../types";
const campaignItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "campaign", label: "General" },
{ id: "fields", label: "Fields" },
{ id: "recipients", label: "Recipients" },
{ id: "template", label: "Template" },
{ id: "files", label: "Attachments" }
];
const sendItems: { id: CampaignWorkspaceSection; label: string }[] = [
{ id: "mail-settings", label: "Server settings" },
{ id: "global-settings", label: "Global settings" },
{ id: "review", label: "Review" },
{ id: "send", label: "Send" },
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }
];
export default function SectionSidebar({
active,
onSelect
}: {
active: CampaignWorkspaceSection;
onSelect: (section: CampaignWorkspaceSection) => void;
}) {
return (
<aside className="section-sidebar">
<button className={`section-link section-link-primary ${active === "overview" ? "active" : ""}`} onClick={() => onSelect("overview")}>Overview</button>
<div className="section-title section-title-lower">CAMPAIGN</div>
{campaignItems.map((item) => (
<button key={item.id} className={`section-link ${active === item.id ? "active" : ""}`} onClick={() => onSelect(item.id)}>
{item.label}
</button>
))}
<div className="section-title section-title-lower">SEND CAMPAIGN</div>
{sendItems.map((item) => (
<button key={item.id} className={`section-link ${active === item.id ? "active" : ""}`} onClick={() => onSelect(item.id)}>
{item.label}
</button>
))}
<div className="section-title section-title-lower">ADVANCED</div>
<button className={`section-link subtle ${active === "json" ? "active" : ""}`} onClick={() => onSelect("json")}>JSON</button>
</aside>
);
}

138
src/layout/Titlebar.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useRef, useState, useEffect } from "react";
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu";
import LoginModal from "../features/auth/LoginModal";
import { logout } from "../api/auth";
type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
};
export default function Titlebar({ settings, auth, onSettingsChange, onAuthChange }: Props) {
const [accountOpen, setAccountOpen] = useState(false);
const [tenantOpen, setTenantOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null);
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
const canSwitchTenant = tenants.length > 1;
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (accountRef.current && !accountRef.current.contains(target)) {
setAccountOpen(false);
}
if (tenantRef.current && !tenantRef.current.contains(target)) {
setTenantOpen(false);
}
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
function handleLogin(response: LoginResponse) {
const active = response.active_tenant ?? response.tenant;
onAuthChange(
{
user: response.user,
tenant: active,
active_tenant: active,
tenants: response.tenants ?? [active],
scopes: response.scopes,
roles: response.roles,
groups: response.groups
},
response.access_token
);
}
async function handleLogout() {
try {
await logout(settings);
} catch {
// Logout is best effort; clear local session either way.
}
onAuthChange(null, "");
setAccountOpen(false);
}
function handleTenantSelect(tenant: AuthTenantMembership) {
// Backend-side active tenant switching will be wired later. For now this is a UI placeholder.
onAuthChange(auth ? { ...auth, tenant, active_tenant: tenant } : null);
setTenantOpen(false);
}
return (
<header className="titlebar">
{auth && activeTenant && (
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">tenant:</span>
{canSwitchTenant ? (
<>
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
<strong>{activeTenant.name}</strong>
<span className="tenant-caret"></span>
</button>
{tenantOpen && (
<div className="dropdown-menu tenant-menu">
{tenants.map((tenant) => {
const active = tenant.id === activeTenant.id;
return (
<button
key={tenant.id}
className={`dropdown-item ${active ? "active" : ""}`}
onClick={() => handleTenantSelect(tenant)}
>
<span>{tenant.name}</span>
{active && <Check size={16} />}
</button>
);
})}
</div>
)}
</>
) : (
<strong>{activeTenant.name}</strong>
)}
</div>
)}
<div className="titlebar-spacer" />
<HelpMenu />
<div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
<UserCircle size={22} />
<span>{auth?.user.display_name || auth?.user.email || "Sign in"}</span>
<span className="tenant-caret"></span>
</button>
{accountOpen && (
<div className="dropdown-menu account-menu">
{auth ? (
<>
<div className="account-menu-header">
<strong>{auth.user.display_name || auth.user.email}</strong>
<span>{auth.user.email}</span>
</div>
<button className="dropdown-item" onClick={() => setAccountOpen(false)}><Settings size={16} /> Account settings</button>
<button className="dropdown-item" onClick={handleLogout}><LogOut size={16} /> Sign out</button>
</>
) : (
<button className="dropdown-item" onClick={() => { setLoginOpen(true); setAccountOpen(false); }}><UserCircle size={16} /> Sign in</button>
)}
</div>
)}
</div>
{loginOpen && <LoginModal settings={settings} onClose={() => setLoginOpen(false)} onLogin={handleLogin} />}
</header>
);
}

20
src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/tokens.css";
import "./styles/layout.css";
import "./styles/forms.css";
import "./styles/tables.css";
import "./styles/badges.css";
import "./styles/components.css";
import "./styles/auth-gate.css";
import "./styles/campaign-workspace.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

162
src/styles/auth-gate.css Normal file
View File

@@ -0,0 +1,162 @@
/* Auth gate + titlebar/tenant/help hover refinements */
.public-shell {
grid-template-columns: 58px 1fr;
}
.public-main {
grid-template-rows: 64px 1fr;
}
.icon-rail.compact .brand-mark {
margin-top: 15px;
}
.public-content {
min-height: calc(100vh - 64px);
background:
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
var(--bg);
}
.public-landing {
min-height: calc(100vh - 64px);
display: grid;
place-items: center;
padding: 42px;
}
.public-card {
width: min(720px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 42px 48px;
}
.public-kicker {
color: var(--accent);
text-transform: uppercase;
font-weight: 800;
font-size: 13px;
letter-spacing: .08em;
margin-bottom: 14px;
}
.public-card h1 {
margin: 0;
max-width: 620px;
font-size: clamp(30px, 4vw, 46px);
line-height: 1.08;
color: var(--text-strong);
letter-spacing: -.035em;
}
.public-card p {
margin: 18px 0 0;
max-width: 560px;
color: var(--muted);
font-size: 17px;
line-height: 1.55;
}
.public-actions {
margin-top: 28px;
display: flex;
gap: 12px;
}
.public-footnote {
margin-top: 22px;
color: var(--muted);
font-size: 13px;
}
/* Tenant: no field border. Only Titlebar decides whether it is rendered/dropdown. */
.tenant-selector {
position: relative;
height: auto;
min-width: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 0;
gap: 5px;
}
.tenant-name-button {
border: 0;
background: transparent;
color: var(--text-strong);
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit;
font-weight: 700;
padding: 8px 10px;
margin: -8px -10px;
border-radius: 7px;
cursor: pointer;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
}
/* Help/account should feel button-like only on interaction. */
.titlebar-link,
.account-pill {
padding: 8px 10px;
margin: -8px -10px;
border-radius: 7px;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
cursor: pointer;
}
.tenant-name-button:hover,
.titlebar-link:hover,
.account-pill:hover,
.context-menu-wrap:focus-within .account-pill {
background: rgba(0,0,0,.055);
color: var(--text-strong);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.04);
}
.dropdown-menu {
animation: dropdown-in .08s ease-out;
padding: 6px;
}
.dropdown-item {
border-radius: 6px;
transition: background-color .12s ease, color .12s ease;
}
.dropdown-item:hover,
.dropdown-item.active {
background: rgba(239, 107, 58, .10);
color: var(--text-strong);
}
.dropdown-item.active {
font-weight: 700;
}
.tenant-menu {
min-width: 230px;
}
.tenant-menu .dropdown-item {
justify-content: space-between;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

5
src/styles/badges.css Normal file
View File

@@ -0,0 +1,5 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: #e7e4df; color: #666; text-transform: uppercase; }
.status-ready, .status-sent, .status-appended { background: #d6eee9; color: #34796d; }
.status-warning, .status-needs-review, .status-pending { background: #ffedc6; color: #a06b00; }
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; }
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; }

View File

@@ -0,0 +1,741 @@
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
.workspace-data-page .card { margin-bottom: 18px; }
.workspace-heading .mono-small { margin-top: 8px; }
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
.small-note { font-size: 12px; }
.wizard-action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 14px;
}
.wizard-action-card {
min-height: 176px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.wizard-action-card h3 {
margin: 0;
color: var(--text-strong);
font-size: 16px;
}
.wizard-action-card p {
margin: 8px 0 18px;
color: var(--muted);
line-height: 1.45;
}
.wizard-action-card a { margin-top: auto; text-decoration: none; }
.data-table-wrap { margin: 0; overflow-x: auto; }
.data-table th:nth-child(1), .data-table td:nth-child(1) { width: 76px; }
.data-table th:nth-child(2), .data-table td:nth-child(2) { min-width: 220px; }
.data-table th:nth-child(3), .data-table td:nth-child(3) { width: 120px; }
.data-table code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #5d5a55;
white-space: pre-wrap;
}
.template-preview {
min-height: 260px;
max-height: 520px;
overflow: auto;
margin: 0;
white-space: pre-wrap;
color: var(--text);
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 6px;
padding: 14px;
font: inherit;
line-height: 1.5;
}
.field-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.field-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
padding: 4px 10px;
color: var(--text-strong);
font-size: 13px;
font-weight: 700;
}
@media (max-width: 1000px) {
.wizard-action-grid { grid-template-columns: 1fr; }
}
.locked-wizard-card { grid-template-columns: 1fr; }
.standalone-wizard-body { min-height: auto; }
/* Editable campaign data surfaces. */
.responsive-form-grid { align-items: start; }
.json-edit-block {
display: grid;
gap: 8px;
margin-top: 16px;
}
.json-edit-block textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
color: #4d4944;
}
.danger-text { color: var(--danger-text); }
.page-bottom-actions {
justify-content: flex-end;
margin-top: 18px;
}
.section-mini-heading {
margin: 18px 0 8px;
font-size: 12px;
color: #6b6863;
letter-spacing: .04em;
text-transform: uppercase;
}
.template-editor-grid { grid-template-columns: minmax(420px, 1.4fr) minmax(320px, .8fr); }
.recipient-table-wrap,
.files-table .app-table-wrap { overflow-x: auto; }
.recipient-table th,
.recipient-table td,
.files-table th,
.files-table td { white-space: nowrap; }
.recipient-table code,
.files-table code {
white-space: pre-wrap;
font-size: 12px;
}
.placeholder-stack {
display: grid;
gap: 8px;
margin-top: 12px;
}
.placeholder-stack span {
display: block;
border: 1px dashed var(--line-dark);
border-radius: 6px;
background: var(--panel-soft);
padding: 10px 12px;
color: var(--muted);
font-weight: 700;
}
@media (max-width: 1100px) {
.template-editor-grid { grid-template-columns: 1fr; }
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
}
/* Campaign data layout refinement. */
.campaign-data-stack {
display: grid;
gap: 18px;
}
.form-subsection {
display: grid;
gap: 12px;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.form-subsection:first-child {
border-top: 0;
padding-top: 0;
}
.form-subsection h3,
.subsection-heading h3 {
margin: 0;
color: var(--text-strong);
font-size: 14px;
letter-spacing: .01em;
}
.subsection-heading.split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-grid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 8px 16px;
}
.mail-server-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 22px;
}
.mail-server-subsection {
border-top: 0;
padding-top: 0;
align-content: start;
}
.form-span-full {
grid-column: 1 / -1;
}
.toggle-span-full {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
padding: 10px 12px;
}
.compact-table-wrap {
margin-top: 8px;
}
.direct-attachment-table th:nth-child(1),
.direct-attachment-table td:nth-child(1) { width: 220px; }
.direct-attachment-table th:nth-child(3),
.direct-attachment-table td:nth-child(3) { width: 180px; }
.direct-attachment-table th:last-child,
.direct-attachment-table td:last-child { width: 96px; }
.table-action-cell {
text-align: right;
white-space: nowrap;
}
.empty-table-cell {
color: var(--muted);
font-size: 13px;
padding: 18px 14px !important;
text-align: center;
}
.field-editor-table th:nth-child(1),
.field-editor-table td:nth-child(1) { min-width: 80px; }
.field-editor-table th:nth-child(2),
.field-editor-table td:nth-child(2) { min-width: 80px; }
.field-editor-table th:nth-child(3),
.field-editor-table td:nth-child(3) { width: 140px; }
.field-editor-table th:nth-child(4),
.field-editor-table td:nth-child(4) { width: 175px; }
.field-editor-table th:nth-child(5),
.field-editor-table td:nth-child(5) { min-width: 80px; }
.field-editor-table th:nth-child(6),
.field-editor-table td:nth-child(6) { width: 205px; }
.field-editor-table th:last-child,
.field-editor-table td:last-child { width: 96px; }
.additional-global-values-table th:last-child,
.additional-global-values-table td:last-child { width: 96px; }
.schema-note {
margin-top: 12px;
}
.additional-global-values-table th:nth-child(1),
.additional-global-values-table td:nth-child(1) { width: 260px; }
@media (max-width: 1100px) {
.mail-server-settings-grid { grid-template-columns: 1fr; }
.toggle-grid { grid-template-columns: 1fr; }
}
/* Campaign workspace navigation and cross-section references. */
.section-link-primary {
margin-top: 2px;
font-weight: 700;
}
.campaign-settings-grid {
align-items: start;
}
.override-toggle-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
}
.related-link-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 12px;
}
.related-link-card {
display: grid;
gap: 6px;
min-height: 88px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
color: inherit;
text-decoration: none;
transition: border-color .16s ease, transform .16s ease, background .16s ease;
}
.related-link-card:hover {
border-color: var(--accent-soft);
background: var(--panel);
transform: translateY(-1px);
}
.related-link-card span {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.compact-detail-list {
gap: 8px;
}
.global-values-preview {
margin-top: 14px;
}
.template-side-stack {
display: grid;
gap: 18px;
align-content: start;
}
.template-preview-box {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
}
.template-preview-box h3 {
margin: 0;
color: var(--text-strong);
font-size: 15px;
}
.template-preview-box pre {
margin: 0;
white-space: pre-wrap;
color: var(--text);
font-family: inherit;
font-size: 13px;
line-height: 1.55;
}
.attachment-base-grid {
margin-bottom: 16px;
}
@media (max-width: 1280px) {
.related-link-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
.override-toggle-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
}
@media (max-width: 780px) {
.related-link-grid,
.override-toggle-grid { grid-template-columns: 1fr; }
}
/* Campaign list and module shell refinements. */
.subsection-bottom-actions {
justify-content: flex-end;
margin-top: 12px;
}
.campaigns-page {
padding: 28px 34px;
}
.campaigns-page > .alert {
margin: 0 0 18px;
}
.campaigns-page .card {
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
background: var(--panel);
overflow: visible;
}
.campaigns-page .card-header {
position: sticky;
top: 0;
z-index: 30;
min-height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid var(--line);
border-radius: var(--radius) var(--radius) 0 0;
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
}
.campaigns-page .card-header::after {
left: 12px;
right: 12px;
}
.campaigns-page .card-body {
padding: 0;
}
.campaign-table-wrap {
margin: 0;
}
.campaign-table thead,
.campaign-table thead th {
top: 64px;
}
.campaign-table th:first-child,
.campaign-table td:first-child {
padding-left: 24px;
}
.campaign-table th:last-child,
.campaign-table td:last-child {
padding-right: 24px;
}
.module-workspace .section-sidebar {
padding-top: 18px;
}
.settings-dashboard-grid {
align-items: start;
}
.module-table th,
.module-table td {
white-space: nowrap;
}
.module-table td:first-child {
font-weight: 700;
color: var(--text-strong);
}
.module-big-number {
display: block;
color: var(--text-strong);
font-size: 24px;
line-height: 1.2;
margin-bottom: 8px;
}
.stacked-actions {
align-items: stretch;
flex-direction: column;
}
.stacked-actions .btn {
justify-content: center;
}
@media (max-width: 900px) {
.campaigns-page {
padding: 18px;
}
}
/* Module entry/detail pages (Templates, Files). */
.module-entry-page {
max-width: none;
}
.module-card-heading {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
.module-card-heading h2 {
margin: 0;
font-size: 1.06rem;
}
.module-card-heading span {
color: var(--muted-text);
font-size: 0.78rem;
font-weight: 500;
}
.module-table-wrap {
width: 100%;
}
.module-entry-table th:last-child {
width: 1%;
}
.module-entry-table td {
vertical-align: middle;
}
.module-title-cell {
display: flex;
flex-direction: column;
gap: 0.18rem;
min-width: 15rem;
}
.module-title-cell strong {
color: var(--text);
font-size: 0.94rem;
font-weight: 700;
}
.module-title-cell span {
color: var(--muted-text);
font-size: 0.78rem;
line-height: 1.35;
}
.small-text {
font-size: 0.78rem;
}
.compact-chip-row {
gap: 0.28rem;
flex-wrap: nowrap;
}
.message-preview {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-subtle);
padding: 1rem;
}
.message-preview strong {
display: block;
margin-bottom: 0.85rem;
}
.message-preview pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
color: var(--text);
line-height: 1.55;
}
@media (max-width: 980px) {
.module-entry-table {
min-width: 880px;
}
}
/* Campaign overview configuration shortcuts. */
.overview-config-grid {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 18px;
align-items: stretch;
}
.overview-config-card {
min-height: 218px;
border: 1px solid var(--line);
border-radius: 6px;
background: linear-gradient(#ffffff, var(--panel-soft));
box-shadow: 0 1px 2px rgba(38, 35, 30, .04);
padding: 16px;
display: grid;
grid-template-rows: 22px 44px 1fr 35px;
gap: 8px;
align-items: start;
height: 100%;
}
.overview-config-card h3 {
margin: 0;
color: var(--text-strong);
font-size: 16px;
line-height: 22px;
}
.overview-config-card p {
margin: 0;
color: var(--muted);
line-height: 1.45;
}
.overview-config-facts {
display: grid;
grid-template-rows: repeat(3, minmax(31px, auto));
gap: 0;
width: 100%;
margin: 0;
align-self: stretch;
}
.overview-config-facts div {
display: grid;
grid-template-columns: minmax(96px, .72fr) minmax(0, 1.28fr);
gap: 10px;
align-items: baseline;
min-height: 31px;
border-top: 1px solid var(--line);
padding-top: 8px;
}
.overview-config-facts dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
.overview-config-facts dd {
margin: 0;
min-width: 0;
overflow: hidden;
color: var(--text-strong);
font-size: 13px;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-config-actions {
display: flex;
flex-wrap: wrap;
justify-content: right;
align-self: end;
gap: 8px;
width: 100%;
}
.overview-config-actions a { text-decoration: none; }
.overview-summary-grid {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
@media (max-width: 1100px) {
.overview-config-grid,
.overview-summary-grid { grid-template-columns: 1fr; }
.overview-config-card { grid-template-rows: auto auto auto auto; }
}
/* Template editor refinements. */
.template-body-mode {
display: inline-flex;
gap: 4px;
width: fit-content;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
padding: 4px;
}
.template-body-mode button {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 700;
padding: 7px 12px;
}
.template-body-mode button.active {
background: #fff;
color: var(--text-strong);
box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.template-editor-actions {
justify-content: flex-end;
}
.field-chip-button {
cursor: pointer;
border-color: var(--line-dark);
}
.field-chip-button:hover,
.field-chip-button:focus-visible {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,.09);
}
.field-chip-button.used {
background: var(--green-soft);
border-color: #78b7a9;
color: #2d685d;
}
.field-chip-button.undefined {
background: #fff1d0;
border-color: #d5a64a;
color: #7c5412;
}
.field-chip-namespace {
border-right: 1px solid rgba(0,0,0,.18);
color: var(--muted);
margin-right: 2px;
padding-right: 6px;
text-transform: uppercase;
font-size: 10px;
letter-spacing: .05em;
}
.template-preview-modal {
width: min(920px, 100%);
}
.template-preview-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.template-preview-toolbar p {
margin: 4px 0 0;
}
.template-preview-frame {
width: 100%;
min-height: 360px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.template-action-dialog {
width: min(620px, 100%);
}
@media (max-width: 720px) {
.template-preview-toolbar {
display: grid;
}
}
/* Restored workspace layout rules removed by the template editor patch. */
.attachment-base-stack {
display: grid;
gap: 16px;
margin-bottom: 16px;
}
.attachment-base-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, .36fr);
gap: 18px;
align-items: end;
}
.attachment-base-toggle {
display: flex;
min-height: 44px;
align-items: center;
padding-bottom: 2px;
}
.campaign-settings-stack {
display: grid;
gap: 18px;
}
.campaign-identity-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.campaign-identity-grid .form-field:nth-child(3),
.campaign-identity-grid .form-field:nth-child(4) {
grid-column: 1 / -1;
}
.campaign-header-stack {
display: grid;
gap: 18px;
}
.campaign-header-grid,
.campaign-sender-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, .36fr);
gap: 18px;
align-items: end;
}
.campaign-header-toggle,
.campaign-sender-toggle {
display: flex;
min-height: 44px;
align-items: center;
padding-bottom: 2px;
}
.recipient-add-row td {
background: var(--panel-soft);
}
.recipient-add-row .email-address-input {
max-width: 720px;
}
.unsaved-changes-dialog {
max-width: 520px;
}
.unsaved-changes-actions {
justify-content: flex-end;
}
@media (max-width: 1100px) {
.campaign-header-grid,
.campaign-sender-grid,
.attachment-base-grid { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.campaign-identity-grid { grid-template-columns: 1fr; }
}

454
src/styles/components.css Normal file
View File

@@ -0,0 +1,454 @@
/* Shared application components: loading indicator, mail-address editor and toggle switch. */
.page-title-with-loader {
display: inline-flex;
align-items: center;
gap: 10px;
}
.loading-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
vertical-align: middle;
}
.loading-indicator-sm { width: 22px; height: 22px; }
.loading-indicator-md { width: 30px; height: 30px; }
.loading-envelope {
position: relative;
display: inline-block;
width: 18px;
height: 13px;
border: 2px solid #8c8881;
border-radius: 3px;
background: rgba(255,255,255,.7);
animation: loading-envelope-float .74s ease-in-out infinite alternate;
}
.loading-envelope::before,
.loading-envelope::after {
content: "";
position: absolute;
top: 1px;
width: 10px;
height: 10px;
border-top: 2px solid #8c8881;
}
.loading-envelope::before {
left: 1px;
transform: rotate(36deg);
transform-origin: top left;
}
.loading-envelope::after {
right: 1px;
transform: rotate(-36deg);
transform-origin: top right;
}
@keyframes loading-envelope-float {
from { transform: translateY(0) rotate(-3deg); opacity: .62; }
to { transform: translateY(-2px) rotate(3deg); opacity: 1; }
}
.email-address-input {
display: grid;
gap: 10px;
}
.email-chip-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 34px;
}
.email-chip {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
border: 1px solid #c9c5bd;
border-radius: 999px;
background: linear-gradient(#ffffff, #f2f1ef);
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
padding: 5px 8px 5px 11px;
color: var(--text-strong);
font-size: 13px;
line-height: 1.2;
}
.email-chip.invalid {
border-color: #c96b63;
background: #f6e3df;
color: #873c35;
}
.email-chip-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.email-chip-address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 12px;
}
.email-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 0;
border-radius: 999px;
background: rgba(0,0,0,.08);
color: #57534d;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.email-chip-remove:hover { background: rgba(0,0,0,.15); }
.email-chip-empty {
color: var(--muted);
font-size: 13px;
}
.email-address-hint {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.recipient-editor-table th:nth-child(2),
.recipient-editor-table td:nth-child(2) { min-width: 430px; }
.recipient-editor-table th:last-child,
.recipient-editor-table td:last-child { width: 92px; text-align: right; }
.recipient-field-input,
.recipient-attachments-input {
min-width: 150px;
font-size: 12px;
}
.recipient-attachments-input {
min-width: 260px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.card-title-node,
.card-heading-with-loader {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text-strong);
font-weight: 800;
}
.card-heading-with-loader {
font-size: 18px;
}
/* Mail-style address editor: textarea surface + pills + add dialog. */
.email-address-editor {
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
padding: 7px 8px;
}
.email-address-editor:focus-within {
border-color: #9bb7d3;
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8);
}
.email-address-editor.has-error {
border-color: #c96b63;
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13);
}
.email-address-editor .email-chip-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
min-height: 0;
}
.email-address-textarea {
flex: 1 1 230px;
min-width: 190px;
min-height: 26px;
max-height: 96px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
resize: none;
overflow: hidden;
padding: 4px 2px;
font: inherit;
line-height: 1.35;
}
.email-address-textarea:focus {
outline: none;
box-shadow: none;
}
.email-address-input.has-add-button .email-address-editor {
padding-right: 42px;
}
.email-address-plus {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid #c7c2b8;
border-radius: 999px;
background: linear-gradient(#fff, #f0efec);
color: #4f4b46;
cursor: pointer;
font-size: 19px;
font-weight: 800;
line-height: 1;
padding: 0;
}
.email-address-plus:hover {
border-color: #aaa49a;
background: linear-gradient(#fff, #e9e7e3);
}
.email-address-popover {
position: absolute;
z-index: 12;
top: calc(100% + 7px);
right: 0;
width: min(360px, calc(100vw - 64px));
display: grid;
gap: 10px;
border: 1px solid var(--line-dark);
border-radius: 8px;
background: #fff;
box-shadow: var(--shadow-popover);
padding: 14px;
}
.email-address-popover h4 {
margin: 0;
color: var(--text-strong);
font-size: 15px;
}
.email-address-popover label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.email-address-popover input {
font-size: 14px;
}
.email-address-suggestions {
display: grid;
gap: 4px;
max-width: 560px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
padding: 6px;
}
.email-address-suggestions button {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-strong);
cursor: pointer;
padding: 7px 8px;
text-align: left;
}
.email-address-suggestions button:hover {
background: var(--panel-soft);
}
.email-address-suggestions small {
color: var(--muted);
}
.email-address-input.compact {
min-width: 420px;
}
.email-address-input.compact .email-address-editor {
min-height: 40px;
}
.email-address-input.compact .email-address-textarea {
flex-basis: 180px;
min-width: 150px;
}
.email-address-input.disabled .email-address-editor {
background: var(--panel-soft);
}
.email-address-input.disabled .email-address-plus,
.email-address-input.disabled .email-address-textarea,
.email-address-input.disabled .email-address-suggestions,
.email-address-input.disabled .email-address-popover {
display: none;
}
/* Bootstrap-like switch controls without importing Bootstrap. */
.toggle-switch-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
color: var(--text);
cursor: pointer;
font-weight: 700;
}
.toggle-switch-row.disabled {
cursor: default;
opacity: .72;
}
.toggle-switch-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.toggle-switch-track {
position: relative;
flex: 0 0 auto;
width: 42px;
height: 24px;
border: 1px solid #aeb4bb;
border-radius: 999px;
background: #cfd4da;
box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
}
.toggle-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.22);
transition: transform .16s ease;
}
.toggle-switch-input:checked + .toggle-switch-track {
border-color: #0d6efd;
background: #0d6efd;
}
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
transform: translateX(18px);
}
.toggle-switch-input:focus-visible + .toggle-switch-track {
box-shadow: 0 0 0 3px rgba(13,110,253,.2), inset 0 1px 2px rgba(0,0,0,.12);
}
.toggle-switch-input:disabled + .toggle-switch-track {
filter: grayscale(.15);
opacity: .7;
}
.toggle-switch-copy {
display: grid;
gap: 2px;
}
.toggle-switch-label {
color: var(--text-strong);
}
.toggle-switch-help {
color: var(--muted);
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
@media (max-width: 900px) {
.email-address-input.compact { min-width: 260px; }
.email-address-input.has-add-button .email-address-editor { padding-right: 39px; }
.email-address-popover {
left: 0;
right: auto;
width: min(340px, calc(100vw - 48px));
}
}
/* Reusable inline help markers for form labels and compact contextual hints. */
.field-label {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.field-label-text {
min-width: 0;
}
.inline-help {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
outline: none;
}
.inline-help-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 999px;
color: #686560;
background: var(--line-dark);
font-size: 11px;
font-weight: 800;
line-height: 1;
}
.inline-help:focus-visible .inline-help-mark {
box-shadow: var(--focus-ring);
}
.inline-help-bubble {
position: absolute;
z-index: 40;
left: 50%;
bottom: calc(100% + 9px);
width: max-content;
max-width: min(320px, calc(100vw - 48px));
transform: translate(-50%, 3px);
border: 1px solid var(--line-dark);
border-radius: 7px;
background: var(--surface);
box-shadow: var(--shadow-popover);
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
padding: 9px 10px;
white-space: normal;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity .14s ease .35s, transform .14s ease .35s, visibility 0s linear .49s;
}
.inline-help-bubble::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
width: 9px;
height: 9px;
border-right: 1px solid var(--line-dark);
border-bottom: 1px solid var(--line-dark);
background: var(--surface);
transform: translate(-50%, -5px) rotate(45deg);
}
.inline-help:hover .inline-help-bubble,
.inline-help:focus-within .inline-help-bubble {
visibility: visible;
opacity: 1;
transform: translate(-50%, 0);
transition-delay: .35s, .35s, .35s;
}

12
src/styles/forms.css Normal file
View File

@@ -0,0 +1,12 @@
.form-grid { display: grid; gap: 18px; }
.form-grid.compact { grid-template-columns: 1fr 1fr; }
.form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: #6b6863; }
.form-help { font-size: 12px; color: var(--muted); }
input, select, textarea { border: 1px solid var(--line); border-radius: 5px; background: #fff; font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: inset 0 1px 2px rgba(0,0,0,.04); }
textarea { resize: vertical; }
.btn { border: 1px solid var(--line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: #f8f8f7; color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.15); }
.btn-primary { background: var(--green); border-color: #5aa99b; color: #fff; }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
.btn-danger { background: var(--red); color: #fff; border-color: #c94d43; }
.btn:disabled { opacity: .55; cursor: not-allowed; }

142
src/styles/layout.css Normal file
View File

@@ -0,0 +1,142 @@
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 58px 1fr; }
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; min-height: 100vh; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; }
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute;
top: 9px;
left: 9px;
width: 15px;
height: 15px;
border-radius: 50%;
content: "";
background-color: var(--rail-bg);
}
.icon-nav { width: 100%; display: flex; flex-direction: column; }
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
.icon-rail-bottom { margin-top: auto; padding: 20px 0; }
.app-main { min-width: 0; display: grid; grid-template-rows: 64px 51px 1fr; min-height: 100vh; }
.titlebar { background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
.titlebar-spacer { flex: 1; }
.titlebar-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.account-pill { color: var(--text); }
.api-mini { display: flex; gap: 6px; }
.api-mini input { width: 155px; height: 30px; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: 1px solid var(--line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 90; }
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: #615f5c; }
.crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; }
.workspace { height: calc(100vh - 112px); display: grid; grid-template-columns: 198px 1fr; }
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; }
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; }
.section-link { width: calc(100% + 3px); height: 48px; border: 0; padding: 0 22px; background: transparent; text-align: left; color: #686560; font: inherit; cursor: pointer; margin-left: -3px; }
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; }
.workspace-content { overflow: auto; }
.content-pad { padding: 28px 34px; }
.page-heading { margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; }
.page-heading p { margin: 6px 0 0; color: var(--muted); }
.page-heading.split { display: flex; align-items: center; justify-content: space-between; }
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: scroll; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; }
.card-body { padding: 22px 24px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.metric-grid.inside { margin: 14px 0; }
.metric-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); }
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
.wizard-heading { display: flex; justify-content: space-between; margin-bottom: 18px; }
.wizard-heading h1 { margin: 0; }
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
.wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.stepper { list-style: none; padding: 22px 0; margin: 0; background: #f6f5f3; border-right: 1px solid var(--line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: #999; cursor: pointer; }
.step.active button { background: #fff; color: var(--text-strong); }
.step-number { width: 32px; height: 32px; border-radius: 50%; background: #d8d6d2; color: #fff; display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
.step.active .step-number { background: var(--accent); }
.step small { display: block; margin-top: 3px; color: var(--muted); }
.step-intro h2 { margin: 0; color: var(--text-strong); }
.step-intro p { margin-top: 6px; color: var(--muted); }
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.alert.warning { background: #ffe1a3; } .alert.danger { background: #f3c5be; }
.table-like { border: 1px solid var(--line); }
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: 1px solid var(--line); }
.table-row-link:hover { background: var(--panel-soft); }
.mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
.code-panel { background: #292929; color: #f1f1f1; padding: 18px; border-radius: 4px; overflow: auto; }
@media (max-width: 900px) {
.api-mini { display: none; }
.workspace { grid-template-columns: 1fr; }
.section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; }
.metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }
}
/* Layout additions: login, help dropdown, overlays */
.tenant-selector.disabled { opacity: .86; cursor: default; }
.context-menu-wrap { position: relative; }
.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: #fff; border: 1px solid var(--line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
.dropdown-menu hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
.dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 4px; text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; }
.dropdown-item:hover { background: var(--panel-soft); color: var(--text-strong); }
.dropdown-item small { margin-left: auto; color: var(--muted); }
.account-menu { min-width: 260px; }
.account-menu-header { padding: 8px 9px 10px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
.account-menu-header strong { display: block; color: var(--text-strong); }
.account-menu-header span { color: var(--muted); font-size: 13px; }
.overlay-backdrop { position: fixed; inset: 0; background: rgba(37,40,42,.38); display: grid; place-items: center; z-index: 9000; padding: 24px; }
.modal-panel { width: min(560px, 100%); max-height: calc(100vh - 48px); overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 24px 80px rgba(0,0,0,.26); }
.modal-header { min-height: 58px; display: flex; align-items: center; padding: 0 22px; border-bottom: 1px solid var(--line); }
.modal-header h2 { margin: 0; font-size: 18px; color: var(--text-strong); }
.modal-close { margin-left: auto; border: 0; background: transparent; font-size: 22px; line-height: 1; cursor: pointer; color: var(--muted); }
.modal-body { padding: 22px; }
.modal-footer { padding: 16px 22px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; background: var(--panel-soft); }
.login-hint { background: #f6f5f3; border: 1px solid var(--line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
.help-panel-section { margin-bottom: 16px; }
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
.about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); margin-bottom: 12px; }
.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: 1px solid var(--line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: #fff; font-size: 12px; font-weight: 700; color: #666; }
.titlebar .tenant-selector strong { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Campaign workspace polish */
.crumb-link { color: inherit; text-decoration: none; border-radius: 4px; padding: 4px 5px; margin: -4px -5px; }
.crumb-link:hover { background: rgba(255,255,255,.35); color: var(--text-strong); }
.compact-actions { margin: 0; align-items: center; }
.below-grid { margin-top: 18px; }
.detail-list { display: grid; gap: 12px; margin: 0; }
.detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.detail-list div:last-child { border-bottom: 0; padding-bottom: 0; }
.detail-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.detail-list dd { margin: 0; min-width: 0; }
.next-action-card { border-left: 4px solid var(--blue); padding-left: 16px; }
.next-action-card h2 { margin: 0; color: var(--text-strong); font-size: 19px; }
.next-action-card p { margin: 8px 0 0; color: var(--muted); }
.next-action-good { border-left-color: var(--green); }
.next-action-warning { border-left-color: var(--amber); }
.next-action-info { border-left-color: var(--blue); }
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.summary-tile { background: var(--panel-soft); border: 1px solid var(--line); border-radius: 6px; padding: 14px; }
.summary-tile span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.summary-tile strong { display: block; margin-top: 6px; color: var(--text-strong); font-size: 24px; }
.empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
.empty-state h2 { margin: 0; color: var(--text-strong); }
.empty-state p { max-width: 520px; margin: 8px auto 18px; color: var(--muted); }
@media (max-width: 900px) {
.page-heading.split { align-items: flex-start; flex-direction: column; }
.summary-grid, .detail-list div { grid-template-columns: 1fr; }
}

128
src/styles/tables.css Normal file
View File

@@ -0,0 +1,128 @@
/* Legacy mapping / rule tables used by early wizard surfaces. */
.mapping-table {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.mapping-header,
.mapping-row {
display: grid;
grid-template-columns: 1.3fr 1.3fr .8fr 1fr .6fr;
align-items: center;
}
.mapping-header {
background: var(--bar);
color: #6b6863;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.mapping-header span,
.mapping-row span {
padding: 12px 14px;
border-right: 1px solid var(--line);
}
.mapping-row {
background: #fff;
border-top: 1px solid var(--line);
}
.attachment-rule-card {
margin: 18px 0;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 18px;
}
.attachment-rule-header {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
/* Application data tables, visually aligned with the SaaS-style reference table. */
.app-table-wrap {
margin: -22px -24px;
overflow-x: auto;
}
.app-table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
.app-table thead {
background: var(--bar);
color: #625f5a;
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
}
.app-table th {
height: 40px;
padding: 0 16px;
border-right: 1px solid var(--line-dark);
font-weight: 800;
text-align: left;
white-space: nowrap;
}
.app-table th:last-child { border-right: 0; }
.app-table td {
min-height: 56px;
padding: 16px;
border-top: 1px solid var(--line);
vertical-align: middle;
}
.app-table tbody tr:hover { background: var(--panel-soft); }
.table-primary-link,
.table-action-link {
color: var(--text-strong);
font-weight: 800;
text-decoration: none;
}
.table-primary-link:hover,
.table-action-link:hover { text-decoration: underline; }
.table-subline {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
/* Campaign list table; the containing card/sticky behavior lives in campaign-workspace.css. */
.campaign-table-wrap {
margin: 0;
overflow-x: auto;
overflow-y: visible;
}
.campaign-table {
table-layout: fixed;
}
.campaign-table thead tr {
box-shadow: 0 12px 16px -18px rgba(45, 43, 40, .8);
}
.campaign-table thead th {
background: var(--bar);
border-right: 1px solid var(--line-dark);
}
.campaign-table th:nth-child(1),
.campaign-table td:nth-child(1) { width: auto; min-width: 360px; }
.campaign-table th:nth-child(2),
.campaign-table td:nth-child(2) { width: 150px; }
.campaign-table th:nth-child(3),
.campaign-table td:nth-child(3) { width: 230px; }
.campaign-table th:nth-child(4),
.campaign-table td:nth-child(4) { width: 230px; }
.campaign-table th:nth-child(5),
.campaign-table td:nth-child(5) { width: 86px; text-align: right; }
.campaign-table td.updated-cell {
color: #696660;
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
}
.campaign-table td.version-cell {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}

50
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,50 @@
:root {
--rail-bg: #25282a;
--rail-bg-active: #1c1e20;
--accent: #ef6b3a;
--accent-soft: rgba(239, 107, 58, .35);
--bg: #e9e7e4;
--bar: #d4d0ca;
--panel: #f7f6f4;
--panel-soft: #ffffff;
--panel-header: #ffffff;
--surface: #ffffff;
--surface-subtle: var(--panel-soft);
--line: #d6d2cc;
--line-dark: #bdb8b0;
--border: var(--line);
--text: #48494c;
--text-strong: #303135;
--muted: #8a8781;
--muted-text: var(--muted);
--green: #7bbcaf;
--amber: #f5c56c;
--red: #e06a5f;
--blue: #7ea6c5;
--success-bg: #d8eee8;
--success-text: #315f55;
--info-bg: #dce9f3;
--info-text: #365c76;
--danger-text: #a64840;
--focus-ring: 0 0 0 3px rgba(82, 130, 177, .14);
--shadow: 0 1px 2px rgba(0,0,0,.15), 0 10px 30px rgba(0,0,0,.04);
--shadow-menu: 0 16px 40px rgba(0,0,0,.18);
--shadow-popover: 0 3px 8px -3px rgba(0,0,0,.16);
--radius: 8px;
--radius-sm: 4px;
--radius-md: var(--radius);
--radius-lg: 12px;
--radius-pill: 999px;
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: var(--font); color: var(--text); background: var(--bg); }
a { color: inherit; }

101
src/types.ts Normal file
View File

@@ -0,0 +1,101 @@
export type ApiSettings = {
apiBaseUrl: string;
apiKey: string;
accessToken: string;
};
export type AuthUser = {
id: string;
email: string;
display_name?: string | null;
is_tenant_admin?: boolean;
};
export type AuthTenant = {
id: string;
slug: string;
name: string;
};
export type AuthTenantMembership = AuthTenant & {
roles?: string[];
is_active?: boolean;
};
export type AuthRole = {
id: string;
slug: string;
name: string;
permissions: string[];
};
export type AuthGroup = {
id: string;
slug: string;
name: string;
};
export type AuthInfo = {
user: AuthUser;
// Backwards-compatible active tenant alias returned by older/newer APIs.
tenant: AuthTenant;
active_tenant?: AuthTenant;
tenants?: AuthTenantMembership[];
scopes: string[];
roles: AuthRole[];
groups: AuthGroup[];
};
export type LoginResponse = AuthInfo & {
access_token: string;
token_type: "bearer";
expires_at: string;
};
export type NavSection =
| "dashboard"
| "campaigns"
| "templates"
| "files"
| "reports"
| "settings"
| "admin";
export type CampaignWorkspaceSection =
| "overview"
| "campaign"
| "global-settings"
| "fields"
| "recipients"
| "template"
| "files"
| "mail-settings"
| "review"
| "send"
| "report"
| "audit"
| "json";
export type WizardStep = {
id: string;
label: string;
description?: string;
status?: "todo" | "active" | "done" | "warning" | "blocked";
};
export type CampaignListItem = {
id: string;
external_id?: string;
name: string;
description?: string | null;
status: string;
current_version_id?: string | null;
created_at?: string;
updated_at?: string;
updatedAt?: string;
ready?: number;
warnings?: number;
blocked?: number;
sent?: number;
failed?: number;
};

101
src/utils/emailAddresses.ts Normal file
View File

@@ -0,0 +1,101 @@
import { asArray, asRecord } from "../features/campaigns/utils/campaignView";
export type MailboxAddress = {
name?: string;
email: string;
};
export function normalizeEmailAddress(address: MailboxAddress): MailboxAddress {
return {
name: (address.name ?? "").trim(),
email: (address.email ?? "").trim().toLowerCase()
};
}
export function isValidEmailAddress(email: string): boolean {
const normalized = email.trim();
if (!normalized || normalized.length > 254) return false;
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(normalized);
}
export function addressFromRecord(value: unknown): MailboxAddress | null {
const record = asRecord(value);
const email = typeof record.email === "string" ? record.email.trim() : "";
if (!email) return null;
const name = typeof record.name === "string" ? record.name.trim() : "";
return normalizeEmailAddress({ name, email });
}
export function addressesFromValue(value: unknown): MailboxAddress[] {
if (Array.isArray(value)) {
return value.map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
}
const single = addressFromRecord(value);
return single ? [single] : [];
}
export function parseMailboxAddressText(input: string): MailboxAddress | null {
const text = input.trim().replace(/[;,]+$/g, "");
if (!text) return null;
const angleMatch = text.match(/^(.+?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/);
if (angleMatch) {
return normalizeEmailAddress({ name: cleanAddressName(angleMatch[1]), email: angleMatch[2] });
}
const emailMatch = text.match(/([^\s<>;,]+@[^\s<>;,]+\.[^\s<>;,]+)/);
if (!emailMatch) return null;
const email = emailMatch[1];
const name = cleanAddressName(`${text.slice(0, emailMatch.index)} ${text.slice((emailMatch.index ?? 0) + email.length)}`);
return normalizeEmailAddress({ name, email });
}
function cleanAddressName(value: string): string {
return value
.trim()
.replace(/[<>]/g, "")
.replace(/^[\s"'`]+|[\s"'`]+$/g, "")
.replace(/[;,]+$/g, "")
.trim();
}
export function collectCampaignAddressSuggestions(draft: Record<string, unknown> | null | undefined): MailboxAddress[] {
if (!draft) return [];
const suggestions: MailboxAddress[] = [];
const recipients = asRecord(draft.recipients);
const sender = addressFromRecord(recipients.from);
if (sender) suggestions.push(sender);
for (const key of ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]) {
suggestions.push(...addressesFromValue(recipients[key]));
}
const entries = asRecord(draft.entries);
for (const entryValue of asArray(entries.inline)) {
const entry = asRecord(entryValue);
const toAddresses = asArray(entry.to).map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
suggestions.push(...toAddresses);
const direct = addressFromRecord(entry.recipient) ?? addressFromRecord(entry);
if (direct) suggestions.push(direct);
}
return dedupeAddresses(suggestions);
}
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Map<string, MailboxAddress>();
addresses.map(normalizeEmailAddress).forEach((address) => {
if (!address.email) return;
const existing = seen.get(address.email);
if (!existing || (!existing.name && address.name)) {
seen.set(address.email, address);
}
});
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
}
export function addressDisplayName(address: MailboxAddress): string {
const normalized = normalizeEmailAddress(address);
return normalized.name || normalized.email;
}

86
src/utils/fieldHelp.ts Normal file
View File

@@ -0,0 +1,86 @@
const FIELD_HELP_BY_LABEL: Record<string, string> = {
"API base URL": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
"Automation API key": "Fallback API key for scripted or non-login access. Interactive browser sessions should use login tokens instead.",
"Email": "Email address used to identify the user account for login or address entry.",
"Password": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
"Name": "Human-readable display name shown in lists, reports or outgoing address headers.",
"Email address": "The mailbox address, for example office@example.org.",
"Campaign ID": "Stable technical identifier used in JSON, reports, filenames and backend references.",
"Campaign name": "Human-readable campaign name shown in lists, review screens and reports.",
"Mode": "Campaign operating mode. Use draft while editing, test for trial runs and send when preparing the real campaign.",
"Scenario": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
"Description": "Internal explanation of the campaign purpose. This is not sent to recipients.",
"Default From address": "Default sender address used for outgoing messages unless individual senders are allowed and set per recipient.",
"From name": "Display name shown with the sender address in outgoing messages.",
"From email": "Mailbox address used as the sender of outgoing messages.",
"Global Reply-To address": "Default reply destination for recipient responses. Leave empty to let replies go to the From address.",
"Reply-To": "Address recipients should reply to when it differs from the sender address.",
"To": "Global To recipients added to every message in addition to recipient-specific recipients.",
"Global recipients": "Default recipients included in every generated message.",
"CC": "Global carbon-copy recipients included in every generated message.",
"BCC": "Global blind-copy recipients included in every generated message.",
"Template source": "Where this campaign template comes from. Inline means it is stored directly in the campaign draft.",
"Library template": "Reusable template record this campaign should refer to once the template backend is available.",
"Subject": "Message subject. Placeholders can reference campaign/global/recipient fields.",
"Body": "Template body content shown for review. Placeholders are checked against campaign fields.",
"Plain text body": "Plain text version of the email body. It should remain readable without HTML rendering.",
"HTML body": "Optional HTML version of the email body for richer formatting.",
"Attachment base path": "Base folder used to resolve campaign attachment rules and relative file patterns.",
"Campaign attachment base path": "Base folder used during campaign creation to resolve attachment files and patterns.",
"Default missing behavior": "Default review behavior when an expected attachment cannot be found.",
"Default ambiguous behavior": "Default review behavior when a file pattern matches more than one attachment.",
"Base directory": "Folder below the campaign attachment base path where this rule starts looking for files.",
"File filter": "Filename or pattern used to select matching files. Field placeholders can be introduced later.",
"Include subdirectories": "Whether the rule should also search below nested folders.",
"Allow multiple matches": "Whether more than one matching file may be attached for this rule.",
"Missing behavior": "Action or review severity when this rule finds no matching file.",
"Ambiguous behavior": "Action or review severity when this rule finds multiple possible matches.",
"Host": "Mail server hostname or IP address for the selected protocol.",
"SMTP host": "SMTP server hostname or IP address used for sending messages.",
"Port": "Network port used by the selected mail protocol.",
"SMTP port": "Network port used by the SMTP server, commonly 587 for STARTTLS or 465 for TLS.",
"Username": "Login name for the selected mail server. Often the same as the mailbox address.",
"Security": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
"Timeout seconds": "Maximum time to wait for the mail server before the connection test or send attempt fails.",
"Detected/saved sent folder": "IMAP folder name used as the detected or manually configured Sent folder.",
"Append folder": "Folder where successfully sent messages should be appended via IMAP.",
"IMAP append to Sent": "Whether sent messages should also be copied into the configured Sent folder via IMAP.",
"Messages per minute": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
"Concurrency": "Number of send operations that may run in parallel. Keep low until account and provider limits are clear.",
"Max attempts": "Maximum retry attempts for a message before it remains failed for review.",
"Source type": "Recipient data source format. Inline data is edited in the campaign; external sources will be parsed later.",
"Source path": "Path or identifier for an external recipient source such as a CSV file.",
"Remember previously used addresses": "Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only.",
"External address-book sync": "Placeholder for CardDAV, Google, LDAP or similar address sources.",
"Allow individual senders": "Permit recipient rows to override the campaign's default sender address.",
"Allow individual From": "Permit per-recipient sender overrides when building messages.",
"Allow individual Reply-To": "Permit recipient rows to override the global Reply-To address.",
"Allow individual To": "Permit recipient rows to define their own To recipients in addition to global headers.",
"Allow individual CC": "Permit recipient rows to define their own CC recipients.",
"Allow individual BCC": "Permit recipient rows to define their own BCC recipients.",
"Allow individual attachments": "Permit recipient-specific attachment rules in addition to global files.",
"Send without attachments": "Allow messages to be sent even when no attachment is resolved. Disable to force review or blocking.",
"Status tracking": "Store per-message build, validation, queue, send and IMAP status for review and reporting.",
"Suggest addresses from this campaign": "Use addresses already present in this campaign as autocomplete suggestions.",
"Remember newly used addresses": "Prepare newly entered addresses for a future user-level address memory.",
"Show guided warnings while editing": "Show inline guidance and warnings while campaign data is being edited.",
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
"Subdirs": "Search nested folders below the configured base directory.",
"Can override": "Allow recipient-specific values to override the global value for this field.",
"Enable IMAP": "Enable IMAP settings so the server can test login, list folders and optionally append sent copies.",
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
};
export function helpForFieldLabel(label: unknown): string | undefined {
if (typeof label !== "string") return undefined;
return FIELD_HELP_BY_LABEL[label];
}

64
src/utils/helpContext.ts Normal file
View File

@@ -0,0 +1,64 @@
export type HelpContext = {
id: string;
title: string;
route: string;
};
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
data: { id: "campaign.settings", title: "Campaign settings" },
campaign: { id: "campaign.settings", title: "Campaign settings" },
fields: { id: "campaign.fields", title: "Campaign fields" },
template: { id: "campaign.template", title: "Template" },
files: { id: "campaign.attachments", title: "Attachments" },
attachments: { id: "campaign.attachments", title: "Attachments" },
recipients: { id: "campaign.recipients", title: "Recipients" },
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
mail: { id: "campaign.server-settings", title: "Server settings" },
"global-settings": { id: "campaign.global-settings", title: "Global settings" },
settings: { id: "campaign.global-settings", title: "Global settings" },
review: { id: "campaign.review", title: "Review" },
send: { id: "campaign.send", title: "Send" },
report: { id: "campaign.report", title: "Report" },
reports: { id: "campaign.report", title: "Report" },
audit: { id: "campaign.audit", title: "Audit log" },
json: { id: "campaign.json", title: "JSON" }
};
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
dashboard: { id: "app.dashboard", title: "Dashboard" },
campaigns: { id: "campaigns.list", title: "Campaigns" },
templates: { id: "templates.list", title: "Templates" },
files: { id: "files.list", title: "Files" },
reports: { id: "reports.list", title: "Reports" },
settings: { id: "app.settings", title: "Settings" },
admin: { id: "app.admin", title: "Admin" }
};
export function helpContextForPathname(pathname: string): HelpContext {
const route = pathname || "/";
const segments = route.split("/").filter(Boolean);
if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "Campaign overview", route };
if (segments[2] === "wizard") {
const step = segments[3] || "create";
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
}
const context = campaignSectionContexts[segments[2]];
if (context) return { ...context, route };
return { id: "campaign.workspace", title: "Campaign workspace", route };
}
const context = topLevelContexts[segments[0] || "campaigns"];
if (context) return { ...context, route };
return { id: "app.general", title: "Application", route };
}
export function helpQueryForContext(context: HelpContext): string {
return `context=${encodeURIComponent(context.id)}`;
}
function capitalize(value: string): string {
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

18
vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: process.env.VITE_API_PROXY_TARGET || "http://127.0.0.1:8000",
changeOrigin: true
},
"/health": {
target: process.env.VITE_API_PROXY_TARGET || "http://127.0.0.1:8000",
changeOrigin: true
}
}
}
});