@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +41 -2
- package/amplify/site-backend.ts +46 -2
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +22 -92
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +273 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { loadReferenceReviewDetails } from "../lib/reference-review-details";
|
|
4
|
+
|
|
3
5
|
import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
|
|
4
6
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
5
7
|
import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
|
|
@@ -830,10 +832,8 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
|
|
|
830
832
|
};
|
|
831
833
|
}
|
|
832
834
|
try {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
return { status: "signedOut", isEditor: false, isAdmin: false, auth: snapshot.auth, error: null };
|
|
836
|
-
}
|
|
835
|
+
// One refreshed session supplies both identity and current group claims.
|
|
836
|
+
// A second, sequential session read only extends the blocking access check.
|
|
837
837
|
const refreshed = await loadReaderSessionSnapshot({ forceRefresh: true });
|
|
838
838
|
if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
|
|
839
839
|
return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
|
|
@@ -881,9 +881,8 @@ export async function loadEditorNewsDeskDashboard({ isAdmin }: { isAdmin: boolea
|
|
|
881
881
|
isAdmin ? loadUserDirectory() : Promise.resolve([]),
|
|
882
882
|
]);
|
|
883
883
|
const summaryError = summaryResult.status === "rejected" ? summaryResult.reason : null;
|
|
884
|
-
if (summaryError
|
|
885
|
-
|
|
886
|
-
}
|
|
884
|
+
if (summaryError) throw summaryError;
|
|
885
|
+
if (userDirectoryResult.status === "rejected") throw userDirectoryResult.reason;
|
|
887
886
|
const summary = summaryResult.status === "fulfilled" ? summaryResult.value : null;
|
|
888
887
|
const userDirectory = userDirectoryResult.status === "fulfilled" ? userDirectoryResult.value : [];
|
|
889
888
|
|
|
@@ -1852,9 +1851,29 @@ export async function loadEditorSemanticRelationsData(): Promise<SemanticRelatio
|
|
|
1852
1851
|
pageCount += 1;
|
|
1853
1852
|
} while (nextToken && pageCount < 40);
|
|
1854
1853
|
|
|
1854
|
+
if (nextToken) throw new Error("The activity feed is incomplete. Select a reference to load its prediction directly.");
|
|
1855
1855
|
return rows;
|
|
1856
1856
|
}
|
|
1857
1857
|
|
|
1858
|
+
/** Selected-reference details use indexes, not the bounded global activity feed. */
|
|
1859
|
+
export async function loadEditorReferenceReviewDetails(lineageId: string) {
|
|
1860
|
+
configureAmplifyClient();
|
|
1861
|
+
const testMock = getTestEditorNewsroomMock();
|
|
1862
|
+
return loadReferenceReviewDetails(lineageId, {
|
|
1863
|
+
relations: async (direction) => {
|
|
1864
|
+
if (testMock?.semanticRelations) return testMock.semanticRelations;
|
|
1865
|
+
return loadSemanticRelationsByState({
|
|
1866
|
+
field: direction === "outgoing" ? "listSemanticRelationsBySubjectState" : "listSemanticRelationsByObjectState",
|
|
1867
|
+
keyName: direction === "outgoing" ? "subjectStateKey" : "objectStateKey",
|
|
1868
|
+
keyValue: `reference#${lineageId}#current`,
|
|
1869
|
+
query: direction === "outgoing" ? LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY : LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY,
|
|
1870
|
+
});
|
|
1871
|
+
},
|
|
1872
|
+
message: async (id) => testMock?.messages ? testMock.messages.find((message) => message.id === id) ?? null : getMessageRecordById(id),
|
|
1873
|
+
payloads: (id) => loadModelPayloadsForOwner("message", id, ["metadata"]),
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1858
1877
|
export async function loadNewsroomSemanticRelationPage(options: NewsroomSemanticRelationPageOptions = {}): Promise<NewsroomRecordPage<SemanticRelationRecord>> {
|
|
1859
1878
|
return loadNewsroomFeedPage<SemanticRelationRecord>({
|
|
1860
1879
|
query: NEWSROOM_SEMANTIC_RELATION_FEED_QUERY,
|
|
@@ -3134,7 +3153,7 @@ async function loadSemanticRelationsByState({
|
|
|
3134
3153
|
});
|
|
3135
3154
|
assertNoGraphQLErrors(response.errors);
|
|
3136
3155
|
const connection = response.data?.[field];
|
|
3137
|
-
if (!connection)
|
|
3156
|
+
if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
|
|
3138
3157
|
for (const item of (connection.items ?? []).filter(Boolean) as SemanticRelationRecord[]) {
|
|
3139
3158
|
if (!item.id || seenIds.has(item.id)) continue;
|
|
3140
3159
|
seenIds.add(item.id);
|
|
@@ -3142,6 +3161,7 @@ async function loadSemanticRelationsByState({
|
|
|
3142
3161
|
}
|
|
3143
3162
|
nextToken = connection.nextToken ?? null;
|
|
3144
3163
|
} while (nextToken && pageCount < 200);
|
|
3164
|
+
if (nextToken) throw new Error("Reference details are incomplete. Try again before reviewing its prediction.");
|
|
3145
3165
|
return rows;
|
|
3146
3166
|
}
|
|
3147
3167
|
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { callNewsletter, type NewsletterApi } from "../lib/newsletter-client";
|
|
5
|
+
import { createFixtureApi } from "../lib/newsletter-fixture";
|
|
6
|
+
import {
|
|
7
|
+
ISSUE_STATUS_FILTERS,
|
|
8
|
+
canApprove,
|
|
9
|
+
canCancel,
|
|
10
|
+
canStop,
|
|
11
|
+
failureText,
|
|
12
|
+
formatInZone,
|
|
13
|
+
isoToZonedInput,
|
|
14
|
+
nextWholeHourInput,
|
|
15
|
+
statusLabel,
|
|
16
|
+
subscribersToCsv,
|
|
17
|
+
validTimeZone,
|
|
18
|
+
zonedInputToIso,
|
|
19
|
+
type NewsletterDelivery,
|
|
20
|
+
type NewsletterExportRow,
|
|
21
|
+
type NewsletterFailure,
|
|
22
|
+
type NewsletterIssue,
|
|
23
|
+
type NewsletterOverview,
|
|
24
|
+
type NewsletterPreview,
|
|
25
|
+
type NewsletterSubscriber,
|
|
26
|
+
} from "../lib/newsletter-model";
|
|
27
|
+
import { DeliveriesCard, IssueSummary, ListsCard, SiteStatusCard, SubscribersTable } from "./newsletter-sections";
|
|
28
|
+
|
|
29
|
+
type Message = { kind: "ok" | "problem"; text: string } | null;
|
|
30
|
+
type Drawer = { issueId: string; mode: "preview" | "approve" | "stop" | "cancel" } | null;
|
|
31
|
+
type Result<T> = ({ ok: true } & T) | NewsletterFailure;
|
|
32
|
+
|
|
33
|
+
const isFailure = (value: { ok: boolean }): value is NewsletterFailure => value.ok === false;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Administration, Newsletter: subscriber counts, issues with their actions, delivery history, the pause, and (for admins) the
|
|
37
|
+
* subscriber list, CSV export and erasure. It never subscribes to anything live: it refreshes after each action and on the Refresh button.
|
|
38
|
+
*/
|
|
39
|
+
export function NewsletterPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
40
|
+
const api: NewsletterApi = useMemo(() => (isDemo ? createFixtureApi({ admin: isAdmin }) : callNewsletter), [isDemo, isAdmin]);
|
|
41
|
+
const live = useRef(true);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
live.current = true;
|
|
44
|
+
return () => {
|
|
45
|
+
live.current = false;
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const [overview, setOverview] = useState<NewsletterOverview | null>(null);
|
|
50
|
+
const [issues, setIssues] = useState<NewsletterIssue[] | null>(null);
|
|
51
|
+
const [edited, setEdited] = useState<Record<string, boolean>>({});
|
|
52
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
53
|
+
const [statusFilter, setStatusFilter] = useState("");
|
|
54
|
+
const [message, setMessage] = useState<Message>(null);
|
|
55
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
56
|
+
const [drawer, setDrawer] = useState<Drawer>(null);
|
|
57
|
+
const [preview, setPreview] = useState<Result<NewsletterPreview> | null>(null);
|
|
58
|
+
const [approveAt, setApproveAt] = useState("");
|
|
59
|
+
const [deliveriesFor, setDeliveriesFor] = useState<string | null>(null);
|
|
60
|
+
const [deliveries, setDeliveries] = useState<NewsletterDelivery[]>([]);
|
|
61
|
+
const [deliveriesCursor, setDeliveriesCursor] = useState<string | null>(null);
|
|
62
|
+
const [deliveriesLoading, setDeliveriesLoading] = useState(false);
|
|
63
|
+
const [subscribers, setSubscribers] = useState<NewsletterSubscriber[] | null>(null);
|
|
64
|
+
const [subscribersCursor, setSubscribersCursor] = useState<string | null>(null);
|
|
65
|
+
const [listFilter, setListFilter] = useState("");
|
|
66
|
+
const [subscriberStatus, setSubscriberStatus] = useState("");
|
|
67
|
+
const [eraseEmail, setEraseEmail] = useState("");
|
|
68
|
+
const [eraseConfirm, setEraseConfirm] = useState(false);
|
|
69
|
+
|
|
70
|
+
const zone = useMemo(() => {
|
|
71
|
+
const roundup = overview?.lists.find((list) => list.timezone)?.timezone;
|
|
72
|
+
return validTimeZone(roundup) ?? validTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? "UTC";
|
|
73
|
+
}, [overview]);
|
|
74
|
+
|
|
75
|
+
const loadOverview = useCallback(async () => {
|
|
76
|
+
const result = (await api("newsletterOverview")) as Result<NewsletterOverview>;
|
|
77
|
+
if (!live.current) return;
|
|
78
|
+
if (isFailure(result)) {
|
|
79
|
+
setProblem(result.error === "forbidden" ? "forbidden" : failureText(result));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setProblem(null);
|
|
83
|
+
setOverview(result);
|
|
84
|
+
}, [api]);
|
|
85
|
+
|
|
86
|
+
const loadIssues = useCallback(async () => {
|
|
87
|
+
const result = (await api("newsletterIssues", { limit: 100, ...(statusFilter ? { status: statusFilter } : {}) })) as Result<{ issues: NewsletterIssue[] }>;
|
|
88
|
+
if (!live.current) return;
|
|
89
|
+
if (isFailure(result)) {
|
|
90
|
+
setMessage({ kind: "problem", text: failureText(result) });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setIssues(result.issues);
|
|
94
|
+
// The list does not say whether an approved issue was edited; the single-issue read does. Scheduled issues are few.
|
|
95
|
+
const scheduled = result.issues.filter((issue) => issue.status === "scheduled").slice(0, 10);
|
|
96
|
+
const checks = await Promise.all(scheduled.map(async (issue) => [issue.issueId, await api("newsletterIssue", { issueId: issue.issueId })] as const));
|
|
97
|
+
if (!live.current) return;
|
|
98
|
+
setEdited(Object.fromEntries(checks.map(([id, one]) => [id, !isFailure(one as Result<object>) && Boolean((one as { issue?: NewsletterIssue }).issue?.editedSinceApproval)])));
|
|
99
|
+
}, [api, statusFilter]);
|
|
100
|
+
|
|
101
|
+
const loadSubscribers = useCallback(
|
|
102
|
+
async (cursor?: string | null) => {
|
|
103
|
+
if (!isAdmin) return;
|
|
104
|
+
const result = (await api("newsletterSubscribers", {
|
|
105
|
+
...(listFilter ? { listId: listFilter } : {}),
|
|
106
|
+
...(subscriberStatus ? { status: subscriberStatus } : {}),
|
|
107
|
+
...(cursor ? { cursor } : {}),
|
|
108
|
+
})) as Result<{ subscribers: NewsletterSubscriber[]; cursor?: string }>;
|
|
109
|
+
if (!live.current) return;
|
|
110
|
+
if (isFailure(result)) {
|
|
111
|
+
setMessage({ kind: "problem", text: failureText(result) });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setSubscribers((current) => (cursor && current ? [...current, ...result.subscribers] : result.subscribers));
|
|
115
|
+
setSubscribersCursor(result.cursor ?? null);
|
|
116
|
+
},
|
|
117
|
+
[api, isAdmin, listFilter, subscriberStatus],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const loadDeliveries = useCallback(
|
|
121
|
+
async (issueId: string, cursor?: string | null) => {
|
|
122
|
+
setDeliveriesLoading(true);
|
|
123
|
+
const result = (await api("newsletterIssueDeliveries", { issueId, ...(cursor ? { cursor } : {}) })) as Result<{ deliveries: NewsletterDelivery[]; cursor?: string }>;
|
|
124
|
+
if (!live.current) return;
|
|
125
|
+
setDeliveriesLoading(false);
|
|
126
|
+
if (isFailure(result)) {
|
|
127
|
+
setMessage({ kind: "problem", text: failureText(result) });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
setDeliveries((current) => (cursor ? [...current, ...result.deliveries] : result.deliveries));
|
|
131
|
+
setDeliveriesCursor(result.cursor ?? null);
|
|
132
|
+
},
|
|
133
|
+
[api],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const refresh = useCallback(async () => {
|
|
137
|
+
await Promise.all([loadOverview(), loadIssues(), loadSubscribers()]);
|
|
138
|
+
if (deliveriesFor) await loadDeliveries(deliveriesFor);
|
|
139
|
+
}, [loadOverview, loadIssues, loadSubscribers, loadDeliveries, deliveriesFor]);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
void loadOverview();
|
|
143
|
+
}, [loadOverview]);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
void loadIssues();
|
|
146
|
+
}, [loadIssues]);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
setSubscribers(null);
|
|
149
|
+
void loadSubscribers();
|
|
150
|
+
}, [loadSubscribers]);
|
|
151
|
+
|
|
152
|
+
/** Runs an operation with the buttons disabled, says what happened, then reloads what it may have changed. */
|
|
153
|
+
const act = async (key: string, run: () => Promise<{ text: string } | NewsletterFailure>, reload: Array<"overview" | "issues" | "subscribers"> = ["overview", "issues"]) => {
|
|
154
|
+
setBusy(key);
|
|
155
|
+
setMessage(null);
|
|
156
|
+
try {
|
|
157
|
+
const outcome = await run();
|
|
158
|
+
if (!live.current) return;
|
|
159
|
+
if ("ok" in outcome) {
|
|
160
|
+
setMessage({ kind: "problem", text: failureText(outcome) });
|
|
161
|
+
} else {
|
|
162
|
+
setMessage({ kind: "ok", text: outcome.text });
|
|
163
|
+
await Promise.all([
|
|
164
|
+
reload.includes("overview") ? loadOverview() : null,
|
|
165
|
+
reload.includes("issues") ? loadIssues() : null,
|
|
166
|
+
reload.includes("subscribers") ? loadSubscribers() : null,
|
|
167
|
+
deliveriesFor ? loadDeliveries(deliveriesFor) : null,
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
if (live.current) setBusy(null);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const openPreview = async (issue: NewsletterIssue) => {
|
|
176
|
+
if (drawer?.issueId === issue.issueId && drawer.mode === "preview") {
|
|
177
|
+
setDrawer(null);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
setDrawer({ issueId: issue.issueId, mode: "preview" });
|
|
181
|
+
setPreview(null);
|
|
182
|
+
const result = (await api("newsletterPreview", { issueId: issue.issueId })) as Result<NewsletterPreview>;
|
|
183
|
+
if (live.current) setPreview(result);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const openApprove = (issue: NewsletterIssue) => {
|
|
187
|
+
setApproveAt(issue.sendAt ? isoToZonedInput(issue.sendAt, zone) : nextWholeHourInput(new Date(), zone));
|
|
188
|
+
setDrawer({ issueId: issue.issueId, mode: "approve" });
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const approve = (issue: NewsletterIssue) => {
|
|
192
|
+
const sendAt = zonedInputToIso(approveAt, zone);
|
|
193
|
+
if (!sendAt) {
|
|
194
|
+
setMessage({ kind: "problem", text: "Choose a date and time." });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
void act(`approve:${issue.issueId}`, async () => {
|
|
198
|
+
const result = (await api("newsletterApproveIssue", { issueId: issue.issueId, sendAt })) as Result<{ issue: NewsletterIssue; adjusted?: boolean; warnings?: string[] }>;
|
|
199
|
+
if (isFailure(result)) return result;
|
|
200
|
+
setDrawer(null);
|
|
201
|
+
const when = formatInZone(result.issue.sendAt, zone);
|
|
202
|
+
const extras = [result.adjusted ? "That was too soon, so it was moved a couple of minutes ahead." : "", ...(result.warnings ?? [])].filter(Boolean);
|
|
203
|
+
return { text: `Approved for ${when}.${extras.length ? ` ${extras.join(" ")}` : ""}` };
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const simple = (issue: NewsletterIssue, operation: "newsletterCancelIssue" | "newsletterStopIssue", done: string) => {
|
|
208
|
+
void act(`${operation}:${issue.issueId}`, async () => {
|
|
209
|
+
const result = (await api(operation, { issueId: issue.issueId })) as Result<object>;
|
|
210
|
+
if (isFailure(result)) return result;
|
|
211
|
+
setDrawer(null);
|
|
212
|
+
return { text: done };
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const testSend = (issue: NewsletterIssue) =>
|
|
217
|
+
void act(`test:${issue.issueId}`, async () => {
|
|
218
|
+
const result = (await api("newsletterTestSend", { issueId: issue.issueId })) as Result<object>;
|
|
219
|
+
return isFailure(result) ? result : { text: "Test sent to your address." };
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
const newDraft = (listId: string) =>
|
|
223
|
+
void act(`draft:${listId}`, async () => {
|
|
224
|
+
const result = (await api("newsletterCreateDraft", { listId })) as Result<{ status?: string; items?: number }>;
|
|
225
|
+
if (isFailure(result)) return result;
|
|
226
|
+
if (result.status === "created") return { text: `Draft created with ${result.items ?? 0} ${result.items === 1 ? "item" : "items"}. Open it in the editor to write the introduction.` };
|
|
227
|
+
if (result.status === "exists") return { text: "This week already has a draft for that list." };
|
|
228
|
+
return { text: "Nothing has been published since the last issue, so no draft was made." };
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const showDeliveries = (issue: NewsletterIssue) => {
|
|
232
|
+
setDeliveriesFor(issue.issueId);
|
|
233
|
+
setDeliveries([]);
|
|
234
|
+
setDeliveriesCursor(null);
|
|
235
|
+
void loadDeliveries(issue.issueId);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const resume = () =>
|
|
239
|
+
void act("resume", async () => {
|
|
240
|
+
const result = (await api("newsletterResume")) as Result<object>;
|
|
241
|
+
return isFailure(result) ? result : { text: "Sending resumed. Approve any blocked issue again." };
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const exportCsv = () =>
|
|
245
|
+
void act("export", async () => {
|
|
246
|
+
const rows: NewsletterExportRow[] = [];
|
|
247
|
+
let cursor: string | undefined;
|
|
248
|
+
do {
|
|
249
|
+
const result = (await api("newsletterExport", cursor ? { cursor } : {})) as Result<{ rows: NewsletterExportRow[]; cursor?: string }>;
|
|
250
|
+
if (isFailure(result)) return result;
|
|
251
|
+
rows.push(...result.rows);
|
|
252
|
+
cursor = result.cursor;
|
|
253
|
+
} while (cursor);
|
|
254
|
+
const url = URL.createObjectURL(new Blob([subscribersToCsv(rows)], { type: "text/csv;charset=utf-8" }));
|
|
255
|
+
const link = document.createElement("a");
|
|
256
|
+
link.href = url;
|
|
257
|
+
link.download = "newsletter-subscribers.csv";
|
|
258
|
+
link.click();
|
|
259
|
+
URL.revokeObjectURL(url);
|
|
260
|
+
return { text: `Exported ${rows.length} confirmed and pending ${rows.length === 1 ? "subscriber" : "subscribers"}.` };
|
|
261
|
+
}, []);
|
|
262
|
+
|
|
263
|
+
const erase = () =>
|
|
264
|
+
void act("erase", async () => {
|
|
265
|
+
const email = eraseEmail.trim().toLowerCase();
|
|
266
|
+
const result = (await api("newsletterErase", { email })) as Result<{ removed: number }>;
|
|
267
|
+
if (isFailure(result)) return result;
|
|
268
|
+
setEraseEmail("");
|
|
269
|
+
setEraseConfirm(false);
|
|
270
|
+
return { text: `Erased ${email}: ${result.removed} ${result.removed === 1 ? "record" : "records"} removed.` };
|
|
271
|
+
}, ["overview", "subscribers"]);
|
|
272
|
+
|
|
273
|
+
if (problem === "forbidden") {
|
|
274
|
+
return (
|
|
275
|
+
<div data-news-desk-admin-panel="newsletter">
|
|
276
|
+
<div className="category-steering-empty" role="alert" data-newsletter-state="forbidden">
|
|
277
|
+
<strong>The newsletter is for editors and admins</strong>
|
|
278
|
+
<p>Your account does not have access to this page. Ask an admin for the editor role.</p>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const roundupLists = overview?.lists.filter((list) => list.kind === "roundup") ?? [];
|
|
285
|
+
const deliveriesIssue = issues?.find((issue) => issue.issueId === deliveriesFor) ?? null;
|
|
286
|
+
const loading = overview === null && !problem;
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div data-news-desk-admin-panel="newsletter">
|
|
290
|
+
<article className="news-desk-doctrine-card">
|
|
291
|
+
<div className="news-desk-doctrine-card__header">
|
|
292
|
+
<div>
|
|
293
|
+
<h3>Newsletter</h3>
|
|
294
|
+
<span>Subscribers, issues and delivery for the site's email. {isAdmin ? "You are an admin: you can approve, stop and see addresses." : "You are an editor: you can preview and test, and see counts. An admin approves."}</span>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="news-desk-doctrine-card__actions">
|
|
298
|
+
<button type="button" data-newsletter-refresh disabled={busy !== null} onClick={() => void refresh()}>Refresh</button>
|
|
299
|
+
</div>
|
|
300
|
+
<p role="status" aria-live="polite" data-newsletter-message data-kind={message?.kind ?? (loading ? "loading" : "idle")}>
|
|
301
|
+
{message ? (message.kind === "problem" ? `Problem: ${message.text}` : message.text) : loading ? "Loading..." : problem ?? ""}
|
|
302
|
+
</p>
|
|
303
|
+
</article>
|
|
304
|
+
|
|
305
|
+
{overview ? <ListsCard overview={overview} /> : null}
|
|
306
|
+
{overview ? <SiteStatusCard paused={overview.paused} isAdmin={isAdmin} busy={busy !== null} onResume={resume} /> : null}
|
|
307
|
+
|
|
308
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="issues">
|
|
309
|
+
<div className="news-desk-doctrine-card__header">
|
|
310
|
+
<div><h3>Issues</h3><span>Newest first. Times are in {zone}; hover a time for UTC.</span></div>
|
|
311
|
+
</div>
|
|
312
|
+
<label className="news-desk-doctrine-card__field">
|
|
313
|
+
<span>Show</span>
|
|
314
|
+
<select value={statusFilter} data-newsletter-status-filter onChange={(event) => setStatusFilter(event.target.value)}>
|
|
315
|
+
<option value="">All issues</option>
|
|
316
|
+
{ISSUE_STATUS_FILTERS.map((status) => <option key={status} value={status}>{statusLabel(status)}</option>)}
|
|
317
|
+
</select>
|
|
318
|
+
</label>
|
|
319
|
+
{roundupLists.length ? (
|
|
320
|
+
<div className="news-desk-doctrine-card__actions">
|
|
321
|
+
{roundupLists.map((list) => (
|
|
322
|
+
<button key={list.id} type="button" data-newsletter-new-draft={list.id} disabled={busy !== null} onClick={() => newDraft(list.id)}>New draft: {list.name}</button>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
) : null}
|
|
326
|
+
{issues === null ? <p>Loading issues...</p> : issues.length === 0 ? (
|
|
327
|
+
<p data-newsletter-no-issues>{statusFilter ? `No ${statusLabel(statusFilter).toLowerCase()} issues.` : "No issues yet. A roundup draft appears on its schedule, or make one with New draft."}</p>
|
|
328
|
+
) : (
|
|
329
|
+
<div className="news-desk-user-list">
|
|
330
|
+
{issues.map((issue) => {
|
|
331
|
+
const open = drawer?.issueId === issue.issueId ? drawer.mode : null;
|
|
332
|
+
const row = { ...issue, editedSinceApproval: edited[issue.issueId] };
|
|
333
|
+
return (
|
|
334
|
+
<div className="news-desk-user-row" key={issue.issueId} data-newsletter-issue={issue.issueId} data-status={issue.status}>
|
|
335
|
+
<div><IssueSummary issue={row} zone={zone} demo={isDemo} /></div>
|
|
336
|
+
<div className="news-desk-user-row__actions">
|
|
337
|
+
<button type="button" data-newsletter-action="preview" aria-expanded={open === "preview"} onClick={() => void openPreview(issue)}>Preview</button>
|
|
338
|
+
<button type="button" data-newsletter-action="test" disabled={busy !== null} onClick={() => testSend(issue)}>Test send</button>
|
|
339
|
+
<button type="button" data-newsletter-action="deliveries" onClick={() => showDeliveries(issue)}>Deliveries</button>
|
|
340
|
+
{isAdmin && canApprove(issue.status) ? (
|
|
341
|
+
<button type="button" data-newsletter-action="approve" aria-expanded={open === "approve"} disabled={busy !== null} onClick={() => openApprove(issue)}>
|
|
342
|
+
{issue.status === "scheduled" ? "Change time" : "Approve for a time"}
|
|
343
|
+
</button>
|
|
344
|
+
) : null}
|
|
345
|
+
{isAdmin && canCancel(issue.status) ? (
|
|
346
|
+
<button type="button" data-newsletter-action="cancel" disabled={busy !== null} onClick={() => simple(issue, "newsletterCancelIssue", "Cancelled. The issue is a draft again.")}>Cancel</button>
|
|
347
|
+
) : null}
|
|
348
|
+
{isAdmin && canStop(issue.status) ? (
|
|
349
|
+
<button type="button" data-newsletter-action="stop" disabled={busy !== null} onClick={() => setDrawer({ issueId: issue.issueId, mode: "stop" })}>Stop</button>
|
|
350
|
+
) : null}
|
|
351
|
+
</div>
|
|
352
|
+
{open === "preview" ? (
|
|
353
|
+
<div className="news-desk-newsletter__drawer" data-newsletter-preview>
|
|
354
|
+
{preview === null ? <p>Loading preview...</p> : isFailure(preview) ? (
|
|
355
|
+
<p className="category-steering-alert" role="alert">{failureText(preview)}</p>
|
|
356
|
+
) : (
|
|
357
|
+
<>
|
|
358
|
+
<p className="news-desk-delegation-note">
|
|
359
|
+
Subject: <strong>{preview.subject}</strong> · {Math.ceil(preview.bytes / 1024)} KB of HTML
|
|
360
|
+
{preview.bytes > 100 * 1024 ? " (Gmail clips messages over 102 KB)" : ""}
|
|
361
|
+
</p>
|
|
362
|
+
{!preview.postalAddress ? (
|
|
363
|
+
<p className="category-steering-alert" role="alert" data-newsletter-postal>{failureText({ ok: false, error: "postal_address_missing" })} The preview shows a placeholder.</p>
|
|
364
|
+
) : null}
|
|
365
|
+
<iframe title={`Preview of ${issue.subject}`} sandbox="" srcDoc={preview.html} className="news-desk-newsletter__frame" />
|
|
366
|
+
<h4>Plain text version</h4>
|
|
367
|
+
<pre className="news-desk-newsletter__text">{preview.text}</pre>
|
|
368
|
+
</>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
) : null}
|
|
372
|
+
{open === "approve" ? (
|
|
373
|
+
<div className="news-desk-newsletter__drawer" data-newsletter-approve>
|
|
374
|
+
<label className="news-desk-doctrine-card__field">
|
|
375
|
+
<span>Send at ({zone})</span>
|
|
376
|
+
<input type="datetime-local" value={approveAt} data-newsletter-send-at-input onChange={(event) => setApproveAt(event.target.value)} />
|
|
377
|
+
</label>
|
|
378
|
+
<p className="news-desk-delegation-note">This sends the issue as it is now. If it is edited before this time, it goes back to draft and sends nothing. A time less than two minutes ahead is moved to two minutes from now.</p>
|
|
379
|
+
<div className="news-desk-doctrine-card__actions">
|
|
380
|
+
<button type="button" data-newsletter-confirm-approve disabled={busy !== null} onClick={() => approve(issue)}>Approve for this time</button>
|
|
381
|
+
<button type="button" onClick={() => setDrawer(null)}>Keep as is</button>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
) : null}
|
|
385
|
+
{open === "stop" ? (
|
|
386
|
+
<div className="news-desk-newsletter__drawer" data-newsletter-stop>
|
|
387
|
+
<p className="category-steering-alert">Stop sending this issue? The addresses already mailed stay mailed; the rest are not sent. This cannot be resumed.</p>
|
|
388
|
+
<div className="news-desk-doctrine-card__actions">
|
|
389
|
+
<button type="button" data-newsletter-confirm-stop disabled={busy !== null} onClick={() => simple(issue, "newsletterStopIssue", "Stopped. No more of this issue will be sent.")}>Stop sending</button>
|
|
390
|
+
<button type="button" onClick={() => setDrawer(null)}>Keep sending</button>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
) : null}
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
})}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
</article>
|
|
400
|
+
|
|
401
|
+
{deliveriesIssue ? (
|
|
402
|
+
<DeliveriesCard
|
|
403
|
+
issue={deliveriesIssue}
|
|
404
|
+
rows={deliveries}
|
|
405
|
+
events={(overview?.events ?? []).filter((event) => event.issue === deliveriesIssue.issueId)}
|
|
406
|
+
hasMore={deliveriesCursor !== null}
|
|
407
|
+
loading={deliveriesLoading}
|
|
408
|
+
isAdmin={isAdmin}
|
|
409
|
+
onMore={() => void loadDeliveries(deliveriesIssue.issueId, deliveriesCursor)}
|
|
410
|
+
/>
|
|
411
|
+
) : (
|
|
412
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="deliveries">
|
|
413
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Delivery history</h3><span>Choose Deliveries on an issue to see who it went to and what happened</span></div></div>
|
|
414
|
+
</article>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="subscribers">
|
|
418
|
+
<div className="news-desk-doctrine-card__header">
|
|
419
|
+
<div><h3>Subscribers</h3><span>{isAdmin ? "Addresses are personal data. Only admins see them." : "Only admins see addresses."}</span></div>
|
|
420
|
+
</div>
|
|
421
|
+
{isAdmin ? (
|
|
422
|
+
<>
|
|
423
|
+
<div className="news-desk-newsletter__filters">
|
|
424
|
+
<label className="news-desk-doctrine-card__field">
|
|
425
|
+
<span>List</span>
|
|
426
|
+
<select value={listFilter} data-newsletter-list-filter onChange={(event) => setListFilter(event.target.value)}>
|
|
427
|
+
<option value="">All lists</option>
|
|
428
|
+
{(overview?.lists ?? []).map((list) => <option key={list.id} value={list.id}>{list.name}</option>)}
|
|
429
|
+
</select>
|
|
430
|
+
</label>
|
|
431
|
+
<label className="news-desk-doctrine-card__field">
|
|
432
|
+
<span>Status</span>
|
|
433
|
+
<select value={subscriberStatus} data-newsletter-subscriber-filter onChange={(event) => setSubscriberStatus(event.target.value)}>
|
|
434
|
+
<option value="">Any status</option>
|
|
435
|
+
<option value="confirmed">Confirmed</option>
|
|
436
|
+
<option value="pending">Waiting to confirm</option>
|
|
437
|
+
<option value="unsubscribed">Unsubscribed</option>
|
|
438
|
+
</select>
|
|
439
|
+
</label>
|
|
440
|
+
</div>
|
|
441
|
+
<div className="news-desk-doctrine-card__actions">
|
|
442
|
+
<button type="button" data-newsletter-export disabled={busy !== null} onClick={exportCsv}>Export CSV (confirmed and pending)</button>
|
|
443
|
+
</div>
|
|
444
|
+
{subscribers === null ? <p>Loading subscribers...</p> : subscribers.length ? <SubscribersTable rows={subscribers} zone={zone} /> : <p>No subscribers match.</p>}
|
|
445
|
+
{subscribersCursor ? (
|
|
446
|
+
<div className="news-desk-doctrine-card__actions"><button type="button" data-newsletter-more-subscribers onClick={() => void loadSubscribers(subscribersCursor)}>Load more</button></div>
|
|
447
|
+
) : null}
|
|
448
|
+
<div className="news-desk-merge-panel" data-newsletter-erase>
|
|
449
|
+
<strong>Erase one subscriber</strong>
|
|
450
|
+
<p className="news-desk-delegation-note">Removes the address, its list memberships, any suppression and its delivery records. Use it for a request to be forgotten. It cannot be undone.</p>
|
|
451
|
+
<label className="news-desk-doctrine-card__field">
|
|
452
|
+
<span>Address to erase</span>
|
|
453
|
+
<input type="email" value={eraseEmail} autoComplete="off" data-newsletter-erase-input onChange={(event) => { setEraseEmail(event.target.value); setEraseConfirm(false); }} />
|
|
454
|
+
</label>
|
|
455
|
+
{eraseConfirm ? (
|
|
456
|
+
<div className="news-desk-doctrine-card__actions">
|
|
457
|
+
<span>Erase {eraseEmail.trim()} for good?</span>
|
|
458
|
+
<button type="button" data-newsletter-confirm-erase disabled={busy !== null} onClick={erase}>Erase permanently</button>
|
|
459
|
+
<button type="button" onClick={() => setEraseConfirm(false)}>Keep</button>
|
|
460
|
+
</div>
|
|
461
|
+
) : (
|
|
462
|
+
<div className="news-desk-doctrine-card__actions">
|
|
463
|
+
<button type="button" data-newsletter-erase-button disabled={!eraseEmail.trim() || busy !== null} onClick={() => setEraseConfirm(true)}>Erase…</button>
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
</>
|
|
468
|
+
) : (
|
|
469
|
+
<p className="news-desk-delegation-note" data-newsletter-editor-note>You can see how many people are on each list above. The address list, export and erasure are for admins.</p>
|
|
470
|
+
)}
|
|
471
|
+
</article>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|