From c0ba3a379807286c52a6a2a0818c81845659507e Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:56:49 -0700 Subject: [PATCH 1/2] 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 --- .env.example | 12 + app/(admin)/workshop-inquiries/page.tsx | 114 +++++ app/(marketing)/workshops/page.tsx | 85 ++++ app/api/workshop-inquiries/export/route.ts | 75 +++ app/api/workshop-inquiries/purge/route.ts | 24 + app/api/workshop-signup/route.ts | 90 ++++ .../QuoteBlock/QuoteBlock.container.tsx | 13 + .../sections/QuoteBlock/QuoteBlock.types.ts | 5 +- .../sections/QuoteBlock/QuoteBlock.view.tsx | 43 ++ .../WhoRunsIt/WhoRunsIt.container.tsx | 14 + .../sections/WhoRunsIt/WhoRunsIt.types.ts | 3 + .../sections/WhoRunsIt/WhoRunsIt.view.tsx | 73 +++ app/components/sections/WhoRunsIt/index.tsx | 2 + .../WorkshopCircle.container.tsx | 14 + .../WorkshopCircle/WorkshopCircle.types.ts | 3 + .../WorkshopCircle/WorkshopCircle.view.tsx | 99 ++++ .../sections/WorkshopCircle/index.tsx | 2 + .../WorkshopHero/WorkshopHero.container.tsx | 14 + .../WorkshopHero/WorkshopHero.types.ts | 3 + .../WorkshopHero/WorkshopHero.view.tsx | 80 ++++ .../sections/WorkshopHero/index.tsx | 2 + .../WorkshopOverview.container.tsx | 14 + .../WorkshopOverview.types.ts | 3 + .../WorkshopOverview.view.tsx | 56 +++ .../sections/WorkshopOverview/index.tsx | 2 + .../WorkshopSignup.container.tsx | 94 ++++ .../WorkshopSignup/WorkshopSignup.types.ts | 36 ++ .../WorkshopSignup/WorkshopSignup.view.tsx | 447 ++++++++++++++++++ .../sections/WorkshopSignup/index.tsx | 2 + lib/publicSitemap.ts | 1 + lib/server/mail.ts | 34 ++ .../validation/workshopSignupSchemas.ts | 115 +++++ lib/server/workshopInquiryAdmin.ts | 39 ++ lib/server/workshopSignup.ts | 231 +++++++++ lib/server/workshopSignupDraft.ts | 45 ++ lib/workshopSignup.ts | 165 +++++++ lib/workshopSignupMail.ts | 90 ++++ messages/en/index.ts | 4 + messages/en/marketing.ts | 2 + messages/en/metadata.json | 7 + messages/en/pages/workshopInquiries.json | 17 + messages/en/pages/workshops.json | 199 ++++++++ .../migration.sql | 28 ++ prisma/schema.prisma | 25 + .../marketing/workshops/who-runs-it-mark.svg | 9 + .../marketing/workshops/who-runs-it-shape.svg | 3 + tests/components/WorkshopSignup.test.tsx | 55 +++ tests/unit/publicSitemap.test.ts | 1 + tests/unit/workshopSignup.test.ts | 101 ++++ tests/unit/workshopSignupPostRoute.test.ts | 179 +++++++ 50 files changed, 2773 insertions(+), 1 deletion(-) create mode 100644 app/(admin)/workshop-inquiries/page.tsx create mode 100644 app/(marketing)/workshops/page.tsx create mode 100644 app/api/workshop-inquiries/export/route.ts create mode 100644 app/api/workshop-inquiries/purge/route.ts create mode 100644 app/api/workshop-signup/route.ts create mode 100644 app/components/sections/WhoRunsIt/WhoRunsIt.container.tsx create mode 100644 app/components/sections/WhoRunsIt/WhoRunsIt.types.ts create mode 100644 app/components/sections/WhoRunsIt/WhoRunsIt.view.tsx create mode 100644 app/components/sections/WhoRunsIt/index.tsx create mode 100644 app/components/sections/WorkshopCircle/WorkshopCircle.container.tsx create mode 100644 app/components/sections/WorkshopCircle/WorkshopCircle.types.ts create mode 100644 app/components/sections/WorkshopCircle/WorkshopCircle.view.tsx create mode 100644 app/components/sections/WorkshopCircle/index.tsx create mode 100644 app/components/sections/WorkshopHero/WorkshopHero.container.tsx create mode 100644 app/components/sections/WorkshopHero/WorkshopHero.types.ts create mode 100644 app/components/sections/WorkshopHero/WorkshopHero.view.tsx create mode 100644 app/components/sections/WorkshopHero/index.tsx create mode 100644 app/components/sections/WorkshopOverview/WorkshopOverview.container.tsx create mode 100644 app/components/sections/WorkshopOverview/WorkshopOverview.types.ts create mode 100644 app/components/sections/WorkshopOverview/WorkshopOverview.view.tsx create mode 100644 app/components/sections/WorkshopOverview/index.tsx create mode 100644 app/components/sections/WorkshopSignup/WorkshopSignup.container.tsx create mode 100644 app/components/sections/WorkshopSignup/WorkshopSignup.types.ts create mode 100644 app/components/sections/WorkshopSignup/WorkshopSignup.view.tsx create mode 100644 app/components/sections/WorkshopSignup/index.tsx create mode 100644 lib/server/validation/workshopSignupSchemas.ts create mode 100644 lib/server/workshopInquiryAdmin.ts create mode 100644 lib/server/workshopSignup.ts create mode 100644 lib/server/workshopSignupDraft.ts create mode 100644 lib/workshopSignup.ts create mode 100644 lib/workshopSignupMail.ts create mode 100644 messages/en/pages/workshopInquiries.json create mode 100644 messages/en/pages/workshops.json create mode 100644 prisma/migrations/20261103120000_add_workshop_inquiry/migration.sql create mode 100644 public/assets/marketing/workshops/who-runs-it-mark.svg create mode 100644 public/assets/marketing/workshops/who-runs-it-shape.svg create mode 100644 tests/components/WorkshopSignup.test.tsx create mode 100644 tests/unit/workshopSignup.test.ts create mode 100644 tests/unit/workshopSignupPostRoute.test.ts diff --git a/.env.example b/.env.example index a711771..56e76f3 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,18 @@ SMTP_FROM="Community Rule " # CR-107: inbox for Ask an organizer form submissions (requires CLOUDRON_MAIL_SMTP_* in production). ORGANIZER_INQUIRY_TO= +# Workshop sign-up inbox. Leave unset until the address is approved. The form still saves the row. +WORKSHOP_INQUIRY_TO= + +# Optional calendar link for organizations of 1,000 members or more. Shown on the confirmation and in the reply email. +NEXT_PUBLIC_WORKSHOP_SCHEDULING_URL= + +# Shared token for /workshop-inquiries and the CSV export. Unset hides the page. +WORKSHOP_INQUIRY_ADMIN_TOKEN= + +# Bearer token for POST /api/workshop-inquiries/purge (retry unsent mail, delete inquiries older than 12 months). +WORKSHOP_INQUIRY_PURGE_TOKEN= + # Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. # Server draft sync (default on). Set to `false` to disable PUT/GET /api/drafts/me. NEXT_PUBLIC_ENABLE_BACKEND_SYNC= diff --git a/app/(admin)/workshop-inquiries/page.tsx b/app/(admin)/workshop-inquiries/page.tsx new file mode 100644 index 0000000..3f00a3e --- /dev/null +++ b/app/(admin)/workshop-inquiries/page.tsx @@ -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 ( +
+

