Merge pull request 'Add the Workshops page and keep sign-ups on the site' (#90) from adilallo/feature/CR-229-workshops into main

Reviewed-on: #90
This commit was merged in pull request #90.
This commit is contained in:
an.di committed 2026-11-04 03:11:21 +00:00
commit 18abeb56dd
53 files changed
+2776 -1

No files matched your search

+12
View File
@@ -18,6 +18,18 @@ SMTP_FROM="Community Rule <noreply@localhost>"
# 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=
+3
View File
@@ -53,6 +53,9 @@ npm-cache/
# Header Create-rule portraits
!public/assets/marketing/avatar-*.png
# Workshop facilitator portraits
!public/assets/marketing/workshops/*.png
# Open Graph 1200×630 share images (`npm run generate:og-images`)
!public/assets/marketing/og-image.png
!public/assets/marketing/og-image-templates.png
+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";
+1
View File
@@ -6,6 +6,7 @@ import { USE_CASE_DETAIL_SLUGS } from "./useCaseSyntheticPost";
const STATIC_PUBLIC_PATHS = [
"/",
"/about",
"/workshops",
"/learn",
"/templates",
"/blog",
+34
View File
@@ -1,5 +1,10 @@
import nodemailer from "nodemailer";
import { logger } from "../logger";
import {
buildWorkshopConfirmationMail,
buildWorkshopInboxMail,
type WorkshopInquiryMailInput,
} from "../workshopSignupMail";
import { getSmtpUrl } from "./env";
function escapeHtml(value: string): string {
@@ -134,6 +139,35 @@ export async function sendOrganizerInquiryNotification(params: {
});
}
export async function sendWorkshopInquiryNotification(
input: WorkshopInquiryMailInput & { to: string },
): Promise<void> {
const { subject, text, html } = buildWorkshopInboxMail(input);
await sendHtmlMail({
to: input.to,
replyTo: input.values.email,
subject,
text,
html,
devLog: `[dev] Workshop inquiry ${input.requestId} from ${input.values.email} to ${input.to}:\n${text}`,
});
}
export async function sendWorkshopInquiryConfirmation(input: {
to: string;
largeOrganization: boolean;
schedulingUrl: string;
}): Promise<void> {
const { subject, text, html } = buildWorkshopConfirmationMail(input);
await sendHtmlMail({
to: input.to,
subject,
text,
html,
devLog: `[dev] Workshop confirmation for ${input.to} (large=${input.largeOrganization})`,
});
}
export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
@@ -0,0 +1,115 @@
import {
WORKSHOP_CHALLENGE_IDS,
WORKSHOP_HONEYPOT_FIELD,
WORKSHOP_MEMBER_COUNTS,
WORKSHOP_ORGANIZATION_KINDS,
WORKSHOP_ROLES,
WORKSHOP_STARTED_AT_FIELD,
type WorkshopFieldErrors,
type WorkshopFormValues,
} from "../../workshopSignup";
const CHALLENGE_SET = new Set<string>(WORKSHOP_CHALLENGE_IDS);
const KIND_SET = new Set<string>(WORKSHOP_ORGANIZATION_KINDS);
const MEMBER_SET = new Set<string>(WORKSHOP_MEMBER_COUNTS);
const ROLE_SET = new Set<string>(WORKSHOP_ROLES);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export interface ParsedWorkshopSignup {
values: WorkshopFormValues;
fieldErrors: WorkshopFieldErrors;
honeypot: string;
startedAt: number | null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function asStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim().length > 0) {
return [value];
}
return [];
}
export function parseWorkshopSignupPayload(raw: unknown): ParsedWorkshopSignup {
const record =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const challenges = [
...new Set(asStringList(record.challenges).map((item) => item.trim())),
].filter((item) => CHALLENGE_SET.has(item));
const values: WorkshopFormValues = {
challenges,
challengeOther: asString(record.challengeOther).trim().slice(0, 200),
unspokenQuestion: asString(record.unspokenQuestion).trim(),
organizationName: asString(record.organizationName).trim(),
organizationKind: asString(record.organizationKind).trim(),
memberCount: asString(record.memberCount).trim(),
contactName: asString(record.contactName).trim(),
email: asString(record.email).trim().toLowerCase(),
role: asString(record.role).trim(),
sourcePage: asString(record.sourcePage).trim() || "/workshops",
sourceUrl: asString(record.sourceUrl).trim().slice(0, 2000),
};
const fieldErrors: WorkshopFieldErrors = {};
if (values.challenges.length === 0) fieldErrors.challenges = "required";
if (
values.challenges.includes("something-else") &&
values.challengeOther.length === 0
) {
fieldErrors.challengeOther = "required";
}
if (values.unspokenQuestion.length > 1000) fieldErrors.unspokenQuestion = "tooLong";
if (values.organizationName.length === 0) fieldErrors.organizationName = "required";
else if (values.organizationName.length > 150) fieldErrors.organizationName = "tooLong";
if (!KIND_SET.has(values.organizationKind)) fieldErrors.organizationKind = "required";
if (!MEMBER_SET.has(values.memberCount)) fieldErrors.memberCount = "required";
if (values.contactName.length === 0) fieldErrors.contactName = "required";
else if (values.contactName.length > 100) fieldErrors.contactName = "tooLong";
if (values.email.length === 0 || !EMAIL_PATTERN.test(values.email) || values.email.length > 254) {
fieldErrors.email = values.email.length === 0 ? "required" : "invalid";
}
if (values.role.length > 0 && !ROLE_SET.has(values.role)) fieldErrors.role = "invalid";
const startedRaw = record[WORKSHOP_STARTED_AT_FIELD];
const startedAt =
typeof startedRaw === "number"
? startedRaw
: typeof startedRaw === "string" && startedRaw.trim().length > 0
? Number(startedRaw)
: null;
return {
values,
fieldErrors,
honeypot: asString(record[WORKSHOP_HONEYPOT_FIELD]).trim(),
startedAt: startedAt !== null && Number.isFinite(startedAt) ? startedAt : null,
};
}
export function workshopSignupFromFormData(formData: FormData): ParsedWorkshopSignup {
return parseWorkshopSignupPayload({
challenges: formData.getAll("challenges"),
challengeOther: formData.get("challengeOther"),
unspokenQuestion: formData.get("unspokenQuestion"),
organizationName: formData.get("organizationName"),
organizationKind: formData.get("organizationKind"),
memberCount: formData.get("memberCount"),
contactName: formData.get("contactName"),
email: formData.get("email"),
role: formData.get("role"),
sourcePage: formData.get("sourcePage"),
sourceUrl: formData.get("sourceUrl"),
[WORKSHOP_STARTED_AT_FIELD]: formData.get(WORKSHOP_STARTED_AT_FIELD),
[WORKSHOP_HONEYPOT_FIELD]: formData.get(WORKSHOP_HONEYPOT_FIELD),
});
}
+39
View File
@@ -0,0 +1,39 @@
import { cookies } from "next/headers";
import { sha256Hex } from "./hash";
export const WORKSHOP_INQUIRY_ADMIN_COOKIE = "workshop_inquiry_admin";
export function workshopInquiryAdminToken(): string | null {
const raw = process.env.WORKSHOP_INQUIRY_ADMIN_TOKEN?.trim();
return raw && raw.length > 0 ? raw : null;
}
export function workshopInquiryPurgeToken(): string | null {
const raw = process.env.WORKSHOP_INQUIRY_PURGE_TOKEN?.trim();
return raw && raw.length > 0 ? raw : null;
}
function cookieValue(token: string): string {
return sha256Hex(`workshop-inquiry-admin:${token}`);
}
export async function isWorkshopInquiryAdmin(): Promise<boolean> {
const token = workshopInquiryAdminToken();
if (!token) return false;
const store = await cookies();
return store.get(WORKSHOP_INQUIRY_ADMIN_COOKIE)?.value === cookieValue(token);
}
export async function grantWorkshopInquiryAdmin(password: string): Promise<boolean> {
const token = workshopInquiryAdminToken();
if (!token || password !== token) return false;
const store = await cookies();
store.set(WORKSHOP_INQUIRY_ADMIN_COOKIE, cookieValue(token), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 12,
});
return true;
}
+231
View File
@@ -0,0 +1,231 @@
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
import {
sendWorkshopInquiryConfirmation,
sendWorkshopInquiryNotification,
} from "./mail";
import { rateLimitKey } from "./rateLimit";
import { logger } from "../logger";
import {
WORKSHOP_INQUIRY_RETENTION_MS,
WORKSHOP_MIN_FILL_MS,
suggestWorkshopRoute,
type WorkshopFormValues,
} from "../workshopSignup";
import {
parseWorkshopSignupPayload,
type ParsedWorkshopSignup,
} from "./validation/workshopSignupSchemas";
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
export type WorkshopSignupStatus =
| "sent"
| "invalid"
| "failed"
| "rate_limited"
| "unavailable";
export interface WorkshopSignupResult {
status: WorkshopSignupStatus;
largeOrganization: boolean;
values: WorkshopFormValues;
fieldErrors: ParsedWorkshopSignup["fieldErrors"];
}
function inboxAddress(): string | null {
const raw = process.env.WORKSHOP_INQUIRY_TO?.trim();
return raw && raw.length > 0 ? raw : null;
}
function schedulingUrl(): string {
return process.env.NEXT_PUBLIC_WORKSHOP_SCHEDULING_URL?.trim() ?? "";
}
function resultFromParse(
parsed: ParsedWorkshopSignup,
status: WorkshopSignupStatus,
largeOrganization = false,
): WorkshopSignupResult {
return {
status,
largeOrganization,
values: parsed.values,
fieldErrors: parsed.fieldErrors,
};
}
/**
* Save a valid inquiry, then email the inbox and the person who wrote.
* A filled honeypot or a too-fast submit shows the confirmation and saves nothing.
* A failed save sends nothing. A failed email keeps the row for a later retry.
*/
export async function submitWorkshopSignup(
raw: unknown,
requestId: string,
): Promise<WorkshopSignupResult> {
const parsed = parseWorkshopSignupPayload(raw);
if (Object.keys(parsed.fieldErrors).length > 0) {
return resultFromParse(parsed, "invalid");
}
const tooFast =
parsed.startedAt === null ||
Date.now() - parsed.startedAt < WORKSHOP_MIN_FILL_MS;
if (parsed.honeypot.length > 0 || tooFast) {
return resultFromParse(parsed, "sent", false);
}
const limited = rateLimitKey(
`workshop-signup-email:${parsed.values.email}`,
EMAIL_MIN_INTERVAL_MS,
);
if (limited.ok === false) {
return resultFromParse(parsed, "rate_limited");
}
if (!isDatabaseConfigured()) {
return resultFromParse(parsed, "unavailable");
}
const route = suggestWorkshopRoute(parsed.values);
let inquiryId: string;
try {
const row = await prisma.workshopInquiry.create({
data: {
challenges: parsed.values.challenges,
challengeOther: parsed.values.challengeOther || null,
unspokenQuestion: parsed.values.unspokenQuestion || null,
organizationName: parsed.values.organizationName,
organizationKind: parsed.values.organizationKind,
memberCount: parsed.values.memberCount,
contactName: parsed.values.contactName,
email: parsed.values.email,
role: parsed.values.role || null,
sourcePage: parsed.values.sourcePage,
sourceUrl: parsed.values.sourceUrl || null,
routeSuggestion: route.note,
largeOrganization: route.largeOrganization,
},
});
inquiryId = row.id;
} catch (err) {
logger.error("workshop-signup.save", err);
return resultFromParse(parsed, "failed");
}
await deliverWorkshopInquiryMail(inquiryId, parsed.values, route, requestId, {
inbox: true,
confirmation: true,
});
return resultFromParse(parsed, "sent", route.largeOrganization);
}
async function deliverWorkshopInquiryMail(
inquiryId: string,
values: WorkshopFormValues,
route: { note: string; largeOrganization: boolean },
requestId: string,
which: { inbox: boolean; confirmation: boolean },
): Promise<void> {
const to = inboxAddress();
if (which.inbox && to) {
try {
await sendWorkshopInquiryNotification({
to,
values,
routeNote: route.note,
largeOrganization: route.largeOrganization,
requestId,
schedulingUrl: schedulingUrl(),
});
await prisma.workshopInquiry.update({
where: { id: inquiryId },
data: { inboxNotifiedAt: new Date() },
});
} catch (err) {
logger.error("workshop-signup.inbox-mail", err);
}
}
if (which.confirmation) {
try {
await sendWorkshopInquiryConfirmation({
to: values.email,
largeOrganization: route.largeOrganization,
schedulingUrl: schedulingUrl(),
});
await prisma.workshopInquiry.update({
where: { id: inquiryId },
data: { confirmationSentAt: new Date() },
});
} catch (err) {
logger.error("workshop-signup.confirmation-mail", err);
}
}
}
/** Retry inbox and confirmation mail for rows that were saved but not sent. */
export async function retryPendingWorkshopInquiryMail(): Promise<number> {
if (!isDatabaseConfigured()) return 0;
const pending = await prisma.workshopInquiry.findMany({
where: {
OR: [{ inboxNotifiedAt: null }, { confirmationSentAt: null }],
},
orderBy: { createdAt: "asc" },
take: 50,
});
let sent = 0;
for (const row of pending) {
const values: WorkshopFormValues = {
challenges: row.challenges,
challengeOther: row.challengeOther ?? "",
unspokenQuestion: row.unspokenQuestion ?? "",
organizationName: row.organizationName,
organizationKind: row.organizationKind,
memberCount: row.memberCount,
contactName: row.contactName,
email: row.email,
role: row.role ?? "",
sourcePage: row.sourcePage,
sourceUrl: row.sourceUrl ?? "",
};
const beforeInbox = row.inboxNotifiedAt;
const beforeConfirmation = row.confirmationSentAt;
await deliverWorkshopInquiryMail(
row.id,
values,
{ note: row.routeSuggestion, largeOrganization: row.largeOrganization },
`retry-${row.id}`,
{
inbox: row.inboxNotifiedAt === null,
confirmation: row.confirmationSentAt === null,
},
);
const after = await prisma.workshopInquiry.findUnique({ where: { id: row.id } });
if (
after &&
((beforeInbox === null && after.inboxNotifiedAt !== null) ||
(beforeConfirmation === null && after.confirmationSentAt !== null))
) {
sent += 1;
}
}
return sent;
}
/** Delete inquiries older than the retention period. */
export async function purgeExpiredWorkshopInquiries(now = new Date()): Promise<number> {
if (!isDatabaseConfigured()) return 0;
const cutoff = new Date(now.getTime() - WORKSHOP_INQUIRY_RETENTION_MS);
const result = await prisma.workshopInquiry.deleteMany({
where: { createdAt: { lt: cutoff } },
});
return result.count;
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import {
EMPTY_WORKSHOP_FORM_VALUES,
type WorkshopFieldErrors,
type WorkshopFormValues,
} from "../workshopSignup";
export const WORKSHOP_SIGNUP_DRAFT_COOKIE = "workshop_signup_draft";
export interface WorkshopSignupDraft {
values: WorkshopFormValues;
fieldErrors: WorkshopFieldErrors;
}
export function encodeWorkshopSignupDraft(draft: WorkshopSignupDraft): string {
return Buffer.from(JSON.stringify(draft), "utf8").toString("base64url");
}
export function decodeWorkshopSignupDraft(raw: string | undefined): WorkshopSignupDraft | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Partial<WorkshopSignupDraft>;
if (!parsed.values || typeof parsed.values !== "object") return null;
return {
values: { ...EMPTY_WORKSHOP_FORM_VALUES, ...parsed.values },
fieldErrors: parsed.fieldErrors ?? {},
};
} catch {
return null;
}
}
export function attachWorkshopSignupDraft(
response: NextResponse,
draft: WorkshopSignupDraft,
): NextResponse {
response.cookies.set(WORKSHOP_SIGNUP_DRAFT_COOKIE, encodeWorkshopSignupDraft(draft), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/workshops",
maxAge: 60 * 15,
});
return response;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Shared workshop-signup constants and routing. Safe for the form and the server.
* Member count never maps to a price.
*/
export const WORKSHOP_SIGNUP_ANCHOR_ID = "workshop-signup";
export const WORKSHOP_HONEYPOT_FIELD = "company_website";
export const WORKSHOP_STARTED_AT_FIELD = "startedAt";
/** Ignore submits that arrive faster than a person can fill the form. */
export const WORKSHOP_MIN_FILL_MS = 4_000;
/** Proposed retention. Inquiries that do not lead to a workshop are deleted after this. */
export const WORKSHOP_INQUIRY_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
export const WORKSHOP_CHALLENGE_IDS = [
"members-rarely-vote",
"preparing-the-board",
"board-and-management",
"committees-unsure",
"disagreement",
"big-change",
"new-directors",
"co-op-principles",
"something-else",
] as const;
export type WorkshopChallengeId = (typeof WORKSHOP_CHALLENGE_IDS)[number];
export const WORKSHOP_ORGANIZATION_KINDS = [
"credit-union",
"mutual",
"consumer-coop",
"utility-coop",
"agricultural-coop",
"housing-coop",
"worker-coop",
"league",
"volunteer",
"something-else",
] as const;
export type WorkshopOrganizationKind = (typeof WORKSHOP_ORGANIZATION_KINDS)[number];
export const WORKSHOP_MEMBER_COUNTS = [
"under-1000",
"1000-10000",
"10000-100000",
"100000-1000000",
"over-1000000",
"not-sure",
] as const;
export type WorkshopMemberCount = (typeof WORKSHOP_MEMBER_COUNTS)[number];
export const WORKSHOP_ROLES = [
"board",
"executive",
"staff",
"member",
"something-else",
] as const;
export type WorkshopRole = (typeof WORKSHOP_ROLES)[number];
const LARGE_MEMBER_COUNTS = new Set<WorkshopMemberCount>([
"1000-10000",
"10000-100000",
"100000-1000000",
"over-1000000",
]);
export type WorkshopFieldErrorCode = "required" | "invalid" | "tooLong";
export type WorkshopFieldErrors = Partial<
Record<
| "challenges"
| "challengeOther"
| "unspokenQuestion"
| "organizationName"
| "organizationKind"
| "memberCount"
| "contactName"
| "email"
| "role",
WorkshopFieldErrorCode
>
>;
export interface WorkshopFormValues {
challenges: string[];
challengeOther: string;
unspokenQuestion: string;
organizationName: string;
organizationKind: string;
memberCount: string;
contactName: string;
email: string;
role: string;
sourcePage: string;
sourceUrl: string;
}
export const EMPTY_WORKSHOP_FORM_VALUES: WorkshopFormValues = {
challenges: [],
challengeOther: "",
unspokenQuestion: "",
organizationName: "",
organizationKind: "",
memberCount: "",
contactName: "",
email: "",
role: "",
sourcePage: "/workshops",
sourceUrl: "",
};
export interface WorkshopRouteSuggestion {
/** True when the organization has 1,000 members or more. Never a price. */
largeOrganization: boolean;
/** Inbox note. Names the next conversation, never a fee. */
note: string;
}
export function isLargeMemberCount(memberCount: string): boolean {
return LARGE_MEMBER_COUNTS.has(memberCount as WorkshopMemberCount);
}
/**
* Inbox routing. Volunteer-run groups go to the kit. Organizations of 1,000
* members or more are flagged large and go to the facilitator lead. Leagues
* go to a partnership call. Everyone else gets the one-page brief.
*/
export function suggestWorkshopRoute(input: {
organizationKind: string;
memberCount: string;
}): WorkshopRouteSuggestion {
if (input.organizationKind === "volunteer") {
return {
largeOrganization: false,
note: "Volunteer-run group: send the kit and office hours.",
};
}
if (isLargeMemberCount(input.memberCount)) {
return {
largeOrganization: true,
note: "Large organization: call with the facilitator lead.",
};
}
if (input.organizationKind === "league") {
return {
largeOrganization: false,
note: "League, association or co-op developer: partnership call.",
};
}
return {
largeOrganization: false,
note: "Send the one-page brief, then offer a call.",
};
}
+90
View File
@@ -0,0 +1,90 @@
import type { WorkshopFormValues } from "./workshopSignup";
export interface WorkshopInquiryMailInput {
values: WorkshopFormValues;
routeNote: string;
largeOrganization: boolean;
requestId: string;
schedulingUrl: string;
}
function line(label: string, value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? `${label}: ${trimmed}` : `${label}:`;
}
/** Inbox note for the team. Includes the answers and a route. No price. */
export function buildWorkshopInboxMail(input: WorkshopInquiryMailInput): {
subject: string;
text: string;
html: string;
} {
const { values } = input;
const challenges = values.challenges.join(", ");
const text = [
`Request ID: ${input.requestId}`,
line("Organization", values.organizationName),
line("Kind", values.organizationKind),
line("Members", values.memberCount),
`Large organization: ${input.largeOrganization ? "yes" : "no"}`,
line("Route", input.routeNote),
line("Name", values.contactName),
line("Email", values.email),
line("Role", values.role),
line("Challenges", challenges),
line("Something else", values.challengeOther),
line("Unspoken question", values.unspokenQuestion),
line("Page", values.sourcePage),
line("Link", values.sourceUrl),
].join("\n");
const html = text
.split("\n")
.map((row) => `<p>${escapeHtml(row)}</p>`)
.join("");
return {
subject: `Workshop inquiry from ${values.organizationName}`,
text,
html,
};
}
/** Confirmation to the person who wrote. Promises two working days and names no price. */
export function buildWorkshopConfirmationMail(input: {
largeOrganization: boolean;
schedulingUrl: string;
}): { subject: string; text: string; html: string } {
const subject = "We received your workshop note";
const intro =
"Thank you. A facilitator will read this closely and write back within two working days.";
const next = input.largeOrganization
? "Organizations of this size start with a call."
: "The kit has the four questions and the thirteen templates if you want a head start.";
const schedule =
input.largeOrganization && input.schedulingUrl.trim().length > 0
? `Choose a time: ${input.schedulingUrl.trim()}`
: "";
const text = [intro, next, schedule].filter((part) => part.length > 0).join("\n\n");
const html =
`<div style="background:#141414;padding:24px 32px;">` +
`<p style="margin:0;font-family:Inter,Arial,sans-serif;font-size:14px;letter-spacing:0.08em;color:#ffffff;">COMMUNITYRULE</p>` +
`</div>` +
`<div style="background:#ffffff;padding:32px;font-family:Inter,Arial,sans-serif;color:#141414;">` +
`<p>${escapeHtml(intro)}</p>` +
`<p>${escapeHtml(next)}</p>` +
(schedule
? `<p><a href="${escapeHtml(input.schedulingUrl.trim())}">Choose a time</a></p>`
: "") +
`</div>`;
return { subject, text, html };
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+4
View File
@@ -19,6 +19,8 @@ import home from "./pages/home.json";
import templates from "./pages/templates.json";
import learn from "./pages/learn.json";
import about from "./pages/about.json";
import workshops from "./pages/workshops.json";
import workshopInquiries from "./pages/workshopInquiries.json";
import useCases from "./pages/useCases.json";
import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
@@ -94,6 +96,8 @@ export default {
templates,
learn,
about,
workshops,
workshopInquiries,
useCases,
useCasesDetail,
useCasesCompletedRules,
+2
View File
@@ -33,6 +33,7 @@ import home from "./pages/home.json";
import templates from "./pages/templates.json";
import learn from "./pages/learn.json";
import about from "./pages/about.json";
import workshops from "./pages/workshops.json";
import useCases from "./pages/useCases.json";
import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
@@ -77,6 +78,7 @@ const marketingMessages = {
templates,
learn,
about,
workshops,
useCases,
useCasesDetail,
useCasesCompletedRules,
+7
View File
@@ -20,6 +20,13 @@
"title": "About",
"description": "Why the Media Economies Design Lab at CU Boulder rebuilt CommunityRule, what organizers told us and who builds it."
},
"workshops": {
"title": "Workshops",
"description": "A facilitated workshop for credit unions, mutuals and co-ops. Talk with a facilitator about the rules you need before the hard decision."
},
"workshopInquiries": {
"title": "Workshop inquiries"
},
"learn": {
"title": "Learn"
},
+17
View File
@@ -0,0 +1,17 @@
{
"_comment": "Operator view of workshop inquiries. Access is a shared token, not a public page.",
"title": "Workshop inquiries",
"unlockLabel": "Access token",
"unlockButton": "Open inquiries",
"unlockError": "That token does not open inquiries.",
"empty": "No inquiries yet.",
"export": "Export CSV",
"columns": {
"when": "Received",
"organization": "Organization",
"kind": "Kind",
"members": "Members",
"route": "Route",
"contact": "Contact"
}
}
+199
View File
@@ -0,0 +1,199 @@
{
"_comment": "Workshops page. Quote wording, the line under Send, and the /kit note are the current Figma frames and are still pending final approval. The page names no price and no count of organizations.",
"hero": {
"eyebrow": "For credit unions, mutuals and co-ops",
"title": "Get the rules right before the hard decision",
"body": "Facilitators from the Media Economies Design Lab at the University of Colorado Boulder help your board, committee or council work through a hard challenge and agree on the rules that will carry you through it.",
"button": "Talk to a facilitator",
"credit": "From the lab and the organizers working with it.",
"cards": [
{ "id": "talk", "label": "How you talk", "color": "yellow" },
{ "id": "belongs", "label": "Who belongs", "color": "teal" },
{ "id": "decide", "label": "How you decide", "color": "lavender" },
{ "id": "disagree", "label": "What happens when you disagree", "color": "red" }
]
},
"overview": {
"title": "Start with one room",
"body": "Bring the challenge you keep putting off",
"button": "Talk to a facilitator",
"rooms": [
{ "id": "boards", "label": "Boards", "color": "teal" },
{ "id": "nominations", "label": "Nomination committees", "color": "lime" },
{ "id": "membership", "label": "Membership groups", "color": "lavender" },
{ "id": "votes", "label": "Teams getting ready for big votes", "color": "red" }
]
},
"circle": {
"title": "Then we get around a table",
"paragraphs": [
"This is where we dig into the challenge you brought. Big votes, boards that split on every big call, members who stopped showing up to vote. We’ve studied how co-ops, mutual aid communities and open source projects thrive so we can help others benefit from that experience.",
"We also bring a deck of hundreds of ways to decide, from consensus to sortition, and four questions about how you decide, who belongs, how you talk and what happens when you disagree.",
"We use the answers to those questions to work through your organization’s challenge with you. You leave with the right rules for what you are facing, written so your community is prepared to handle problems like this moving forward."
],
"button": "Talk to a facilitator",
"cards": [
{ "id": "decide", "label": "how you decide", "color": "lavender" },
{ "id": "belongs", "label": "who belongs", "color": "teal" },
{ "id": "talk", "label": "how you talk", "color": "yellow" },
{ "id": "disagree", "label": "what happens when you disagree", "color": "red" }
],
"deck": ["Lazy consensus", "Random choice", "Majority rule"],
"prompt": "What would it look like if you broke your own rules?"
},
"whoRunsIt": {
"title": "Facilitated by people who built CommunityRule",
"button": "Talk to a facilitator",
"people": [
{
"id": "nathan",
"name": "Nathan Schneider",
"bio": "Nathan Schneider started CommunityRule and wrote Everything for Everyone, a book about the co-op tradition.",
"image": "/assets/marketing/workshops/nathan-schneider.png",
"imageAlt": "Nathan Schneider"
},
{
"id": "dani",
"name": "Dani Shipley",
"bio": "Dani Shipley led experience design on CommunityRule and organizes with co-ops and mutual aid communities in Colorado.",
"image": "/assets/marketing/workshops/dani-shipley.png",
"imageAlt": "Dani Shipley"
}
]
},
"quote": {
"text": "We can learn to revive the democratic spirits dormant in so many credit unions, electric co-ops, insurance mutuals, and employee stock-ownership plans.",
"author": "Nathan Schneider",
"source": "Everything for Everyone, 2018"
},
"signup": {
"title": "Tell us about your organization",
"intro": "Here is the offer. A facilitator, a table and four questions your people have been answering by habit. Here is the ask. Tell us a little about your organization. It takes about two minutes.",
"optional": "Optional",
"send": "Send to a facilitator",
"sending": "Sending",
"assurance": "We will only write to you about this. Nothing goes on a mailing list.",
"sections": {
"takeOn": "What you want to take on",
"organization": "Your organization",
"aboutYou": "About you"
},
"challenges": {
"label": "What challenges do you want to take on?",
"hint": "Tick any that fit",
"otherLabel": "Tell us a little more",
"options": [
{ "id": "members-rarely-vote", "label": "Members rarely vote or take part" },
{ "id": "preparing-the-board", "label": "Finding and preparing people for the board" },
{ "id": "board-and-management", "label": "Unclear lines between the board and management" },
{ "id": "committees-unsure", "label": "Committees or councils unsure what they decide" },
{ "id": "disagreement", "label": "Disagreement on the board, or a split to avoid" },
{ "id": "big-change", "label": "A big change ahead, like a merger, a bylaw change or a new leader" },
{ "id": "new-directors", "label": "New directors or members learning how things really work" },
{ "id": "co-op-principles", "label": "Staying true to co-op principles as we grow" },
{ "id": "something-else", "label": "Something else" }
]
},
"question": {
"label": "What question has nobody at your organization asked out loud?",
"hint": "The one everyone works around"
},
"organizationName": { "label": "Organization name" },
"organizationKind": {
"label": "What kind of organization is it?",
"placeholder": "Select type...",
"options": [
{ "id": "credit-union", "label": "Credit union" },
{ "id": "mutual", "label": "Mutual insurer or other mutual" },
{ "id": "consumer-coop", "label": "Consumer or purchasing co-op" },
{ "id": "utility-coop", "label": "Electric, telecom or utility co-op" },
{ "id": "agricultural-coop", "label": "Agricultural or producer co-op" },
{ "id": "housing-coop", "label": "Housing co-op" },
{ "id": "worker-coop", "label": "Worker co-op" },
{ "id": "league", "label": "League, association or co-op developer" },
{ "id": "volunteer", "label": "Volunteer-run group" },
{ "id": "something-else", "label": "Something else" }
]
},
"members": {
"label": "How many members?",
"hint": "Members, member-owners or policyholders",
"placeholder": "Select size...",
"options": [
{ "id": "under-1000", "label": "Fewer than 1,000" },
{ "id": "1000-10000", "label": "1,000 to 10,000" },
{ "id": "10000-100000", "label": "10,000 to 100,000" },
{ "id": "100000-1000000", "label": "100,000 to 1 million" },
{ "id": "over-1000000", "label": "More than 1 million" },
{ "id": "not-sure", "label": "Not sure" }
]
},
"name": { "label": "Your name" },
"email": { "label": "Email" },
"role": {
"label": "Your role",
"placeholder": "Select role...",
"options": [
{ "id": "board", "label": "Board member or chair" },
{ "id": "executive", "label": "CEO or executive" },
{ "id": "staff", "label": "Staff" },
{ "id": "member", "label": "Member" },
{ "id": "something-else", "label": "Something else" }
]
},
"errors": {
"summary": "A few answers need a look.",
"failed": "We could not send that. Your answers are still here.",
"rateLimited": "Please wait a moment and try again. Your answers are still here.",
"challenges": "Tick at least one.",
"challengeOther": "Tell us what else you want to take on.",
"unspokenQuestion": "Keep this to 1,000 characters.",
"organizationName": "Tell us your organization's name.",
"organizationKind": "Choose the kind of organization.",
"memberCount": "Pick a rough number, or Not sure.",
"contactName": "Tell us your name.",
"email": "Check your email address. We need it to write back.",
"role": "Choose a role from the list."
},
"sent": {
"standardTitle": "Thank you. A facilitator will read this closely and write back within two working days.",
"standardBody": "The kit has the four questions and the thirteen templates if you want a head start.",
"standardButton": "Download the kit",
"largeTitle": "Thank you. A facilitator will read this closely and write back within two working days.",
"largeBody": "Organizations of this size start with a call.",
"largeButton": "Choose a time"
},
"side": {
"eyebrow": "Media Economies Design Lab at the University of Colorado Boulder",
"title": "Three things people ask first",
"questions": [
{
"q": "Would this replace our bylaws?",
"a": "No. Bylaws say what is legal, and your regulator’s rules still apply. A Rule says how you actually decide, and it sits beside them."
},
{
"q": "What does it cost?",
"a": "We work that out together, once we understand what you need. What you pay keeps the kit free for volunteer-run communities."
},
{
"q": "Who sees what we tell you?",
"a": "Only the people who answer workshop inquiries. Nothing you tell us is quoted or used without your permission, and your organization’s Rule stays private unless you share it."
}
],
"volunteerTitle": "Volunteer-run community?",
"volunteerBodyBefore": "You do not need this form. We have ",
"volunteerLink": "a free kit and office hours",
"volunteerBodyAfter": " for volunteer-run communities.",
"volunteerButton": "Download the kit",
"kitHref": "/kit",
"nextTitle": "What happens when you submit",
"steps": [
"Someone reads every form and writes back within two working days.",
"A short call to hear what is going on. It commits you to nothing.",
"A suggestion shaped to your organization, with how many people can take part and what it would cost."
],
"nextCredit": "Media Economies Design Lab at the University of Colorado Boulder"
},
"honeypotLabel": "Company website"
}
}
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "WorkshopInquiry" (
"id" TEXT NOT NULL,
"challenges" TEXT[],
"challengeOther" TEXT,
"unspokenQuestion" TEXT,
"organizationName" TEXT NOT NULL,
"organizationKind" TEXT NOT NULL,
"memberCount" TEXT NOT NULL,
"contactName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT,
"sourcePage" TEXT NOT NULL,
"sourceUrl" TEXT,
"routeSuggestion" TEXT NOT NULL,
"largeOrganization" BOOLEAN NOT NULL,
"inboxNotifiedAt" TIMESTAMP(3),
"confirmationSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkshopInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WorkshopInquiry_email_idx" ON "WorkshopInquiry"("email");
-- CreateIndex
CREATE INDEX "WorkshopInquiry_createdAt_idx" ON "WorkshopInquiry"("createdAt");
+25
View File
@@ -147,6 +147,31 @@ model MethodFacet {
@@index([group, value, matches])
}
/// Workshop page inquiry. Kept for the privacy-policy period, then deleted.
/// Member count is stored as the answer given. It never sets a price.
model WorkshopInquiry {
id String @id @default(cuid())
challenges String[]
challengeOther String?
unspokenQuestion String?
organizationName String
organizationKind String
memberCount String
contactName String
email String
role String?
sourcePage String
sourceUrl String?
routeSuggestion String
largeOrganization Boolean
inboxNotifiedAt DateTime?
confirmationSentAt DateTime?
createdAt DateTime @default(now())
@@index([email])
@@index([createdAt])
}
/// Template-level recommendation matrix (Template Composition, cols G–Y). Canonical
/// JSON in `data/templates/templateFacet.json`; rebuilt at seed like
/// `MethodFacet`. One row per `(templateSlug, group, value)` where the matrix
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

@@ -0,0 +1,9 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="209.373" height="419.361" viewBox="0 0 209.373 419.361" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 2147205370">
<path id="Vector" d="M80.8444 61.3494L147.958 128.463L80.8444 195.576L13.7312 128.463L80.8444 61.3494Z" fill="#016C3C"/>
<path id="Vector_2" d="M0 0H47.5275V95.055H0V0Z" fill="#016C3C"/>
<path id="Vector_3" d="M114.46 161.996H209.373V256.909H114.46V161.996Z" fill="#016C3C"/>
<path id="Vector_4" d="M80.993 223.804L13.8798 290.917L80.993 358.03L148.106 290.917L80.993 223.804Z" fill="#016C3C"/>
<path id="Vector_5" d="M47.5303 324.306H0.00281059V419.361H47.5303V324.306Z" fill="#016C3C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 713 B

@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="419" height="419" viewBox="0 0 419 419" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Subtract" d="M36.6449 121.031C-12.215 169.891 -12.2149 249.109 36.645 297.969L121.031 382.355C169.891 431.215 249.109 431.215 297.969 382.355L382.355 297.969C431.215 249.109 431.215 169.891 382.355 121.031L297.969 36.6449C249.109 -12.215 169.891 -12.2149 121.031 36.645L36.6449 121.031ZM153.038 153.042C121.857 184.223 121.857 234.778 153.038 265.959C184.22 297.141 234.775 297.141 265.956 265.959C297.137 234.778 297.137 184.223 265.956 153.042C234.775 121.86 184.22 121.86 153.038 153.042Z" fill-rule="evenodd" clip-rule="evenodd" fill="#FFB6C2"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

+55
View File
@@ -0,0 +1,55 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, it } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import WorkshopHero from "../../app/components/sections/WorkshopHero";
import WorkshopOverview from "../../app/components/sections/WorkshopOverview";
import WorkshopSignup from "../../app/components/sections/WorkshopSignup";
import messages from "../../messages/en/index";
describe("Workshops page sections", () => {
it("points every talk-to-a-facilitator button at the form", () => {
render(
<>
<WorkshopHero />
<WorkshopOverview />
</>,
);
const links = screen.getAllByRole("link", { name: "Talk to a facilitator" });
expect(links.length).toBeGreaterThanOrEqual(2);
for (const link of links) {
expect(link).toHaveAttribute("href", "#workshop-signup");
}
});
it("shows the full field set, one optional marker, and the settled challenge line", () => {
render(<WorkshopSignup issuedAt={Date.now() - 60_000} />);
expect(screen.getByText("How many members?")).toBeInTheDocument();
expect(screen.getByText("Members, member-owners or policyholders")).toBeInTheDocument();
expect(screen.getByText("Your role")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Select role..." })).toBeInTheDocument();
expect(
screen.getByText("A big change ahead, like a merger, a bylaw change or a new leader"),
).toBeInTheDocument();
expect(screen.getAllByText("Optional")).toHaveLength(2);
expect(screen.queryByText("What you want to take on.")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send to a facilitator" })).toBeInTheDocument();
});
it("replaces the form with the large-organization confirmation and names no price", () => {
render(
<WorkshopSignup
issuedAt={Date.now() - 60_000}
schedulingUrl="https://calendar.example/workshop"
initialStatus="sent"
initialLarge
/>,
);
expect(screen.getAllByText(/two working days/).length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: "Choose a time" })).toHaveAttribute(
"href",
"https://calendar.example/workshop",
);
expect(screen.queryByText(/\$\d/)).not.toBeInTheDocument();
expect(messages.pages.workshops.whoRunsIt.title).not.toMatch(/paid/);
});
});
+1
View File
@@ -10,6 +10,7 @@ describe("buildPublicSitemap", () => {
expect(urls).toContain(PRODUCTION_SITE_ORIGIN);
expect(urls).toContain(sitemapUrl("/about"));
expect(urls).toContain(sitemapUrl("/workshops"));
expect(urls).toContain(sitemapUrl("/learn"));
expect(urls).toContain(sitemapUrl("/templates"));
expect(urls).toContain(sitemapUrl("/blog"));
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { buildWorkshopConfirmationMail, buildWorkshopInboxMail } from "../../lib/workshopSignupMail";
import {
EMPTY_WORKSHOP_FORM_VALUES,
suggestWorkshopRoute,
type WorkshopFormValues,
} from "../../lib/workshopSignup";
import { parseWorkshopSignupPayload } from "../../lib/server/validation/workshopSignupSchemas";
const values: WorkshopFormValues = {
...EMPTY_WORKSHOP_FORM_VALUES,
challenges: ["big-change"],
organizationName: "Northside Credit Union",
organizationKind: "credit-union",
memberCount: "1000-10000",
contactName: "Ada Lovelace",
email: "ada@example.com",
sourcePage: "/workshops",
sourceUrl: "https://communityrule.com/workshops",
};
describe("suggestWorkshopRoute", () => {
it("sends volunteer-run groups to the kit", () => {
expect(
suggestWorkshopRoute({ organizationKind: "volunteer", memberCount: "10000-100000" }),
).toEqual({
largeOrganization: false,
note: "Volunteer-run group: send the kit and office hours.",
});
});
it("flags 1,000 or more members as a large organization and names no price", () => {
const route = suggestWorkshopRoute({
organizationKind: "credit-union",
memberCount: "1000-10000",
});
expect(route.largeOrganization).toBe(true);
expect(route.note).toBe("Large organization: call with the facilitator lead.");
expect(route.note.toLowerCase()).not.toMatch(/price|fee|\$|£|€/);
});
it("sends smaller leagues to a partnership call", () => {
expect(
suggestWorkshopRoute({ organizationKind: "league", memberCount: "under-1000" }).note,
).toBe("League, association or co-op developer: partnership call.");
});
it("sends everyone else the one-page brief", () => {
expect(
suggestWorkshopRoute({ organizationKind: "housing-coop", memberCount: "not-sure" }).note,
).toBe("Send the one-page brief, then offer a call.");
});
});
describe("workshop signup mail", () => {
it("keeps a price out of the inbox note and the confirmation", () => {
const inbox = buildWorkshopInboxMail({
values,
routeNote: "Large organization: call with the facilitator lead.",
largeOrganization: true,
requestId: "req-1",
schedulingUrl: "https://calendar.example/workshop",
});
const confirmation = buildWorkshopConfirmationMail({
largeOrganization: true,
schedulingUrl: "https://calendar.example/workshop",
});
const combined = `${inbox.text}\n${confirmation.text}`.toLowerCase();
expect(combined).not.toMatch(/price|fee|\$|£|€/);
expect(confirmation.text).toContain("two working days");
expect(confirmation.text).toContain("Choose a time");
expect(inbox.text).toContain("Northside Credit Union");
expect(inbox.text).toContain("https://communityrule.com/workshops");
});
});
describe("parseWorkshopSignupPayload", () => {
it("keeps answers and reports each missing required field", () => {
const parsed = parseWorkshopSignupPayload({
unspokenQuestion: "How do we grow?",
organizationName: "Northside",
email: "not-an-email",
});
expect(parsed.values.unspokenQuestion).toBe("How do we grow?");
expect(parsed.values.organizationName).toBe("Northside");
expect(parsed.fieldErrors.challenges).toBe("required");
expect(parsed.fieldErrors.organizationKind).toBe("required");
expect(parsed.fieldErrors.memberCount).toBe("required");
expect(parsed.fieldErrors.contactName).toBe("required");
expect(parsed.fieldErrors.email).toBe("invalid");
});
it("accepts the full field set", () => {
const parsed = parseWorkshopSignupPayload({
...values,
startedAt: Date.now() - 10_000,
});
expect(parsed.fieldErrors).toEqual({});
expect(parsed.values.email).toBe("ada@example.com");
});
});
+179
View File
@@ -0,0 +1,179 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const createMock = vi.fn();
const updateMock = vi.fn();
const sendInboxMock = vi.fn();
const sendConfirmationMock = vi.fn();
const isDatabaseConfiguredMock = vi.fn();
const rateLimitKeyMock = vi.hoisted(() =>
vi.fn(
(_key: string, _minIntervalMs: number): { ok: true } | { ok: false; retryAfterMs: number } => ({
ok: true,
}),
),
);
vi.mock("../../lib/server/db", () => ({
prisma: {
workshopInquiry: {
create: (...args: unknown[]) => createMock(...args),
update: (...args: unknown[]) => updateMock(...args),
},
},
}));
vi.mock("../../lib/server/mail", () => ({
sendWorkshopInquiryNotification: (...args: unknown[]) => sendInboxMock(...args),
sendWorkshopInquiryConfirmation: (...args: unknown[]) => sendConfirmationMock(...args),
}));
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: (key: string, minIntervalMs: number) => rateLimitKeyMock(key, minIntervalMs),
}));
import { POST } from "../../app/api/workshop-signup/route";
const startedAt = Date.now() - 30_000;
function payload(overrides: Record<string, unknown> = {}) {
return {
challenges: ["big-change"],
challengeOther: "",
unspokenQuestion: "Who decides when we merge?",
organizationName: "Northside Credit Union",
organizationKind: "credit-union",
memberCount: "under-1000",
contactName: "Ada Lovelace",
email: "Ada@Example.com",
role: "board",
sourcePage: "/workshops",
sourceUrl: "https://communityrule.com/workshops",
startedAt,
company_website: "",
...overrides,
};
}
describe("POST /api/workshop-signup", () => {
beforeEach(() => {
createMock.mockReset();
updateMock.mockReset();
sendInboxMock.mockReset();
sendConfirmationMock.mockReset();
rateLimitKeyMock.mockReset();
isDatabaseConfiguredMock.mockReset();
createMock.mockResolvedValue({ id: "inq-1" });
updateMock.mockResolvedValue({});
sendInboxMock.mockResolvedValue(undefined);
sendConfirmationMock.mockResolvedValue(undefined);
rateLimitKeyMock.mockImplementation(() => ({ ok: true as const }));
isDatabaseConfiguredMock.mockReturnValue(true);
process.env.WORKSHOP_INQUIRY_TO = "workshops@example.com";
});
afterEach(() => {
delete process.env.WORKSHOP_INQUIRY_TO;
delete process.env.NEXT_PUBLIC_WORKSHOP_SCHEDULING_URL;
});
it("saves the row, then emails the inbox, then confirms", async () => {
const order: string[] = [];
createMock.mockImplementation(async () => {
order.push("save");
return { id: "inq-1" };
});
sendInboxMock.mockImplementation(async () => {
order.push("inbox");
});
sendConfirmationMock.mockImplementation(async () => {
order.push("confirmation");
});
const res = await POST(
new NextRequest("https://x.test/api/workshop-signup", {
method: "POST",
body: JSON.stringify(payload()),
}),
undefined,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
ok: true,
status: "sent",
largeOrganization: false,
});
expect(order).toEqual(["save", "inbox", "confirmation"]);
expect(createMock.mock.calls[0][0].data.email).toBe("ada@example.com");
expect(createMock.mock.calls[0][0].data.routeSuggestion).toBe(
"Send the one-page brief, then offer a call.",
);
});
it("shows the confirmation and saves nothing when the honeypot is filled", async () => {
const res = await POST(
new NextRequest("https://x.test/api/workshop-signup", {
method: "POST",
body: JSON.stringify(payload({ company_website: "https://spam.example" })),
}),
undefined,
);
expect(res.status).toBe(200);
expect(createMock).not.toHaveBeenCalled();
expect(sendInboxMock).not.toHaveBeenCalled();
});
it("returns the form errors and keeps the answers", async () => {
const res = await POST(
new NextRequest("https://x.test/api/workshop-signup", {
method: "POST",
body: JSON.stringify(payload({ email: "not-an-email", organizationName: "Northside" })),
}),
undefined,
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.status).toBe("invalid");
expect(body.fieldErrors.email).toBe("invalid");
expect(body.values.organizationName).toBe("Northside");
expect(createMock).not.toHaveBeenCalled();
expect(sendInboxMock).not.toHaveBeenCalled();
});
it("keeps the row and skips a thrown inbox email", async () => {
sendInboxMock.mockRejectedValue(new Error("smtp down"));
const res = await POST(
new NextRequest("https://x.test/api/workshop-signup", {
method: "POST",
body: JSON.stringify(payload({ memberCount: "1000-10000" })),
}),
undefined,
);
expect(res.status).toBe(200);
expect(createMock).toHaveBeenCalledTimes(1);
expect(sendConfirmationMock).toHaveBeenCalledTimes(1);
const body = await res.json();
expect(body.largeOrganization).toBe(true);
expect(JSON.stringify(body)).not.toMatch(/price|\$/);
});
it("sends nothing when the row cannot be saved", async () => {
createMock.mockRejectedValue(new Error("db down"));
const res = await POST(
new NextRequest("https://x.test/api/workshop-signup", {
method: "POST",
body: JSON.stringify(payload()),
}),
undefined,
);
expect(res.status).toBe(502);
expect(sendInboxMock).not.toHaveBeenCalled();
expect(sendConfirmationMock).not.toHaveBeenCalled();
});
});