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>
448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
"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);
|