@anthusai/papyrus 2.0.0-next.44 → 2.0.0-next.46

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.
@@ -0,0 +1,202 @@
1
+ /** Shapes and wording for the newsroom Newsletter panel. `src/papyrus_content/newsletter/` (panel.py, issues.py) produces them. */
2
+ import { getNewsroomNavHref } from "./newsroom-nav";
3
+ import { csvCell } from "./signup-csv";
4
+
5
+ export type NewsletterCounts = Partial<Record<"queued" | "sent" | "failed" | "suppressed" | "skipped" | "delivered" | "bounced" | "complained" | "softBounced", number>>;
6
+
7
+ export type NewsletterList = { id: string; name: string; kind: string; confirmed: number; pending: number; unsubscribed: number; timezone?: string };
8
+
9
+ export type NewsletterEvent = { type: string; at: string; issue?: string; list?: string; detail?: string };
10
+
11
+ export type NewsletterOverview = {
12
+ lists: NewsletterList[];
13
+ totals: { confirmed: number; pending: number; unsubscribed: number };
14
+ suppressed: number;
15
+ paused: { paused: boolean; reason?: string; pausedAt?: string; resumedAt?: string };
16
+ events: NewsletterEvent[];
17
+ };
18
+
19
+ export type NewsletterIssue = {
20
+ issueId: string;
21
+ listId: string;
22
+ listName?: string;
23
+ kind: string;
24
+ itemId?: string;
25
+ subject: string;
26
+ status: string;
27
+ sendAt?: string;
28
+ approvedBy?: string;
29
+ approvedAt?: string;
30
+ sentAt?: string;
31
+ counts: NewsletterCounts;
32
+ note?: string;
33
+ archiveUrl?: string;
34
+ editedSinceApproval?: boolean;
35
+ };
36
+
37
+ export type NewsletterPreview = { subject: string; html: string; text: string; bytes: number; postalAddress: boolean };
38
+
39
+ export type NewsletterDelivery = { address: string; status: string; at: string; eventAt?: string; error?: string };
40
+
41
+ export type NewsletterSubscriber = { email: string; status: string; lists: { id: string; status: string }[]; createdAt: string; confirmedAt?: string };
42
+
43
+ export type NewsletterExportRow = {
44
+ email: string;
45
+ status: string;
46
+ lists: string;
47
+ createdAt: string;
48
+ consentRequestedAt: string;
49
+ consentConfirmedAt: string;
50
+ consentFormId: string;
51
+ consentPageUrl: string;
52
+ consentWording: string;
53
+ };
54
+
55
+ export type NewsletterFailure = { ok: false; error: string; message?: string };
56
+
57
+ /** The newsroom words for the statuses the functions write. A status this panel does not know is shown as written, never hidden. */
58
+ const STATUS_LABELS: Record<string, string> = {
59
+ draft: "Draft",
60
+ awaiting_approval: "Awaiting approval",
61
+ scheduled: "Scheduled",
62
+ sending: "Sending",
63
+ sent: "Sent",
64
+ stopped: "Stopped",
65
+ cancelled: "Cancelled",
66
+ blocked: "Blocked",
67
+ };
68
+ /** A mark beside the word, so no status relies on colour alone. */
69
+ const STATUS_MARKS: Record<string, string> = { draft: "✎", awaiting_approval: "?", scheduled: "◷", sending: "▶", sent: "✓", stopped: "■", cancelled: "–", blocked: "!" };
70
+
71
+ export function statusLabel(status: string): string {
72
+ if (STATUS_LABELS[status]) return STATUS_LABELS[status];
73
+ const words = status.replace(/_/g, " ").trim();
74
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : "Unknown";
75
+ }
76
+
77
+ export function statusMark(status: string): string {
78
+ return STATUS_MARKS[status] ?? "·";
79
+ }
80
+
81
+ export const ISSUE_STATUS_FILTERS = ["draft", "awaiting_approval", "scheduled", "sending", "sent", "blocked", "stopped", "cancelled"] as const;
82
+
83
+ /** Approval accepts a draft, an alert awaiting approval, a scheduled issue (a new time) and a blocked one (cause fixed). */
84
+ export function canApprove(status: string): boolean {
85
+ return status === "draft" || status === "awaiting_approval" || status === "scheduled" || status === "blocked";
86
+ }
87
+ export const canCancel = (status: string): boolean => status === "scheduled";
88
+ export const canStop = (status: string): boolean => status === "sending";
89
+
90
+ export function issueEditorHref(itemId: string, demo?: boolean): string {
91
+ return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(itemId)}`, demo);
92
+ }
93
+
94
+ const ERROR_WORDS: Record<string, string> = {
95
+ forbidden: "Your role cannot do this.",
96
+ not_found: "That issue no longer exists.",
97
+ invalid_state: "The issue changed state. Refresh and try again.",
98
+ item_missing: "The issue's item no longer exists.",
99
+ render_failed: "The issue cannot be rendered. Open it in the editor and fix the body.",
100
+ bad_request: "The request was not understood.",
101
+ no_address: "Your account has no email address to send a test to.",
102
+ unavailable: "The newsletter service could not be reached. Try again in a minute.",
103
+ };
104
+
105
+ /** One sentence for a failed operation. A missing postal address is an operator task, so the panel says where it is set. */
106
+ export function failureText(failure: NewsletterFailure): string {
107
+ if (failure.error === "postal_address_missing") {
108
+ return "This site has no postal address for the email footer, and mail law requires one. An operator sets it in the site's AWS configuration (an SSM parameter); it cannot be set from the newsroom.";
109
+ }
110
+ return ERROR_WORDS[failure.error] ?? failure.message ?? "The request failed.";
111
+ }
112
+
113
+ // -- times ---------------------------------------------------------------------------------------------------------------
114
+
115
+ export function formatUtc(iso: string | null | undefined): string {
116
+ if (!iso) return "never";
117
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
118
+ return match ? `${match[1]} ${match[2]} UTC` : iso;
119
+ }
120
+
121
+ export function validTimeZone(zone: string | null | undefined): string | null {
122
+ if (!zone) return null;
123
+ try {
124
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
125
+ return zone;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function wallParts(instant: Date, zone: string): { year: number; month: number; day: number; hour: number; minute: number } {
132
+ const parts = new Intl.DateTimeFormat("en-US", {
133
+ timeZone: zone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
134
+ }).formatToParts(instant);
135
+ const pick = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0");
136
+ return { year: pick("year"), month: pick("month"), day: pick("day"), hour: pick("hour") % 24, minute: pick("minute") };
137
+ }
138
+
139
+ /** Minutes the zone is ahead of UTC at that instant. */
140
+ function offsetMinutes(instant: Date, zone: string): number {
141
+ const wall = wallParts(instant, zone);
142
+ const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
143
+ return Math.round((asUtc - Math.floor(instant.getTime() / 60000) * 60000) / 60000);
144
+ }
145
+
146
+ const pad = (value: number) => String(value).padStart(2, "0");
147
+
148
+ /** Local wall time `YYYY-MM-DDTHH:mm` (a datetime-local value) as an ISO instant, for the given zone. */
149
+ export function zonedInputToIso(local: string, zone: string): string | null {
150
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(local);
151
+ if (!match) return null;
152
+ const [, year, month, day, hour, minute] = match.map(Number) as unknown as number[];
153
+ const wallAsUtc = Date.UTC(year, month - 1, day, hour, minute);
154
+ let guess = wallAsUtc - offsetMinutes(new Date(wallAsUtc), zone) * 60000;
155
+ guess = wallAsUtc - offsetMinutes(new Date(guess), zone) * 60000; // once more: the offset can differ across a clock change
156
+ const instant = new Date(guess);
157
+ return Number.isNaN(instant.getTime()) ? null : instant.toISOString().replace(".000Z", "Z");
158
+ }
159
+
160
+ /** The next whole hour on the zone's clock, as a datetime-local value. */
161
+ export function nextWholeHourInput(now: Date, zone: string): string {
162
+ const wall = wallParts(now, zone);
163
+ const next = new Date(Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour + 1, 0));
164
+ return `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}T${pad(next.getUTCHours())}:00`;
165
+ }
166
+
167
+ /** An ISO instant as a datetime-local value in the zone. */
168
+ export function isoToZonedInput(iso: string, zone: string): string {
169
+ const wall = wallParts(new Date(iso), zone);
170
+ return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}`;
171
+ }
172
+
173
+ /** `2027-01-15 09:00 EST` in the zone. */
174
+ export function formatInZone(iso: string | null | undefined, zone: string): string {
175
+ if (!iso) return "not set";
176
+ const instant = new Date(iso);
177
+ if (Number.isNaN(instant.getTime())) return iso;
178
+ const label = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "short" }).formatToParts(instant).find((part) => part.type === "timeZoneName")?.value ?? zone;
179
+ return `${isoToZonedInput(iso, zone).replace("T", " ")} ${label}`;
180
+ }
181
+
182
+ // -- counts and CSV ------------------------------------------------------------------------------------------------------
183
+
184
+ /** "12 sent, 2 delivered, 1 bounced"; only counters above zero, in reading order. */
185
+ export function countsText(counts: NewsletterCounts): string {
186
+ const order: [keyof NewsletterCounts, string][] = [
187
+ ["queued", "queued"], ["sent", "sent"], ["delivered", "delivered"], ["bounced", "bounced"], ["complained", "complained"],
188
+ ["failed", "failed"], ["suppressed", "suppressed"], ["skipped", "skipped"], ["softBounced", "soft bounced"],
189
+ ];
190
+ const parts = order.filter(([name]) => (counts[name] ?? 0) > 0).map(([name, label]) => `${counts[name]} ${label}`);
191
+ return parts.length ? parts.join(", ") : "nothing sent yet";
192
+ }
193
+
194
+ export const NEWSLETTER_CSV_HEADER = [
195
+ "email", "status", "lists", "createdAt", "consentRequestedAt", "consentConfirmedAt", "consentFormId", "consentPageUrl", "consentWording",
196
+ ] as const;
197
+
198
+ /** Every cell goes through the formula neutraliser: addresses and consent wording are text people typed. */
199
+ export function subscribersToCsv(rows: NewsletterExportRow[]): string {
200
+ const lines = rows.map((row) => NEWSLETTER_CSV_HEADER.map((column) => csvCell(String(row[column] ?? ""))).join(","));
201
+ return `${[NEWSLETTER_CSV_HEADER.join(","), ...lines].join("\r\n")}\r\n`;
202
+ }
package/package.json CHANGED
@@ -91,6 +91,8 @@
91
91
  "./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
