Files
gerbeur/src/pages/Unsubscribe.tsx
khannurien fd0d81fe66 v4: the emails' words live in the catalogs alone and their look is read from the site's stylesheets — every email message was written as i18n._({ id, message }), so its English sat in the template and again in en.po, and api/mail/styles.ts held a copy of each theme's palette, fonts and radii that a test chased. The templates now name each message by its id only (i18n._("email.reset.title", values)) and its English is written in locales/en.po, its French in fr.po; the four labels the settings tab and the unsubscribe page share with the emails read the same ids through @lingui/react's Trans instead of restating the English. i18n:extract runs without --overwrite, which would write the id where the English is, and i18n:compile runs --strict, so a language lacking a message fails the build; it caught email.digest.hl.mentionIn, never translated into French. The new api/mail/theme-tokens.ts reads src/tokens.css and src/themes/<style>.css and resolves a token for one scheme (var(), light-dark(), color-mix(), translucent colors laid over the card), and styles.ts names tokens for every color, GeoCities' rainbow, Melange's spice and shadow, Kanagawa's syntax hues, the font stack and the corner radii, keeping only what is the email's own (type scale, title, frame, divider, avatar rounding). The copies had drifted: GeoCities' stack had lost Marker Felt and its light emails drew the dark rainbow, which now also recolors its dividers on the "auto" scheme. The pills take the site's badge color, now the --color-badge-bg token that the private badge reads too. The Dockerfile copies src/tokens.css and src/themes/ into the runtime image, which reads them at load. The catalogs were re-extracted, having been stale against the code. 240 backend tests (new: the token reader, the dark rainbow; the palette-copy test is gone) and 661 frontend tests pass, and tsc, deno check and doc:lint are clean; sendDueDigests' empty-digest test fails now and then, as it did before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:21:45 +00:00

127 lines
3.9 KiB
TypeScript

/**
* Renders the page a digest's unsubscribe link opens, where the reader turns
* that digest off with one click, signed in or not.
*
* @module
*/
import { useState } from "react";
import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
// A label shared with the emails is named by its id, its words being in
// locales/ only; the macro's Trans takes the English in place.
import { Trans as Message } from "@lingui/react";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../config/api.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { FormError } from "../components/form/FormError.tsx";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { expectOk } from "../utils/shared/api.ts";
import { readUnsubscribeCadence } from "../utils/auth/unsubscribe.ts";
/**
* Renders `/unsubscribe?token=`, for anyone holding a digest's link. It asks
* before turning the digest off, since mail scanners open every link in an
* email, then posts the token to `POST /api/users/unsubscribe`.
*/
export function Unsubscribe(): React.JSX.Element {
useDocumentTitle(t`Unsubscribe`);
const [params] = useSearchParams();
const { user } = useAuth();
const token = params.get("token") ?? "";
const cadence = readUnsubscribeCadence(token);
const [done, setDone] = useState(false);
const form = useApiForm<Record<string, never>>({ defaultValues: {} });
const onSubmit = form.submit(async () => {
await expectOk(
await fetch(
`${API_URL}/api/users/unsubscribe?token=${encodeURIComponent(token)}`,
{ method: "POST" },
),
);
setDone(true);
});
const settingsLink = user && (
<p className="auth-card-footer">
<Link to={`/users/${user.username}/~/settings`}>
<Trans>Your settings</Trans>
</Link>
</p>
);
if (!cadence) {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Invalid link</Trans>
</h1>
<p>
<Trans>This unsubscribe link is missing or malformed.</Trans>
</p>
{settingsLink}
</div>
</PageShell>
);
}
const weekly = cadence === "weekly";
if (done) {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Unsubscribed</Trans>
</h1>
<p>
{weekly
? (
<Trans>
You won't get the weekly digest any more. You can turn it back
on in your settings.
</Trans>
)
: (
<Trans>
You won't get the monthly recap any more. You can turn it back
on in your settings.
</Trans>
)}
</p>
{settingsLink}
</div>
</PageShell>
);
}
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
{weekly
? <Trans>Turn off the weekly digest?</Trans>
: <Trans>Turn off the monthly recap?</Trans>}
</h1>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Couldn't unsubscribe`} />
<SubmitButton pendingLabel={<Trans>Turning off…</Trans>}>
{weekly
? <Message id="digest.weekly.off" />
: <Message id="digest.monthly.off" />}
</SubmitButton>
</form>
</FormProvider>
{settingsLink}
</div>
</PageShell>
);
}