intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 3f0b14a726
commit 2e593b7fa4
31 changed files with 3523 additions and 1672 deletions

View File

@@ -162,6 +162,59 @@ export type RecipientImportMappingProfileListResponse = {
profiles: RecipientImportMappingProfile[];
};
export type CampaignAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type CampaignAddressLookupResponse = {
available: boolean;
candidates: CampaignAddressLookupCandidate[];
};
export type CampaignRecipientAddressSource = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
recipient_count: number;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourcesResponse = {
available: boolean;
sources: CampaignRecipientAddressSource[];
};
export type CampaignRecipientSnapshotItem = {
contact_id: string;
display_name: string;
email: string;
email_label?: string | null;
fields: Record<string, unknown>;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourceSnapshot = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
generated_at: string;
recipients: CampaignRecipientSnapshotItem[];
provenance: Record<string, unknown>;
};
export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
@@ -419,6 +472,34 @@ export async function deleteRecipientImportMappingProfile(settings: ApiSettings,
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function lookupCampaignAddresses(
settings: ApiSettings,
campaignId: string,
query: string,
limit = 25)
: Promise<CampaignAddressLookupResponse> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<CampaignAddressLookupResponse>(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`);
}
export async function listCampaignRecipientAddressSources(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignRecipientAddressSourcesResponse> {
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
}
export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings,
campaignId: string,
sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
method: "POST",
body: JSON.stringify({ source_id: sourceId })
});
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}

View File

@@ -1 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";

View File

@@ -1,142 +1,55 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
import type {
ApiSettings,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapTestPayload,
MailProfilePolicy,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfilePayload,
MailSmtpTestPayload,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
const profileActionEndpoints = {
smtp: "test-smtp",
imap: "test-imap",
folders: "list-imap-folders"
} as const;
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
};
const rawSettingsEndpoints = {
smtp: "/api/v1/mail/test-smtp",
imap: "/api/v1/mail/test-imap",
folders: "/api/v1/mail/list-imap-folders"
} as const;
export type MailImapTestPayload = MailSmtpTestPayload & {
sent_folder?: string | null;
};
function runProfileAction<TResponse>(
settings: ApiSettings,
profileId: string,
action: keyof typeof profileActionEndpoints
): Promise<TResponse> {
return apiPost<TResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
);
}
export type MailTransportCredentialsPayload = {
username?: string | null;
password?: string | null;
};
export type MailServerProfileCredentialsPayload = {
smtp?: MailTransportCredentialsPayload;
imap?: MailTransportCredentialsPayload;
};
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 type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
};
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
function runRawSettingsAction<TResponse, TPayload>(
settings: ApiSettings,
payload: TPayload,
action: keyof typeof rawSettingsEndpoints
): Promise<TResponse> {
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
@@ -152,71 +65,40 @@ export async function getMailProfilePolicy(
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
scope_id: scopeId,
campaign_id: campaignId
}));
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
}
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)
});
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
}
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)
});
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
}
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)
});
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";

View File

@@ -1,97 +0,0 @@
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
const personalContacts = [
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
const groupContacts = [
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
const tenantContacts = [
{ name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" },
{ name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }];
function contactColumns(): DataGridColumn<{name: string;email: string;source: string;tags: string;}>[] {
return [
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
{ id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
{ id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source },
{ id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }];
}
export default function AddressBookPage() {
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
return (
<div className="content-pad workspace-data-page module-entry-page address-book-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
</div>
<div className="button-row compact-actions">
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
</div>
</div>
<div className="metric-grid">
<Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
<Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
<Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
<Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
<div className="address-book-scope-list">
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
<AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
<AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
</div>
</Card>
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
<div className="placeholder-stack">
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
</div>
</Card>
</div>
<Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
<DataGrid
id="address-book-contacts"
rows={allContacts}
columns={contactColumns()}
getRowKey={(contact) => contact.source + "-" + contact.email}
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
className="compact-table-wrap module-table" />
</Card>
</div>);
}
function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) {
return (
<div className="address-book-scope-card">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<StatusBadge status={status} />
</div>);
}

View File

@@ -1,24 +1,28 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
createRecipientImportMappingProfile,
listCampaignRecipientAddressSources,
listRecipientImportMappingProfiles,
lookupCampaignAddresses,
snapshotCampaignRecipientAddressSource,
updateRecipientImportMappingProfile,
type CampaignAddressLookupCandidate,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot,
type RecipientImportMappingProfilePayload } from
"../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -34,17 +38,18 @@ import {
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
importedRowsNeedAttachmentSource,
matchRecipientMappingProfiles,
materializeRecipientImport,
materializeRecipientImportWithAttachmentDefaults,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type ImportedRecipientRow,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview,
type RecipientImportTable,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
@@ -61,9 +66,10 @@ import {
import {
addressesFromValue,
collectCampaignAddressSuggestions,
dedupeAddresses,
type MailboxAddress } from
"@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
const recipientHeaderRows = [
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
@@ -71,6 +77,7 @@ const recipientHeaderRows = [
const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type AddressSourceFilter = "all" | "books" | "lists";
type EntryAddressColumn = {
key: RecipientAddressKey | "from" | "reply_to";
@@ -82,8 +89,17 @@ type EntryAddressColumn = {
};
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
const [addressSourceImportInitialId, setAddressSourceImportInitialId] = useState("");
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
const [addressLookupQuery, setAddressLookupQuery] = useState("");
const [addressLookupSuggestions, setAddressLookupSuggestions] = useState<MailboxAddress[]>([]);
const [addressLookupCache, setAddressLookupCache] = useState<Record<string, MailboxAddress[]>>({});
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
@@ -110,6 +126,35 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const cachedAddressSuggestions = useMemo(
() => dedupeAddresses(Object.values(addressLookupCache).flat()),
[addressLookupCache]
);
const combinedAddressSuggestions = useMemo(
() => dedupeAddresses([...addressSuggestions, ...cachedAddressSuggestions, ...addressLookupSuggestions]),
[addressLookupSuggestions, addressSuggestions, cachedAddressSuggestions]
);
const addressSourceRevisionById = useMemo(
() => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])),
[addressSources]
);
const staleAddressImports = useMemo(() => {
return asArray(entries.imports).
map(asRecord).
filter((record) => record.source_type === "addresses").
map((record) => {
const sourceId = typeof record.source_id === "string" ? record.source_id : "";
const importedRevision = typeof record.source_revision === "string" ? record.source_revision : "";
const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : "";
return {
sourceId,
sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId,
importedRevision,
currentRevision
};
}).
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
}, [addressSourceRevisionById, entries.imports]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
@@ -133,6 +178,75 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
return columns;
}, [recipientsSection]);
useEffect(() => {
let cancelled = false;
setAddressSourcesLoading(true);
void listCampaignRecipientAddressSources(settings, campaignId).
then((response) => {
if (cancelled) return;
setAddressSourcesAvailable(response.available);
setAddressSources(response.available ? response.sources ?? [] : []);
}).
catch(() => {
if (cancelled) return;
setAddressSourcesAvailable(false);
setAddressSources([]);
}).
finally(() => {
if (!cancelled) setAddressSourcesLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
const query = addressLookupQuery.trim();
if (query.length < 2) {
setAddressLookupSuggestions(addressLookupCache[""] ?? []);
return;
}
const cached = addressLookupCache[query.toLowerCase()];
if (cached) {
setAddressLookupSuggestions(cached);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void lookupCampaignAddresses(settings, campaignId, query).
then((response) => {
if (cancelled) return;
if (!response.available) {
setAddressLookupSuggestions([]);
return;
}
const nextSuggestions = mailboxAddressesFromLookupCandidates(response.candidates);
setAddressLookupCache((current) => ({ ...current, [query.toLowerCase()]: nextSuggestions }));
setAddressLookupSuggestions(nextSuggestions);
}).
catch(() => {
if (!cancelled) setAddressLookupSuggestions([]);
});
}, 100);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [addressLookupCache, addressLookupQuery, campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
void lookupCampaignAddresses(settings, campaignId, "", 100).
then((response) => {
if (cancelled || !response.available) return;
setAddressLookupCache((current) => ({ ...current, "": mailboxAddressesFromLookupCandidates(response.candidates) }));
}).
catch(() => undefined);
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
const handleAddressSuggestionQueryChange = useCallback((query: string) => {
setAddressLookupQuery(query);
}, []);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
@@ -200,63 +314,47 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
const nextDraft = needsAttachmentSource ?
{
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
} :
importedDraft;
setDraft(nextDraft);
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
markDirty();
setImportOpen(false);
}
function applyAddressSourceImport(snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) {
if (locked || !draft) return;
const preview = buildAddressSourceImportPreview(snapshot, inlineEntries);
const provenance = createAddressSourceImportProvenance(snapshot, preview, mode);
applyRecipientImport(preview, mode, provenance);
setAddressSourceImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.sender_recipients.922c6d24</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
<>
<CampaignDraftPageScaffold
settings={settings}
campaignId={campaignId}
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
loading={loading}
version={version}
versions={data.versions}
saveState={saveState}
onReload={reload}
onSave={() => saveDraft("manual")}
dirty={dirty}
locked={locked}
draft={draft}
error={error}
localError={localError}
currentVersionId={data.campaign?.current_version_id}>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="i18n:govoplan-campaign.campaign_sender.b12e8ae2" collapsible>
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
suggestions={combinedAddressSuggestions}
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
allowMultiple={false}
disabled={locked}
addLabel="i18n:govoplan-campaign.set_from.11fe7396"
@@ -278,7 +376,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
<FormField label="i18n:govoplan-campaign.global_reply_to_address.bec1a89b">
<EmailAddressInput
value={globalReplyTo}
suggestions={addressSuggestions}
suggestions={combinedAddressSuggestions}
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
allowMultiple
disabled={locked}
addLabel="i18n:govoplan-campaign.add_reply_to.84b195f4"
@@ -305,7 +404,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
<FormField label={row.label}>
<EmailAddressInput
value={globalRecipientValues[row.key] ?? []}
suggestions={addressSuggestions}
suggestions={combinedAddressSuggestions}
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
allowMultiple
disabled={locked}
addLabel={row.addLabel}
@@ -326,16 +426,44 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
</Card>
<Card title="i18n:govoplan-campaign.recipient_profiles.3dc9671c" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
<Card
title="i18n:govoplan-campaign.recipient_profiles.3dc9671c"
actions={
<div className="button-row compact-actions">
{(addressSourcesAvailable || addressSourcesLoading) &&
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
Import address book/list
</Button>
}
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
</div>
}>
{inlineEntries.length === 0 && Boolean(source.type) &&
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
}
{staleAddressImports.length > 0 &&
<DismissibleAlert tone="warning" dismissible={false}>
<div className="stale-address-import-warning">
<span>Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.</span>
<div className="button-row compact-actions">
{staleAddressImports.map((item) =>
<Button
key={item.sourceId}
type="button"
onClick={() => {setAddressSourceImportInitialId(item.sourceId);setAddressSourceImportOpen(true);}}>
Re-import {item.sourceLabel || "source"}
</Button>
)}
</div>
</div>
</DismissibleAlert>
}
{!source.type &&
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions: combinedAddressSuggestions, onAddressSuggestionQueryChange: handleAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026"
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />}
@@ -354,9 +482,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
}
</Card>
</>
</LoadingFrame>
</CampaignDraftPageScaffold>
{importOpen &&
<RecipientImportDialog
settings={settings}
@@ -368,8 +494,263 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
onImport={applyRecipientImport} />
}
</div>);
{addressSourceImportOpen &&
<AddressSourceImportDialog
settings={settings}
campaignId={campaignId}
sources={addressSources}
initialSourceId={addressSourceImportInitialId}
onCancel={() => setAddressSourceImportOpen(false)}
onImport={applyAddressSourceImport} />
}
</>);
}
type AddressSourceImportDialogProps = {
settings: ApiSettings;
campaignId: string;
sources: CampaignRecipientAddressSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
};
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
}
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
if (addressSourceType(source) === "list") return "Address list";
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
return "Address book";
}
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
const scopeType = String(provenance.scope_type ?? "").trim();
const scopeId = String(provenance.scope_id ?? "").trim();
if (!scopeType) return "Unknown scope";
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
}
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
return [
source.source_label,
source.source_kind,
source.source_id,
addressSourceTypeLabel(source),
addressSourceScopeLabel(source),
provenance.address_book_id,
provenance.address_list_id,
provenance.scope_type,
provenance.scope_id,
provenance.tenant_id
].map((value) => String(value ?? "").toLowerCase()).join(" ");
}
function shortSourceRevision(value: string): string {
if (!value) return "no revision";
if (value.length <= 24) return value;
return `${value.slice(0, 19)}...`;
}
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
const navigate = useGuardedNavigate();
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
const [sourceQuery, setSourceQuery] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const sourceCounts = useMemo(() => {
return sources.reduce(
(counts, source) => {
counts[addressSourceType(source)] += 1;
return counts;
},
{ book: 0, list: 0 }
);
}, [sources]);
const filteredSources = useMemo(() => {
const query = sourceQuery.trim().toLowerCase();
return sources.filter((source) => {
const type = addressSourceType(source);
if (sourceFilter === "books" && type !== "book") return false;
if (sourceFilter === "lists" && type !== "list") return false;
if (!query) return true;
return addressSourceSearchText(source).includes(query);
});
}, [sourceFilter, sourceQuery, sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
}, [filteredSources, selectedSourceId]);
useEffect(() => {
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
setSourceFilter("all");
setSourceQuery("");
setSelectedSourceId(initialSourceId);
}
}, [initialSourceId, sources]);
useEffect(() => {
if (!selectedSourceId) {
setSnapshot(null);
return;
}
let cancelled = false;
setLoading(true);
setError("");
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
then((nextSnapshot) => {
if (!cancelled) setSnapshot(nextSnapshot);
}).
catch((err) => {
if (cancelled) return;
setSnapshot(null);
setError(err instanceof Error ? err.message : String(err));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function openAddressBookModule() {
onCancel();
navigate("/address-book");
}
return (
<Dialog
open
title="Import recipients from addresses"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={loading}
closeOnBackdrop={!loading}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
Import recipients
</Button>
</>
}>
<div className="address-source-import-controls">
<div>
<SegmentedControl
ariaLabel="Address source type"
value={sourceFilter}
onChange={setSourceFilter}
size="content"
width="inline"
disabled={loading || sources.length === 0}
options={[
{ id: "all", label: `All (${sources.length})` },
{ id: "books", label: `Books (${sourceCounts.book})` },
{ id: "lists", label: `Lists (${sourceCounts.list})` }
]}
/>
</div>
<input
type="search"
value={sourceQuery}
disabled={loading || sources.length === 0}
placeholder="Search address sources"
aria-label="Search address sources"
onChange={(event) => setSourceQuery(event.target.value)}
/>
<Button type="button" onClick={openAddressBookModule}>
Manage address books
</Button>
</div>
<div className="campaign-header-grid recipient-import-upload-grid">
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
{filteredSources.map((source) =>
<button
type="button"
key={source.source_id}
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
disabled={loading}
role="radio"
aria-checked={source.source_id === selectedSourceId}
onClick={() => setSelectedSourceId(source.source_id)}>
<span className="address-source-option-main">
<strong>{source.source_label}</strong>
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
</span>
<span className="address-source-option-meta">
<span>{source.recipient_count} recipients</span>
<code>{shortSourceRevision(source.source_revision)}</code>
</span>
</button>
)}
{sources.length > 0 && filteredSources.length === 0 &&
<div className="empty-state compact-empty">No address sources match the current filter.</div>
}
</div>
<FormField label="Import mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
{snapshot &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</>
}
</Dialog>);
}
type RecipientImportDialogProps = {
@@ -1188,18 +1569,162 @@ function formatImportBytes(value?: number | null): string {
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
const tableRows = [
headers,
...snapshot.recipients.map((recipient) => headers.map((header) => {
if (header === "display_name") return recipient.display_name;
if (header === "email") return recipient.email;
return fieldValueToString((recipient.fields ?? {})[header]);
}))];
const table: RecipientImportTable = {
delimiter: ",",
headers,
headerRows: [headers],
rows: tableRows,
dataRows: tableRows.slice(1),
firstDataRowNumber: 2
};
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
const fieldNamesToCreate = new Set<string>();
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
const email = recipient.email.trim();
const name = recipient.display_name.trim();
const fields = stringFieldsFromAddressSource(recipient.fields);
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
const id = uniqueImportId(
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
usedIds
);
const issues: string[] = [];
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
return {
rowNumber: table.firstDataRowNumber + index,
id,
name,
email,
active: true,
addresses: {
from: [],
to: email ? [{ name, email }] : [],
cc: [],
bcc: [],
reply_to: []
},
fields,
patterns: [],
issues
};
});
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: 0
};
}
function createAddressSourceImportProvenance(
snapshot: CampaignRecipientAddressSourceSnapshot,
preview: RecipientImportPreview,
mode: RecipientImportMode)
: RecipientImportProvenance {
const now = new Date().toISOString();
return {
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
imported_at: now,
mode,
source_type: "addresses",
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
filename: null,
sheet_name: null,
encoding: null,
delimiter: null,
header_rows: 0,
quoted: null,
value_separators: null,
rows_total: preview.rows.length,
valid_rows: preview.validCount,
invalid_rows: preview.invalidCount,
imported_rows: preview.validCount,
field_names_created: preview.fieldNamesToCreate.slice(),
attachment_patterns: 0,
mapping: []
};
}
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value ?? {})) {
const fieldValue = fieldValueToString(rawValue);
if (fieldValue) fields[key] = fieldValue;
}
return fields;
}
function fieldValueToString(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value).trim();
}
function idFragmentFromEmail(value: string): string {
return safeImportIdFragment(value.split("@")[0] ?? "");
}
function safeImportIdFragment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
}
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
const base = safeImportIdFragment(preferred) || "recipient";
let candidate = base;
let counter = 2;
while (usedIds.has(candidate)) {
candidate = `${base}-${counter}`;
counter += 1;
}
usedIds.add(candidate);
return candidate;
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";
}
function mailboxAddressesFromLookupCandidates(candidates: CampaignAddressLookupCandidate[]): MailboxAddress[] {
return dedupeAddresses(
candidates.
map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null).
filter((address): address is MailboxAddress => Boolean(address?.email))
);
}
type RecipientProfileColumnContext = {
locked: boolean;
entries: Record<string, unknown>[];
entryDefaults: Record<string, unknown>;
entryAddressColumns: EntryAddressColumn[];
addressSuggestions: MailboxAddress[];
onAddressSuggestionQueryChange: (query: string) => void;
translateText: (value: string) => string;
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
@@ -1208,7 +1733,7 @@ type RecipientProfileColumnContext = {
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, onAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
@@ -1222,6 +1747,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
<EmailAddressInput
value={getEntryAddresses(entry, column.key)}
suggestions={addressSuggestions}
onSuggestionQueryChange={onAddressSuggestionQueryChange}
allowMultiple={column.allowMultiple}
compact
disabled={locked}
@@ -1237,7 +1763,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
disabled={locked}
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)} />
<span className="muted">i18n:govoplan-campaign.merge_with_global.ba230098 {column.label}</span>
<span className="muted">{translateText("i18n:govoplan-campaign.merge_with_global.ba230098")} {translateText(column.label)}</span>
</div>
}
</div>,

View File

@@ -4,18 +4,15 @@ import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { materializeRecipientImportWithAttachmentDefaults, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { RecipientImportDialog } from "./RecipientDataPage";
import { addressesFromValue } from "@govoplan/core-webui";
@@ -80,56 +77,31 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource ?
{
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
} :
importedDraft;
setDraft(nextDraft);
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode }));
markDirty();
setImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
<>
<CampaignDraftPageScaffold
settings={settings}
campaignId={campaignId}
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
loading={loading}
version={version}
versions={data.versions}
saveState={saveState}
onReload={reload}
onSave={() => saveDraft("manual")}
dirty={dirty}
locked={locked}
draft={draft}
error={error}
localError={localError}
currentVersionId={data.campaign?.current_version_id}>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
{inlineEntries.length === 0 && Boolean(source.type) &&
@@ -158,8 +130,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
</div>
}
</Card>
</>
</LoadingFrame>
</CampaignDraftPageScaffold>
{importOpen &&
<RecipientImportDialog
@@ -172,7 +143,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
onImport={applyRecipientImport} />
}
</div>);
</>);
}

View File

@@ -95,8 +95,8 @@ const stateColors: Record<FlowState, string> = {
active: "var(--blue)",
locked: "var(--line-dark)",
running: "var(--blue)",
partial: "#9b86c7",
stale: "#9b86c7",
partial: "var(--review-flow-purple)",
stale: "var(--review-flow-purple)",
pending: "var(--muted)"
};

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import LockedVersionNotice from "./LockedVersionNotice";
import VersionLine from "./VersionLine";
type CampaignDraftPageScaffoldProps = {
settings: ApiSettings;
campaignId: string;
title: string;
loading: boolean;
version: CampaignVersionDetail | null;
versions: CampaignVersionListItem[];
saveState: string;
onReload: () => Promise<void>;
onSave: () => void;
dirty: boolean;
locked: boolean;
draft: Record<string, unknown> | null;
error?: string | null;
localError?: string | null;
currentVersionId?: string | null;
children: ReactNode;
};
export default function CampaignDraftPageScaffold({
settings,
campaignId,
title,
loading,
version,
versions,
saveState,
onReload,
onSave,
dirty,
locked,
draft,
error,
localError,
currentVersionId,
children
}: CampaignDraftPageScaffoldProps) {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<VersionLine version={version} versions={versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={onReload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>{children}</>
</LoadingFrame>
</div>);
}

View File

@@ -4,7 +4,6 @@ type XlsxWorkbookSheet = {
data: unknown[][];
};
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
type RawXlsxWorkbookReader = (input: unknown) => Promise<XlsxWorkbookSheet[]>;
export type ImportFieldDefinition = {
name: string;
@@ -16,7 +15,11 @@ export type ImportFieldDefinition = {
export type ImportAttachmentBasePath = {
id?: string;
name?: string;
path?: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type RecipientImportMode = "append" | "replace";
@@ -29,7 +32,7 @@ export type CsvParseOptions = {
quoted: boolean;
};
export type RecipientImportSourceType = "csv" | "xlsx" | "text";
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
@@ -99,6 +102,10 @@ export type RecipientImportProvenance = {
imported_at: string;
mode: RecipientImportMode;
source_type: RecipientImportSourceType;
source_id?: string | null;
source_label?: string | null;
source_revision?: string | null;
source_provenance?: Record<string, unknown>;
filename?: string | null;
sheet_name?: string | null;
encoding?: string | null;
@@ -211,15 +218,6 @@ async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
const module = await import("read-excel-file/browser");
return module.default as XlsxWorkbookReader;
}
const globals = globalThis as typeof globalThis & {
Buffer?: { from(input: ArrayBuffer): unknown };
process?: { versions?: { node?: string } };
};
if (globals.process?.versions?.node && globals.Buffer?.from) {
const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<{ default: RawXlsxWorkbookReader }>;
const module = await dynamicImport("read-excel-file/node");
return (input: ArrayBuffer) => module.default(globals.Buffer!.from(input));
}
const module = await import("read-excel-file/universal");
return module.default as XlsxWorkbookReader;
}
@@ -463,6 +461,37 @@ options: MaterializeImportOptions)
};
}
export function materializeRecipientImportWithAttachmentDefaults(
draft: Record<string, unknown>,
preview: RecipientImportPreview,
options: MaterializeImportOptions)
: Record<string, unknown> {
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeImportAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: ImportAttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = options.attachmentBasePath ?? nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { ...options, attachmentBasePath });
if (!needsAttachmentSource) return importedDraft;
return {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
};
}
export function createRecipientImportProvenance(input: {
preview: RecipientImportPreview;
mappings: RecipientColumnMapping[];
@@ -513,6 +542,27 @@ export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
}
function normalizeImportAttachmentBasePaths(value: unknown, attachments: ImportRecord, fallbackAllowIndividual = false): ImportAttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual"),
unsent_warning: getBool(basePath, "unsent_warning")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
unsent_warning: false
}];
}
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
return {
id: row.id,
@@ -815,6 +865,14 @@ function getText(record: ImportRecord, key: string, fallback = ""): string {
return fallback;
}
function getBool(record: ImportRecord, key: string, fallback = false): boolean {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
if (typeof value === "number") return value !== 0;
return fallback;
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@@ -2,7 +2,6 @@ export { default } from "./module";
export * from "./module";
export * from "./api/campaigns";
export * from "./features/campaigns/policyUi";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";

View File

@@ -5,7 +5,6 @@ import { getCampaign } from "./api/campaigns";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
@@ -35,7 +34,6 @@ export const campaignModule: PlatformWebModule = {
order: 30
},
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
routes: [
@@ -43,7 +41,6 @@ export const campaignModule: PlatformWebModule = {
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
};

View File

@@ -10,7 +10,7 @@
}
.wizard-action-card {
min-height: 176px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
padding: 16px;
@@ -37,7 +37,7 @@
.data-table code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #5d5a55;
color: var(--text-subtle);
white-space: pre-wrap;
}
@@ -49,7 +49,7 @@
white-space: pre-wrap;
color: var(--text);
background: var(--panel-soft);
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
padding: 14px;
font: inherit;
@@ -64,7 +64,7 @@
display: inline-flex;
align-items: center;
min-height: 28px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--panel-soft);
padding: 4px 10px;
@@ -90,13 +90,13 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
color: #4d4944;
color: var(--text);
}
.danger-text { color: var(--danger-text); }
.section-mini-heading {
margin: 18px 0 8px;
font-size: 12px;
color: #6b6863;
color: var(--text-label);
letter-spacing: .04em;
text-transform: uppercase;
}
@@ -140,7 +140,7 @@
.form-subsection {
display: grid;
gap: 12px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 14px;
}
.form-subsection:first-child {
@@ -169,7 +169,7 @@
grid-column: 1 / -1;
}
.toggle-span-full {
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: var(--panel-soft);
padding: 10px 12px;
@@ -240,7 +240,7 @@
gap: 6px;
min-height: 88px;
padding: 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 12px;
background: var(--panel-soft);
color: inherit;
@@ -272,7 +272,7 @@
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 12px;
background: var(--panel-soft);
}
@@ -314,10 +314,10 @@
z-index: 30;
min-height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid var(--line);
background: var(--surface);
border-bottom: var(--border-line);
border-radius: var(--radius) var(--radius) 0 0;
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
box-shadow: var(--shadow-campaign-card);
}
.campaigns-page .card-body {
padding: 0;
@@ -449,7 +449,7 @@
}
.message-preview {
border: 1px solid var(--border);
border: var(--border-line);
border-radius: var(--radius-md);
background: var(--surface-subtle);
padding: 1rem;
@@ -484,10 +484,10 @@
}
.overview-config-card {
min-height: 218px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: linear-gradient(#ffffff, var(--panel-soft));
box-shadow: 0 1px 2px rgba(38, 35, 30, .04);
background: linear-gradient(var(--control-gradient-start), var(--panel-soft));
box-shadow: var(--shadow-campaign-soft);
padding: 16px;
display: grid;
grid-template-rows: 22px 44px 1fr 35px;
@@ -520,7 +520,7 @@
gap: 10px;
align-items: baseline;
min-height: 31px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 8px;
}
.overview-config-facts dt {
@@ -577,20 +577,20 @@
.field-chip-button:hover,
.field-chip-button:focus-visible {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,.09);
box-shadow: var(--shadow-campaign-small);
}
.field-chip-button.used {
background: var(--green-soft);
border-color: #78b7a9;
color: #2d685d;
border-color: var(--success-border-soft);
color: var(--success-text-strong);
}
.field-chip-button.undefined {
background: #fff1d0;
border-color: #d5a64a;
color: #7c5412;
background: var(--warning-muted-bg);
border-color: var(--warning-border);
color: var(--warning-deep);
}
.field-chip-namespace {
border-right: 1px solid rgba(0,0,0,.18);
border-right: var(--border-muted-line);
color: var(--muted);
margin-right: 2px;
padding-right: 6px;
@@ -614,9 +614,9 @@
.template-preview-frame {
width: 100%;
min-height: 360px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: #fff;
background: var(--surface);
}
.template-action-dialog {
width: min(620px, 100%);
@@ -749,7 +749,7 @@
display: inline-flex;
justify-content: center;
min-height: 24px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
@@ -853,7 +853,7 @@
.attachment-file-browser-panel {
display: grid;
gap: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 12px;
background: var(--panel-soft);
padding: 14px;
@@ -908,7 +908,7 @@
.chooser-display-input:focus,
.field-with-action input.chooser-display-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(239, 107, 58, .16);
box-shadow: var(--accent-ring-soft);
}
.chooser-display-input:disabled,
@@ -948,7 +948,7 @@
align-items: center;
justify-content: center;
min-height: 24px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
@@ -973,30 +973,6 @@
.recipient-data-table td:nth-child(3) { min-width: 192px; }
.recipient-index-cell { white-space: nowrap; }
.address-book-scope-list {
display: grid;
gap: 10px;
}
.address-book-scope-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
}
.address-book-scope-card strong {
display: block;
color: var(--text-strong);
}
.address-book-scope-card p {
margin: 5px 0 0;
color: var(--muted);
}
.locked-version-notice .alert-message {
display: flex;
align-items: center;
@@ -1012,12 +988,12 @@
}
.locked-version-feedback {
color: var(--success, #157347);
color: var(--success);
font-weight: 600;
}
.locked-version-error {
color: var(--danger, #b42318);
color: var(--danger-border-deep);
font-weight: 600;
}
@@ -1045,7 +1021,7 @@
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
color: var(--text, #243247);
color: var(--text);
text-decoration: none;
line-height: 1;
font-size: 0.72rem;
@@ -1057,8 +1033,8 @@
}
.version-arrow:hover {
background: var(--surface-subtle, rgba(15, 23, 42, 0.08));
color: var(--text, #111827);
background: var(--surface-subtle);
color: var(--text);
}
.version-arrow.disabled {
@@ -1108,7 +1084,7 @@
overflow: auto;
border: 1px solid var(--line-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.62);
background: var(--panel-glass);
color: var(--text-primary);
padding: 12px;
white-space: pre-wrap;
@@ -1154,7 +1130,7 @@
max-width: 100%;
border: 1px solid var(--line-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.68);
background: var(--panel-glass);
padding: 0.55rem 0.7rem;
color: var(--text-primary);
}
@@ -1188,7 +1164,7 @@
margin: 0;
}
.warning-text {
color: var(--warning, #8a5b00);
color: var(--warning);
}
/* Managed attachment chooser refinements. */
@@ -1212,7 +1188,7 @@
}
/* Review & Send workflow page. */
.review-send-page {
--review-flow-purple: #9b86c7;
--review-flow-purple: var(--review-flow-purple);
}
.review-flow-navigation {
@@ -1221,10 +1197,10 @@
z-index: 35;
margin: 0 0 24px;
padding: 15px 10px 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
background: var(--campaign-card-bg);
box-shadow: 0 8px 24px var(--campaign-stage-shadow-color);
backdrop-filter: blur(12px);
overflow-x: auto;
}
@@ -1258,7 +1234,7 @@
.review-flow-navigation-item:hover .review-flow-navigation-icon,
.review-flow-navigation-item:focus-visible .review-flow-navigation-icon {
background: color-mix(in srgb, var(--review-nav-color) 15%, #fff);
background: color-mix(in srgb, var(--review-nav-color) 15%, var(--surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-nav-color) 12%, transparent);
}
@@ -1274,7 +1250,7 @@
border: 1px solid var(--review-nav-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-nav-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-nav-color) 8%, #fff);
background: color-mix(in srgb, var(--review-nav-color) 8%, var(--surface));
transition: background .16s ease, box-shadow .16s ease;
}
@@ -1346,8 +1322,8 @@
border: 1px solid var(--review-stage-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-stage-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-stage-color) 9%, #fff);
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10);
background: color-mix(in srgb, var(--review-stage-color) 9%, var(--surface));
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color);
}
.review-flow-stage[data-state="running"] .review-flow-stage-node {
@@ -1402,8 +1378,8 @@
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
border-radius: var(--radius-pill);
color: color-mix(in srgb, var(--review-badge-color) 70%, #242424);
background: color-mix(in srgb, var(--review-badge-color) 18%, #fff);
color: color-mix(in srgb, var(--review-badge-color) 70%, var(--neutral-950));
background: color-mix(in srgb, var(--review-badge-color) 18%, var(--surface));
font-size: 11px;
font-weight: 800;
letter-spacing: .03em;
@@ -1447,7 +1423,7 @@
padding: 28px;
color: var(--text);
text-align: center;
background: rgba(247, 246, 244, .46);
background: var(--campaign-card-bg-soft);
}
.review-flow-lock-message::before {
@@ -1455,12 +1431,12 @@
inset: 50% auto auto 50%;
width: min(430px, calc(100% - 44px));
height: 110px;
border: 1px solid rgba(189, 184, 176, .8);
border: 1px solid var(--campaign-panel-border);
border-radius: 10px;
content: "";
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, .91);
box-shadow: 0 12px 32px rgba(48, 49, 53, .14);
background: var(--campaign-panel-bg);
box-shadow: 0 12px 32px var(--campaign-panel-shadow-color);
}
.review-flow-lock-message > * {
@@ -1484,10 +1460,10 @@
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 50%;
color: var(--muted);
background: #fff;
background: var(--surface);
}
.review-flow-fact-grid,
@@ -1504,7 +1480,7 @@
gap: 5px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
@@ -1534,26 +1510,26 @@
margin: 0 0 14px;
padding: 10px 12px;
border-left: 3px solid var(--blue);
background: color-mix(in srgb, var(--blue) 10%, #fff);
background: color-mix(in srgb, var(--blue) 10%, var(--surface));
color: var(--text);
font-size: 13px;
}
.review-flow-inline-note.is-complete {
border-left-color: var(--green);
background: color-mix(in srgb, var(--green) 12%, #fff);
background: color-mix(in srgb, var(--green) 12%, var(--surface));
}
.review-flow-inline-note.is-warning {
border-left-color: var(--amber);
background: color-mix(in srgb, var(--amber) 16%, #fff);
background: color-mix(in srgb, var(--amber) 16%, var(--surface));
}
.review-flow-inline-note.is-danger {
border-left-color: var(--red);
background: color-mix(in srgb, var(--red) 10%, #fff);
background: color-mix(in srgb, var(--red) 10%, var(--surface));
}
.review-flow-inline-note.is-stale {
border-left-color: var(--review-flow-purple);
background: color-mix(in srgb, var(--review-flow-purple) 11%, #fff);
background: color-mix(in srgb, var(--review-flow-purple) 11%, var(--surface));
}
.review-flow-result-line {
@@ -1596,7 +1572,7 @@
gap: 12px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-left: 3px solid var(--blue);
border-radius: 6px;
background: var(--panel-soft);
@@ -1665,8 +1641,8 @@
}
@keyframes review-flow-pulse {
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px rgba(48, 49, 53, .10); }
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px var(--campaign-stage-shadow-color); }
}
@media (max-width: 980px) {
@@ -1775,7 +1751,7 @@
.attachment-zip-name-button[aria-invalid="true"] {
border-color: var(--red);
box-shadow: 0 0 0 2px rgba(224, 106, 95, 0.16);
box-shadow: 0 0 0 2px var(--danger-focus-ring-soft);
}
.attachment-zip-name-button:disabled {
@@ -1797,7 +1773,7 @@
.template-expression-dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
background: var(--panel);
}
@@ -1810,7 +1786,7 @@
.template-expression-dialog-footer {
padding: 12px 18px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
background: var(--panel);
}
@@ -1835,7 +1811,7 @@
.template-expression-value-field input[aria-invalid="true"] {
border-color: var(--red);
box-shadow: 0 0 0 2px rgba(224, 106, 95, 0.16), inset 0 1px 2px rgba(0, 0, 0, .04);
box-shadow: var(--danger-focus-ring-soft), var(--shadow-control-inset);
}
.attachment-zip-name-error {
@@ -1872,7 +1848,7 @@
border: 2px solid var(--line-strong, var(--line-subtle));
border-radius: 14px;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.4);
background: var(--panel-glass);
}
.attachment-zip-group > header {
@@ -1905,6 +1881,11 @@
flex: 0 0 auto;
}
.stale-address-import-warning {
display: grid;
gap: 8px;
}
.recipient-profiles-section {
display: grid;
gap: 12px;
@@ -1943,9 +1924,9 @@
max-height: 280px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
}
@@ -1969,7 +1950,7 @@
}
.recipient-outcome-error {
color: var(--danger, #b91c1c);
color: var(--danger-border-deep);
}
.attempt-history-section {
@@ -1983,7 +1964,7 @@
.attempt-history-wrap {
overflow: auto;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
}
@@ -1996,7 +1977,7 @@
.attempt-history-table th,
.attempt-history-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
text-align: left;
vertical-align: top;
}
@@ -2022,9 +2003,9 @@
padding: 14px;
margin: 12px 0;
overflow-wrap: anywhere;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
font-size: 14px;
user-select: all;
}
@@ -2033,9 +2014,9 @@
max-height: 420px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
}
@@ -2055,10 +2036,10 @@
}
.recipient-import-steps {
padding: 13px 10px 9px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
background: var(--campaign-card-bg);
box-shadow: 0 8px 24px var(--campaign-stage-shadow-color);
overflow-x: auto;
}
.recipient-import-step-track {
@@ -2091,8 +2072,8 @@
}
.recipient-import-step-item:not(:disabled):hover .recipient-import-step-icon,
.recipient-import-step-item:focus-visible .recipient-import-step-icon {
background: color-mix(in srgb, var(--accent, #5d776c) 14%, #fff);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #5d776c) 12%, transparent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.recipient-import-step-item:focus-visible {
outline: none;
@@ -2102,17 +2083,17 @@
height: 38px;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent, #5d776c) 70%, var(--line));
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--line));
border-radius: 50%;
color: color-mix(in srgb, var(--accent, #5d776c) 82%, var(--text));
background: color-mix(in srgb, var(--accent, #5d776c) 8%, #fff);
color: color-mix(in srgb, var(--accent) 82%, var(--text));
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
font-weight: 700;
transition: background .16s ease, box-shadow .16s ease, color .16s ease;
}
.recipient-import-step-item.is-current .recipient-import-step-icon,
.recipient-import-step-item.is-complete .recipient-import-step-icon {
color: #fff;
background: color-mix(in srgb, var(--accent, #5d776c) 86%, #333);
color: var(--on-accent);
background: color-mix(in srgb, var(--accent) 86%, var(--neutral-900));
}
.recipient-import-step-copy {
display: grid;
@@ -2144,7 +2125,7 @@
height: 2px;
margin: 21px 2px 0;
flex: 0 0 auto;
background: color-mix(in srgb, var(--accent, #5d776c) 55%, var(--line));
background: color-mix(in srgb, var(--accent) 55%, var(--line));
opacity: .82;
}
.recipient-import-step-panel {
@@ -2172,7 +2153,89 @@
min-height: 38px;
}
.recipient-import-summary {
grid-template-columns: repeat(4, minmax(120px, 1fr));
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.address-source-import-controls {
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto;
align-items: center;
gap: 10px;
}
.address-source-import-controls input[type="search"] {
min-width: 0;
}
.address-source-picker {
display: grid;
gap: 8px;
max-height: 290px;
overflow: auto;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.address-source-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
width: 100%;
min-height: 58px;
padding: 10px 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
box-shadow: var(--shadow-control);
}
.address-source-option:hover,
.address-source-option:focus-visible {
border-color: color-mix(in srgb, var(--accent) 44%, var(--line));
background: var(--bar);
outline: none;
}
.address-source-option.is-selected {
border-color: color-mix(in srgb, var(--accent) 62%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
}
.address-source-option:disabled {
cursor: not-allowed;
opacity: .62;
}
.address-source-option-main,
.address-source-option-meta {
display: grid;
gap: 4px;
min-width: 0;
}
.address-source-option-main strong {
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-source-option-main span,
.address-source-option-meta span {
color: var(--muted);
font-size: 12px;
}
.address-source-option-meta {
justify-items: end;
}
.address-source-option-meta code {
max-width: 180px;
color: var(--text-subtle);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-empty {
min-height: 78px;
padding: 16px;
}
.recipient-import-preview-surface {
overflow: auto;
@@ -2184,7 +2247,7 @@
}
.recipient-import-preview-table th,
.recipient-import-preview-table td {
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
@@ -2208,7 +2271,7 @@
font-size: 12px;
}
.recipient-import-raw-table tr.is-header-row td {
background: color-mix(in srgb, var(--accent, #5d776c) 8%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
font-weight: 700;
}
.recipient-import-mapping-table select,
@@ -2232,7 +2295,7 @@
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
@@ -2245,9 +2308,9 @@
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--warning-bg, #fff3cd) 62%, transparent);
background: color-mix(in srgb, var(--warning-bg) 62%, transparent);
}
.recipient-import-unmatched-patterns ul {
display: grid;
@@ -2267,6 +2330,15 @@
}
}
@media (max-width: 760px) {
.address-source-import-controls {
grid-template-columns: 1fr;
}
.address-source-option {
grid-template-columns: 1fr;
}
.address-source-option-meta {
justify-items: start;
}
.recipient-import-summary {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
@@ -2292,7 +2364,7 @@
.admin-details-grid > div {
min-width: 0;
padding: 10px 0;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.admin-details-grid dt {
color: var(--muted);
@@ -2310,8 +2382,8 @@
gap: 5px;
padding: 3px 7px;
border-radius: 999px;
background: #e8eef5;
color: #36566f;
background: var(--info-muted-bg);
color: var(--info-text-muted);
font-size: 11px;
font-weight: 800;
}
@@ -2342,10 +2414,10 @@
.admin-managed-notice {
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid #c8d6e3;
border: 1px solid var(--info-muted-border);
border-radius: var(--radius-sm);
background: #eef4f8;
color: #294a61;
background: var(--info-bg);
color: var(--info-text-deep);
line-height: 1.45;
}