mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
added email and country in form submissions
This commit is contained in:
parent
f4967d62fd
commit
e074d2037f
9 changed files with 354 additions and 161 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
84
ui/src/components/lead-forms/detectCountry.ts
Normal file
84
ui/src/components/lead-forms/detectCountry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "0–5k" },
|
||||
{ value: "5k-100k", label: "5k–100k" },
|
||||
{ value: "100k+", label: "100k+" },
|
||||
{ value: "exploring", label: "Exploring" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: what best describes you.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue