added email and country in form submissions

This commit is contained in:
Pritesh 2026-06-17 18:21:49 +05:30
parent f4967d62fd
commit e074d2037f
9 changed files with 354 additions and 161 deletions

View file

@ -1,6 +1,5 @@
BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_NODE_ENV=development
# Base URL of the separate user_onboarding service (lead-gen + onboarding form
# submissions). Leave unset to disable those POSTs (PostHog capture still fires).
NEXT_PUBLIC_ONBOARDING_API_URL=http://localhost:8001
# form submissions backend
# NEXT_PUBLIC_ONBOARDING_API_URL=http://localhost:8001

View file

@ -4,7 +4,7 @@ import { ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
import { useAppConfig } from "@/context/AppConfigContext";
import { CaptchaChallenge } from "./CaptchaChallenge";
import {
@ -28,7 +28,9 @@ interface EnterpriseModalProps {
}
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const { config } = useAppConfig();
// Deployment provenance (analytics only); OSS submits via the public contact-sales path.
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaActive, setCaptchaActive] = useState(false);
@ -87,11 +89,10 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
setCaptchaActive(false);
setSubmitting(true);
try {
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
const token = await getAccessToken().catch(() => undefined);
await submitLead({
kind: "enterprise",
source,
origin,
payload: {
name: value.name,
company: value.company,
@ -103,7 +104,6 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
deployment: showDeployment ? value.deployment || "yes" : "yes",
agentGoal: value.agentGoal,
},
token,
});
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
reset();

View file

@ -1,7 +1,7 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
@ -14,10 +14,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useAppConfig } from "@/context/AppConfigContext";
import { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
import { FormTrustLine } from "./FormTrustLine";
import { isValidEmail } from "./isPersonalEmail";
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { PhoneField } from "./PhoneField";
@ -31,9 +33,17 @@ interface HireExpertModalProps {
}
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const { user } = useAuth(); // logged-in identity (prefills the email field)
const { config } = useAppConfig();
// Deployment provenance (analytics only): cloud → cloud_app, else oss_app. OSS submits the
// lead anonymously (cloud can't verify its token), so the email field below is the identity.
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
// Logged-in user's email (Stack uses primaryEmail; local uses email) — prefilled, editable.
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [email, setEmail] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [agentGoal, setAgentGoal] = useState("");
const [phone, setPhone] = useState("");
@ -41,8 +51,13 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
const [captchaActive, setCaptchaActive] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Prefill the email from the logged-in user when the modal opens (don't clobber edits).
useEffect(() => {
if (open && userEmail) setEmail((e) => e || userEmail);
}, [open, userEmail]);
const reset = () => {
setName(""); setCompany(""); setJobTitle(""); setAgentGoal("");
setName(""); setCompany(""); setEmail(""); setJobTitle(""); setAgentGoal("");
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
};
@ -51,6 +66,7 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
const baseValid =
Boolean(name.trim()) &&
Boolean(company.trim()) &&
isValidEmail(email) &&
Boolean(jobTitle.trim()) &&
Boolean(agentGoal.trim()) &&
Boolean(phone.trim()) &&
@ -72,13 +88,11 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
setCaptchaActive(false);
setSubmitting(true);
try {
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
const token = await getAccessToken().catch(() => undefined);
await submitLead({
kind: "hire_expert",
source,
payload: { name, company, jobTitle, agentGoal, phone, volume },
token,
origin,
payload: { name, company, email, jobTitle, agentGoal, phone, volume },
});
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
reset();
@ -123,6 +137,11 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-email">Email</Label>
<Input id="hire-email" type="email" placeholder="you@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-title">Job title</Label>
<Input id="hire-title" placeholder="VP Operations" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />

View file

@ -13,6 +13,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useAppConfig } from "@/context/AppConfigContext";
import { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
@ -23,32 +25,44 @@ import {
} from "./EnterpriseLeadFields";
import { validateWorkEmail } from "./isPersonalEmail";
import {
ONBOARDING_HEARD_OPTIONS,
ONBOARDING_MIGRATION_OPTIONS,
ONBOARDING_ONPREM_OPTIONS,
ONBOARDING_ONPREM_PERSONAS,
ONBOARDING_PERSONA_OPTIONS,
ONBOARDING_USAGE_CONTEXT_OPTIONS,
ONBOARDING_VOLUME_OPTIONS,
} from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { submitLead } from "./submitLead";
import { type OnboardingAnswers, skipOnboarding, submitOnboarding } from "./submitOnboarding";
import { type OnboardingAnswers, submitOnboarding } from "./submitOnboarding";
interface OnboardingModalProps {
open: boolean;
// Called after a tracked outcome (submit or skip) to dismiss the gate and
// stamp the matching server-side flag (completed_at vs skipped).
// Called after a tracked submit to dismiss the gate and stamp the server-side
// "completed" flag. Onboarding is compulsory — `skipped` is always false now.
onComplete: (skipped: boolean) => void;
}
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [companyName, setCompanyName] = useState("");
const [usageContext, setUsageContext] = useState("");
const { user } = useAuth(); // logged-in identity → onboarding email (sent silently)
const { config } = useAppConfig();
// Deployment provenance (analytics only).
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
// The logged-in user's email (Stack uses primaryEmail; local uses email). Sent in the
// body — there is no visible email field on the onboarding form.
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
const [persona, setPersona] = useState("");
const [onPremNeed, setOnPremNeed] = useState("");
const [migratingFrom, setMigratingFrom] = useState("");
const [migratingOtherProvider, setMigratingOtherProvider] = useState("");
const [switchReason, setSwitchReason] = useState("");
const [howHeard, setHowHeard] = useState("");
const [volume, setVolume] = useState("");
const [submitting, setSubmitting] = useState(false);
// Inline on-prem expansion: the FULL enterprise form, submitted through the
// same /api/v1/leads/enterprise path as the standalone Enterprise modal.
// Inline on-prem expansion: the FULL enterprise form, submitted through the same
// /api/v1/leads/enterprise path as the standalone Enterprise modal.
const [onPremExpanded, setOnPremExpanded] = useState(false);
const [ef, setEf] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [efEmailError, setEfEmailError] = useState<string | null>(null);
@ -57,12 +71,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const showOnPrem = ONBOARDING_ONPREM_PERSONAS.includes(persona);
const showManagedNote = showOnPrem && onPremNeed === "yes";
const wantsOnPrem = showManagedNote && onPremExpanded;
const isOtherProvider = migratingFrom === "other";
const isMigrating = Boolean(migratingFrom) && migratingFrom !== "no";
// All four questions are required (onboarding is compulsory). "Other" provider also
// needs its free-text name; the "why switching" note is optional.
const baseValid =
Boolean(persona) &&
Boolean(migratingFrom) &&
(!isOtherProvider || Boolean(migratingOtherProvider.trim())) &&
Boolean(howHeard) &&
Boolean(volume);
const canSubmit = baseValid && !submitting;
const answers = (): OnboardingAnswers => ({
companyName: companyName.trim() || undefined,
usageContext: usageContext || undefined,
persona: persona || undefined,
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
migratingFrom: migratingFrom || undefined,
migratingOtherProvider: isOtherProvider ? migratingOtherProvider.trim() || undefined : undefined,
switchReason: isMigrating ? switchReason.trim() || undefined : undefined,
howHeard: howHeard || undefined,
volume: volume || undefined,
});
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
@ -70,11 +99,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
if ("workEmail" in patch) setEfEmailError(null);
};
const expandOnPrem = () => {
setOnPremExpanded(true);
// Seed company from what we already collected (don't clobber edits).
setEf((v) => (v.company ? v : { ...v, company: companyName.trim() }));
};
const expandOnPrem = () => setOnPremExpanded(true);
const collapseOnPrem = () => {
setOnPremExpanded(false);
@ -82,28 +107,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
setEfEmailError(null);
};
// Best-effort persistence must never trap the user behind this hard gate.
// Dismiss immediately, then fire the token + network work in the background.
const finish = (skipped: boolean, withEnterprise: boolean) => {
// Best-effort persistence must never trap the user. Dismiss immediately, then fire
// the network work in the background. `withEnterprise` = also send the on-prem lead.
const finish = (withEnterprise: boolean) => {
if (submitting) return;
setSubmitting(true);
const data = answers();
const efSnapshot = withEnterprise ? { ...ef } : null;
onComplete(skipped);
onComplete(false); // compulsory — always "completed", never skipped
void (async () => {
const token = await getAccessToken().catch(() => undefined);
try {
if (skipped) await skipOnboarding(data, token);
else await submitOnboarding(data, token);
await submitOnboarding(data, origin, userEmail);
// Two distinct submissions on success: onboarding answers above, and the
// enterprise on-prem lead here (same endpoint as the standalone form).
if (efSnapshot) {
await submitLead({
kind: "enterprise",
source: "onboarding",
origin,
payload: {
name: efSnapshot.name,
company: efSnapshot.company || companyName.trim() || undefined,
company: efSnapshot.company || undefined,
jobTitle: efSnapshot.jobTitle,
workEmail: efSnapshot.workEmail,
phone: efSnapshot.phone,
@ -112,22 +136,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
deployment: "yes",
agentGoal: efSnapshot.agentGoal,
},
token,
});
// Only the on-prem/enterprise lead path sends an email; plain
// onboarding does not. Confirm the email just for this path.
// Only the on-prem/enterprise lead path sends an email; plain onboarding
// does not. Confirm the email just for this path.
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
}
} catch {
// Swallowed — the user is already in the product; network calls are
// bounded by a timeout in onboardingServiceClient.
// Swallowed — the user is already in the product; calls are timeout-bounded.
}
})();
};
const handleSubmit = () => {
// Onboarding answers are all optional, so we only gate on the enterprise
// fields when the user has actually engaged the on-prem section.
if (!baseValid) {
toast.error(
isOtherProvider && !migratingOtherProvider.trim()
? "Please tell us which provider you're migrating from"
: "Please answer all the questions",
);
return;
}
// If the user engaged the on-prem section, validate it + pop the anti-spam check.
if (wantsOnPrem) {
const err = validateWorkEmail(ef.workEmail);
if (err) { setEfEmailError(err); return; }
@ -135,26 +164,23 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
toast.error("Please complete the on-prem details below, or remove that section.");
return;
}
// Pop the anti-spam check on top of the modal before sending the lead.
setCaptchaActive(true);
return;
}
finish(false, false);
finish(false);
};
// Runs once the captcha popup is verified (on-prem path).
const submitWithOnPrem = () => {
setCaptchaActive(false);
finish(false, true);
finish(true);
};
const handleSkip = () => finish(true, false);
return (
<LeadModalShell
open={open}
// Hard gate: no outside/escape close, hide the built-in ×. The only exits
// are Skip or Get started.
// Hard gate: no outside/escape close, hide the built-in ×. Onboarding is
// compulsory — the only exit is "Get started" once the questions are answered.
onOpenChange={() => {}}
contentProps={{
className: "[&>button]:hidden",
@ -166,30 +192,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
eyebrow="Welcome"
title="Welcome to Dograh"
description="A few quick questions so we can tailor your experience. Takes ~20 seconds."
primary={{ label: "Get started", onClick: handleSubmit, disabled: submitting }}
secondary={{ label: "Skip for now", onClick: handleSkip, disabled: submitting }}
primary={{ label: "Get started", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
overlay={captchaActive ? <CaptchaChallenge onVerified={submitWithOnPrem} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="ob-company">
Company name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ob-company" placeholder="Acme Inc." value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-usage">Where do you plan to use this?</Label>
<Select value={usageContext} onValueChange={setUsageContext}>
<SelectTrigger id="ob-usage"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_USAGE_CONTEXT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-persona">What best describes you?</Label>
<Select
@ -275,6 +281,76 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ob-volume">Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger id="ob-volume"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-migrating">Are you migrating from another provider?</Label>
<Select
value={migratingFrom}
onValueChange={(v) => {
setMigratingFrom(v);
if (v !== "other") setMigratingOtherProvider("");
if (v === "no") setSwitchReason("");
}}
>
<SelectTrigger id="ob-migrating"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_MIGRATION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{isOtherProvider && (
<div className="mt-2 space-y-1.5">
<Label htmlFor="ob-other-provider">Other provider</Label>
<Input
id="ob-other-provider"
placeholder="Enter the provider here"
value={migratingOtherProvider}
onChange={(e) => setMigratingOtherProvider(e.target.value)}
/>
</div>
)}
{isMigrating && (
<div className="mt-2 space-y-1.5">
<Label htmlFor="ob-switch-reason">
Why are you switching? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="ob-switch-reason"
rows={2}
placeholder="e.g. cost, self-hosting, concurrency, data security, latency"
value={switchReason}
onChange={(e) => setSwitchReason(e.target.value)}
/>
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-heard">How did you hear about us?</Label>
<Select value={howHeard} onValueChange={setHowHeard}>
<SelectTrigger id="ob-heard"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_HEARD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</LeadModalShell>
);

View file

@ -0,0 +1,84 @@
// Best-effort country detection for lead provenance — no permission prompt, no
// network call, no precise geolocation. Primary signal: the browser's IANA timezone
// → ISO 3166-1 country (location-based; timezones shared by several countries resolve
// to the larger/likelier one). Fallback: the browser locale's region. Returns a
// human-readable country name (e.g. "India") — it goes in the founders-email subject —
// or undefined if nothing resolves. Sent silently in the form body; never shown.
// IANA timezone → ISO 3166-1 alpha-2. Curated to the common business regions; anything
// not listed falls back to the locale region below. Shared zones → larger country.
const TZ_TO_ISO: Record<string, string> = {
// United States
"America/New_York": "US", "America/Detroit": "US", "America/Chicago": "US",
"America/Denver": "US", "America/Phoenix": "US", "America/Los_Angeles": "US",
"America/Anchorage": "US", "America/Adak": "US", "America/Boise": "US",
"America/Indiana/Indianapolis": "US", "Pacific/Honolulu": "US",
// Canada
"America/Toronto": "CA", "America/Montreal": "CA", "America/Vancouver": "CA",
"America/Edmonton": "CA", "America/Winnipeg": "CA", "America/Halifax": "CA",
"America/St_Johns": "CA", "America/Regina": "CA",
// Mexico & Central/South America
"America/Mexico_City": "MX", "America/Tijuana": "MX", "America/Monterrey": "MX",
"America/Cancun": "MX", "America/Sao_Paulo": "BR", "America/Bahia": "BR",
"America/Argentina/Buenos_Aires": "AR", "America/Santiago": "CL",
"America/Bogota": "CO", "America/Lima": "PE", "America/Caracas": "VE",
"America/Guayaquil": "EC", "America/Montevideo": "UY",
// United Kingdom & Ireland
"Europe/London": "GB", "Europe/Dublin": "IE",
// Europe
"Europe/Paris": "FR", "Europe/Berlin": "DE", "Europe/Madrid": "ES",
"Europe/Rome": "IT", "Europe/Amsterdam": "NL", "Europe/Brussels": "BE",
"Europe/Zurich": "CH", "Europe/Vienna": "AT", "Europe/Stockholm": "SE",
"Europe/Oslo": "NO", "Europe/Copenhagen": "DK", "Europe/Helsinki": "FI",
"Europe/Warsaw": "PL", "Europe/Prague": "CZ", "Europe/Budapest": "HU",
"Europe/Bucharest": "RO", "Europe/Athens": "GR", "Europe/Lisbon": "PT",
"Europe/Moscow": "RU", "Europe/Kiev": "UA", "Europe/Kyiv": "UA",
"Europe/Istanbul": "TR",
// South Asia
"Asia/Kolkata": "IN", "Asia/Calcutta": "IN", "Asia/Karachi": "PK",
"Asia/Dhaka": "BD", "Asia/Colombo": "LK", "Asia/Kathmandu": "NP",
// Middle East
"Asia/Dubai": "AE", "Asia/Riyadh": "SA", "Asia/Qatar": "QA",
"Asia/Kuwait": "KW", "Asia/Jerusalem": "IL", "Asia/Tehran": "IR",
"Asia/Baghdad": "IQ", "Asia/Amman": "JO",
// East Asia
"Asia/Shanghai": "CN", "Asia/Hong_Kong": "HK", "Asia/Taipei": "TW",
"Asia/Tokyo": "JP", "Asia/Seoul": "KR",
// Southeast Asia
"Asia/Singapore": "SG", "Asia/Bangkok": "TH", "Asia/Jakarta": "ID",
"Asia/Kuala_Lumpur": "MY", "Asia/Manila": "PH", "Asia/Ho_Chi_Minh": "VN",
// Oceania
"Australia/Sydney": "AU", "Australia/Melbourne": "AU", "Australia/Brisbane": "AU",
"Australia/Perth": "AU", "Australia/Adelaide": "AU", "Pacific/Auckland": "NZ",
// Africa
"Africa/Johannesburg": "ZA", "Africa/Lagos": "NG", "Africa/Cairo": "EG",
"Africa/Nairobi": "KE", "Africa/Casablanca": "MA", "Africa/Accra": "GH",
};
// Resolve the browser locale's region (e.g. "en-GB" → "GB"), maximizing likely subtags
// for bare languages (e.g. "en" → "US"). Returns an ISO alpha-2 or undefined.
function localeRegion(): string | undefined {
if (typeof navigator === "undefined" || !navigator.language) return undefined;
try {
return new Intl.Locale(navigator.language).maximize().region ?? undefined;
} catch {
return undefined;
}
}
export function detectCountry(): string | undefined {
let iso: string | undefined;
try {
iso = TZ_TO_ISO[Intl.DateTimeFormat().resolvedOptions().timeZone];
} catch {
// Intl unavailable — fall through to the locale region.
}
iso = iso || localeRegion();
if (!iso) return undefined;
try {
// Human-readable name for the founders-email subject (e.g. "IN" → "India").
return new Intl.DisplayNames(["en"], { type: "region" }).of(iso) ?? iso;
} catch {
return iso;
}
}

View file

@ -13,6 +13,11 @@ export type LeadSource =
export type LeadKind = "hire_expert" | "enterprise";
// Provenance stamped by the in-app forms (analytics only; the marketing site and
// server use "website"). Derived from AppConfig deploymentMode: cloud → "cloud_app",
// otherwise "oss_app". OSS submits via the public no-token endpoints.
export type LeadOrigin = "cloud_app" | "oss_app";
// Monthly call-volume buckets. Values MUST match the backend qualifier enum
// (user_onboarding flows): "0-5k" | "5k-100k" | "100k+" | "not-sure".
export const VOLUME_OPTIONS = [
@ -49,12 +54,36 @@ export const ENTERPRISE_DEPLOYMENT_OPTIONS = [
// Post-signup onboarding form options
// ---------------------------------------------------------------------------
// Onboarding: where do you plan to use this (highest-signal question — keep exact).
export const ONBOARDING_USAGE_CONTEXT_OPTIONS = [
{ value: "for_my_clients", label: "For my clients" },
{ value: "for_my_company", label: "For my company" },
{ value: "personal", label: "Personal use case" },
{ value: "exploring", label: "Just exploring" },
// Onboarding: are you migrating from another provider? (a trimmed competitor list).
// "no" → not migrating; "other" → reveals a free-text provider field.
export const ONBOARDING_MIGRATION_OPTIONS = [
{ value: "no", label: "No, I'm not migrating" },
{ value: "vapi", label: "Vapi" },
{ value: "retell", label: "Retell" },
{ value: "bland", label: "Bland" },
{ value: "elevenlabs", label: "ElevenLabs" },
{ value: "synthflow", label: "Synthflow" },
{ value: "other", label: "Other" },
] as const;
// Onboarding: how did you hear about us? (trimmed).
export const ONBOARDING_HEARD_OPTIONS = [
{ value: "github", label: "GitHub" },
{ value: "search_engine", label: "Search engine" },
{ value: "social_media", label: "Social media (Twitter, LinkedIn)" },
{ value: "youtube", label: "YouTube" },
{ value: "ai_tool", label: "AI tool (ChatGPT, Claude)" },
{ value: "referral", label: "Someone told me about it" },
{ value: "other", label: "Other" },
] as const;
// Onboarding: expected monthly call volume. Its own set — value "exploring" is NOT
// the qualifier's "not-sure"; onboarding has no flow, so this is analytics-only.
export const ONBOARDING_VOLUME_OPTIONS = [
{ value: "0-5k", label: "05k" },
{ value: "5k-100k", label: "5k100k" },
{ value: "100k+", label: "100k+" },
{ value: "exploring", label: "Exploring" },
] as const;
// Onboarding: what best describes you.

View file

@ -1,37 +1,26 @@
// Thin client for the SEPARATE user_onboarding service (its own base URL).
// Not part of the generated Dograh SDK — a different host. Sends the SAME Dograh
// Bearer token the browser already holds. All calls are BEST-EFFORT: failures are
// swallowed so a down/erroring service never blocks the user from the product.
// Not part of the generated Dograh SDK — a different host. All endpoints are PUBLIC
// (no auth token); identity is the email carried in the body. Every call is
// BEST-EFFORT: failures are swallowed so a down/erroring service never blocks the user.
// Base URL of the user_onboarding service; unset → calls are skipped (no-op).
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL;
// Base URL of the user_onboarding service. Unset (the default for self-hosted OSS —
// .env.example ships this commented out) → fall back to our cloud leads backend so we
// still receive OSS form submissions. Override the env var to point elsewhere (or to a
// local backend) to stop sending leads to us.
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL || "https://leads.dograh.com";
// Bound every call so a slow/hung service can never freeze the UI (the onboarding
// modal used to await this with no timeout). Best-effort: failures are surfaced
// via console.error (captured as Sentry breadcrumbs) but never thrown.
// Bound every call so a slow/hung service can never freeze the UI. Best-effort:
// failures are surfaced via console.error (Sentry breadcrumbs) but never thrown.
const TIMEOUT_MS = 6000;
// POST a JSON body to the onboarding service. The Dograh auth token is attached
// when supplied; public endpoints (contact-sales) are called without one.
async function post(path: string, token: string | undefined, body: unknown): Promise<void> {
if (!BASE_URL) {
// Misconfig would otherwise be invisible: a submit dropped on the floor
// while PostHog still records the event as "submitted".
console.error(
`[onboarding] NEXT_PUBLIC_ONBOARDING_API_URL is unset — "${path}" not persisted to the onboarding service`,
);
return;
}
// POST a JSON body to the onboarding service (public — no auth header).
async function post(path: string, body: unknown): Promise<void> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
@ -54,28 +43,15 @@ const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
enterprise: "/api/v1/leads/enterprise",
};
// Persist a lead submission (hire-expert / enterprise).
// Persist a lead submission (hire-expert / enterprise). Email is in the body.
export async function postLeadToService(
kind: "hire_expert" | "enterprise",
token: string,
body: Record<string, unknown>,
): Promise<void> {
await post(LEAD_PATH[kind], token, body);
}
// Persist a logged-out enterprise lead via the PUBLIC contact-sales endpoint
// (no auth; the service applies a honeypot + per-IP rate limit). It runs the
// same unified enterprise flow as the authenticated /leads/enterprise path.
export async function postContactSalesToService(
body: Record<string, unknown>,
): Promise<void> {
await post("/api/v1/contact-sales", undefined, body);
await post(LEAD_PATH[kind], body);
}
// Persist an onboarding submission (or skip — body carries `skipped`).
export async function postOnboardingToService(
token: string,
body: Record<string, unknown>,
): Promise<void> {
await post("/api/v1/onboarding", token, body);
export async function postOnboardingToService(body: Record<string, unknown>): Promise<void> {
await post("/api/v1/onboarding", body);
}

View file

@ -1,14 +1,15 @@
// Single submission seam for all lead forms.
// Fires a PostHog capture, and (when a token is supplied) POSTs to the separate
// user_onboarding service. The service call is best-effort — PostHog is the
// durable record and the user is never blocked if the service is down.
// Fires a PostHog capture (the durable record) and POSTs to the separate, PUBLIC
// user_onboarding service (best-effort — the user is never blocked if it's down).
// No auth token: identity is the email in the payload.
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import type { LeadKind, LeadSource } from "./leadFieldOptions";
import { postContactSalesToService, postLeadToService } from "./onboardingServiceClient";
import { detectCountry } from "./detectCountry";
import type { LeadKind, LeadOrigin, LeadSource } from "./leadFieldOptions";
import { postLeadToService } from "./onboardingServiceClient";
const SUBMIT_EVENT: Record<LeadKind, string> = {
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
@ -18,24 +19,18 @@ const SUBMIT_EVENT: Record<LeadKind, string> = {
export interface SubmitLeadArgs {
kind: LeadKind;
source: LeadSource;
// Field values, already validated by the caller. Non-sensitive lead data.
// Deployment provenance (analytics only): "cloud_app" | "oss_app".
origin: LeadOrigin;
// Field values, already validated by the caller. Includes the contact email.
payload: Record<string, unknown>;
// Dograh auth token; when present the lead is also persisted to the service.
token?: string;
}
export async function submitLead({ kind, source, payload, token }: SubmitLeadArgs): Promise<void> {
export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise<void> {
// `country` is detected silently (timezone/locale) and sent in the body — no visible
// field. It feeds the founders-notification email subject server-side.
const body = { source, origin, country: detectCountry(), ...payload };
// PostHog capture — the durable record, always fired.
posthog.capture(SUBMIT_EVENT[kind], { source, ...payload });
// Persist to the separate user_onboarding service (best-effort).
if (token) {
await postLeadToService(kind, token, { source, ...payload });
} else if (kind === "enterprise") {
// Logged-out visitor (e.g. the auth-page Enterprise Enquiry CTA): the
// public contact-sales endpoint persists the lead and runs the same
// unified enterprise flow server-side, keyed off `workEmail` (which the
// form requires when unauthenticated).
await postContactSalesToService({ source, ...payload });
}
posthog.capture(SUBMIT_EVENT[kind], body);
// Persist to the separate user_onboarding service (best-effort, public).
await postLeadToService(kind, body);
}

View file

@ -1,34 +1,49 @@
// Submission seam for the post-signup onboarding form.
// Fires a PostHog capture (submit or skip) AND, when a token is supplied, POSTs
// the answers to the separate user_onboarding service (best-effort). The "show
// once per user" flag itself is stamped on the server-backed onboarding state
// by the caller (LeadFormsContext.completeOnboarding → OnboardingContext), not here.
// Fires a PostHog capture AND POSTs the answers to the separate, PUBLIC
// user_onboarding service (best-effort). The "show once per user" flag is stamped
// on the server-backed onboarding state by the caller, not here.
//
// No auth token. The logged-in user's email is passed in from the modal (available in
// the frontend session for both cloud and OSS) and sent in the body — there is no
// visible email field. `country` is detected silently and sent too. Onboarding is now
// COMPULSORY (no skip).
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import { detectCountry } from "./detectCountry";
import type { LeadOrigin } from "./leadFieldOptions";
import { postOnboardingToService } from "./onboardingServiceClient";
export interface OnboardingAnswers {
companyName?: string;
usageContext?: string;
persona?: string;
// Only present when persona unlocks the on-prem question.
onPremNeed?: string;
// Are you migrating from another provider? ("no" | a provider | "other").
migratingFrom?: string;
// Free-text provider name when migratingFrom === "other".
migratingOtherProvider?: string;
// Free-text "why are you switching?" (shown when migrating).
switchReason?: string;
// How did you hear about us?
howHeard?: string;
// Expected monthly call volume (0-5k | 5k-100k | 100k+ | exploring).
volume?: string;
}
export async function submitOnboarding(answers: OnboardingAnswers, token?: string): Promise<void> {
posthog.capture(PostHogEvent.ONBOARDING_SUBMITTED, { ...answers });
if (token) {
await postOnboardingToService(token, { source: "onboarding", ...answers, skipped: false });
}
}
export async function skipOnboarding(answers: OnboardingAnswers, token?: string): Promise<void> {
// Skipping is itself signal — capture whatever was filled before the skip.
posthog.capture(PostHogEvent.ONBOARDING_SKIPPED, { ...answers });
if (token) {
await postOnboardingToService(token, { source: "onboarding", ...answers, skipped: true });
}
export async function submitOnboarding(
answers: OnboardingAnswers,
origin: LeadOrigin,
email?: string,
): Promise<void> {
posthog.capture(PostHogEvent.ONBOARDING_SUBMITTED, { ...answers, origin });
await postOnboardingToService({
source: "onboarding",
origin,
country: detectCountry(),
...(email ? { email } : {}),
...answers,
skipped: false, // onboarding is compulsory now — kept for stored-shape continuity
});
}