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

+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.",
};
}