diff --git a/DESIGN.md b/DESIGN.md index 7b2f3078..85788062 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -241,6 +241,12 @@ The signature structure. A page advances as a sequence of bands rather than one One per page, at the foot. Nightfall surface, `rounded-md` (16px), generous padding, white bold copy, one inverse pill button. This is where the ask lands, and it is the only dark thing on the page. +Implemented as `src/components/TerminalCta.astro` (heading prop, body slot, `actions` and optional `footnote` slots). Use it rather than hand-building the block. + +### Implementation + +The type scale, bands, buttons, links and focus rings above live as Tailwind class strings in `src/utils/designClasses.ts`. Import them on public pages instead of redefining them per page. + ### Navigation `Navigation.astro`, shared by every live page through `Layout.astro`. Ink text at rest, Grove Green on hover and active, Outfit label weight throughout, one primary action at the right. Never serif. diff --git a/src/components/MailtoFallback.astro b/src/components/MailtoFallback.astro new file mode 100644 index 00000000..1ed292be --- /dev/null +++ b/src/components/MailtoFallback.astro @@ -0,0 +1,118 @@ +--- +// Fallback for mailto links that open nothing (no mail app configured, +// webmail users, locked-down work machines). Add `data-mailto-fallback` to an +// (use `data-mailto-fallback="dark"` on the Nightfall +// CTA) and render this component once on the page. +// +// On click the address is copied to the clipboard (it must happen inside the +// click gesture or browsers refuse) and the mailto proceeds normally. A page +// cannot see whether a mail app opened, so we use the standard signal: if the +// window has not blurred or been hidden shortly afterwards, nothing took over, +// and a small "Email address copied" tooltip appears above the link. If the +// clipboard is unavailable, the address is selected instead with a +// Ctrl+C / Cmd+C hint. Links read their href at click time, so addresses +// assembled by script (the obfuscated one on /contact) work too. +--- + + diff --git a/src/components/TerminalCta.astro b/src/components/TerminalCta.astro new file mode 100644 index 00000000..bae2f04e --- /dev/null +++ b/src/components/TerminalCta.astro @@ -0,0 +1,49 @@ +--- +// The terminal call to action (DESIGN.md §5): the single Nightfall block per +// page, placed last before the footer. Pass the heading as a prop; the default +// slot is the supporting copy, the "actions" slot holds the buttons (use +// inverseButton / ghostOnDarkButton from utils/designClasses), and the +// optional "footnote" slot sits beneath them. +import { container } from "../utils/designClasses"; + +interface Props { + heading: string; + /** id for the heading, referenced by the section's aria-labelledby. */ + headingId?: string; + /** + * Set false when the section above already ends with bottom padding on a + * Paper surface, so the gap is not doubled. Keep true after a band. + */ + padTop?: boolean; +} + +const { heading, headingId = "terminal-cta-heading", padTop = true } = Astro.props; +const spacing = padTop ? "py-16 min-[810px]:py-20" : "pb-16 min-[810px]:pb-20"; +const hasFootnote = Astro.slots.has("footnote"); +--- + +
+
+
+

+ {heading} +

+
+ +
+
+ +
+ { + hasFootnote && ( +

+ +

+ ) + } +
+
+
diff --git a/src/components/e4p/EndorsementForm.tsx b/src/components/e4p/EndorsementForm.tsx index 1771bdd1..3b8a428c 100644 --- a/src/components/e4p/EndorsementForm.tsx +++ b/src/components/e4p/EndorsementForm.tsx @@ -1,51 +1,132 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import TextField from "@mui/material/TextField"; -import Checkbox from "@mui/material/Checkbox"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Button from "@mui/material/Button"; -import Alert from "@mui/material/Alert"; -import CircularProgress from "@mui/material/CircularProgress"; -import Box from "@mui/material/Box"; - -type FormData = { - contactName: string; - contactEmail: string; - organizationName: string; - organizationWebsite: string; - campaignName: string; - request: string; - campaignPurpose: string; - campaignLink: string; - notableSupporters: string; - isT4PProject: boolean; -}; - -const inputSx = { - backgroundColor: "white", - borderRadius: "8px", - "& .MuiInputBase-input": { - backgroundColor: "white", - }, -}; +import FormField, { inputClass } from "../endorsements/FormField"; +import { + MAX_TEXT_LENGTH, + requiredText, + submitErrorMessage, + validEmail, + validLink, + withinCap, + type EndorsementRequest, +} from "../endorsements/endorsementValidation"; +import { focusRing, ghostButton, inlineLink, primaryButton } from "../../utils/designClasses"; + +// Posts to /api/endorsement-request. The payload is the EndorsementRequest +// object exactly as react-hook-form collects it: the same ten keys the API +// reads, unchanged. Validation mirrors the server (see endorsementValidation). + +type Status = "idle" | "submitting" | "sent"; + +const legendClass = "text-xs font-bold uppercase tracking-[0.14em] text-grove-deep"; +const fieldsetClass = "grid gap-6 border-t border-ink-divider pt-6 first:border-t-0 first:pt-0"; +const NETWORK_ERROR = + "Your request didn't send. Check your connection and try again; your answers are still here."; + +function readServerMessage(body: unknown): string | undefined { + if (body && typeof body === "object" && "error" in body) { + const { error } = body as { error: unknown }; + return typeof error === "string" ? error : undefined; + } + return undefined; +} + +function Confirmation({ email, onReset }: { email: string; onReset: () => void }) { + const headingRef = useRef(null); + + useEffect(() => { + headingRef.current?.focus(); + }, []); + + return ( +
+ +

