Add the Workshops page and keep each sign-up on the site.

The form is built once to the settled field set, and a valid send is saved before anyone is emailed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-03 19:56:49 -07:00
1 parent 974a5ace4c
commit c0ba3a3798
50 files changed
+2773 -1

No files matched your search

@@ -0,0 +1,94 @@
"use client";
import { memo, useState, type FormEvent } from "react";
import {
EMPTY_WORKSHOP_FORM_VALUES,
WORKSHOP_HONEYPOT_FIELD,
WORKSHOP_STARTED_AT_FIELD,
} from "../../../../lib/workshopSignup";
import WorkshopSignupView from "./WorkshopSignup.view";
import type { WorkshopSignupProps, WorkshopSignupStatus } from "./WorkshopSignup.types";
/** Figma: "Section / Workshop Signup" (24323:55538). Full field set at every width. */
function WorkshopSignupContainer({
className,
issuedAt,
schedulingUrl = "",
sourceUrl = "",
initialStatus = "idle",
initialLarge = false,
initialValues = EMPTY_WORKSHOP_FORM_VALUES,
initialFieldErrors = {},
}: WorkshopSignupProps) {
const [status, setStatus] = useState<WorkshopSignupStatus>(initialStatus);
const [largeOrganization, setLargeOrganization] = useState(initialLarge);
const [fieldErrors, setFieldErrors] = useState(initialFieldErrors);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setStatus("sending");
try {
const response = await fetch("/api/workshop-signup", {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
challenges: data.getAll("challenges"),
challengeOther: data.get("challengeOther"),
unspokenQuestion: data.get("unspokenQuestion"),
organizationName: data.get("organizationName"),
organizationKind: data.get("organizationKind"),
memberCount: data.get("memberCount"),
contactName: data.get("contactName"),
email: data.get("email"),
role: data.get("role"),
sourcePage: "/workshops",
sourceUrl,
[WORKSHOP_HONEYPOT_FIELD]: data.get(WORKSHOP_HONEYPOT_FIELD),
[WORKSHOP_STARTED_AT_FIELD]: Number(data.get(WORKSHOP_STARTED_AT_FIELD) ?? issuedAt),
}),
});
const body = (await response.json()) as {
status?: string;
largeOrganization?: boolean;
fieldErrors?: WorkshopSignupProps["initialFieldErrors"];
};
if (body.status === "sent") {
setLargeOrganization(Boolean(body.largeOrganization));
setFieldErrors({});
setStatus("sent");
return;
}
if (body.status === "invalid") {
setFieldErrors(body.fieldErrors ?? {});
setStatus("invalid");
return;
}
if (body.status === "rate_limited" || response.status === 429) {
setStatus("rate_limited");
return;
}
setStatus("failed");
} catch {
setStatus("failed");
}
};
return (
<WorkshopSignupView
className={className ?? ""}
issuedAt={issuedAt}
schedulingUrl={schedulingUrl}
sourceUrl={sourceUrl}
status={status}
largeOrganization={largeOrganization}
values={initialValues}
fieldErrors={fieldErrors}
onSubmit={onSubmit}
/>
);
}
WorkshopSignupContainer.displayName = "WorkshopSignup";
export default memo(WorkshopSignupContainer);
@@ -0,0 +1,36 @@
import type { FormEvent } from "react";
import type {
WorkshopFieldErrors,
WorkshopFormValues,
} from "../../../../lib/workshopSignup";
export type WorkshopSignupStatus =
| "idle"
| "sending"
| "sent"
| "invalid"
| "failed"
| "rate_limited";
export interface WorkshopSignupProps {
className?: string;
issuedAt: number;
schedulingUrl?: string;
sourceUrl?: string;
initialStatus?: Exclude<WorkshopSignupStatus, "sending">;
initialLarge?: boolean;
initialValues?: WorkshopFormValues;
initialFieldErrors?: WorkshopFieldErrors;
}
export interface WorkshopSignupViewProps {
className: string;
issuedAt: number;
schedulingUrl: string;
sourceUrl: string;
status: WorkshopSignupStatus;
largeOrganization: boolean;
values: WorkshopFormValues;
fieldErrors: WorkshopFieldErrors;
onSubmit: (_event: FormEvent<HTMLFormElement>) => void;
}
@@ -0,0 +1,447 @@
"use client";
import { memo } from "react";
import Alert from "../../modals/Alert";
import Button from "../../buttons/Button";
import { useMessages } from "../../../contexts/MessagesContext";
import {
WORKSHOP_HONEYPOT_FIELD,
WORKSHOP_SIGNUP_ANCHOR_ID,
WORKSHOP_STARTED_AT_FIELD,
type WorkshopFieldErrors,
} from "../../../../lib/workshopSignup";
import type { WorkshopSignupViewProps } from "./WorkshopSignup.types";
const fieldClass =
"h-[52px] w-full rounded-[12px] border bg-[var(--color-surface-default-primary)] px-4 text-[16px] leading-6 text-[var(--color-content-default-primary)] outline-none focus-visible:border-white";
function borderClass(invalid: boolean): string {
return invalid
? "border-[var(--color-border-default-utility-negative)]"
: "border-[var(--color-border-default-tertiary)]";
}
function errorText(
errors: WorkshopFieldErrors,
key: keyof WorkshopFieldErrors,
messages: Record<string, string>,
): string | undefined {
const code = errors[key];
if (!code) return undefined;
return messages[key];
}
function WorkshopSignupView({
className,
issuedAt,
schedulingUrl,
sourceUrl,
status,
largeOrganization,
values,
fieldErrors,
onSubmit,
}: WorkshopSignupViewProps) {
const copy = useMessages().pages.workshops.signup;
const sending = status === "sending";
const sent = status === "sent";
const showSummary = status === "invalid" && Object.keys(fieldErrors).length > 0;
const banner =
status === "failed"
? copy.errors.failed
: status === "rate_limited"
? copy.errors.rateLimited
: showSummary
? copy.errors.summary
: null;
return (
<section
id={WORKSHOP_SIGNUP_ANCHOR_ID}
data-figma-node="24323-55538"
className={`scroll-mt-28 bg-[var(--color-surface-default-primary)] px-[var(--space-400)] py-[var(--space-1800)] md:px-[var(--space-1200)] lg:px-[96px] lg:py-[120px] ${className}`}
>
<div className="mx-auto grid w-full max-w-[1440px] gap-12 lg:grid-cols-[minmax(0,1fr)_320px] lg:gap-10">
<div className="relative min-w-0">
<div className="pointer-events-none absolute -left-16 top-10 hidden size-[280px] rotate-[15deg] rounded-full bg-[var(--color-surface-invert-brand-lavender)] lg:block" aria-hidden />
<div className="pointer-events-none absolute -left-10 bottom-24 hidden size-[240px] -rotate-[15deg] rounded-[64px] bg-[var(--color-surface-invert-brand-teal)] lg:block" aria-hidden />
<div className="relative flex flex-col gap-4 text-[var(--color-content-default-primary)]">
<h2 className="x-large-heading">{copy.title}</h2>
<p className="medium-paragraph max-w-[640px]">{copy.intro}</p>
</div>
<div className="relative mt-8 rounded-[24px] bg-[var(--color-surface-default-secondary)] p-6 md:p-10 lg:p-12">
{sent ? (
<div className="flex flex-col items-start gap-4">
<p className="text-[20px] font-medium leading-7 text-[var(--color-content-default-primary)]">
{largeOrganization ? copy.sent.largeTitle : copy.sent.standardTitle}
</p>
<p className="medium-paragraph text-[var(--color-content-default-tertiary)]">
{largeOrganization ? copy.sent.largeBody : copy.sent.standardBody}
</p>
{largeOrganization ? (
schedulingUrl ? (
<Button href={schedulingUrl} size="small">
{copy.sent.largeButton}
</Button>
) : (
<p className="medium-label text-[var(--color-content-default-primary)]">
{copy.sent.largeButton}
</p>
)
) : (
<Button href={copy.side.kitHref} buttonType="outline" palette="inverse" size="small">
{copy.sent.standardButton}
</Button>
)}
</div>
) : (
<form
action="/api/workshop-signup"
method="post"
className="flex flex-col gap-10"
onSubmit={onSubmit}
noValidate
>
<input type="hidden" name={WORKSHOP_STARTED_AT_FIELD} value={issuedAt} />
<input type="hidden" name="sourcePage" value="/workshops" />
<input type="hidden" name="sourceUrl" value={sourceUrl} />
<div className="absolute left-[-9999px] h-px w-px overflow-hidden" aria-hidden="true">
<label>
{copy.honeypotLabel}
<input name={WORKSHOP_HONEYPOT_FIELD} tabIndex={-1} autoComplete="off" />
</label>
</div>
{banner ? (
<Alert
type="banner"
status="danger"
size="s"
title={banner}
hasBodyText={false}
/>
) : null}
<fieldset className="flex flex-col gap-6">
<legend className="medium-label text-[var(--color-content-default-tertiary)]">
{copy.sections.takeOn}
</legend>
<div className="flex flex-col gap-3">
<p className="medium-label text-[var(--color-content-default-primary)]">
{copy.challenges.label}
</p>
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">
{copy.challenges.hint}
</p>
<div className="flex flex-col gap-3">
{copy.challenges.options.map((option) =>
option.id === "something-else" ? (
<div key={option.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<label
htmlFor="workshop-something-else"
className="col-start-1 row-start-1 text-[14px] leading-5 text-[var(--color-content-default-primary)]"
>
{option.label}
</label>
<input
id="workshop-something-else"
type="checkbox"
name="challenges"
value={option.id}
defaultChecked={values.challenges.includes(option.id)}
className="peer col-start-2 row-start-1 size-6 shrink-0 appearance-none rounded-[4px] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-primary)] checked:bg-white"
/>
<div className="col-span-2 row-start-2 hidden flex-col gap-2 peer-checked:flex">
<label
className="medium-label text-[var(--color-content-default-primary)]"
htmlFor="workshop-challenge-other"
>
{copy.challenges.otherLabel}
</label>
<input
id="workshop-challenge-other"
name="challengeOther"
defaultValue={values.challengeOther}
maxLength={200}
className={`${fieldClass} ${borderClass(Boolean(fieldErrors.challengeOther))}`}
/>
{errorText(fieldErrors, "challengeOther", copy.errors) ? (
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">
{errorText(fieldErrors, "challengeOther", copy.errors)}
</p>
) : null}
</div>
</div>
) : (
<label key={option.id} className="flex items-center gap-3">
<span className="min-w-0 flex-1 text-[14px] leading-5 text-[var(--color-content-default-primary)]">
{option.label}
</span>
<input
type="checkbox"
name="challenges"
value={option.id}
defaultChecked={values.challenges.includes(option.id)}
className="size-6 shrink-0 appearance-none rounded-[4px] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-primary)] checked:bg-white"
/>
</label>
),
)}
</div>
{errorText(fieldErrors, "challenges", copy.errors) ? (
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">
{errorText(fieldErrors, "challenges", copy.errors)}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<p className="medium-label text-[var(--color-content-default-primary)]">
{copy.question.label}{" "}
<span className="text-[var(--color-content-default-tertiary)]">{copy.optional}</span>
</p>
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">
{copy.question.hint}
</p>
<textarea
name="unspokenQuestion"
defaultValue={values.unspokenQuestion}
maxLength={1000}
rows={5}
className={`min-h-[120px] w-full rounded-[12px] border bg-[var(--color-surface-default-primary)] px-4 py-3 text-[16px] leading-6 text-[var(--color-content-default-primary)] outline-none ${borderClass(Boolean(fieldErrors.unspokenQuestion))}`}
/>
</div>
</fieldset>
<fieldset className="flex flex-col gap-6">
<legend className="medium-label text-[var(--color-content-default-tertiary)]">
{copy.sections.organization}
</legend>
<Field
label={copy.organizationName.label}
name="organizationName"
defaultValue={values.organizationName}
maxLength={150}
invalid={Boolean(fieldErrors.organizationName)}
message={errorText(fieldErrors, "organizationName", copy.errors)}
/>
<SelectField
label={copy.organizationKind.label}
name="organizationKind"
placeholder={copy.organizationKind.placeholder}
options={copy.organizationKind.options}
defaultValue={values.organizationKind}
invalid={Boolean(fieldErrors.organizationKind)}
message={errorText(fieldErrors, "organizationKind", copy.errors)}
/>
<SelectField
label={copy.members.label}
hint={copy.members.hint}
name="memberCount"
placeholder={copy.members.placeholder}
options={copy.members.options}
defaultValue={values.memberCount}
invalid={Boolean(fieldErrors.memberCount)}
message={errorText(fieldErrors, "memberCount", copy.errors)}
/>
</fieldset>
<fieldset className="flex flex-col gap-6">
<legend className="medium-label text-[var(--color-content-default-tertiary)]">
{copy.sections.aboutYou}
</legend>
<Field
label={copy.name.label}
name="contactName"
defaultValue={values.contactName}
maxLength={100}
invalid={Boolean(fieldErrors.contactName)}
message={errorText(fieldErrors, "contactName", copy.errors)}
/>
<Field
label={copy.email.label}
name="email"
type="email"
defaultValue={values.email}
invalid={Boolean(fieldErrors.email)}
message={errorText(fieldErrors, "email", copy.errors)}
/>
<SelectField
label={copy.role.label}
optional={copy.optional}
name="role"
placeholder={copy.role.placeholder}
options={copy.role.options}
defaultValue={values.role}
invalid={Boolean(fieldErrors.role)}
message={errorText(fieldErrors, "role", copy.errors)}
/>
</fieldset>
<div className="flex flex-col items-start gap-4">
<Button type="submit" size="small" disabled={sending}>
{sending ? copy.sending : copy.send}
</Button>
<p className="text-[12px] leading-4 text-[var(--color-content-default-tertiary)]">
{copy.assurance}
</p>
</div>
</form>
)}
</div>
</div>
<aside className="flex flex-col gap-8 lg:pt-[156px]">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<p className="x-small-label-caps text-[var(--color-content-default-tertiary)]">
{copy.side.eyebrow}
</p>
<h3 className="x-small-heading text-[var(--color-content-default-primary)]">
{copy.side.title}
</h3>
</div>
{copy.side.questions.map((item) => (
<div key={item.q} className="flex flex-col gap-2">
<p className="text-[14px] font-bold leading-5 text-[var(--color-content-default-primary)]">
{item.q}
</p>
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">{item.a}</p>
</div>
))}
<div className="flex flex-col gap-3 rounded-[12px] bg-[var(--color-surface-default-secondary)] p-4">
<p className="text-[14px] font-bold leading-5 text-[var(--color-content-default-primary)]">
{copy.side.volunteerTitle}
</p>
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">
{copy.side.volunteerBodyBefore}
<a href={copy.side.kitHref} className="underline">
{copy.side.volunteerLink}
</a>
{copy.side.volunteerBodyAfter}
</p>
<Button href={copy.side.kitHref} buttonType="outline" palette="inverse" size="small" className="w-full">
{copy.side.volunteerButton}
</Button>
</div>
</div>
<div className="flex flex-col gap-6 rounded-[12px] bg-[var(--color-surface-default-secondary)] p-6">
<p className="x-small-label-caps text-[var(--color-content-default-primary)]">
{copy.side.nextTitle}
</p>
<ol className="flex flex-col gap-4">
{copy.side.steps.map((step, index) => (
<li key={step} className="flex items-baseline gap-3">
<span className="medium-label text-[var(--color-content-default-brand-teal)]">
{index + 1}
</span>
<span className="small-paragraph text-[var(--color-content-default-tertiary)]">
{step}
</span>
</li>
))}
</ol>
<p className="border-t border-[var(--color-border-default-secondary)] pt-4 text-[12px] leading-[14px] text-[var(--color-content-default-tertiary)]">
{copy.side.nextCredit}
</p>
</div>
</aside>
</div>
</section>
);
}
function Field({
label,
name,
defaultValue,
maxLength,
invalid,
message,
type = "text",
}: {
label: string;
name: string;
defaultValue: string;
maxLength?: number;
invalid: boolean;
message?: string;
type?: string;
}) {
const id = `workshop-${name}`;
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="medium-label text-[var(--color-content-default-primary)]">
{label}
</label>
<input
id={id}
name={name}
type={type}
defaultValue={defaultValue}
maxLength={maxLength}
aria-invalid={invalid || undefined}
className={`${fieldClass} ${borderClass(invalid)}`}
/>
{message ? (
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">{message}</p>
) : null}
</div>
);
}
function SelectField({
label,
hint,
optional,
name,
placeholder,
options,
defaultValue,
invalid,
message,
}: {
label: string;
hint?: string;
optional?: string;
name: string;
placeholder: string;
options: ReadonlyArray<{ id: string; label: string }>;
defaultValue: string;
invalid: boolean;
message?: string;
}) {
const id = `workshop-${name}`;
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="medium-label text-[var(--color-content-default-primary)]">
{label}{" "}
{optional ? (
<span className="text-[var(--color-content-default-tertiary)]">{optional}</span>
) : null}
</label>
{hint ? (
<p className="small-paragraph text-[var(--color-content-default-tertiary)]">{hint}</p>
) : null}
<select
id={id}
name={name}
defaultValue={defaultValue}
aria-invalid={invalid || undefined}
className={`${fieldClass} ${borderClass(invalid)}`}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
{message ? (
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">{message}</p>
) : null}
</div>
);
}
export default memo(WorkshopSignupView);
@@ -0,0 +1,2 @@
export { default } from "./WorkshopSignup.container";
export type { WorkshopSignupProps } from "./WorkshopSignup.types";