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:
1 parent
974a5ace4c
commit
c0ba3a3798
50 files changed
+2773
-1
No files matched your search
@@ -6,6 +6,7 @@ import { USE_CASE_DETAIL_SLUGS } from "./useCaseSyntheticPost";
|
||||
const STATIC_PUBLIC_PATHS = [
|
||||
"/",
|
||||
"/about",
|
||||
"/workshops",
|
||||
"/learn",
|
||||
"/templates",
|
||||
"/blog",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
Reference in new issue
Block a user