+ Request received +

+

+ Thank you. We aim to review every request within 5 to 7 days, and we'll write to{" "} + {email} if we need more + information or once we've decided. +

+ +
+ ); +} + +function SubmitError({ message }: { message: string }) { + return ( +
+

{message}

+

+ Try again in a moment. If it keeps failing,{" "} + + contact us + {" "} + and we'll take your request directly. +

+
+ ); +} + +function Spinner() { + return ( + + ); +} export default function EndorsementForm() { - const [submitted, setSubmitted] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [status, setStatus] = useState("idle"); + const [submitError, setSubmitError] = useState(null); + const [sentTo, setSentTo] = useState(""); const { register, handleSubmit, reset, + watch, formState: { errors }, - } = useForm({ + } = useForm({ defaultValues: { isT4PProject: false }, }); - const onSubmit = async (data: FormData) => { - setSubmitting(true); - setError(null); + const purposeLength = watch("campaignPurpose")?.length ?? 0; + const submitting = status === "submitting"; + + const onSubmit = async (data: EndorsementRequest) => { + setStatus("submitting"); + setSubmitError(null); try { const response = await fetch("/api/endorsement-request", { @@ -55,197 +136,268 @@ export default function EndorsementForm() { }); if (!response.ok) { - const body = await response.json(); - throw new Error(body.error || "Submission failed"); + const body: unknown = await response.json().catch(() => undefined); + setSubmitError(submitErrorMessage(response.status, readServerMessage(body))); + setStatus("idle"); + return; } + setSentTo(data.contactEmail.trim()); reset(); - setSubmitted(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong. Please try again."); - } finally { - setSubmitting(false); + setStatus("sent"); + } catch { + setSubmitError(NETWORK_ERROR); + setStatus("idle"); } }; + if (status === "sent") { + return setStatus("idle")} />; + } + + // Shared props for each control: describedby/invalid from FormField, plus + // aria-required on everything except the one optional field. + const a11y = (ids: { describedBy: string | undefined; invalid: boolean }, required = true) => ({ + "aria-describedby": ids.describedBy, + "aria-invalid": ids.invalid, + "aria-required": required || undefined, + }); + return ( -
- - {/* Contact info row */} - - - Contact name * - - } - fullWidth - sx={inputSx} - error={!!errors.contactName} - helperText={errors.contactName?.message} - {...register("contactName", { required: "Contact name is required" })} - /> - - Contact email * - - } - type="email" - fullWidth - sx={inputSx} - error={!!errors.contactEmail} - helperText={errors.contactEmail?.message} - {...register("contactEmail", { - required: "Contact email is required", - pattern: { value: /^\S+@\S+\.\S+$/, message: "Invalid email address" }, - })} - /> - - - {/* Organization row */} - - - Organization name * - - } - fullWidth - sx={inputSx} - error={!!errors.organizationName} - helperText={errors.organizationName?.message} - {...register("organizationName", { required: "Organization name is required" })} - /> - - Organization website * - - } - type="url" - fullWidth - sx={inputSx} - error={!!errors.organizationWebsite} - helperText={errors.organizationWebsite?.message} - {...register("organizationWebsite", { - required: "Organization website is required", - pattern: { - value: /^https?:\/\/.+/, - message: "Must be a valid URL starting with http:// or https://", - }, - })} - /> - - - {/* Campaign name */} - - Campaign name * - - } - fullWidth - sx={inputSx} - error={!!errors.campaignName} - helperText={errors.campaignName?.message} - {...register("campaignName", { required: "Campaign name is required" })} - /> - - {/* Request */} - - Your request * - - } - fullWidth - sx={inputSx} - error={!!errors.request} - helperText={errors.request?.message || "Briefly describe what you're requesting from T4P"} - {...register("request", { required: "Please describe your request" })} - /> - - {/* Campaign purpose */} - - Purpose of your campaign * - - } - fullWidth - multiline - minRows={4} - sx={inputSx} - error={!!errors.campaignPurpose} - helperText={ - errors.campaignPurpose?.message || "Describe the goals and purpose of your campaign" - } - {...register("campaignPurpose", { required: "Campaign purpose is required" })} - /> - - {/* Campaign link */} - - Campaign link * - - } - type="url" - fullWidth - sx={inputSx} - error={!!errors.campaignLink} - helperText={errors.campaignLink?.message || "Link to your campaign website or page"} - {...register("campaignLink", { - required: "Campaign link is required", - pattern: { - value: /^https?:\/\/.+/, - message: "Must be a valid URL starting with http:// or https://", - }, - })} - /> - - {/* Notable supporters */} - - - {/* T4P project checkbox */} - } - label="This campaign is an existing Tech for Palestine project" - /> - - {error && {error}} - - -