158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { getApiBase } from "../lib/auth";
|
||
import BrandingLogo from "../ui/BrandingLogo";
|
||
import "../email-recaps/recaps.css";
|
||
|
||
type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
|
||
|
||
export default function NewsletterLinkPage() {
|
||
const [link, setLink] = useState<LinkAction | null>(null);
|
||
const [state, setState] = useState("loading");
|
||
const [error, setError] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const currentLink = useRef<LinkAction | null>(null);
|
||
|
||
useEffect(() => {
|
||
let controller: AbortController | null = null;
|
||
const checkLink = () => {
|
||
controller?.abort();
|
||
const abort = new AbortController();
|
||
controller = abort;
|
||
setError("");
|
||
setState("loading");
|
||
setLink(null);
|
||
setBusy(false);
|
||
currentLink.current = 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 newsletters.");
|
||
setState("error");
|
||
return;
|
||
}
|
||
const payload = { action, token } as LinkAction;
|
||
currentLink.current = payload;
|
||
setLink(payload);
|
||
void fetch(`${getApiBase()}/newsletter-subscription/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 () => {
|
||
currentLink.current = null;
|
||
controller?.abort();
|
||
window.removeEventListener("hashchange", checkLink);
|
||
};
|
||
}, []);
|
||
|
||
const apply = async () => {
|
||
if (!link || busy) return;
|
||
const payload = link;
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const response = await fetch(`${getApiBase()}/newsletter-subscription/confirm`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
credentials: "omit",
|
||
});
|
||
const result = await response.json().catch(() => ({}));
|
||
if (currentLink.current !== payload) return;
|
||
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, "", "/newsletter-subscription");
|
||
} catch (err) {
|
||
if (currentLink.current === payload)
|
||
setError(err instanceof Error ? err.message : "Could not update your preference.");
|
||
} finally {
|
||
if (currentLink.current === payload) 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">Magent newsletters</span>
|
||
<h1>
|
||
{state === "enabled"
|
||
? "You’re on the list."
|
||
: state === "off"
|
||
? "Newsletters are turned off."
|
||
: state === "loading"
|
||
? "Checking your email link"
|
||
: state === "error"
|
||
? "This link needs another look"
|
||
: link?.action === "unsubscribe"
|
||
? "Unsubscribe from newsletters?"
|
||
: "Your next watch starts here."}
|
||
</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 new-arrival newsletters. Your personal monthly recaps are managed separately."
|
||
: state === "ready"
|
||
? "Confirm to receive new movies, TV updates and featured picks, with posters and links to watch."
|
||
: ""}
|
||
</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 newsletters"
|
||
: "Confirm newsletter subscription"}
|
||
</button>
|
||
)}
|
||
{(done || state === "error") && (
|
||
<a className="recap-text-link" href="/profile#newsletters">
|
||
Manage email preferences ↗
|
||
</a>
|
||
)}
|
||
{state === "loading" && <p role="status">One moment…</p>}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|