diff --git a/apps/site/src/forms.ts b/apps/site/src/forms.ts index daeaf10..4792de2 100644 --- a/apps/site/src/forms.ts +++ b/apps/site/src/forms.ts @@ -5,7 +5,7 @@ * confirmed sends the mail again; a confirmed address answers the same as a new one, so the form * never tells whether an address is on the list. */ -import { parseNotify, parseRequest, parseWaitlist, SubmissionError } from "@openclapper/home-vocab"; +import { parseRequest, parseWaitlist, SubmissionError } from "@openclapper/home-vocab"; import { HttpError, json, readJson, type Router } from "./http.ts"; import { sendConfirmation } from "./mail.ts"; import { verifyTurnstile, TurnstileError } from "./turnstile.ts"; @@ -27,7 +27,7 @@ const fieldError = (err: unknown) => { throw err; }; -async function guard(ctx: Ctx, body: Record, action: "waitlist" | "request" | "notify") { +async function guard(ctx: Ctx, body: Record, action: "waitlist" | "request") { try { await verifyTurnstile(body.turnstile, action, ctx.env, ctx.request.headers.get("cf-connecting-ip"), ctx.local); } catch (err) { @@ -71,35 +71,6 @@ export function registerForms(router: Router) { return json({ ok: true }); }); - /** - * The short page's form: an email to be told when the contribution phase starts. The same double opt-in - * as the sign-up, on the same table, as an updates-only row with an empty about-you block. - */ - router.on("POST", "/api/notify", async (ctx) => { - const { env, request } = ctx; - const body = await readJson>(request); - let n; - try { n = parseNotify(body); } catch (err) { return fieldError(err); } - await guard(ctx, body, "notify"); - const now = new Date(); - const existing = await env.DB.prepare("SELECT id, confirmed_at, token_sent_at FROM waitlist WHERE email = ?1").bind(n.email).first<{ id: string; confirmed_at: string | null; token_sent_at: string | null }>(); - if (existing?.confirmed_at) return json({ ok: true }); - if (existing && existing.token_sent_at && now.getTime() - Date.parse(existing.token_sent_at) < RESEND_AFTER_MS) return json({ ok: true }); - const t = token(); - if (existing) { - await env.DB.prepare("UPDATE waitlist SET updates = 1, token = ?2, token_sent_at = ?3 WHERE id = ?1").bind(existing.id, t, now.toISOString()).run(); - } else { - await env.DB.prepare("INSERT INTO waitlist (id, email, tester, updates, created_at, token, token_sent_at) VALUES (?1, ?2, 0, 1, ?3, ?4, ?3)").bind(crypto.randomUUID(), n.email, now.toISOString(), t).run(); - } - try { - await sendConfirmation(env.EMAIL, env, n.email, t, { tester: false, updates: true }); - } catch (err) { - console.error("confirmation mail failed", err); - throw new HttpError(502, "the confirmation mail could not be sent; try again in a few minutes"); - } - return json({ ok: true }); - }); - /** The link in the mail. Always ends on the page: confirmed, or expired/unknown (the page says to sign up again). */ router.on("GET", "/api/waitlist/confirm/:token", async ({ env }, { token: t }) => { const back = (state: "yes" | "no") => Response.redirect(`${env.SITE_URL.replace(/\/$/, "")}/?confirmed=${state}#waitlist`, 303); diff --git a/apps/site/test/site.test.ts b/apps/site/test/site.test.ts index 05273fa..8563243 100644 --- a/apps/site/test/site.test.ts +++ b/apps/site/test/site.test.ts @@ -159,24 +159,6 @@ describe("waiting list", () => { }); }); -describe("notify (the short page)", () => { - beforeEach(() => { siteverify = passFor("notify"); }); - test("an email alone: an updates-only row with an empty about-you block, the same double opt-in", async () => { - await json(call(base, "POST", "/api/notify", { body: { email: "soon@example.test", turnstile: "tok" } }), 200); - const row = await env.DB.prepare("SELECT tester, updates, years, systems, confirmed_at FROM waitlist WHERE email = 'soon@example.test'").first<{ tester: number; updates: number; years: string | null; systems: string; confirmed_at: string | null }>(); - expect([row!.tester, row!.updates, row!.years, row!.systems, row!.confirmed_at]).toEqual([0, 1, null, "[]", null]); - expect(sent).toHaveLength(1); - expect(sent[0].subject).toContain("OpenClapper updates"); - const link = sent[0].text.match(/https:\S+/)![0]; - expect((await call(base, "GET", new URL(link).pathname)).headers.get("location")).toBe(`${SITE}/?confirmed=yes#waitlist`); - await json(call(base, "POST", "/api/notify", { body: { email: "soon@example.test", turnstile: "tok" } }), 200); - expect(sent).toHaveLength(1); - expect((await json(call(base, "POST", "/api/notify", { body: { email: "nope", turnstile: "tok" } }), 400)).field).toBe("email"); - siteverify = passFor("waitlist"); - await json(call(base, "POST", "/api/notify", { body: { email: "x@example.test", turnstile: "tok" } }), 403); - }); -}); - describe("feature requests", () => { beforeEach(() => { siteverify = passFor("request"); }); test("a submission stores the person once and every entry in order", async () => { @@ -259,7 +241,7 @@ describe("admin", () => { describe("routes", () => { test("every registered route is exercised above (add a test when you add a route)", () => { const tested = new Set([ - "POST /api/waitlist", "GET /api/waitlist/confirm/:token", "POST /api/notify", "POST /api/requests", + "POST /api/waitlist", "GET /api/waitlist/confirm/:token", "POST /api/requests", "GET /admin/api/whoami", "GET /admin/api/waitlist", "GET /admin/api/waitlist.csv", "PATCH /admin/api/waitlist/:id", "DELETE /admin/api/waitlist/:id", "GET /admin/api/requests", "PATCH /admin/api/requests/:id", "DELETE /admin/api/requests/:id", ]); diff --git a/apps/site/ui/index.html b/apps/site/ui/index.html index 440f550..40e01a7 100644 --- a/apps/site/ui/index.html +++ b/apps/site/ui/index.html @@ -6,7 +6,7 @@ OpenClapper · an open-source tool for organising film productions - + @@ -25,8 +25,6 @@ document.documentElement.setAttribute("data-theme", o === "sun" || o === "set" ? o : mq && mq.matches ? "set" : "sun"); })(); - -
diff --git a/apps/site/ui/src/Teaser.tsx b/apps/site/ui/src/Teaser.tsx index 6ae7007..d079608 100644 --- a/apps/site/ui/src/Teaser.tsx +++ b/apps/site/ui/src/Teaser.tsx @@ -1,27 +1,13 @@ /** - * The short page until the alpha (issue #8): one centred card with the logo, the goal, where the - * project stands (with the link to the code), and one email field for a single mail when the alpha - * starts. Strings from teaser.ts, English and German. + * The short page until the alpha (issue #8): one centred card with the logo, the goal and where the + * project stands, with the link to the code. No form for now (issue #12): the confirmation mail of the + * double opt-in needs a sending provider first. Strings from teaser.ts, English and German. */ -import { LIMITS } from "@openclapper/home-vocab"; -import { Banner, Button, Field, IconButton, Input, THEME_CYCLE, THEME_ICON, THEME_LABEL, useTheme } from "@openclapper/home-ui"; -import { useEffect, useState } from "preact/hooks"; -import { ApiError, post } from "./api.ts"; +import { Button, IconButton, THEME_CYCLE, THEME_ICON, THEME_LABEL, useTheme } from "@openclapper/home-ui"; import { useT } from "./teaser.ts"; -import { useTurnstile } from "./turnstile.ts"; export const CORE = "https://github.com/OpenClapper-dev/core"; -/** The confirmation link lands on /?confirmed=yes|no#waitlist; the state is read once and the URL cleaned. */ -function useConfirmed(): "yes" | "no" | null { - const [state] = useState<"yes" | "no" | null>(() => { - const v = new URLSearchParams(location.search).get("confirmed"); - return v === "yes" || v === "no" ? v : null; - }); - useEffect(() => { if (state) history.replaceState(null, "", `${location.pathname}${location.hash}`); }, []); - return state; -} - /** Language and theme, the two buttons on both pages. */ export function Controls() { const [theme, setTheme] = useTheme(); @@ -34,52 +20,8 @@ export function Controls() { ); } -/** The one form: an email, the bot check, the double opt-in. */ -function Notify({ confirmed }: { confirmed: "yes" | "no" | null }) { - const [t] = useT(); - const [email, setEmail] = useState(""); - const [errors, setErrors] = useState>({}); - const [busy, setBusy] = useState(false); - const [done, setDone] = useState(null); - const ts = useTurnstile("notify"); - const submit = async (e: Event) => { - e.preventDefault(); - if (!ts.token) { setErrors({ turnstile: t.botWait }); return; } - setBusy(true); - setErrors({}); - try { - await post("/api/notify", { email, turnstile: ts.token }); - setDone(email); - setEmail(""); - } catch (err) { - const a = err as ApiError; - setErrors({ [a.field ?? "form"]: a.message }); - } finally { - setBusy(false); - ts.reset(); - } - }; - if (done) return {t.done(done)}; - return ( -
- {confirmed === "yes" && {t.confirmed}} - {confirmed === "no" && {t.expired}} -
- - {(id, invalid) => setEmail(e.currentTarget.value)} />} - - -
-
- {(errors.turnstile || errors.form || ts.failed) && {ts.failed && !errors.form ? t.reload : undefined}} -

{t.fine} {t.privacy}

- - ); -} - export function Teaser() { const [t] = useT(); - const confirmed = useConfirmed(); return (
@@ -97,11 +39,6 @@ export function Teaser() {

{t.stateH}

{t.state1} GitHub{t.state2}

-
-

{t.notifyH}

-

{t.notifyP}

- -