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:
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);
|
||||
Reference in new issue
Block a user