527 lines
21 KiB
TypeScript
527 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import AdminShell from "../../ui/AdminShell";
|
||
import { authFetch, getApiBase } from "../../lib/auth";
|
||
import "../../email-recaps/recaps.css";
|
||
|
||
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null };
|
||
type Delivery = {
|
||
id: string;
|
||
month: string;
|
||
kind: string;
|
||
email: string;
|
||
username: string | null;
|
||
state: string;
|
||
attempts: number;
|
||
created_at: number;
|
||
updated_at: number;
|
||
next_attempt_at: number;
|
||
detail: string;
|
||
};
|
||
type Overview = {
|
||
settings: Settings;
|
||
ready: boolean;
|
||
detail: string;
|
||
months: string[];
|
||
deliveries: Delivery[];
|
||
total: number;
|
||
subscribers: number;
|
||
worker_enabled: boolean;
|
||
};
|
||
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null };
|
||
const monthLabel = (month: string) =>
|
||
new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
|
||
const dateLabel = (value?: number | null) =>
|
||
value
|
||
? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`
|
||
: "Not scheduled";
|
||
const stateLabels: Record<string, string> = {
|
||
queued: "Queued",
|
||
preparing: "Preparing report",
|
||
sending: "Sending",
|
||
sent: "Accepted by mail server",
|
||
retry: "Retry scheduled",
|
||
failed: "Failed",
|
||
unknown: "Needs review",
|
||
cancelled: "Cancelled",
|
||
};
|
||
|
||
export default function EmailRecapsAdminPage() {
|
||
const router = useRouter();
|
||
const [data, setData] = useState<Overview | null>(null);
|
||
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: "" });
|
||
const [month, setMonth] = useState("");
|
||
const [preview, setPreview] = useState<Preview | null>(null);
|
||
const [previewMode, setPreviewMode] = useState<"html" | "text">("html");
|
||
const [error, setError] = useState("");
|
||
const [notice, setNotice] = useState("");
|
||
const [busy, setBusy] = useState("");
|
||
const [offset, setOffset] = useState(0);
|
||
const [revision, setRevision] = useState(0);
|
||
const testRequest = useRef<{ month: string; id: string } | null>(null);
|
||
const initialized = useRef(false);
|
||
const previewController = useRef<AbortController | null>(null);
|
||
|
||
const responseData = useCallback(
|
||
async (response: Response) => {
|
||
if (response.status === 401) {
|
||
router.replace("/login?next=%2Fadmin%2Frecaps");
|
||
throw new Error("Sign in to continue.");
|
||
}
|
||
if (response.status === 403) {
|
||
router.replace("/");
|
||
throw new Error("Administrator access is required.");
|
||
}
|
||
const result = await response.json().catch(() => ({}));
|
||
if (!response.ok)
|
||
throw new Error(
|
||
typeof result.detail === "string"
|
||
? result.detail
|
||
: "Could not complete this action. Check your settings and try again.",
|
||
);
|
||
return result;
|
||
},
|
||
[router],
|
||
);
|
||
|
||
useEffect(() => {
|
||
void revision;
|
||
const abort = new AbortController();
|
||
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal })
|
||
.then(responseData)
|
||
.then((result: Overview) => {
|
||
if (abort.signal.aborted) return;
|
||
setData(result);
|
||
if (!initialized.current) {
|
||
setSettings(result.settings);
|
||
setMonth(result.months[0] || "");
|
||
initialized.current = true;
|
||
}
|
||
})
|
||
.catch((err: Error) => {
|
||
if (!abort.signal.aborted) setError(err.message);
|
||
});
|
||
return () => abort.abort();
|
||
}, [offset, revision, responseData]);
|
||
|
||
useEffect(() => {
|
||
if (!data?.deliveries.some((delivery) => ["queued", "preparing", "sending", "retry"].includes(delivery.state)))
|
||
return;
|
||
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
|
||
return () => window.clearInterval(timer);
|
||
}, [data]);
|
||
useEffect(() => () => previewController.current?.abort(), []);
|
||
|
||
const dirty =
|
||
!!data &&
|
||
(settings.enabled !== data.settings.enabled ||
|
||
settings.day !== data.settings.day ||
|
||
settings.hour !== data.settings.hour ||
|
||
settings.public_url !== data.settings.public_url);
|
||
const save = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (busy) return;
|
||
setBusy("save");
|
||
setError("");
|
||
setNotice("");
|
||
try {
|
||
const { enabled, day, hour } = settings;
|
||
const result = (await responseData(
|
||
await authFetch(`${getApiBase()}/admin/email-recaps`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled, day, hour }),
|
||
}),
|
||
)) as Settings;
|
||
setSettings(result);
|
||
setData((current) => (current ? { ...current, settings: result } : current));
|
||
setPreview(null);
|
||
setNotice(
|
||
result.enabled
|
||
? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.`
|
||
: "Settings saved. Scheduled delivery is paused.",
|
||
);
|
||
setRevision((value) => value + 1);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Could not save the schedule.");
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
};
|
||
|
||
const loadPreview = async () => {
|
||
if (busy || !month) return;
|
||
const abort = new AbortController();
|
||
previewController.current?.abort();
|
||
previewController.current = abort;
|
||
setBusy("preview");
|
||
setError("");
|
||
setNotice("");
|
||
setPreview(null);
|
||
try {
|
||
const result = (await responseData(
|
||
await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal }),
|
||
)) as Preview;
|
||
if (!abort.signal.aborted) setPreview(result);
|
||
} catch (err) {
|
||
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not prepare your preview.");
|
||
} finally {
|
||
if (!abort.signal.aborted) setBusy("");
|
||
}
|
||
};
|
||
|
||
const sendTest = async () => {
|
||
if (busy || !preview || preview.month !== month) return;
|
||
if (!testRequest.current || testRequest.current.month !== month)
|
||
testRequest.current = { month, id: crypto.randomUUID() };
|
||
setBusy("test");
|
||
setError("");
|
||
setNotice("");
|
||
try {
|
||
const result = await responseData(
|
||
await authFetch(`${getApiBase()}/admin/email-recaps/test`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ month, request_id: testRequest.current.id }),
|
||
}),
|
||
);
|
||
setNotice(result.message);
|
||
testRequest.current = null;
|
||
setOffset(0);
|
||
setRevision((value) => value + 1);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Could not queue your test.");
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<AdminShell
|
||
title="Monthly email recaps"
|
||
subtitle="Give each user a personal look back at their month in viewing."
|
||
actions={
|
||
<a className="ghost-button" href="/admin/notifications">
|
||
Email settings ↗
|
||
</a>
|
||
}
|
||
>
|
||
<div className="recap-admin">
|
||
{error && (
|
||
<p className="error-banner" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{notice && (
|
||
<p className="status-banner" role="status">
|
||
{notice}
|
||
</p>
|
||
)}
|
||
{!data && !error && <p role="status">Loading email recaps…</p>}
|
||
{!data && error && (
|
||
<button
|
||
className="ghost-button"
|
||
type="button"
|
||
onClick={() => {
|
||
setError("");
|
||
setRevision((value) => value + 1);
|
||
}}
|
||
>
|
||
Try again
|
||
</button>
|
||
)}
|
||
{data && (
|
||
<>
|
||
<div className="recap-overview-strip">
|
||
<div>
|
||
<span className={`recap-pill ${data.settings.enabled ? "is-enabled" : ""}`}>
|
||
{data.settings.enabled ? "Schedule running" : "Schedule paused"}
|
||
</span>
|
||
<p>
|
||
{data.settings.enabled
|
||
? `Next send ${dateLabel(data.settings.next_send_at)}`
|
||
: "Start the schedule when you’re ready for monthly delivery."}
|
||
</p>
|
||
</div>
|
||
<div className="recap-subscriber-count">
|
||
<strong>{data.subscribers}</strong>
|
||
<span>confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}</span>
|
||
</div>
|
||
</div>
|
||
<div className="recap-admin-grid">
|
||
<section className="admin-panel recap-panel">
|
||
<div className="recap-section-heading">
|
||
<div>
|
||
<span className="recap-eyebrow">Set the rhythm</span>
|
||
<h2>Monthly schedule</h2>
|
||
</div>
|
||
</div>
|
||
<p>
|
||
Send the previous month’s report to users who have opted in and confirmed their email. All report
|
||
periods and send times use UTC.
|
||
</p>
|
||
<form className="recap-schedule-form" onSubmit={save}>
|
||
<label htmlFor="recap-public-url">
|
||
Public Magent address
|
||
<input id="recap-public-url" type="url" value={settings.public_url} readOnly />
|
||
<small>
|
||
Inherited from <Link href="/admin/general">Hosting & proxy</Link>. Email links update when
|
||
that address changes.
|
||
</small>
|
||
</label>
|
||
<div className="recap-schedule-fields">
|
||
<label htmlFor="recap-day">
|
||
Day of the month
|
||
<select
|
||
id="recap-day"
|
||
value={settings.day}
|
||
onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })}
|
||
disabled={!!busy}
|
||
>
|
||
{Array.from({ length: 28 }, (_, index) => (
|
||
<option key={index + 1} value={index + 1}>
|
||
{index + 1}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label htmlFor="recap-hour">
|
||
Send time (UTC)
|
||
<select
|
||
id="recap-hour"
|
||
value={settings.hour}
|
||
onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}
|
||
disabled={!!busy}
|
||
>
|
||
{Array.from({ length: 24 }, (_, hour) => (
|
||
<option key={hour} value={hour}>
|
||
{String(hour).padStart(2, "0")}:00 UTC
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label className="recap-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.enabled}
|
||
disabled={!!busy}
|
||
onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })}
|
||
/>
|
||
<span>Enable scheduled monthly recaps</span>
|
||
</label>
|
||
<p className="recap-muted">
|
||
Starting or changing the schedule begins at its next future send time. Pausing cancels queued
|
||
monthly emails.
|
||
</p>
|
||
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>
|
||
{busy === "save" ? "Saving…" : "Save schedule"}
|
||
</button>
|
||
</form>
|
||
{!data.ready && (
|
||
<p className="recap-setup-note">
|
||
{data.detail} <a href="/admin/notifications">Review email settings ↗</a>
|
||
</p>
|
||
)}
|
||
</section>
|
||
<section className="admin-panel recap-panel">
|
||
<span className="recap-eyebrow">Make it yours</span>
|
||
<h2>Preview your recap</h2>
|
||
<p>
|
||
See your own viewing highlights in the email design. A test goes only to your confirmed profile email.
|
||
</p>
|
||
<label className="recap-month-label" htmlFor="recap-month">
|
||
Report month
|
||
<select
|
||
id="recap-month"
|
||
value={month}
|
||
disabled={!!busy}
|
||
onChange={(event) => {
|
||
setMonth(event.target.value);
|
||
setPreview(null);
|
||
testRequest.current = null;
|
||
}}
|
||
>
|
||
{data.months.map((value) => (
|
||
<option key={value} value={value}>
|
||
{monthLabel(value)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="recap-actions">
|
||
<button
|
||
type="button"
|
||
className="account-primary"
|
||
onClick={() => void loadPreview()}
|
||
disabled={!!busy || dirty || !month}
|
||
>
|
||
{busy === "preview" ? "Preparing preview…" : "Preview my recap"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="account-secondary"
|
||
onClick={() => void sendTest()}
|
||
disabled={!!busy || dirty || !preview || !data.ready}
|
||
>
|
||
{busy === "test" ? "Queuing test…" : "Send test to me"}
|
||
</button>
|
||
</div>
|
||
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
|
||
<div className="recap-preview-guidance">
|
||
<h3>One email. Your month.</h3>
|
||
<ul>
|
||
<li>Minutes, movies, episodes and requests</li>
|
||
<li>Changes from the previous month</li>
|
||
<li>Most watched titles and your longest run</li>
|
||
<li>A link to the full report and easy unsubscribe</li>
|
||
</ul>
|
||
<a href="/profile#monthly-recaps">Confirm your email in Profile ↗</a>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
{preview && (
|
||
<section className="admin-panel recap-panel recap-preview">
|
||
<div className="recap-section-heading">
|
||
<div>
|
||
<span className="recap-eyebrow">Email preview</span>
|
||
<h2>{preview.subject}</h2>
|
||
<p>For {preview.email || "your profile email"} · Preview links use your saved public address.</p>
|
||
</div>
|
||
<div className="recap-mode-buttons">
|
||
<button type="button" aria-pressed={previewMode === "html"} onClick={() => setPreviewMode("html")}>
|
||
Email design
|
||
</button>
|
||
<button type="button" aria-pressed={previewMode === "text"} onClick={() => setPreviewMode("text")}>
|
||
Plain text
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{previewMode === "html" ? (
|
||
<iframe
|
||
title="Monthly recap email preview"
|
||
sandbox=""
|
||
referrerPolicy="no-referrer"
|
||
srcDoc={preview.body_html}
|
||
/>
|
||
) : (
|
||
<pre className="recap-plain-preview">{preview.body_text}</pre>
|
||
)}
|
||
</section>
|
||
)}
|
||
<section className="admin-panel recap-panel">
|
||
<div className="recap-section-heading">
|
||
<div>
|
||
<span className="recap-eyebrow">From queue to inbox</span>
|
||
<h2>Delivery history</h2>
|
||
<p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="ghost-button"
|
||
disabled={!!busy}
|
||
onClick={() => {
|
||
setError("");
|
||
setRevision((value) => value + 1);
|
||
}}
|
||
>
|
||
Refresh history
|
||
</button>
|
||
</div>
|
||
{data.deliveries.length ? (
|
||
<>
|
||
<div className="recap-history-scroll">
|
||
<table className="recap-history">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Recipient</th>
|
||
<th scope="col">Report</th>
|
||
<th scope="col">Delivery</th>
|
||
<th scope="col">Updated</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.deliveries.map((delivery) => (
|
||
<tr key={delivery.id}>
|
||
<td>
|
||
<strong>{delivery.username || "Removed account"}</strong>
|
||
<small>{delivery.email}</small>
|
||
</td>
|
||
<td>
|
||
{monthLabel(delivery.month)}
|
||
<small>
|
||
{delivery.kind === "test"
|
||
? "Test email"
|
||
: delivery.kind === "on_demand"
|
||
? "Requested by user"
|
||
: "Scheduled recap"}
|
||
</small>
|
||
</td>
|
||
<td>
|
||
<span
|
||
className={`recap-pill ${delivery.state === "sent" ? "is-enabled" : ["failed", "unknown"].includes(delivery.state) ? "is-attention" : ""}`}
|
||
>
|
||
{stateLabels[delivery.state] || delivery.state}
|
||
</span>
|
||
<small>
|
||
{delivery.attempts} {delivery.attempts === 1 ? "attempt" : "attempts"} ·{" "}
|
||
{delivery.detail || "Waiting for the next worker check."}
|
||
</small>
|
||
{delivery.state === "retry" && (
|
||
<small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>
|
||
)}
|
||
{delivery.state === "unknown" && (
|
||
<small>Automatic retries are stopped to avoid a duplicate email.</small>
|
||
)}
|
||
</td>
|
||
<td>{dateLabel(delivery.updated_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="recap-pagination">
|
||
<span>
|
||
{offset + 1}–{Math.min(offset + 50, data.total)} of {data.total}
|
||
</span>
|
||
<div className="recap-actions">
|
||
<button
|
||
className="ghost-button"
|
||
type="button"
|
||
disabled={!offset}
|
||
onClick={() => setOffset(Math.max(0, offset - 50))}
|
||
>
|
||
Previous
|
||
</button>
|
||
<button
|
||
className="ghost-button"
|
||
type="button"
|
||
disabled={offset + 50 >= data.total}
|
||
onClick={() => setOffset(offset + 50)}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="recap-empty">
|
||
<span aria-hidden="true">✉</span>
|
||
<h3>Your first recap starts here</h3>
|
||
<p>
|
||
Preview your email, send yourself a test, then start the monthly schedule. Delivery results will
|
||
appear here.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
</AdminShell>
|
||
);
|
||
}
|