{copy.title}

+
+ + + {params.denied ? ( +

+ {copy.unlockError} +

+ ) : null} + +
+
+ ); + } + + const rows = isDatabaseConfigured() + ? await prisma.workshopInquiry.findMany({ orderBy: { createdAt: "desc" }, take: 200 }) + : []; + + return ( +
+
+

{copy.title}

+ + {copy.export} + +
+ {rows.length === 0 ? ( +

{copy.empty}

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
{copy.columns.when}{copy.columns.organization}{copy.columns.kind}{copy.columns.members}{copy.columns.route}{copy.columns.contact}
{row.createdAt.toISOString().slice(0, 10)}{row.organizationName}{row.organizationKind}{row.memberCount}{row.routeSuggestion} + {row.contactName} +
+ {row.email} +
+
+ )} +
+ ); +} diff --git a/app/(marketing)/workshops/page.tsx b/app/(marketing)/workshops/page.tsx new file mode 100644 index 0000000..692039e --- /dev/null +++ b/app/(marketing)/workshops/page.tsx @@ -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 ( +
+ + + + + + +
+ +
+
+ ); +} diff --git a/app/api/workshop-inquiries/export/route.ts b/app/api/workshop-inquiries/export/route.ts new file mode 100644 index 0000000..cd59a00 --- /dev/null +++ b/app/api/workshop-inquiries/export/route.ts @@ -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\"", + }, + }); +}); diff --git a/app/api/workshop-inquiries/purge/route.ts b/app/api/workshop-inquiries/purge/route.ts new file mode 100644 index 0000000..24d345b --- /dev/null +++ b/app/api/workshop-inquiries/purge/route.ts @@ -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 }); +}); diff --git a/app/api/workshop-signup/route.ts b/app/api/workshop-signup/route.ts new file mode 100644 index 0000000..b2ab918 --- /dev/null +++ b/app/api/workshop-signup/route.ts @@ -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, + }); +}); diff --git a/app/components/sections/QuoteBlock/QuoteBlock.container.tsx b/app/components/sections/QuoteBlock/QuoteBlock.container.tsx index 4336544..54a84e3 100644 --- a/app/components/sections/QuoteBlock/QuoteBlock.container.tsx +++ b/app/components/sections/QuoteBlock/QuoteBlock.container.tsx @@ -71,6 +71,19 @@ const QuoteBlockContainer = memo( "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)]", diff --git a/app/components/sections/QuoteBlock/QuoteBlock.types.ts b/app/components/sections/QuoteBlock/QuoteBlock.types.ts index e451cd7..d719fa8 100644 --- a/app/components/sections/QuoteBlock/QuoteBlock.types.ts +++ b/app/components/sections/QuoteBlock/QuoteBlock.types.ts @@ -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 { diff --git a/app/components/sections/QuoteBlock/QuoteBlock.view.tsx b/app/components/sections/QuoteBlock/QuoteBlock.view.tsx index 30cfd1c..cb8ad1b 100644 --- a/app/components/sections/QuoteBlock/QuoteBlock.view.tsx +++ b/app/components/sections/QuoteBlock/QuoteBlock.view.tsx @@ -24,6 +24,49 @@ function QuoteBlockView({ const t = useTranslation("quoteBlock"); const avatarAlt = t("avatarAlt").replace("{author}", author); + if (config.citationLayout) { + return ( +
+
+
+
+
+

+ “{quote}” +

+
+
+ + {author} + + {source ? ( +

+ {source} +

+ ) : null} +
+
+
+ ); + } + if (config.statementLayout) { const statementTextClass = "font-bricolage-grotesque text-medium-heading text-[var(--color-surface-default-tertiary)] md:text-xx-large-heading"; diff --git a/app/components/sections/WhoRunsIt/WhoRunsIt.container.tsx b/app/components/sections/WhoRunsIt/WhoRunsIt.container.tsx new file mode 100644 index 0000000..55faf58 --- /dev/null +++ b/app/components/sections/WhoRunsIt/WhoRunsIt.container.tsx @@ -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 ; +} + +WhoRunsItContainer.displayName = "WhoRunsIt"; + +export default memo(WhoRunsItContainer); diff --git a/app/components/sections/WhoRunsIt/WhoRunsIt.types.ts b/app/components/sections/WhoRunsIt/WhoRunsIt.types.ts new file mode 100644 index 0000000..2995ee7 --- /dev/null +++ b/app/components/sections/WhoRunsIt/WhoRunsIt.types.ts @@ -0,0 +1,3 @@ +export interface WhoRunsItProps { + className?: string; +} diff --git a/app/components/sections/WhoRunsIt/WhoRunsIt.view.tsx b/app/components/sections/WhoRunsIt/WhoRunsIt.view.tsx new file mode 100644 index 0000000..0c8d5a9 --- /dev/null +++ b/app/components/sections/WhoRunsIt/WhoRunsIt.view.tsx @@ -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 ( +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */} + +
+ {/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */} + +
+
+
+

+ {copy.title} +

+
    + {copy.people.map((person) => ( +
  • +
    +

    + {person.name} +

    +

    + {person.bio} +

    +
    + {/* eslint-disable-next-line @next/next/no-img-element -- facilitator portrait from Figma */} + {person.imageAlt} +
  • + ))} +
+
+ +
+
+
+
+ ); +} + +export default memo(WhoRunsItView); diff --git a/app/components/sections/WhoRunsIt/index.tsx b/app/components/sections/WhoRunsIt/index.tsx new file mode 100644 index 0000000..758ef26 --- /dev/null +++ b/app/components/sections/WhoRunsIt/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./WhoRunsIt.container"; +export type { WhoRunsItProps } from "./WhoRunsIt.types"; diff --git a/app/components/sections/WorkshopCircle/WorkshopCircle.container.tsx b/app/components/sections/WorkshopCircle/WorkshopCircle.container.tsx new file mode 100644 index 0000000..842e82a --- /dev/null +++ b/app/components/sections/WorkshopCircle/WorkshopCircle.container.tsx @@ -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 ; +} + +WorkshopCircleContainer.displayName = "WorkshopCircle"; + +export default memo(WorkshopCircleContainer); diff --git a/app/components/sections/WorkshopCircle/WorkshopCircle.types.ts b/app/components/sections/WorkshopCircle/WorkshopCircle.types.ts new file mode 100644 index 0000000..efceaee --- /dev/null +++ b/app/components/sections/WorkshopCircle/WorkshopCircle.types.ts @@ -0,0 +1,3 @@ +export interface WorkshopCircleProps { + className?: string; +} diff --git a/app/components/sections/WorkshopCircle/WorkshopCircle.view.tsx b/app/components/sections/WorkshopCircle/WorkshopCircle.view.tsx new file mode 100644 index 0000000..87ac763 --- /dev/null +++ b/app/components/sections/WorkshopCircle/WorkshopCircle.view.tsx @@ -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 = { + 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 ( +
+
+ {cards.map((card) => ( +
+ {card.label} +
+ ))} +
+
+ {deck.map((label, index) => ( +
+ + {Array.from({ length: 8 }).map((_, dot) => ( + + ))} + + {label} +
+ ))} +
+

+ {prompt} +

+
+ ); +} + +function WorkshopCircleView({ className = "" }: { className?: string }) { + const copy = useMessages().pages.workshops.circle; + + return ( +
+
+
+

+ {copy.title} +

+
+ {copy.paragraphs.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+ +
+
+ +
+
+
+ ); +} + +export default memo(WorkshopCircleView); diff --git a/app/components/sections/WorkshopCircle/index.tsx b/app/components/sections/WorkshopCircle/index.tsx new file mode 100644 index 0000000..546e624 --- /dev/null +++ b/app/components/sections/WorkshopCircle/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./WorkshopCircle.container"; +export type { WorkshopCircleProps } from "./WorkshopCircle.types"; diff --git a/app/components/sections/WorkshopHero/WorkshopHero.container.tsx b/app/components/sections/WorkshopHero/WorkshopHero.container.tsx new file mode 100644 index 0000000..274f980 --- /dev/null +++ b/app/components/sections/WorkshopHero/WorkshopHero.container.tsx @@ -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 ; +} + +WorkshopHeroContainer.displayName = "WorkshopHero"; + +export default memo(WorkshopHeroContainer); diff --git a/app/components/sections/WorkshopHero/WorkshopHero.types.ts b/app/components/sections/WorkshopHero/WorkshopHero.types.ts new file mode 100644 index 0000000..4242b2e --- /dev/null +++ b/app/components/sections/WorkshopHero/WorkshopHero.types.ts @@ -0,0 +1,3 @@ +export interface WorkshopHeroProps { + className?: string; +} diff --git a/app/components/sections/WorkshopHero/WorkshopHero.view.tsx b/app/components/sections/WorkshopHero/WorkshopHero.view.tsx new file mode 100644 index 0000000..9f6376f --- /dev/null +++ b/app/components/sections/WorkshopHero/WorkshopHero.view.tsx @@ -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 = { + 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 ( +
+
+
+
+

+ {copy.eyebrow} +

+

+ {copy.title} +

+

+ {copy.body} +

+
+ +

+ {copy.credit} +

+
+
+
+ {copy.cards.map((card, index) => ( +
0 ? "-mt-3" : ""}`} + > +

{card.label}

+
+ ))} +
+
+ {copy.cards.map((card, index) => ( +
+

{card.label}

+
+ ))} +
+
+
+
+ ); +} + +export default memo(WorkshopHeroView); diff --git a/app/components/sections/WorkshopHero/index.tsx b/app/components/sections/WorkshopHero/index.tsx new file mode 100644 index 0000000..d53b719 --- /dev/null +++ b/app/components/sections/WorkshopHero/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./WorkshopHero.container"; +export type { WorkshopHeroProps } from "./WorkshopHero.types"; diff --git a/app/components/sections/WorkshopOverview/WorkshopOverview.container.tsx b/app/components/sections/WorkshopOverview/WorkshopOverview.container.tsx new file mode 100644 index 0000000..1ecbaad --- /dev/null +++ b/app/components/sections/WorkshopOverview/WorkshopOverview.container.tsx @@ -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 ; +} + +WorkshopOverviewContainer.displayName = "WorkshopOverview"; + +export default memo(WorkshopOverviewContainer); diff --git a/app/components/sections/WorkshopOverview/WorkshopOverview.types.ts b/app/components/sections/WorkshopOverview/WorkshopOverview.types.ts new file mode 100644 index 0000000..5d58335 --- /dev/null +++ b/app/components/sections/WorkshopOverview/WorkshopOverview.types.ts @@ -0,0 +1,3 @@ +export interface WorkshopOverviewProps { + className?: string; +} diff --git a/app/components/sections/WorkshopOverview/WorkshopOverview.view.tsx b/app/components/sections/WorkshopOverview/WorkshopOverview.view.tsx new file mode 100644 index 0000000..a158a73 --- /dev/null +++ b/app/components/sections/WorkshopOverview/WorkshopOverview.view.tsx @@ -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 = { + 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 ( +
+
+
+

+ {copy.title} +

+

+ {copy.body} +

+
+
    + {copy.rooms.map((room) => ( +
  • + + + {room.label} + +
  • + ))} +
+ +
+
+ ); +} + +export default memo(WorkshopOverviewView); diff --git a/app/components/sections/WorkshopOverview/index.tsx b/app/components/sections/WorkshopOverview/index.tsx new file mode 100644 index 0000000..a0fa081 --- /dev/null +++ b/app/components/sections/WorkshopOverview/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./WorkshopOverview.container"; +export type { WorkshopOverviewProps } from "./WorkshopOverview.types"; diff --git a/app/components/sections/WorkshopSignup/WorkshopSignup.container.tsx b/app/components/sections/WorkshopSignup/WorkshopSignup.container.tsx new file mode 100644 index 0000000..f8911c6 --- /dev/null +++ b/app/components/sections/WorkshopSignup/WorkshopSignup.container.tsx @@ -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(initialStatus); + const [largeOrganization, setLargeOrganization] = useState(initialLarge); + const [fieldErrors, setFieldErrors] = useState(initialFieldErrors); + + const onSubmit = async (event: FormEvent) => { + 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 ( + + ); +} + +WorkshopSignupContainer.displayName = "WorkshopSignup"; + +export default memo(WorkshopSignupContainer); diff --git a/app/components/sections/WorkshopSignup/WorkshopSignup.types.ts b/app/components/sections/WorkshopSignup/WorkshopSignup.types.ts new file mode 100644 index 0000000..be81487 --- /dev/null +++ b/app/components/sections/WorkshopSignup/WorkshopSignup.types.ts @@ -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; + 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) => void; +} diff --git a/app/components/sections/WorkshopSignup/WorkshopSignup.view.tsx b/app/components/sections/WorkshopSignup/WorkshopSignup.view.tsx new file mode 100644 index 0000000..86cc5d3 --- /dev/null +++ b/app/components/sections/WorkshopSignup/WorkshopSignup.view.tsx @@ -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 | 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 ( +
+
+
+
+
+
+

{copy.title}

+

{copy.intro}

+
+ +
+ {sent ? ( +
+

+ {largeOrganization ? copy.sent.largeTitle : copy.sent.standardTitle} +

+

+ {largeOrganization ? copy.sent.largeBody : copy.sent.standardBody} +

+ {largeOrganization ? ( + schedulingUrl ? ( + + ) : ( +

+ {copy.sent.largeButton} +

+ ) + ) : ( + + )} +
+ ) : ( +
+ + + + + + {banner ? ( + + ) : null} + +
+ + {copy.sections.takeOn} + +
+

+ {copy.challenges.label} +

+

+ {copy.challenges.hint} +

+
+ {copy.challenges.options.map((option) => + option.id === "something-else" ? ( +
+ + +
+ + + {errorText(fieldErrors, "challengeOther", copy.errors) ? ( +

+ {errorText(fieldErrors, "challengeOther", copy.errors)} +

+ ) : null} +
+
+ ) : ( + + ), + )} +
+ {errorText(fieldErrors, "challenges", copy.errors) ? ( +

+ {errorText(fieldErrors, "challenges", copy.errors)} +

+ ) : null} +
+
+

+ {copy.question.label}{" "} + {copy.optional} +

+

+ {copy.question.hint} +

+