Add opt-in monthly email recaps with scheduling and delivery history
This commit is contained in:
@@ -15,6 +15,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
||||
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options' },
|
||||
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' },
|
||||
{ href: '/admin/recaps', label: 'Monthly email recaps', description: 'Personal viewing emails, schedule and delivery history' },
|
||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
|
||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
|
||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions' },
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
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(() => {
|
||||
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, public_url } = settings
|
||||
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour, public_url }) })) 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" placeholder="https://magent.example.com" maxLength={500} value={settings.public_url} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} disabled={!!busy} required={settings.enabled} /><small>The address users open from this environment’s emails.</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' : '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>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getApiBase } from '../lib/auth'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
import './recaps.css'
|
||||
|
||||
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string }
|
||||
|
||||
export default function EmailRecapLinkPage() {
|
||||
const [link, setLink] = useState<LinkAction | null>(null)
|
||||
const [state, setState] = useState('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let controller: AbortController | null = null
|
||||
const checkLink = () => {
|
||||
controller?.abort()
|
||||
const abort = new AbortController()
|
||||
controller = abort
|
||||
setError(''); setState('loading'); setLink(null)
|
||||
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
const action = params.get('action')
|
||||
const token = params.get('token') || ''
|
||||
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
||||
setError('This email link is incomplete. Open Profile to manage your monthly recaps.'); setState('error'); return
|
||||
}
|
||||
const payload = { action, token } as LinkAction
|
||||
setLink(payload)
|
||||
void fetch(`${getApiBase()}/email-recaps/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => {
|
||||
const result = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.')
|
||||
if (!abort.signal.aborted) setState(result.state)
|
||||
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } })
|
||||
}
|
||||
checkLink()
|
||||
window.addEventListener('hashchange', checkLink)
|
||||
return () => { controller?.abort(); window.removeEventListener('hashchange', checkLink) }
|
||||
}, [])
|
||||
|
||||
const apply = async () => {
|
||||
if (!link || busy) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(link), credentials: 'omit' })
|
||||
const result = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.')
|
||||
setState(result.state)
|
||||
window.history.replaceState(null, '', '/email-recaps')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Could not update your preference.') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const done = state === 'enabled' || state === 'off'
|
||||
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel">
|
||||
<span className="recap-eyebrow">Personal monthly recaps</span>
|
||||
<h1>{state === 'enabled' ? 'You’re on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}</h1>
|
||||
<p>{state === 'enabled' ? 'Your email is confirmed. You’ll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You won’t receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off your monthly viewing emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to receive your minutes, movies, episodes, longest run and requests each month.' : ''}</p>
|
||||
{error && <p className="account-notice is-error" role="alert">{error}</p>}
|
||||
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>}
|
||||
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences ↗</a>}
|
||||
{state === 'loading' && <p role="status">One moment…</p>}
|
||||
</section></main>
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.recap-admin { display: grid; gap: 24px; }
|
||||
.recap-admin-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 24px; }
|
||||
.recap-panel.admin-panel { margin: 0; padding: 28px; min-width: 0; border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); }
|
||||
.recap-panel h2, .recap-preference h2 { margin: 8px 0 12px; font-size: 22px; }
|
||||
.recap-panel h3 { font-size: 16px; }
|
||||
.recap-panel p, .recap-preference p { color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
|
||||
.recap-panel a, .recap-preference a, .recap-text-link { color: #c7bdff; text-decoration: none; }
|
||||
.recap-panel a:hover, .recap-preference a:hover, .recap-text-link:hover { text-decoration: underline; }
|
||||
.recap-eyebrow { display: block; color: #bcb3eb; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.recap-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.recap-section-heading > div { min-width: 0; }
|
||||
.recap-pill { display: inline-flex; align-items: center; padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 99px; font-size: 11px; line-height: 1.4; color: var(--ops-muted); white-space: nowrap; }
|
||||
.recap-pill.is-enabled { color: #cfc7fc; background: #c7bdff12; border-color: #c7bdff40; }
|
||||
.recap-pill.is-attention { color: #eab9a6; border-color: #eab9a650; }
|
||||
.recap-overview-strip { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 28px; border: 1px solid var(--ops-line); border-radius: 14px; background: linear-gradient(110deg, #c7bdff0c, transparent 65%), var(--ops-panel); }
|
||||
.recap-overview-strip p { color: var(--ops-muted); font-size: 13px; margin: 12px 0 0; line-height: 1.6; }
|
||||
.recap-subscriber-count { display: flex; align-items: center; gap: 14px; }
|
||||
.recap-subscriber-count strong { color: #d8d0ff; font-size: 36px; font-weight: 500; }
|
||||
.recap-subscriber-count span { max-width: 100px; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
|
||||
.recap-schedule-form { display: grid; gap: 18px; margin-top: 24px; }
|
||||
.recap-schedule-form label, .recap-month-label { display: grid; gap: 9px; padding: 0; margin: 0; color: var(--ops-text); font-size: 13px; text-transform: none; border: 0; background: none; }
|
||||
.recap-schedule-form input:not([type=checkbox]), .recap-schedule-form select, .recap-month-label select { min-width: 0; width: 100%; min-height: 44px; border: 1px solid var(--ops-line); border-radius: 8px; padding: 10px 12px; font: 13px Inter, sans-serif; }
|
||||
.recap-schedule-form small { color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
|
||||
.recap-schedule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.recap-schedule-form .recap-checkbox { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
|
||||
.recap-checkbox input { width: 18px; height: 18px; accent-color: #c7bdff; }
|
||||
.recap-schedule-form button { justify-self: start; }
|
||||
.recap-schedule-form .recap-muted { margin: -6px 0 0; }
|
||||
.recap-panel .recap-muted, .recap-preference .recap-muted { font-size: 12px; color: var(--ops-faint); }
|
||||
.recap-setup-note { padding: 14px 16px; border: 1px solid var(--ops-line); border-radius: 8px; margin: 20px 0 0; }
|
||||
.recap-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
|
||||
.recap-month-label { margin-top: 24px; }
|
||||
.recap-preview-guidance { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 16px; }
|
||||
.recap-preview-guidance ul { padding-left: 18px; margin: 18px 0; color: var(--ops-muted); font-size: 13px; line-height: 2; }
|
||||
.recap-preview-guidance a { font-size: 13px; }
|
||||
.recap-mode-buttons { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
.recap-mode-buttons button { background: transparent !important; color: var(--ops-muted); border: 1px solid var(--ops-line); padding: 10px 12px; font-size: 12px; text-transform: none; }
|
||||
.recap-mode-buttons button[aria-pressed=true] { border-color: #c7bdff70; color: #d5cdff; background: #c7bdff10 !important; }
|
||||
.recap-preview iframe { display: block; width: 100%; height: 1050px; margin-top: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: #131315; }
|
||||
.recap-plain-preview { white-space: pre-wrap; overflow-wrap: anywhere; padding: 24px; background: #131315; border: 1px solid var(--ops-line); border-radius: 10px; color: var(--ops-muted); font-size: 13px; line-height: 1.8; }
|
||||
.recap-history-scroll { overflow-x: auto; margin-top: 18px; }
|
||||
.recap-history { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.recap-history th { color: var(--ops-faint); font-size: 11px; font-weight: 500; text-align: left; }
|
||||
.recap-history th, .recap-history td { padding: 16px 12px; border-bottom: 1px solid var(--ops-line-soft); vertical-align: top; }
|
||||
.recap-history td { min-width: 135px; line-height: 1.7; }
|
||||
.recap-history td:first-child { min-width: 170px; }
|
||||
.recap-history td:nth-child(3) { min-width: 245px; max-width: 400px; }
|
||||
.recap-history strong { display: block; font-weight: 500; }
|
||||
.recap-history small { display: block; margin-top: 6px; font-size: 11px; color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||
.recap-pagination { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--ops-muted); font-size: 12px; margin-top: 16px; }
|
||||
.recap-pagination .recap-actions { margin: 0; }
|
||||
.recap-empty { text-align: center; padding: 36px 20px 24px; }
|
||||
.recap-empty > span { color: #bcb3eb; font-size: 28px; }
|
||||
.recap-empty p { max-width: 430px; margin: 12px auto; }
|
||||
.recap-preference { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 28px; scroll-margin-top: 24px; }
|
||||
.recap-preference p { max-width: 620px; }
|
||||
.recap-delivery-address { overflow-wrap: anywhere; }
|
||||
.page > main.recap-link-page { max-width: 600px; margin: 70px auto; }
|
||||
.recap-brand { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 32px; text-decoration: none; color: var(--ops-text); font: 500 25px "DM Sans", sans-serif; }
|
||||
.recap-brand img { width: 42px; height: 42px; object-fit: contain; }
|
||||
.recap-brand .brand-logo { width: 42px; height: 42px; flex: 0 0 42px; }
|
||||
.recap-link-page h1 { font-size: clamp(25px, 5vw, 36px); margin: 16px 0; }
|
||||
.recap-link-page p { font-size: 14px; line-height: 1.8; color: var(--ops-muted); }
|
||||
.recap-link-page button, .recap-link-page .recap-text-link { margin-top: 16px; }
|
||||
.recap-link-page .recap-text-link { display: inline-block; font-size: 14px; }
|
||||
@media (max-width: 1000px) { .recap-admin-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) {
|
||||
.recap-panel.admin-panel { padding: 20px 16px; }
|
||||
.recap-overview-strip, .recap-section-heading { flex-direction: column; gap: 16px; }
|
||||
.recap-overview-strip { padding: 20px; }
|
||||
.recap-subscriber-count span { max-width: none; }
|
||||
.recap-schedule-fields { gap: 12px; }
|
||||
.recap-panel h2, .recap-preference h2 { font-size: 20px; }
|
||||
.recap-preview iframe { height: 1200px; }
|
||||
.page > main.recap-link-page { margin: 36px auto; }
|
||||
.recap-link-page .account-panel { padding: 26px 22px; }
|
||||
.recap-pagination { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -30,6 +30,7 @@ function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) {
|
||||
export default function MonthlyReportsPage() {
|
||||
const router = useRouter()
|
||||
const [month, setMonth] = useState('')
|
||||
const [monthReady, setMonthReady] = useState(false)
|
||||
const [months, setMonths] = useState<string[]>([])
|
||||
const [data, setData] = useState<MonthlyReport | null>(null)
|
||||
const [busy, setBusy] = useState(true)
|
||||
@@ -39,6 +40,13 @@ export default function MonthlyReportsPage() {
|
||||
const [downloadError, setDownloadError] = useState('')
|
||||
const downloadController = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMonth(new URLSearchParams(window.location.search).get('month') || '')
|
||||
setMonthReady(true)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (monthReady) window.history.replaceState(null, '', `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ''}`)
|
||||
}, [month, monthReady])
|
||||
useEffect(() => () => downloadController.current?.abort(), [])
|
||||
const load = useCallback(async (signal: AbortSignal) => {
|
||||
setBusy(true)
|
||||
@@ -48,7 +56,7 @@ export default function MonthlyReportsPage() {
|
||||
try {
|
||||
const query = month ? `?month=${encodeURIComponent(month)}` : ''
|
||||
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal })
|
||||
if (response.status === 401) { router.replace('/login?next=%2Finsights%2Freports'); return }
|
||||
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`); return }
|
||||
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.')
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}))
|
||||
@@ -63,10 +71,11 @@ export default function MonthlyReportsPage() {
|
||||
}
|
||||
}, [month, router])
|
||||
useEffect(() => {
|
||||
if (!monthReady) return
|
||||
const controller = new AbortController()
|
||||
void load(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [load, revision])
|
||||
}, [load, revision, monthReady])
|
||||
|
||||
const download = async () => {
|
||||
if (data?.state !== 'ready' || downloading) return
|
||||
@@ -77,7 +86,7 @@ export default function MonthlyReportsPage() {
|
||||
setDownloadError('')
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal })
|
||||
if (response.status === 401) { router.replace('/login?next=%2Finsights%2Freports'); return }
|
||||
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`); return }
|
||||
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.')
|
||||
const blob = await response.blob()
|
||||
if (controller.signal.aborted) return
|
||||
@@ -116,7 +125,7 @@ export default function MonthlyReportsPage() {
|
||||
</div>
|
||||
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
|
||||
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true">◷</span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous month’s comparison.</p></div>}
|
||||
{error && <div className="stats-state" role="alert"><h2>Report couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
|
||||
{error && <div className="stats-state" role="alert"><h2>Report couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>}
|
||||
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
|
||||
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>}
|
||||
{data && summary && changes && <>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../lib/auth'
|
||||
import '../email-recaps/recaps.css'
|
||||
|
||||
type Preference = { state: 'off' | 'pending' | 'expired' | 'enabled'; email: string | null; can_subscribe: boolean; detail: string; schedule_enabled: boolean; next_send_at: number | null; day: number; hour: number; resend_after: number | null }
|
||||
const scheduled = (value: number) => `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' })} UTC`
|
||||
|
||||
export default function MonthlyRecapPreference() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<Preference | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [now, setNow] = useState(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
setError('')
|
||||
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => {
|
||||
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||
if (!response.ok) throw new Error('Could not load your email preference. Please try again.')
|
||||
const result = await response.json() as Preference
|
||||
if (!abort.signal.aborted) setData(result)
|
||||
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
||||
return () => abort.abort()
|
||||
}, [revision, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.resend_after || data.state === 'enabled' || data.resend_after * 1000 <= Date.now()) return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [data?.resend_after, data?.state])
|
||||
|
||||
const save = async (enabled: boolean) => {
|
||||
if (busy) return
|
||||
setBusy(true); setError(''); setNotice('')
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),
|
||||
})
|
||||
if (response.status === 401) { router.replace('/login?next=%2Fprofile'); return }
|
||||
const result = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your email preference.')
|
||||
setData(result); setNow(Date.now())
|
||||
setNotice(result.message || (enabled ? 'Monthly recaps are on.' : 'Monthly recaps are off.'))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not update your email preference.')
|
||||
// A confirmation may be pending even if SMTP could not confirm delivery.
|
||||
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null)
|
||||
if (response?.ok) { setData(await response.json()); setNow(Date.now()) }
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0
|
||||
return <section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
|
||||
<div className="recap-section-heading"><div><span className="recap-eyebrow">A little look back</span><h2 id="recap-preference-title">Your month, delivered.</h2></div>{data && <span className={`recap-pill ${data.state === 'enabled' ? 'is-enabled' : ''}`}>{({ off: 'Off', pending: 'Check your inbox', expired: 'Confirmation expired', enabled: 'Subscribed' })[data.state]}</span>}</div>
|
||||
<p>Your minutes, movies, episodes, longest run and requests, in one personal monthly email. <a href="/insights/reports">Explore your latest report ↗</a></p>
|
||||
{!data && !error && <p role="status">Loading your email preference…</p>}
|
||||
{data && <>
|
||||
{data.state === 'enabled' ? <p className="recap-delivery-address">Recaps will go to <strong>{data.email}</strong>. {data.schedule_enabled && data.next_send_at ? `Next scheduled send: ${scheduled(data.next_send_at)}.` : 'The administrator has paused scheduled delivery.'}</p> : <p>{data.state === 'pending' ? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.` : data.state === 'expired' ? 'Request a new confirmation link to turn on your recaps.' : 'Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile.'}</p>}
|
||||
{!data.can_subscribe && data.state !== 'enabled' && <p className="recap-muted">{data.detail}</p>}
|
||||
{data.state !== 'enabled' && data.can_subscribe && !data.schedule_enabled && <p className="recap-muted">You can subscribe now. Monthly sends will begin when your administrator starts the schedule.</p>}
|
||||
<div className="recap-actions">
|
||||
{data.state !== 'enabled' && <button type="button" className="account-primary" disabled={busy || !data.can_subscribe || cooldown > 0} onClick={() => void save(true)}>{busy ? 'Sending confirmation…' : data.state === 'off' ? 'Email me my monthly recap' : 'Send a new confirmation'}</button>}
|
||||
{data.state !== 'off' && <button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>{busy ? 'Updating…' : data.state === 'enabled' ? 'Turn off recaps' : 'Cancel subscription'}</button>}
|
||||
<button type="button" className="account-secondary" disabled={busy} onClick={() => { setNotice(''); setRevision((value) => value + 1) }}>Refresh preference</button>
|
||||
</div>
|
||||
{cooldown > 0 && data.state !== 'enabled' && <p className="recap-muted">Another confirmation can be requested in {Math.ceil(cooldown / 60)} {Math.ceil(cooldown / 60) === 1 ? 'minute' : 'minutes'}.</p>}
|
||||
</>}
|
||||
{error && <p className="account-notice is-error" role="alert">{error}{!data && <button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>Try again</button>}</p>}
|
||||
{notice && <p className="account-notice is-status" role="status">{notice}</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import MonthlyRecapPreference from './MonthlyRecapPreference'
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -197,6 +198,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</form>
|
||||
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
|
||||
<MonthlyRecapPreference key={user.email || 'no-email'} />
|
||||
</section>
|
||||
|
||||
<section className="account-panel" id="profile-panel-security" role="tabpanel" aria-labelledby="profile-tab-security" hidden={activeTab !== 'security'}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import WorkspaceNavigation from './WorkspaceNavigation'
|
||||
|
||||
export default function ApplicationChrome() {
|
||||
const pathname = usePathname()
|
||||
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup'].includes(pathname)) return null
|
||||
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup', '/email-recaps'].includes(pathname)) return null
|
||||
return <>
|
||||
<header className="header">
|
||||
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
|
||||
|
||||
Reference in New Issue
Block a user