chore: standardize security and quality foundations
This commit is contained in:
+506
-112
@@ -1,132 +1,526 @@
|
||||
'use client'
|
||||
"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'
|
||||
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' }
|
||||
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 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])
|
||||
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(() => {
|
||||
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])
|
||||
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(), [])
|
||||
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 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('')
|
||||
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 { 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)
|
||||
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 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('')
|
||||
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('') }
|
||||
}
|
||||
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>
|
||||
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