diff --git a/ui/.env.example b/ui/.env.example index 08da3a1c..13d914eb 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -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 diff --git a/ui/src/components/lead-forms/EnterpriseModal.tsx b/ui/src/components/lead-forms/EnterpriseModal.tsx index 74217601..052b2871 100644 --- a/ui/src/components/lead-forms/EnterpriseModal.tsx +++ b/ui/src/components/lead-forms/EnterpriseModal.tsx @@ -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(EMPTY_ENTERPRISE_FIELDS); const [emailError, setEmailError] = useState(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(); diff --git a/ui/src/components/lead-forms/HireExpertModal.tsx b/ui/src/components/lead-forms/HireExpertModal.tsx index 25c85d83..345d172a 100644 --- a/ui/src/components/lead-forms/HireExpertModal.tsx +++ b/ui/src/components/lead-forms/HireExpertModal.tsx @@ -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 } +
+ + setEmail(e.target.value)} /> +
+
setJobTitle(e.target.value)} /> diff --git a/ui/src/components/lead-forms/OnboardingModal.tsx b/ui/src/components/lead-forms/OnboardingModal.tsx index 487d3015..46c8d1e6 100644 --- a/ui/src/components/lead-forms/OnboardingModal.tsx +++ b/ui/src/components/lead-forms/OnboardingModal.tsx @@ -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(EMPTY_ENTERPRISE_FIELDS); const [efEmailError, setEfEmailError] = useState(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) => { @@ -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 ( {}} 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 ? setCaptchaActive(false)} /> : undefined} >
-
- - setCompanyName(e.target.value)} /> -
- -
- - -
-
+ + + {ONBOARDING_VOLUME_OPTIONS.map((o) => ( + {o.label} + ))} + + +
+ +
+ + + + {isOtherProvider && ( +
+ + setMigratingOtherProvider(e.target.value)} + /> +
+ )} + + {isMigrating && ( +
+ +