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

+114
View File
@@ -0,0 +1,114 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
import { isDatabaseConfigured } from "../../../lib/server/env";
import { prisma } from "../../../lib/server/db";
import {
grantWorkshopInquiryAdmin,
isWorkshopInquiryAdmin,
workshopInquiryAdminToken,
} from "../../../lib/server/workshopInquiryAdmin";
export const metadata: Metadata = routeMetadata("/workshop-inquiries", {
title: messages.metadata.workshopInquiries.title,
});
async function unlock(formData: FormData) {
"use server";
const password = String(formData.get("password") ?? "");
const ok = await grantWorkshopInquiryAdmin(password);
redirect(ok ? "/workshop-inquiries" : "/workshop-inquiries?denied=1");
}
export default async function WorkshopInquiriesPage({
searchParams,
}: {
searchParams: Promise<{ denied?: string }>;
}) {
if (!workshopInquiryAdminToken()) notFound();
const copy = messages.pages.workshopInquiries;
const params = await searchParams;
const allowed = await isWorkshopInquiryAdmin();
if (!allowed) {
return (
<section className="mx-auto flex w-full max-w-md flex-col gap-4 px-6 py-16">
<h1 className="x-large-heading text-[var(--color-content-default-primary)]">{copy.title}</h1>
<form action={unlock} className="flex flex-col gap-3">
<label className="medium-label" htmlFor="workshop-inquiry-token">
{copy.unlockLabel}
</label>
<input
id="workshop-inquiry-token"
name="password"
type="password"
autoComplete="current-password"
className="h-12 rounded-[12px] border border-[var(--color-border-default-tertiary)] bg-black px-4"
/>
{params.denied ? (
<p className="small-paragraph text-[var(--color-content-default-utility-negative)]">
{copy.unlockError}
</p>
) : null}
<button
type="submit"
className="h-12 rounded-full bg-white px-4 font-medium text-black"
>
{copy.unlockButton}
</button>
</form>
</section>
);
}
const rows = isDatabaseConfigured()
? await prisma.workshopInquiry.findMany({ orderBy: { createdAt: "desc" }, take: 200 })
: [];
return (
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-12">
<div className="flex items-center justify-between gap-4">
<h1 className="x-large-heading text-[var(--color-content-default-primary)]">{copy.title}</h1>
<a href="/api/workshop-inquiries/export" className="medium-label underline">
{copy.export}
</a>
</div>
{rows.length === 0 ? (
<p className="medium-paragraph">{copy.empty}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-[14px]">
<thead>
<tr className="border-b border-[var(--color-border-default-secondary)]">
<th className="py-2 pr-4">{copy.columns.when}</th>
<th className="py-2 pr-4">{copy.columns.organization}</th>
<th className="py-2 pr-4">{copy.columns.kind}</th>
<th className="py-2 pr-4">{copy.columns.members}</th>
<th className="py-2 pr-4">{copy.columns.route}</th>
<th className="py-2 pr-4">{copy.columns.contact}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-[var(--color-border-default-secondary)] align-top">
<td className="py-3 pr-4 whitespace-nowrap">{row.createdAt.toISOString().slice(0, 10)}</td>
<td className="py-3 pr-4">{row.organizationName}</td>
<td className="py-3 pr-4">{row.organizationKind}</td>
<td className="py-3 pr-4">{row.memberCount}</td>
<td className="py-3 pr-4">{row.routeSuggestion}</td>
<td className="py-3 pr-4">
{row.contactName}
<br />
{row.email}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import { connection } from "next/server";
import { cookies, headers } from "next/headers";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
import { EMPTY_WORKSHOP_FORM_VALUES } from "../../../lib/workshopSignup";
import {
WORKSHOP_SIGNUP_DRAFT_COOKIE,
decodeWorkshopSignupDraft,
} from "../../../lib/server/workshopSignupDraft";
import WorkshopHero from "../../components/sections/WorkshopHero";
import WorkshopOverview from "../../components/sections/WorkshopOverview";
import WorkshopCircle from "../../components/sections/WorkshopCircle";
import WhoRunsIt from "../../components/sections/WhoRunsIt";
import QuoteBlock from "../../components/sections/QuoteBlock";
import WorkshopSignup from "../../components/sections/WorkshopSignup";
import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/workshops", {
title: messages.metadata.workshops.title,
description: messages.metadata.workshops.description,
});
interface PageProps {
searchParams: Promise<{ signup?: string; band?: string }>;
}
export default async function WorkshopsPage({ searchParams }: PageProps) {
await connection();
const params = await searchParams;
const headerStore = await headers();
const host = headerStore.get("x-forwarded-host") ?? headerStore.get("host");
const proto = headerStore.get("x-forwarded-proto") ?? "https";
const sourceUrl = host ? `${proto}://${host}/workshops` : "";
const signup = params.signup;
const draft =
signup === "invalid" || signup === "failed"
? decodeWorkshopSignupDraft((await cookies()).get(WORKSHOP_SIGNUP_DRAFT_COOKIE)?.value)
: null;
const initialStatus =
signup === "sent"
? "sent"
: signup === "invalid"
? "invalid"
: signup === "failed"
? "failed"
: "idle";
const page = messages.pages.workshops;
const ask = messages.pages.home.askOrganizer;
return (
<div className="min-h-screen bg-black">
<WorkshopHero />
<WorkshopOverview />
<WorkshopCircle />
<WhoRunsIt />
<QuoteBlock
variant="citation"
id="workshops-quote"
quote={page.quote.text}
author={page.quote.author}
source={page.quote.source}
/>
<WorkshopSignup
issuedAt={Date.now()}
schedulingUrl={process.env.NEXT_PUBLIC_WORKSHOP_SCHEDULING_URL ?? ""}
sourceUrl={sourceUrl}
initialStatus={initialStatus}
initialLarge={params.band === "large"}
initialValues={draft?.values ?? EMPTY_WORKSHOP_FORM_VALUES}
initialFieldErrors={draft?.fieldErrors}
/>
<section className="bg-black">
<AskOrganizer
title={ask.title}
subtitle={ask.subtitle}
buttonText={ask.buttonText}
/>
</section>
</div>
);
}
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { apiRoute } from "../../../../lib/server/apiRoute";
import { isDatabaseConfigured } from "../../../../lib/server/env";
import { prisma } from "../../../../lib/server/db";
import { dbUnavailable, notFound, unauthorized } from "../../../../lib/server/responses";
import { isWorkshopInquiryAdmin } from "../../../../lib/server/workshopInquiryAdmin";
function csvCell(value: string): string {
if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
return value;
}
export const GET = apiRoute("workshop-inquiries.export", async (_request: NextRequest) => {
if (!(await isWorkshopInquiryAdmin())) {
return process.env.WORKSHOP_INQUIRY_ADMIN_TOKEN?.trim()
? unauthorized()
: notFound();
}
if (!isDatabaseConfigured()) return dbUnavailable();
const rows = await prisma.workshopInquiry.findMany({
orderBy: { createdAt: "desc" },
});
const header = [
"id",
"createdAt",
"organizationName",
"organizationKind",
"memberCount",
"largeOrganization",
"routeSuggestion",
"contactName",
"email",
"role",
"challenges",
"challengeOther",
"unspokenQuestion",
"sourcePage",
"sourceUrl",
];
const lines = [header.join(",")];
for (const row of rows) {
lines.push(
[
row.id,
row.createdAt.toISOString(),
row.organizationName,
row.organizationKind,
row.memberCount,
row.largeOrganization ? "yes" : "no",
row.routeSuggestion,
row.contactName,
row.email,
row.role ?? "",
row.challenges.join("; "),
row.challengeOther ?? "",
row.unspokenQuestion ?? "",
row.sourcePage,
row.sourceUrl ?? "",
]
.map((cell) => csvCell(String(cell)))
.join(","),
);
}
return new NextResponse(lines.join("\n"), {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": "attachment; filename=\"workshop-inquiries.csv\"",
},
});
});
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest } from "next/server";
import { apiRoute } from "../../../../lib/server/apiRoute";
import { NextResponse } from "next/server";
import { isDatabaseConfigured } from "../../../../lib/server/env";
import { dbUnavailable, notFound, unauthorized } from "../../../../lib/server/responses";
import { workshopInquiryPurgeToken } from "../../../../lib/server/workshopInquiryAdmin";
import {
purgeExpiredWorkshopInquiries,
retryPendingWorkshopInquiryMail,
} from "../../../../lib/server/workshopSignup";
export const POST = apiRoute("workshop-inquiries.purge", async (request: NextRequest) => {
const token = workshopInquiryPurgeToken();
if (!token) return notFound();
const header = request.headers.get("authorization") ?? "";
if (header !== `Bearer ${token}`) return unauthorized();
if (!isDatabaseConfigured()) return dbUnavailable();
const retried = await retryPendingWorkshopInquiryMail();
const deleted = await purgeExpiredWorkshopInquiries();
return NextResponse.json({ ok: true, retried, deleted });
});
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { apiRoute } from "../../../lib/server/apiRoute";
import { submitWorkshopSignup } from "../../../lib/server/workshopSignup";
import { attachWorkshopSignupDraft } from "../../../lib/server/workshopSignupDraft";
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
import { workshopSignupFromFormData } from "../../../lib/server/validation/workshopSignupSchemas";
const SCOPE = "workshop-signup.submit";
function wantsHtmlForm(request: NextRequest): boolean {
const type = request.headers.get("content-type") ?? "";
return (
type.includes("application/x-www-form-urlencoded") ||
type.includes("multipart/form-data")
);
}
function redirectBack(
request: NextRequest,
status: string,
largeOrganization: boolean,
): NextResponse {
const url = new URL("/workshops", request.url);
url.searchParams.set("signup", status);
if (status === "sent") {
url.searchParams.set("band", largeOrganization ? "large" : "standard");
}
url.hash = "workshop-signup";
return NextResponse.redirect(url, 303);
}
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
const htmlForm = wantsHtmlForm(request);
let raw: unknown;
if (htmlForm) {
const formData = await request.formData();
const parsed = workshopSignupFromFormData(formData);
raw = {
...parsed.values,
company_website: parsed.honeypot,
startedAt: parsed.startedAt,
};
} else {
const parsedBody = await readLimitedJson(request);
if (parsedBody.ok === false) return parsedBody.response;
raw = parsedBody.value;
}
const result = await submitWorkshopSignup(raw, requestId);
if (htmlForm) {
const status =
result.status === "unavailable" ? "failed" : result.status === "sent" ? "sent" : result.status;
const response = redirectBack(request, status === "rate_limited" ? "failed" : status, result.largeOrganization);
if (result.status !== "sent") {
attachWorkshopSignupDraft(response, {
values: result.values,
fieldErrors: result.fieldErrors,
});
}
return response;
}
if (result.status === "invalid") {
return NextResponse.json(
{
ok: false,
status: "invalid",
fieldErrors: result.fieldErrors,
values: result.values,
},
{ status: 400 },
);
}
if (result.status === "rate_limited") {
return NextResponse.json({ ok: false, status: "rate_limited" }, { status: 429 });
}
if (result.status === "failed" || result.status === "unavailable") {
return NextResponse.json({ ok: false, status: "failed" }, { status: 502 });
}
return NextResponse.json({
ok: true,
status: "sent",
largeOrganization: result.largeOrganization,
});
});
@@ -71,6 +71,19 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
},
citation: {
container:
"bg-black px-[var(--space-400)] py-[var(--space-1200)] md:px-[var(--space-1200)] lg:px-[var(--space-2400)] lg:py-[120px]",
card: "",
gap: "",
avatarGap: "",
avatar: "",
quote: "text-[28px] leading-[34px] md:text-[40px] md:leading-[48px]",
author: "text-[12px] leading-[16px] tracking-[0.08em]",
source: "text-[12px] leading-[16px] tracking-[0.08em]",
showDecor: false,
citationLayout: true,
},
statement: {
container:
"flex w-full flex-col items-center px-[var(--space-800)] py-[var(--space-1200)] md:px-[var(--space-2400)] md:py-[var(--space-1200)]",
@@ -2,7 +2,8 @@ export type QuoteBlockVariantValue =
| "compact"
| "standard"
| "extended"
| "statement";
| "statement"
| "citation";
export interface QuoteBlockProps {
/** Default `standard` (home portrait quote). **`statement`** = yellow Section / Quote (**About** + **`/use-cases`** — two paragraphs below **`lg`**, one paragraph from **`lg`**; [21967-24638](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21967-24638&m=dev)). */
@@ -45,6 +46,8 @@ export interface VariantConfig {
* When true, render Figma **Section/Quote** layout (yellow surface; stacked copy below **`lg`**, single paragraph from **`lg`**; no attribution).
*/
statementLayout?: boolean;
/** Lavender citation quote used on Workshops. No avatar. */
citationLayout?: boolean;
}
export interface QuoteBlockViewProps {
@@ -24,6 +24,49 @@ function QuoteBlockView({
const t = useTranslation("quoteBlock");
const avatarAlt = t("avatarAlt").replace("{author}", author);
if (config.citationLayout) {
return (
<section
data-figma-node="23983-53436"
className={`${config.container} ${className}`.trim()}
aria-labelledby={quoteId}
>
<figure className="relative mx-auto w-full max-w-[1216px] overflow-hidden rounded-[24px] bg-[var(--color-surface-invert-brand-lavender)] px-[var(--space-600)] py-[var(--space-1000)] md:px-[var(--space-1200)] md:py-[var(--space-1800)]">
<div
className="pointer-events-none absolute -right-10 -top-16 size-[280px] rounded-full bg-white/25"
aria-hidden
/>
<div
className="pointer-events-none absolute -bottom-20 right-16 size-[320px] rounded-full bg-white/20"
aria-hidden
/>
<blockquote id={quoteId} className="relative">
<p
className={`font-bricolage-grotesque font-medium text-[var(--color-content-invert-primary)] ${config.quote}`}
>
“{quote}”
</p>
</blockquote>
<figcaption className="relative mt-10 flex flex-col gap-1">
<cite
id={authorId}
className={`font-inter not-italic uppercase text-[var(--color-content-invert-primary)] ${config.author}`}
>
{author}
</cite>
{source ? (
<p
className={`font-inter uppercase text-[var(--color-content-invert-primary)] ${config.source}`}
>
{source}
</p>
) : null}
</figcaption>
</figure>
</section>
);
}
if (config.statementLayout) {
const statementTextClass =
"font-bricolage-grotesque text-medium-heading text-[var(--color-surface-default-tertiary)] md:text-xx-large-heading";
@@ -0,0 +1,14 @@
"use client";
import { memo } from "react";
import WhoRunsItView from "./WhoRunsIt.view";
import type { WhoRunsItProps } from "./WhoRunsIt.types";
/** Figma: "Section / Who Runs It" (24265:50770). Names no fee. */
function WhoRunsItContainer({ className }: WhoRunsItProps) {
return <WhoRunsItView className={className} />;
}
WhoRunsItContainer.displayName = "WhoRunsIt";
export default memo(WhoRunsItContainer);
@@ -0,0 +1,3 @@
export interface WhoRunsItProps {
className?: string;
}
@@ -0,0 +1,73 @@
"use client";
import { memo } from "react";
import Button from "../../buttons/Button";
import { useMessages } from "../../../contexts/MessagesContext";
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
function WhoRunsItView({ className = "" }: { className?: string }) {
const copy = useMessages().pages.workshops.whoRunsIt;
return (
<section
data-figma-node="24265-50769"
className={`bg-[var(--color-surface-default-primary)] py-[var(--space-1800)] pl-0 pr-[var(--space-400)] md:pr-[var(--space-1200)] lg:py-[120px] lg:pr-[var(--space-2400)] ${className}`}
>
<div className="flex w-full flex-col items-start gap-12 lg:flex-row lg:items-center lg:gap-20">
<div
className="relative aspect-square w-full max-w-[419px] shrink-0 overflow-hidden bg-[var(--color-content-invert-brand-royal)]"
aria-hidden
>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */}
<img
src="/assets/marketing/workshops/who-runs-it-shape.svg"
alt=""
className="absolute inset-0 size-full object-cover"
/>
<div className="absolute bottom-0 left-0 h-1/2 w-full overflow-hidden bg-[var(--color-surface-invert-brand-lime)]">
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */}
<img
src="/assets/marketing/workshops/who-runs-it-mark.svg"
alt=""
className="size-full object-cover object-left"
/>
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-8 pr-[var(--space-400)] lg:pr-0">
<h2 className="x-large-heading text-[var(--color-content-default-primary)]">
{copy.title}
</h2>
<ul className="flex flex-col gap-8 md:flex-row md:flex-wrap md:gap-16">
{copy.people.map((person) => (
<li key={person.id} className="flex min-w-[240px] flex-1 gap-4">
<div className="min-w-0 flex-1">
<p className="text-[16px] font-bold leading-6 text-[var(--color-content-default-primary)]">
{person.name}
</p>
<p className="small-paragraph text-[var(--color-content-default-secondary)]">
{person.bio}
</p>
</div>
{/* eslint-disable-next-line @next/next/no-img-element -- facilitator portrait from Figma */}
<img
src={person.image}
alt={person.imageAlt}
width={84}
height={84}
className="size-[84px] shrink-0 rounded-full border-2 border-white/40 object-cover"
/>
</li>
))}
</ul>
<div className="border-t border-[var(--color-border-default-secondary)] pt-6">
<Button href={`#${WORKSHOP_SIGNUP_ANCHOR_ID}`} size="small">
{copy.button}
</Button>
</div>
</div>
</div>
</section>
);
}
export default memo(WhoRunsItView);
@@ -0,0 +1,2 @@
export { default } from "./WhoRunsIt.container";
export type { WhoRunsItProps } from "./WhoRunsIt.types";
@@ -0,0 +1,14 @@
"use client";
import { memo } from "react";
import WorkshopCircleView from "./WorkshopCircle.view";
import type { WorkshopCircleProps } from "./WorkshopCircle.types";
/** Figma: "Section / Circle 50 50" (24264:49597). */
function WorkshopCircleContainer({ className }: WorkshopCircleProps) {
return <WorkshopCircleView className={className} />;
}
WorkshopCircleContainer.displayName = "WorkshopCircle";
export default memo(WorkshopCircleContainer);
@@ -0,0 +1,3 @@
export interface WorkshopCircleProps {
className?: string;
}
@@ -0,0 +1,99 @@
"use client";
import { memo } from "react";
import Button from "../../buttons/Button";
import { useMessages } from "../../../contexts/MessagesContext";
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
const CARD_CLASS: Record<string, string> = {
lavender:
"bg-[var(--color-surface-invert-brand-lavender)] text-[var(--color-content-invert-brand-lavender)]",
teal: "bg-[var(--color-surface-invert-brand-teal)] text-[var(--color-content-invert-brand-teal)]",
yellow:
"bg-[var(--color-surface-invert-brand-secondary)] text-[var(--color-content-invert-brand-secondary)]",
red: "bg-[var(--color-surface-invert-brand-red)] text-[var(--color-content-invert-brand-red)]",
};
function TableIllustration({
cards,
deck,
prompt,
}: {
cards: ReadonlyArray<{ id: string; label: string; color: string }>;
deck: readonly string[];
prompt: string;
}) {
return (
<div
className="relative flex aspect-square w-full max-w-[560px] flex-col items-center justify-center overflow-hidden rounded-full border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] px-8 pb-6 pt-10"
aria-hidden
>
<div className="flex w-full max-w-[360px] justify-center gap-2">
{cards.map((card) => (
<div
key={card.id}
className={`flex h-[110px] w-[22%] items-end rounded-[8px] p-2 text-[12px] font-medium leading-[14px] ${CARD_CLASS[card.color] ?? ""}`}
>
{card.label}
</div>
))}
</div>
<div className="mt-4 flex w-[140%] justify-center">
{deck.map((label, index) => (
<div
key={label}
className="flex h-[120px] w-[92px] -mx-3 flex-col items-center justify-end rounded-[6px] border border-[var(--color-border-invert-secondary,#e4e4e4)] bg-white px-2 pb-3 text-center text-[11px] leading-4 text-[var(--color-content-invert-primary)]"
style={{ transform: `rotate(${(index - 1) * 18}deg)` }}
>
<span className="mb-auto mt-2 grid grid-cols-4 gap-1" aria-hidden>
{Array.from({ length: 8 }).map((_, dot) => (
<span key={dot} className="size-[3px] rounded-full bg-neutral-300" />
))}
</span>
{label}
</div>
))}
</div>
<p className="mt-4 max-w-[240px] rounded-[12px] border border-[var(--color-border-default-secondary)] bg-black px-4 py-3 text-center text-[14px] leading-5 text-white">
{prompt}
</p>
</div>
);
}
function WorkshopCircleView({ className = "" }: { className?: string }) {
const copy = useMessages().pages.workshops.circle;
return (
<section
data-figma-node="24264-49598"
className={`bg-[var(--color-surface-default-primary)] px-[var(--space-400)] py-[var(--space-1800)] md:px-[var(--space-1200)] lg:p-[var(--space-2400)] ${className}`}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-center gap-12 lg:flex-row lg:items-center">
<div className="flex w-full max-w-[768px] flex-1 flex-col items-start gap-8">
<h2 className="x-large-heading text-[var(--color-content-default-primary)]">
{copy.title}
</h2>
<div className="flex flex-col gap-4">
{copy.paragraphs.map((paragraph) => (
<p
key={paragraph.slice(0, 24)}
className="medium-paragraph text-[var(--color-content-default-tertiary)]"
>
{paragraph}
</p>
))}
</div>
<Button href={`#${WORKSHOP_SIGNUP_ANCHOR_ID}`} size="small">
{copy.button}
</Button>
</div>
<div className="flex w-full flex-1 justify-center">
<TableIllustration cards={copy.cards} deck={copy.deck} prompt={copy.prompt} />
</div>
</div>
</section>
);
}
export default memo(WorkshopCircleView);
@@ -0,0 +1,2 @@
export { default } from "./WorkshopCircle.container";
export type { WorkshopCircleProps } from "./WorkshopCircle.types";
@@ -0,0 +1,14 @@
"use client";
import { memo } from "react";
import WorkshopHeroView from "./WorkshopHero.view";
import type { WorkshopHeroProps } from "./WorkshopHero.types";
/** Figma: "Section / Workshop Hero" (24262:40645). */
function WorkshopHeroContainer({ className }: WorkshopHeroProps) {
return <WorkshopHeroView className={className} />;
}
WorkshopHeroContainer.displayName = "WorkshopHero";
export default memo(WorkshopHeroContainer);
@@ -0,0 +1,3 @@
export interface WorkshopHeroProps {
className?: string;
}
@@ -0,0 +1,80 @@
"use client";
import { memo } from "react";
import Button from "../../buttons/Button";
import { useMessages } from "../../../contexts/MessagesContext";
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
const CARD_CLASS: Record<string, string> = {
yellow:
"bg-[var(--color-surface-invert-brand-secondary)] text-[var(--color-content-invert-brand-secondary)]",
teal: "bg-[var(--color-surface-invert-brand-teal)] text-[var(--color-content-invert-brand-teal)]",
lavender:
"bg-[var(--color-surface-invert-brand-lavender)] text-[var(--color-content-invert-brand-lavender)]",
red: "bg-[var(--color-surface-invert-brand-red)] text-[var(--color-content-invert-brand-red)]",
};
const DESKTOP_CARD_CLASS = [
"left-[8%] top-[18%] w-[46%] -rotate-3",
"left-[28%] top-[8%] w-[42%] rotate-[9deg]",
"left-0 top-[34%] w-[48%] -rotate-[15deg]",
"left-[36%] top-[42%] w-[52%] rotate-[16deg]",
];
function WorkshopHeroView({ className = "" }: { className?: string }) {
const copy = useMessages().pages.workshops.hero;
const href = `#${WORKSHOP_SIGNUP_ANCHOR_ID}`;
return (
<section
data-figma-node="24258-40644"
className={`bg-[var(--color-surface-default-primary)] px-[var(--space-400)] pb-[var(--space-1800)] pt-[var(--space-1200)] md:px-[var(--space-1200)] lg:px-[var(--space-2400)] lg:pb-[120px] lg:pt-[96px] ${className}`}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-12 lg:flex-row lg:items-center lg:gap-12">
<div className="order-2 flex min-w-0 flex-1 flex-col gap-8 lg:order-1">
<div className="flex flex-col gap-4">
<p className="x-small-label-caps text-[var(--color-content-default-tertiary)]">
{copy.eyebrow}
</p>
<h1 className="x-large-display text-[var(--color-content-default-primary)]">
{copy.title}
</h1>
<p className="large-paragraph text-[var(--color-content-default-tertiary)]">
{copy.body}
</p>
</div>
<Button href={href} size="small">
{copy.button}
</Button>
<p className="border-t border-[var(--color-border-default-secondary)] pt-6 text-[12px] leading-4 text-[var(--color-content-default-tertiary)]">
{copy.credit}
</p>
</div>
<div className="order-1 min-w-0 flex-1 lg:order-2">
<div className="flex flex-col lg:hidden">
{copy.cards.map((card, index) => (
<div
key={card.id}
className={`rounded-[16px] px-4 py-6 ${CARD_CLASS[card.color] ?? ""} ${index > 0 ? "-mt-3" : ""}`}
>
<p className="small-heading">{card.label}</p>
</div>
))}
</div>
<div className="relative hidden h-[480px] lg:block">
{copy.cards.map((card, index) => (
<div
key={card.id}
className={`absolute rounded-[16px] px-4 py-6 ${CARD_CLASS[card.color] ?? ""} ${DESKTOP_CARD_CLASS[index] ?? ""}`}
>
<p className="small-heading">{card.label}</p>
</div>
))}
</div>
</div>
</div>
</section>
);
}
export default memo(WorkshopHeroView);
@@ -0,0 +1,2 @@
export { default } from "./WorkshopHero.container";
export type { WorkshopHeroProps } from "./WorkshopHero.types";
@@ -0,0 +1,14 @@
"use client";
import { memo } from "react";
import WorkshopOverviewView from "./WorkshopOverview.view";
import type { WorkshopOverviewProps } from "./WorkshopOverview.types";
/** Figma: "Section / Workshop Overview" (24263:45262). Room tiles carry nothing into the form. */
function WorkshopOverviewContainer({ className }: WorkshopOverviewProps) {
return <WorkshopOverviewView className={className} />;
}
WorkshopOverviewContainer.displayName = "WorkshopOverview";
export default memo(WorkshopOverviewContainer);
@@ -0,0 +1,3 @@
export interface WorkshopOverviewProps {
className?: string;
}
@@ -0,0 +1,56 @@
"use client";
import { memo } from "react";
import Button from "../../buttons/Button";
import { useMessages } from "../../../contexts/MessagesContext";
import { WORKSHOP_SIGNUP_ANCHOR_ID } from "../../../../lib/workshopSignup";
const DOT_CLASS: Record<string, string> = {
teal: "bg-[var(--color-content-invert-brand-teal)]",
lime: "bg-[var(--color-surface-invert-brand-kiwi)]",
lavender: "bg-[var(--color-content-invert-brand-lavender)]",
red: "bg-[var(--color-content-invert-brand-red)]",
};
function WorkshopOverviewView({ className = "" }: { className?: string }) {
const copy = useMessages().pages.workshops.overview;
return (
<section
data-figma-node="24263-45235"
className={`bg-[var(--color-surface-default-primary)] px-[var(--space-400)] py-[var(--space-1800)] md:px-[var(--space-1200)] lg:px-[112px] lg:py-[120px] ${className}`}
>
<div className="mx-auto flex w-full max-w-[1216px] flex-col items-center gap-12">
<div className="flex max-w-[640px] flex-col items-center gap-2 text-center">
<h2 className="x-large-heading text-[var(--color-content-default-primary)]">
{copy.title}
</h2>
<p className="medium-paragraph text-[var(--color-content-default-tertiary)]">
{copy.body}
</p>
</div>
<ul className="grid w-full max-w-[800px] grid-cols-2 gap-4 md:gap-6">
{copy.rooms.map((room) => (
<li
key={room.id}
className="flex min-h-[100px] items-center gap-4 rounded-[12px] border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] p-6"
>
<span
className={`size-3 shrink-0 rounded-full ${DOT_CLASS[room.color] ?? ""}`}
aria-hidden
/>
<span className="text-[18px] font-medium leading-6 text-[var(--color-content-default-primary)]">
{room.label}
</span>
</li>
))}
</ul>
<Button href={`#${WORKSHOP_SIGNUP_ANCHOR_ID}`} size="small">
{copy.button}
</Button>
</div>
</section>
);
}
export default memo(WorkshopOverviewView);
@@ -0,0 +1,2 @@
export { default } from "./WorkshopOverview.container";
export type { WorkshopOverviewProps } from "./WorkshopOverview.types";
@@ -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";