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,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);