diff --git a/ui/src/components/lead-forms/isPersonalEmail.ts b/ui/src/components/lead-forms/isPersonalEmail.ts new file mode 100644 index 00000000..91e27cd8 --- /dev/null +++ b/ui/src/components/lead-forms/isPersonalEmail.ts @@ -0,0 +1,49 @@ +// Returns true if the email uses a common free/personal domain. +// Used to gate "work email" fields on lead forms. + +const PERSONAL_EMAIL_DOMAINS = new Set([ + "gmail.com", + "googlemail.com", + "yahoo.com", + "yahoo.co.in", + "yahoo.co.uk", + "ymail.com", + "outlook.com", + "hotmail.com", + "hotmail.co.uk", + "live.com", + "msn.com", + "icloud.com", + "me.com", + "mac.com", + "proton.me", + "protonmail.com", + "pm.me", + "aol.com", + "gmx.com", + "gmx.net", + "mail.com", + "zoho.com", + "yandex.com", + "fastmail.com", +]); + +export function isValidEmail(email: string): boolean { + // Pragmatic check — not RFC-perfect, but rejects obvious garbage. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()); +} + +export function isPersonalEmail(email: string): boolean { + const at = email.trim().toLowerCase().split("@"); + if (at.length !== 2) return false; + return PERSONAL_EMAIL_DOMAINS.has(at[1]); +} + +// Convenience validator for work-email fields. +// Returns an error string, or null if valid. +export function validateWorkEmail(email: string): string | null { + if (!email.trim()) return "Work email is required"; + if (!isValidEmail(email)) return "Please enter a valid email address"; + if (isPersonalEmail(email)) return "Please use your work email"; + return null; +} diff --git a/ui/src/components/lead-forms/leadFieldOptions.ts b/ui/src/components/lead-forms/leadFieldOptions.ts new file mode 100644 index 00000000..feafe7f8 --- /dev/null +++ b/ui/src/components/lead-forms/leadFieldOptions.ts @@ -0,0 +1,81 @@ +// Shared dropdown options + lead source/kind types for the lead-gen forms. + +export type LeadSource = + | "sidebar" + | "billing_card" + | "builder_nudge" + | "topup" + | "hire_expert"; + +export type LeadKind = "topup" | "hire_expert" | "enterprise"; + +// Top-up: expected monthly call volume. ">20k" unlocks the volume-pricing block. +export const TOPUP_VOLUME_OPTIONS = [ + { value: "0-5k", label: "0–5k calls/month" }, + { value: "5k-20k", label: "5k–20k calls/month" }, + { value: ">20k", label: ">20k calls/month" }, +] as const; + +// The value that gates the volume-pricing qualifier block. +export const VOLUME_PRICING_GATE = ">20k"; + +// Top-up volume-pricing qualifier: company size (small-business scale). +export const TOPUP_COMPANY_SIZE_OPTIONS = [ + { value: "only_me", label: "Only me" }, + { value: "2-10", label: "2–10" }, + { value: "10-100", label: "10–100" }, + { value: "100-1000", label: "100–1000" }, + { value: "1000+", label: "1000+" }, +] as const; + +// Hire-an-Expert timeline. +export const HIRE_TIMELINE_OPTIONS = [ + { value: "asap", label: "ASAP" }, + { value: "2-4_weeks", label: "2–4 weeks" }, + { value: "1-2_months", label: "1–2 months" }, + { value: "flexible", label: "Flexible" }, + { value: "exploring", label: "Exploring" }, +] as const; + +// Hire-an-Expert expected monthly call volume. +export const HIRE_VOLUME_OPTIONS = [ + { value: "0-5k", label: "0–5k" }, + { value: "5k-100k", label: "5k–100k" }, + { value: "100k+", label: "100k+" }, + { value: "not_sure", label: "Not sure" }, +] as const; + +// Hire-an-Expert current stage. +export const HIRE_STAGE_OPTIONS = [ + { value: "live_process", label: "Have a live process we want to automate" }, + { value: "idea_no_process", label: "Have an idea, no process yet" }, + { value: "researching", label: "Just researching" }, + { value: "built_need_help", label: "Already built something, need help fixing" }, +] as const; + +// Enterprise industry. +export const ENTERPRISE_INDUSTRY_OPTIONS = [ + { value: "financial_services", label: "Financial services" }, + { value: "healthcare", label: "Healthcare" }, + { value: "insurance", label: "Insurance" }, + { value: "government", label: "Government" }, + { value: "telecom", label: "Telecom" }, + { value: "bpo", label: "BPO" }, + { value: "other", label: "Other" }, +] as const; + +// Enterprise company size (enterprise scale — intentionally different from top-up's). +export const ENTERPRISE_COMPANY_SIZE_OPTIONS = [ + { value: "50-200", label: "50–200" }, + { value: "200-1000", label: "200–1000" }, + { value: "1000-5000", label: "1000–5000" }, + { value: "5000+", label: "5000+" }, +] as const; + +// Enterprise timeline. +export const ENTERPRISE_TIMELINE_OPTIONS = [ + { value: "this_quarter", label: "This quarter" }, + { value: "next_quarter", label: "Next quarter" }, + { value: "6_months", label: "6 months" }, + { value: "exploring", label: "Exploring" }, +] as const; diff --git a/ui/src/components/lead-forms/submitLead.ts b/ui/src/components/lead-forms/submitLead.ts new file mode 100644 index 00000000..077a9486 --- /dev/null +++ b/ui/src/components/lead-forms/submitLead.ts @@ -0,0 +1,31 @@ +// Single submission seam for all lead forms. +// Today: fires a PostHog capture. Later: add a POST to the backend +// (MongoDB) endpoint here — no form component will need to change. + +import posthog from "posthog-js"; + +import { PostHogEvent } from "@/constants/posthog-events"; + +import type { LeadKind, LeadSource } from "./leadFieldOptions"; + +const SUBMIT_EVENT: Record = { + topup: PostHogEvent.TOPUP_REQUESTED, + hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED, + enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED, +}; + +export interface SubmitLeadArgs { + kind: LeadKind; + source: LeadSource; + // Field values, already validated by the caller. Non-sensitive lead data. + payload: Record; +} + +export async function submitLead({ kind, source, payload }: SubmitLeadArgs): Promise { + // PostHog capture — the durable record until the backend endpoint lands. + posthog.capture(SUBMIT_EVENT[kind], { source, ...payload }); + + // FUTURE: when the MongoDB endpoint exists, POST here, e.g. + // const res = await submitLeadApiV1LeadsPost({ body: { kind, source, ...payload } }); + // if (res.error) throw new Error(detailFromError(res.error, "Failed to submit")); +}