92
92
  "./components/news-desk-page": "./components/news-desk-page.tsx",
93
93
  "./components/news-desk-taxonomy-client": "./components/news-desk-taxonomy-client.ts",
94
+ "./components/newsletter-panel": "./components/newsletter-panel.tsx",
95
+ "./components/newsletter-sections": "./components/newsletter-sections.tsx",
94
96
  "./components/newspaper": "./components/newspaper.tsx",
95
97
  "./components/newsroom-access-gate": "./components/newsroom-access-gate.tsx",
96
98
  "./components/newsroom-article-editor": "./components/newsroom-article-editor.tsx",
@@ -235,6 +237,9 @@
235
237
  "./lib/monitoring-overview": "./lib/monitoring-overview.ts",
236
238
  "./lib/news-desk-session": "./lib/news-desk-session.ts",
237
239
  "./lib/newsletter-api": "./lib/newsletter-api.ts",
240
+ "./lib/newsletter-client": "./lib/newsletter-client.ts",
241
+ "./lib/newsletter-fixture": "./lib/newsletter-fixture.ts",
242
+ "./lib/newsletter-model": "./lib/newsletter-model.ts",
238
243
  "./lib/newsletter-pages": "./lib/newsletter-pages.ts",
239
244
  "./lib/newspaper-layout": "./lib/newspaper-layout.ts",
240
245
  "./lib/newsroom-articles": "./lib/newsroom-articles.ts",
@@ -428,7 +433,7 @@
428
433
  "optional": true
429
434
  }
430
435
  },
431
- "version": "2.0.0-next.44",
436
+ "version": "2.0.0-next.46",
432
437
  "dependencies": {
433
438
  "@aws-amplify/adapter-nextjs": "^1.7.3",
434
439
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-next.44",
2
+ "version": "2.0.0-next.46",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",