Files
Magent/frontend/app/profile/MonthlyRecapPreference.tsx
T
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

224 lines
8.6 KiB
TypeScript

"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 = {
automatic_monthly: boolean;
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 [automatic, setAutomatic] = useState(false);
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(() => {
void revision;
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);
setAutomatic(result.automatic_monthly);
}
})
.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, monthly = automatic) => {
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, automatic_monthly: monthly }),
});
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);
setAutomatic(result.automatic_monthly);
setNow(Date.now());
setNotice(
result.message || (enabled ? "Personal report emails are enabled." : "Personal report emails 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) {
const fresh = await response.json();
setData(fresh);
setAutomatic(fresh.automatic_monthly);
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 reports, your choice.</h2>
</div>
{data && (
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
{
{ off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Email confirmed" }[
data.state
]
}
</span>
)}
</div>
<p>
Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and
decide whether you also want automatic monthly emails.{" "}
<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.automatic_monthly
? "On demand only: choose a month in Reports and email it whenever you want."
: 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 && automatic && !data.schedule_enabled && (
<p className="recap-muted">
You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
</p>
)}
<label className="recap-delivery-choice">
Delivery preference
<select
value={automatic ? "monthly" : "manual"}
disabled={busy || data.state === "pending"}
onChange={(event) => {
const monthly = event.target.value === "monthly";
setAutomatic(monthly);
if (data.state === "enabled") void save(true, monthly);
}}
>
<option value="manual">On demand only</option>
<option value="monthly">On demand + automatic monthly emails</option>
</select>
</label>
<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"
? "Confirm my email for reports"
: "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 report emails" : "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>
);
}