feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user