Add the Workshops page and keep each sign-up on the site.

The form is built once to the settled field set, and a valid send is saved before anyone is emailed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-03 19:56:49 -07:00
1 parent 974a5ace4c
commit c0ba3a3798
50 files changed
+2773 -1

No files matched your search

+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();
});
});