feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"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 viewing reports</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 all personal viewing report emails. You can still explore all your reports in Magent."
|
||||
: state === "ready"
|
||||
? "Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile."
|
||||
: ""}
|
||||
</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,81 @@
|
||||
.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; }
|
||||
}
|
||||
|
||||
.recap-delivery-choice { display: grid; gap: 8px; margin-block: 16px; }
|
||||
.recap-delivery-choice select { width: 100%; min-width: 0; }
|
||||
Reference in New Issue
Block a user