first wokring prototype
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal 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
125
README.md
@@ -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
12
index.html
Normal 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>
|
||||
BIN
multi-seal-mail-webui.tar.gz
Normal file
BIN
multi-seal-mail-webui.tar.gz
Normal file
Binary file not shown.
BIN
multisealmail-webui-reload-preview-patch.zip
Normal file
BIN
multisealmail-webui-reload-preview-patch.zip
Normal file
Binary file not shown.
BIN
multisealmail-webui-reload-streamline-patch.zip
Normal file
BIN
multisealmail-webui-reload-streamline-patch.zip
Normal file
Binary file not shown.
7
nginx.conf
Normal file
7
nginx.conf
Normal 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
1779
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal 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
113
src/App.tsx
Normal 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
20
src/api/auth.ts
Normal 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
289
src/api/campaigns.ts
Normal 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
62
src/api/client.ts
Normal 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
75
src/api/mail.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
5
src/components/Button.tsx
Normal file
5
src/components/Button.tsx
Normal 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
19
src/components/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/FormField.tsx
Normal file
12
src/components/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/LoadingIndicator.tsx
Normal file
12
src/components/LoadingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/components/MetricCard.tsx
Normal file
9
src/components/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/PageTitle.tsx
Normal file
15
src/components/PageTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/StatusBadge.tsx
Normal file
3
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
return <span className={`status-badge status-${status.toLowerCase().replace(/_/g, "-")}`}>{status}</span>;
|
||||
}
|
||||
19
src/components/Stepper.tsx
Normal file
19
src/components/Stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ToggleSwitch.tsx
Normal file
29
src/components/ToggleSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/components/email/EmailAddressInput.tsx
Normal file
246
src/components/email/EmailAddressInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/help/FieldLabel.tsx
Normal file
17
src/components/help/FieldLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/help/InlineHelp.tsx
Normal file
186
src/components/help/InlineHelp.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/features/PlaceholderPage.tsx
Normal file
15
src/features/PlaceholderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/features/admin/AdminPage.tsx
Normal file
131
src/features/admin/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/features/auth/LoginModal.tsx
Normal file
60
src/features/auth/LoginModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/features/auth/PublicLandingPage.tsx
Normal file
46
src/features/auth/PublicLandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/features/campaigns/AttachmentsDataPage.tsx
Normal file
253
src/features/campaigns/AttachmentsDataPage.tsx
Normal 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 } : {})
|
||||
}));
|
||||
}
|
||||
14
src/features/campaigns/CampaignAuditPage.tsx
Normal file
14
src/features/campaigns/CampaignAuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/features/campaigns/CampaignDataPage.tsx
Normal file
204
src/features/campaigns/CampaignDataPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
src/features/campaigns/CampaignFieldsPage.tsx
Normal file
308
src/features/campaigns/CampaignFieldsPage.tsx
Normal 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);
|
||||
}
|
||||
32
src/features/campaigns/CampaignJsonView.tsx
Normal file
32
src/features/campaigns/CampaignJsonView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/features/campaigns/CampaignListPage.tsx
Normal file
150
src/features/campaigns/CampaignListPage.tsx
Normal 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"
|
||||
});
|
||||
}
|
||||
340
src/features/campaigns/CampaignOverviewPage.tsx
Normal file
340
src/features/campaigns/CampaignOverviewPage.tsx
Normal 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;
|
||||
}
|
||||
38
src/features/campaigns/CampaignReportPage.tsx
Normal file
38
src/features/campaigns/CampaignReportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/features/campaigns/CampaignWorkspace.tsx
Normal file
112
src/features/campaigns/CampaignWorkspace.tsx
Normal 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";
|
||||
}
|
||||
178
src/features/campaigns/GlobalSettingsPage.tsx
Normal file
178
src/features/campaigns/GlobalSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
src/features/campaigns/MailSettingsPage.tsx
Normal file
291
src/features/campaigns/MailSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
src/features/campaigns/RecipientDataPage.tsx
Normal file
305
src/features/campaigns/RecipientDataPage.tsx
Normal 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: "" };
|
||||
}
|
||||
96
src/features/campaigns/ReviewDataPage.tsx
Normal file
96
src/features/campaigns/ReviewDataPage.tsx
Normal 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) })));
|
||||
}
|
||||
66
src/features/campaigns/SendDataPage.tsx
Normal file
66
src/features/campaigns/SendDataPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
558
src/features/campaigns/TemplateDataPage.tsx
Normal file
558
src/features/campaigns/TemplateDataPage.tsx
Normal 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, "\\$&");
|
||||
}
|
||||
20
src/features/campaigns/components/AttachmentRuleCard.tsx
Normal file
20
src/features/campaigns/components/AttachmentRuleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/features/campaigns/components/FieldMappingTable.tsx
Normal file
24
src/features/campaigns/components/FieldMappingTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src/features/campaigns/context/UnsavedChangesContext.tsx
Normal file
177
src/features/campaigns/context/UnsavedChangesContext.tsx
Normal 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]);
|
||||
}
|
||||
61
src/features/campaigns/hooks/useCampaignWorkspaceData.ts
Normal file
61
src/features/campaigns/hooks/useCampaignWorkspaceData.ts
Normal 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 };
|
||||
}
|
||||
155
src/features/campaigns/utils/campaignView.ts
Normal file
155
src/features/campaigns/utils/campaignView.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { CampaignListItem } from "../../../types";
|
||||
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
|
||||
export type CampaignWorkspaceData = {
|
||||
campaign: CampaignListItem | null;
|
||||
versions: CampaignVersionListItem[];
|
||||
currentVersion: CampaignVersionDetail | null;
|
||||
summary: CampaignSummary | null;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return version?.raw_json ?? version?.campaign_json ?? {};
|
||||
}
|
||||
|
||||
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).campaign);
|
||||
}
|
||||
|
||||
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).recipients);
|
||||
}
|
||||
|
||||
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).template);
|
||||
}
|
||||
|
||||
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).delivery);
|
||||
}
|
||||
|
||||
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).entries);
|
||||
}
|
||||
|
||||
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
return asRecord(getCampaignJson(version).attachments);
|
||||
}
|
||||
|
||||
export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
return asArray(getCampaignJson(version).fields);
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
if (version.locked_at || version.published_at) return true;
|
||||
return ["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "");
|
||||
}
|
||||
|
||||
export function versionLockReason(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
if (version.locked_at) return `Locked at ${formatDateTime(version.locked_at)}.`;
|
||||
if (version.published_at) return `Published at ${formatDateTime(version.published_at)}.`;
|
||||
if (["queued", "sending", "completed", "archived", "cancelled"].includes(version.workflow_state ?? "")) {
|
||||
return `Workflow state is ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
}
|
||||
return "Editable working version.";
|
||||
}
|
||||
|
||||
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "—";
|
||||
const flow = version.current_flow || "manual";
|
||||
const step = version.current_step || "not set";
|
||||
return `${humanize(flow)} / ${humanize(step)}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
export function humanize(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
|
||||
if (!summary) return "—";
|
||||
for (const key of keys) {
|
||||
const value = summary[key];
|
||||
if (typeof value === "number" || typeof value === "string") return value;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
||||
let current: unknown = record;
|
||||
for (const part of path) {
|
||||
if (!isRecord(current)) return fallback;
|
||||
current = current[part];
|
||||
}
|
||||
if (typeof current === "string" && current.trim()) return current;
|
||||
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
||||
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
|
||||
if (isRecord(current)) return stringifyPreview(current, 120);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function stringifyPreview(value: unknown, maxLength = 220): string {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
}
|
||||
|
||||
export function cloneCampaignJsonForCopy(
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string
|
||||
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
|
||||
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
|
||||
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
|
||||
const description = String(campaignSection.description || campaign?.description || "");
|
||||
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
|
||||
const name = `${baseName} (copy)`;
|
||||
|
||||
rawJson.campaign = {
|
||||
...campaignSection,
|
||||
id: externalId,
|
||||
name,
|
||||
description
|
||||
};
|
||||
|
||||
return { externalId, name, description, rawJson };
|
||||
}
|
||||
|
||||
export function timestampSlug(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||
}
|
||||
107
src/features/campaigns/utils/draftEditor.ts
Normal file
107
src/features/campaigns/utils/draftEditor.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
|
||||
|
||||
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
export function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? {})) as T;
|
||||
}
|
||||
|
||||
export function ensureCampaignDraft(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||
const raw = cloneJson(getCampaignJson(version));
|
||||
raw.version = typeof raw.version === "string" ? raw.version : "1";
|
||||
raw.campaign = {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
mode: "draft",
|
||||
...asRecord(raw.campaign)
|
||||
};
|
||||
raw.fields = Array.isArray(raw.fields) ? raw.fields : [];
|
||||
raw.global_values = isRecord(raw.global_values) ? raw.global_values : {};
|
||||
raw.server = isRecord(raw.server) ? raw.server : {};
|
||||
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
|
||||
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
|
||||
raw.attachments = {
|
||||
base_path: ".",
|
||||
allow_individual: false,
|
||||
send_without_attachments: true,
|
||||
global: [],
|
||||
missing_behavior: "ask",
|
||||
ambiguous_behavior: "ask",
|
||||
...asRecord(raw.attachments)
|
||||
};
|
||||
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
||||
raw.validation_policy = isRecord(raw.validation_policy) ? raw.validation_policy : {};
|
||||
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
|
||||
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function updateNested(
|
||||
draft: Record<string, unknown>,
|
||||
path: string[],
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
const next = cloneJson(draft);
|
||||
let current: Record<string, unknown> = next;
|
||||
path.forEach((segment, index) => {
|
||||
if (index === path.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
}
|
||||
const existing = current[segment];
|
||||
if (!isRecord(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseJsonTextarea<T>(text: string, fallback: T): { value: T; error: string } {
|
||||
if (!text.trim()) return { value: fallback, error: "" };
|
||||
try {
|
||||
return { value: JSON.parse(text) as T, error: "" };
|
||||
} catch (error) {
|
||||
return { value: fallback, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
export function getBool(record: Record<string, unknown>, key: string, fallback = false): boolean {
|
||||
const value = record[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function getNumber(record: Record<string, unknown>, key: string, fallback = 0): number {
|
||||
const value = record[key];
|
||||
return typeof value === "number" ? value : fallback;
|
||||
}
|
||||
|
||||
export function getText(record: Record<string, unknown>, key: string, fallback = ""): string {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function downloadJson(filename: string, data: Record<string, unknown>) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function safeFileStem(value?: string | null): string {
|
||||
const stem = (value || "campaign").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return stem || "campaign";
|
||||
}
|
||||
405
src/features/campaigns/wizard/CreateWizard.tsx
Normal file
405
src/features/campaigns/wizard/CreateWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/features/campaigns/wizard/ReviewWizard.tsx
Normal file
23
src/features/campaigns/wizard/ReviewWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/campaigns/wizard/SendWizard.tsx
Normal file
22
src/features/campaigns/wizard/SendWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/campaigns/wizard/steps/AttachmentsStep.tsx
Normal file
22
src/features/campaigns/wizard/steps/AttachmentsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/features/campaigns/wizard/steps/BasicsStep.tsx
Normal file
24
src/features/campaigns/wizard/steps/BasicsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/features/campaigns/wizard/steps/FieldsStep.tsx
Normal file
18
src/features/campaigns/wizard/steps/FieldsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/features/campaigns/wizard/steps/RecipientsStep.tsx
Normal file
29
src/features/campaigns/wizard/steps/RecipientsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/features/campaigns/wizard/steps/ReviewStep.tsx
Normal file
19
src/features/campaigns/wizard/steps/ReviewStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/campaigns/wizard/steps/SendStep.tsx
Normal file
22
src/features/campaigns/wizard/steps/SendStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/features/campaigns/wizard/steps/SenderStep.tsx
Normal file
18
src/features/campaigns/wizard/steps/SenderStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/features/campaigns/wizard/steps/TemplateStep.tsx
Normal file
21
src/features/campaigns/wizard/steps/TemplateStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/features/dashboard/DashboardPage.tsx
Normal file
22
src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
src/features/files/FilesPage.tsx
Normal file
282
src/features/files/FilesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/features/settings/SettingsPage.tsx
Normal file
190
src/features/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
src/features/templates/TemplatesPage.tsx
Normal file
285
src/features/templates/TemplatesPage.tsx
Normal 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
48
src/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/layout/BreadcrumbBar.tsx
Normal file
69
src/layout/BreadcrumbBar.tsx
Normal 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
122
src/layout/HelpMenu.tsx
Normal 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
35
src/layout/IconRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/layout/SectionSidebar.tsx
Normal file
49
src/layout/SectionSidebar.tsx
Normal 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
138
src/layout/Titlebar.tsx
Normal 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
20
src/main.tsx
Normal 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
162
src/styles/auth-gate.css
Normal 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
5
src/styles/badges.css
Normal 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; }
|
||||
741
src/styles/campaign-workspace.css
Normal file
741
src/styles/campaign-workspace.css
Normal 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
454
src/styles/components.css
Normal 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
12
src/styles/forms.css
Normal 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
142
src/styles/layout.css
Normal 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
128
src/styles/tables.css
Normal 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
50
src/styles/tokens.css
Normal 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
101
src/types.ts
Normal 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
101
src/utils/emailAddresses.ts
Normal 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
86
src/utils/fieldHelp.ts
Normal 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
64
src/utils/helpContext.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
18
vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user