feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur

Revised lead-capture surfaces and credits bar:

- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
  sticky footer, trust-line slot), PhoneField (react-international-phone,
  dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
  monthly volume. EnterpriseModal: + work email (required logged-out),
  conditional deployment (yes/no/maybe, source-gated), agent goal.
  OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
  chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
  Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
  buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
  drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pritesh 2026-06-09 13:49:07 +05:30
parent 39a5ae4966
commit d694a81f0a
18 changed files with 1051 additions and 512 deletions

View file

@ -42,6 +42,8 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-cta: var(--cta);
--color-cta-foreground: var(--cta-foreground);
}
:root {
@ -77,6 +79,9 @@
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* Single restrained warm accent — used only on primary CTAs + focus rings. */
--cta: oklch(0.72 0.15 65);
--cta-foreground: oklch(0.16 0.02 60);
}
.dark {
@ -111,6 +116,9 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* Warm accent, slightly brighter against the near-black surfaces. */
--cta: oklch(0.78 0.16 67);
--cta-foreground: oklch(0.16 0.02 60);
}
@layer base {
@ -135,3 +143,45 @@
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
@layer components {
/* CSS-only audio-waveform motif for the auth brand panel. A row of bars that
breathe at staggered intervals, evoking live voice. Decorative only. */
.auth-waveform {
display: flex;
align-items: center;
gap: 0.3rem;
height: 3.5rem;
}
.auth-waveform span {
display: block;
width: 0.25rem;
border-radius: 9999px;
background: linear-gradient(
to top,
color-mix(in oklch, var(--cta) 70%, transparent),
color-mix(in oklch, var(--cta) 25%, transparent)
);
animation: auth-wave 1.4s ease-in-out infinite;
transform-origin: center;
}
.auth-waveform span:nth-child(1) { animation-delay: 0s; height: 35%; }
.auth-waveform span:nth-child(2) { animation-delay: 0.15s; height: 65%; }
.auth-waveform span:nth-child(3) { animation-delay: 0.3s; height: 100%; }
.auth-waveform span:nth-child(4) { animation-delay: 0.45s; height: 55%; }
.auth-waveform span:nth-child(5) { animation-delay: 0.6s; height: 80%; }
.auth-waveform span:nth-child(6) { animation-delay: 0.3s; height: 45%; }
.auth-waveform span:nth-child(7) { animation-delay: 0.15s; height: 70%; }
.auth-waveform span:nth-child(8) { animation-delay: 0s; height: 30%; }
@keyframes auth-wave {
0%, 100% { transform: scaleY(0.4); opacity: 0.7; }
50% { transform: scaleY(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.auth-waveform span { animation: none; }
}
}

View file

@ -0,0 +1,110 @@
"use client";
// Compact self-serve "Buy Credits" control for the billing card. Preset amount
// chips plus a custom amount (min $5) feed the Razorpay seam in
// @/lib/billing/topup. Analytics: chip selection and the buy click are captured
// for funnel analysis. The seam currently throws "not wired yet"; we surface
// that as a calm inline note rather than an error toast.
import posthog from "posthog-js";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PostHogEvent } from "@/constants/posthog-events";
import { MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup";
import { cn } from "@/lib/utils";
export function BuyCreditsControl() {
const [selected, setSelected] = useState<number | null>(null);
const [custom, setCustom] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The effective amount: a parsed custom value takes precedence when present.
const customAmount = custom.trim() ? Number(custom) : null;
const amount = customAmount ?? selected;
const valid = amount != null && Number.isFinite(amount) && amount >= MIN_TOPUP_USD;
const selectPreset = (value: number) => {
setSelected(value);
setCustom("");
setError(null);
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: value });
};
const onCustomChange = (raw: string) => {
setCustom(raw);
setSelected(null);
setError(null);
const parsed = Number(raw);
if (raw.trim() && Number.isFinite(parsed) && parsed >= MIN_TOPUP_USD) {
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: parsed });
}
};
const onBuy = async () => {
if (!valid || amount == null) return;
setBusy(true);
setError(null);
posthog.capture(PostHogEvent.BUY_CREDITS_CLICKED, { amount });
try {
await startTopUp(amount);
} catch {
// The seam is intentionally unimplemented until Razorpay lands.
setError("Self-serve top-up is coming soon. Use \"Hire an Expert\" or contact us for now.");
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{TOPUP_PRESETS.map((value) => (
<button
key={value}
type="button"
onClick={() => selectPreset(value)}
aria-pressed={selected === value}
className={cn(
"rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
"border-input text-foreground hover:bg-accent",
selected === value && "border-cta bg-cta/10 text-foreground ring-1 ring-cta/40",
)}
>
${value}
</button>
))}
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
inputMode="decimal"
value={custom}
onChange={(e) => onCustomChange(e.target.value)}
placeholder="Custom"
aria-label={`Custom amount (min $${MIN_TOPUP_USD})`}
className="h-9 w-28 pl-5"
/>
</div>
</div>
{error ? (
<p className="text-xs text-muted-foreground">{error}</p>
) : (
<p className="text-xs text-muted-foreground">Minimum ${MIN_TOPUP_USD}.</p>
)}
<Button
type="button"
onClick={onBuy}
disabled={!valid || busy}
className="bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
{busy ? "Starting…" : "Buy Credits"}
</Button>
</div>
);
}

View file

@ -1,19 +1,22 @@
"use client";
import { UserRound } from "lucide-react";
import posthog from "posthog-js";
import { useCallback, useEffect, useState } from "react";
import { getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet } from "@/client/sdk.gen";
import type { MpsCreditsResponse } from "@/client/types.gen";
import { BuyCreditsControl } from "@/components/billing/BuyCreditsControl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { PostHogEvent } from "@/constants/posthog-events";
import { useLeadForms } from "@/context/LeadFormsContext";
import { useAuth } from "@/lib/auth";
export function DograhCreditsCard() {
const auth = useAuth();
const { openHireExpert, openTopUp } = useLeadForms();
const { openHireExpert, openEnterprise } = useLeadForms();
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
@ -80,16 +83,31 @@ export function DograhCreditsCard() {
</p>
)}
{/* Footer CTAs — card ends with an action */}
<div className="mt-6 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground">Running low?</span>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button variant="outline" className="gap-2" onClick={() => openHireExpert("billing_card")}>
<UserRound className="h-4 w-4" />
Hire an Expert
</Button>
<Button onClick={() => openTopUp("billing_card")}>Request top-up</Button>
{/* Footer CTAs — card ends with self-serve + done-for-you actions */}
<div className="mt-6 space-y-4 border-t pt-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Running low?</p>
<p className="text-sm text-muted-foreground">Top up instantly, or have us build it for you.</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:items-end">
<BuyCreditsControl />
<Button variant="outline" className="gap-2" onClick={() => openHireExpert("billing_card")}>
<UserRound className="h-4 w-4" />
Hire an Expert
</Button>
</div>
</div>
<button
type="button"
onClick={() => {
posthog.capture(PostHogEvent.CUSTOM_PRICING_CLICKED);
openEnterprise("billing_custom_pricing");
}}
className="text-xs text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Contact Us: Custom pricing for committed monthly volume
</button>
</div>
</CardContent>
</Card>

View file

@ -1,16 +1,9 @@
"use client";
import { useState } from "react";
import { ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -21,45 +14,82 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
import { FormTrustLine } from "./FormTrustLine";
import { validateWorkEmail } from "./isPersonalEmail";
import {
ENTERPRISE_COMPANY_SIZE_OPTIONS,
ENTERPRISE_INDUSTRY_OPTIONS,
ENTERPRISE_TIMELINE_OPTIONS,
ENTERPRISE_DEPLOYMENT_OPTIONS,
ENTERPRISE_DEPLOYMENT_SOURCES,
ENTERPRISE_VOLUME_OPTIONS,
type LeadSource,
} from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { MathCaptcha } from "./MathCaptcha";
import { PhoneField } from "./PhoneField";
import { submitLead } from "./submitLead";
interface EnterpriseModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
source: LeadSource;
// Optional values to pre-fill when the modal opens (e.g. company name already
// collected in the onboarding form). Backward-compatible: omitted = no prefill.
prefill?: { company?: string };
}
export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalProps) {
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
const { getAccessToken, isAuthenticated } = useAuth(); // Dograh token for the onboarding service
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [industry, setIndustry] = useState("");
const [companySize, setCompanySize] = useState("");
const [timeline, setTimeline] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [workEmail, setWorkEmail] = useState("");
const [phone, setPhone] = useState("");
const [notes, setNotes] = useState("");
const [volume, setVolume] = useState("");
const [deployment, setDeployment] = useState("");
const [agentGoal, setAgentGoal] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaValid, setCaptchaValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
// The deployment question is only surfaced for custom-volume / Contact-Us /
// pricing-custom-volume entry points; elsewhere it is hidden and the payload
// defaults to "yes".
const showDeployment = ENTERPRISE_DEPLOYMENT_SOURCES.includes(source);
// Work email is mandatory only when the visitor is logged out (we already
// have the email for authenticated users via their Dograh token).
const workEmailRequired = !isAuthenticated;
const reset = () => {
setCompany(""); setIndustry(""); setCompanySize(""); setTimeline("");
setWorkEmail(""); setPhone(""); setNotes(""); setEmailError(null);
setCaptchaValid(false); setSubmitting(false);
setName(""); setCompany(""); setJobTitle(""); setWorkEmail("");
setPhone(""); setVolume(""); setDeployment(""); setAgentGoal("");
setEmailError(null); setCaptchaValid(false); setSubmitting(false);
};
// Seed reusable fields from prefill when the modal opens, so we don't re-ask
// for info already captured upstream (e.g. company name from onboarding).
const prefillCompany = prefill?.company;
useEffect(() => {
if (open && prefillCompany) {
setCompany((prev) => prev || prefillCompany);
}
}, [open, prefillCompany]);
const canSubmit =
Boolean(name.trim()) &&
Boolean(company.trim()) &&
Boolean(phone.trim()) &&
Boolean(volume) &&
(!workEmailRequired || Boolean(workEmail.trim())) &&
captchaValid &&
!submitting;
const handleSubmit = async () => {
const err = validateWorkEmail(workEmail);
if (err) { setEmailError(err); return; }
if (!company.trim() || !industry || !companySize || !timeline) {
if (workEmailRequired || workEmail.trim()) {
const err = validateWorkEmail(workEmail);
if (err) { setEmailError(err); return; }
}
if (!name.trim() || !company.trim() || !phone.trim() || !volume) {
toast.error("Please fill in all required fields");
return;
}
@ -67,10 +97,23 @@ export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalP
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,
payload: { company, industry, companySize, timeline, workEmail, phone, notes },
payload: {
name,
company,
jobTitle,
workEmail,
phone,
volume,
// Hidden entry points imply enterprise intent — default to "yes".
deployment: showDeployment ? deployment || "yes" : "yes",
agentGoal,
},
token,
});
toast.success("Thanks — our team will reach out about enterprise deployment.");
reset();
@ -82,59 +125,40 @@ export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalP
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Enterprise deployment</DialogTitle>
<DialogDescription>
SSO, on-prem, SOC2, data residency. Tell us about your environment.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2 sm:grid-cols-2">
<LeadModalShell
open={open}
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
icon={ShieldCheck}
eyebrow="Enterprise"
title="Talk to our team"
description="SSO, on-prem, data residency, committed volume. Tell us about your environment."
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
trustLine={<FormTrustLine />}
>
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ent-name">Name</Label>
<Input id="ent-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ent-company">Company name</Label>
<Input id="ent-company" value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Industry</Label>
<Select value={industry} onValueChange={setIndustry}>
<SelectTrigger><SelectValue placeholder="Select industry" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_INDUSTRY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Label htmlFor="ent-title">
Job title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ent-title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Company size</Label>
<Select value={companySize} onValueChange={setCompanySize}>
<SelectTrigger><SelectValue placeholder="Select size" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_COMPANY_SIZE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Timeline</Label>
<Select value={timeline} onValueChange={setTimeline}>
<SelectTrigger><SelectValue placeholder="Select timeline" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_TIMELINE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="ent-email">Work email</Label>
<Label htmlFor="ent-email">
Work email{!workEmailRequired && <span className="text-muted-foreground"> (optional)</span>}
</Label>
<Input
id="ent-email"
type="email"
@ -144,37 +168,55 @@ export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalP
/>
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
</div>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="ent-phone">Phone <span className="text-muted-foreground">(optional)</span></Label>
<Input id="ent-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ent-phone">Phone</Label>
<PhoneField id="ent-phone" value={phone} onChange={setPhone} required />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="ent-notes">
Anything else we should know? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="ent-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Regulatory context, urgency, current stack…"
rows={3}
/>
</div>
<div className="sm:col-span-2">
<MathCaptcha id="ent-captcha" onValidChange={setCaptchaValid} />
<div className="space-y-1.5">
<Label>Monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit"}
</Button>
{showDeployment && (
<div className="space-y-1.5">
<Label>Need enterprise deployment (SSO, on-prem, data residency)?</Label>
<Select value={deployment} onValueChange={setDeployment}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_DEPLOYMENT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ent-goal">
What do you want the voice agent to do? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="ent-goal"
value={agentGoal}
onChange={(e) => setAgentGoal(e.target.value)}
placeholder="Use case, regulatory context, current stack…"
rows={3}
/>
</div>
</DialogContent>
</Dialog>
<MathCaptcha id="ent-captcha" onValidChange={setCaptchaValid} />
</div>
</LeadModalShell>
);
}

View file

@ -0,0 +1,10 @@
// Shared reassurance line shown beneath every lead-form submit. A small,
// consistent trust signal — keeps the promise identical across all forms.
export function FormTrustLine() {
return (
<p className="text-center text-xs text-muted-foreground">
Average response: under 10 minutes during business hours.
</p>
);
}

View file

@ -1,19 +1,11 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
@ -22,14 +14,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
import {
HIRE_STAGE_OPTIONS,
HIRE_TIMELINE_OPTIONS,
HIRE_VOLUME_OPTIONS,
type LeadSource,
} from "./leadFieldOptions";
import { FormTrustLine } from "./FormTrustLine";
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { MathCaptcha } from "./MathCaptcha";
import { PhoneField } from "./PhoneField";
import { submitLead } from "./submitLead";
interface HireExpertModalProps {
@ -40,25 +31,32 @@ interface HireExpertModalProps {
}
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [business, setBusiness] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [agentGoal, setAgentGoal] = useState("");
const [phone, setPhone] = useState("");
const [timeline, setTimeline] = useState("");
const [volume, setVolume] = useState("");
const [hasScripts, setHasScripts] = useState("");
const [stage, setStage] = useState("");
const [captchaValid, setCaptchaValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setCompany(""); setBusiness(""); setAgentGoal(""); setPhone("");
setTimeline(""); setVolume(""); setHasScripts(""); setStage("");
setCaptchaValid(false); setSubmitting(false);
setName(""); setCompany(""); setJobTitle(""); setAgentGoal("");
setPhone(""); setVolume(""); setCaptchaValid(false); setSubmitting(false);
};
const canSubmit =
Boolean(name.trim()) &&
Boolean(company.trim()) &&
Boolean(agentGoal.trim()) &&
Boolean(phone.trim()) &&
Boolean(volume) &&
captchaValid &&
!submitting;
const handleSubmit = async () => {
if (!company.trim() || !business.trim() || !timeline || !volume || !hasScripts || !stage) {
if (!name.trim() || !company.trim() || !agentGoal.trim() || !phone.trim() || !volume) {
toast.error("Please fill in all required fields");
return;
}
@ -66,10 +64,13 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
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: { company, business, agentGoal, phone, timeline, volume, hasScripts, stage },
payload: { name, company, jobTitle, agentGoal, phone, volume },
token,
});
toast.success("Thanks — we'll reach out about building your agent.");
reset();
@ -81,115 +82,76 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Let us build your voice agent</DialogTitle>
<DialogDescription>
Building good voice agents is nuanced. Tell us what you need and we&apos;ll take it end-to-end.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<LeadModalShell
open={open}
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
icon={Sparkles}
eyebrow="Done-for-you"
title="Let us build your voice agent"
description="Building good voice agents is nuanced. Tell us what you need and we'll take it end-to-end."
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
helper={
<button
type="button"
onClick={onOpenEnterprise}
className="underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Need enterprise deployment? (SSO, on-prem, data residency)
</button>
}
trustLine={<FormTrustLine />}
>
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="hire-name">Name</Label>
<Input id="hire-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-company">Company name</Label>
<Input id="hire-company" value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-title">
Job title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="hire-title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-goal">What do you want the voice agent to do?</Label>
<Textarea
id="hire-goal"
value={agentGoal}
onChange={(e) => setAgentGoal(e.target.value)}
placeholder="Use case, target outcomes, any remarks…"
rows={3}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="hire-business">What does your business do? <span className="text-muted-foreground">(1 line)</span></Label>
<Input id="hire-business" value={business} onChange={(e) => setBusiness(e.target.value)} />
<Label htmlFor="hire-phone">Phone</Label>
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-goal">
What do you want the agent to do? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="hire-goal"
value={agentGoal}
onChange={(e) => setAgentGoal(e.target.value)}
placeholder="Use case and any remarks…"
rows={3}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-phone">Phone / WhatsApp <span className="text-muted-foreground">(optional)</span></Label>
<Input id="hire-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Timeline</Label>
<Select value={timeline} onValueChange={setTimeline}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{HIRE_TIMELINE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{HIRE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Existing call scripts or workflows to share?</Label>
<RadioGroup value={hasScripts} onValueChange={setHasScripts} className="flex gap-6">
<div className="flex items-center gap-2">
<RadioGroupItem value="yes" id="scripts-yes" />
<Label htmlFor="scripts-yes" className="font-normal">Yes</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="no" id="scripts-no" />
<Label htmlFor="scripts-no" className="font-normal">No</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-1.5">
<Label>Current stage</Label>
<Select value={stage} onValueChange={setStage}>
<SelectTrigger><SelectValue placeholder="Select your current stage" /></SelectTrigger>
<Label>Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{HIRE_STAGE_OPTIONS.map((o) => (
{HIRE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MathCaptcha id="hire-captcha" onValidChange={setCaptchaValid} />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit"}
</Button>
</div>
<div className="mt-3 border-t pt-3">
<button
type="button"
onClick={onOpenEnterprise}
className="text-sm text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Need enterprise deployment? (SSO, on-prem, SOC2, data residency)
</button>
</div>
</DialogContent>
</Dialog>
<MathCaptcha id="hire-captcha" onValidChange={setCaptchaValid} />
</div>
</LeadModalShell>
);
}

View file

@ -0,0 +1,126 @@
"use client";
// Shared chrome for the lead dialogs (HireExpert, Enterprise, post-signup
// Onboarding). Wraps the existing @/components/ui/dialog primitive (which already
// supplies the blurred backdrop) and adds a consistent header (icon + eyebrow +
// title), a scrollable body, a sticky footer (primary CTA + optional ghost
// secondary + optional helper slot), and a bottom trust-line slot. The visual
// language is refined dark minimalism: zinc surface, hairline border, one warm
// accent reserved for the primary action.
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface LeadModalShellProps {
icon: LucideIcon;
title: string;
eyebrow?: string;
description?: string;
children: ReactNode;
// Primary action — rendered with the warm CTA accent.
primary: { label: string; onClick: () => void; disabled?: boolean; loading?: boolean };
// Optional ghost secondary (e.g. Cancel / Skip).
secondary?: { label: string; onClick: () => void; disabled?: boolean };
// Optional helper rendered in the footer beside the actions (e.g. a link).
helper?: ReactNode;
// Optional trust line beneath the footer (we pass <FormTrustLine/>).
trustLine?: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
// Forwarded to DialogContent so callers can lock dismissal (onboarding gate).
contentProps?: React.ComponentProps<typeof DialogContent>;
}
export function LeadModalShell({
icon: Icon,
title,
eyebrow,
description,
children,
primary,
secondary,
helper,
trustLine,
open,
onOpenChange,
contentProps,
}: LeadModalShellProps) {
const { className: contentClassName, ...restContentProps } = contentProps ?? {};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"max-h-[90vh] gap-0 overflow-hidden p-0 sm:max-w-[520px]",
contentClassName,
)}
{...restContentProps}
>
{/* Header */}
<DialogHeader className="space-y-0 border-b border-border/60 px-6 py-5 text-left">
<div className="flex items-start gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/40 text-cta">
<Icon className="size-5" />
</span>
<div className="min-w-0 space-y-1">
{eyebrow && (
<span className="block text-[0.7rem] font-medium uppercase tracking-[0.14em] text-cta/90">
{eyebrow}
</span>
)}
<DialogTitle className="text-lg font-semibold leading-tight">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-sm leading-snug">
{description}
</DialogDescription>
)}
</div>
</div>
</DialogHeader>
{/* Scrollable body */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-5">{children}</div>
{/* Sticky footer */}
<div className="space-y-3 border-t border-border/60 bg-background/80 px-6 py-4 backdrop-blur-sm">
<div className="flex flex-col-reverse items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">{helper}</div>
<div className="flex items-center justify-end gap-2">
{secondary && (
<Button
type="button"
variant="ghost"
onClick={secondary.onClick}
disabled={secondary.disabled}
>
{secondary.label}
</Button>
)}
<Button
type="button"
onClick={primary.onClick}
disabled={primary.disabled || primary.loading}
className="bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
{primary.loading ? "Submitting…" : primary.label}
</Button>
</div>
</div>
{trustLine}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/lib/auth";
import {
ONBOARDING_ONPREM_OPTIONS,
ONBOARDING_ONPREM_PERSONAS,
ONBOARDING_PERSONA_OPTIONS,
ONBOARDING_USAGE_CONTEXT_OPTIONS,
} from "./leadFieldOptions";
import { type OnboardingAnswers, skipOnboarding, submitOnboarding } from "./submitOnboarding";
interface OnboardingModalProps {
open: boolean;
// Called after a tracked outcome (submit or skip) to dismiss the gate.
onComplete: () => void;
// Opens the existing EnterpriseModal, prefilled with what we already collected.
onOpenEnterprise: (prefill: { company?: string }) => void;
}
export function OnboardingModal({ open, onComplete, onOpenEnterprise }: OnboardingModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [companyName, setCompanyName] = useState("");
const [usageContext, setUsageContext] = useState("");
const [persona, setPersona] = useState("");
const [onPremNeed, setOnPremNeed] = useState("");
const [submitting, setSubmitting] = useState(false);
const showOnPrem = ONBOARDING_ONPREM_PERSONAS.includes(persona);
const showManagedNote = showOnPrem && onPremNeed === "yes";
const answers = (): OnboardingAnswers => ({
companyName: companyName.trim() || undefined,
usageContext: usageContext || undefined,
persona: persona || undefined,
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
});
const handleSubmit = async () => {
setSubmitting(true);
try {
const token = await getAccessToken().catch(() => undefined);
await submitOnboarding(answers(), token);
onComplete();
} catch {
// Submission is best-effort. Never block the user from reaching the
// product — treat a failure as complete.
onComplete();
}
};
const handleSkip = async () => {
// Skipping is itself signal — capture whatever was filled.
try {
const token = await getAccessToken().catch(() => undefined);
await skipOnboarding(answers(), token);
} finally {
onComplete();
}
};
return (
<Dialog open={open}>
<DialogContent
// No tracked-outcome-free exits: block Escape, outside-click, and hide
// the built-in close (×). The only ways out are Skip or Get started.
className="max-w-md [&>button]:hidden"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Welcome to Dograh</DialogTitle>
<DialogDescription>
A few quick questions so we can tailor your experience. Takes ~20 seconds.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="ob-company">
Company name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ob-company" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Where do you plan to use this?</Label>
<Select value={usageContext} onValueChange={setUsageContext}>
<SelectTrigger><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>What best describes you?</Label>
<Select
value={persona}
onValueChange={(v) => {
setPersona(v);
// Reset the conditional answer if they leave the on-prem-eligible persona.
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) setOnPremNeed("");
}}
>
<SelectTrigger><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_PERSONA_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showOnPrem && (
<div className="space-y-1.5 rounded-md border-l-2 border-primary bg-muted/40 p-3">
<Label>Do you need on-prem deployment for compliance &amp; data residency?</Label>
<Select value={onPremNeed} onValueChange={setOnPremNeed}>
<SelectTrigger><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_ONPREM_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{showManagedNote && (
<div className="mt-2 space-y-2">
<p className="text-sm text-muted-foreground">
We provide a <span className="font-medium text-foreground">Managed On-Prem solution</span> for
enterprises to ensure compliance and data security. Share your contact and our team will reach out.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenEnterprise({ company: companyName.trim() || undefined })}
>
Talk to us about on-prem
</Button>
<p className="text-xs text-muted-foreground">Optional you can skip and continue.</p>
</div>
)}
</div>
)}
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" onClick={handleSkip} disabled={submitting}>
Skip for now
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving…" : "Get started"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,66 @@
"use client";
// Dark-themed wrapper around react-international-phone's PhoneInput.
// Emits a clean E.164 string (the backend geo/qualification rule keys off the
// dial code). The library is styled with its own CSS variables, which we map to
// our dark surface tokens so the field matches the rest of the form. Default
// country is the US; the user can switch via the flag selector.
import "react-international-phone/style.css";
import { PhoneInput } from "react-international-phone";
import { cn } from "@/lib/utils";
interface PhoneFieldProps {
id?: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
disabled?: boolean;
}
// Map the library's theming variables onto our dark surface tokens so the
// control reads as one cohesive input rather than a third-party widget.
const phoneThemeVars: React.CSSProperties = {
["--react-international-phone-height" as string]: "2.25rem",
["--react-international-phone-background-color" as string]: "transparent",
["--react-international-phone-text-color" as string]: "var(--foreground)",
["--react-international-phone-border-color" as string]: "var(--input)",
["--react-international-phone-border-radius" as string]: "var(--radius-md)",
["--react-international-phone-font-size" as string]: "0.875rem",
["--react-international-phone-country-selector-background-color" as string]:
"transparent",
["--react-international-phone-country-selector-background-color-hover" as string]:
"var(--accent)",
["--react-international-phone-dropdown-item-background-color" as string]:
"var(--popover)",
["--react-international-phone-dropdown-item-text-color" as string]:
"var(--popover-foreground)",
["--react-international-phone-dropdown-item-background-color-hover" as string]:
"var(--accent)",
["--react-international-phone-selected-dropdown-item-background-color" as string]:
"var(--accent)",
};
export function PhoneField({ id, value, onChange, required, disabled }: PhoneFieldProps) {
return (
<div style={phoneThemeVars} className="phone-field-dark">
<PhoneInput
defaultCountry="us"
value={value}
onChange={(phone) => onChange(phone)}
disabled={disabled}
inputProps={{ id, required }}
className="w-full"
inputClassName={cn(
"!w-full !bg-transparent !text-foreground placeholder:!text-muted-foreground",
"focus-visible:!border-ring focus-visible:!ring-[3px] focus-visible:!ring-ring/50 !outline-none",
)}
countrySelectorStyleProps={{
buttonClassName: "!h-9 !border-input !bg-transparent",
}}
/>
</div>
);
}

View file

@ -1,188 +0,0 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { validateWorkEmail } from "./isPersonalEmail";
import {
type LeadSource,
TOPUP_COMPANY_SIZE_OPTIONS,
TOPUP_VOLUME_OPTIONS,
VOLUME_PRICING_GATE,
} from "./leadFieldOptions";
import { MathCaptcha } from "./MathCaptcha";
import { submitLead } from "./submitLead";
interface TopUpModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
source: LeadSource;
onOpenEnterprise: () => void;
}
export function TopUpModal({ open, onOpenChange, source, onOpenEnterprise }: TopUpModalProps) {
const [credits, setCredits] = useState("");
const [useCase, setUseCase] = useState("");
const [volume, setVolume] = useState("");
const [workEmail, setWorkEmail] = useState("");
const [company, setCompany] = useState("");
const [companySize, setCompanySize] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaValid, setCaptchaValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const wantsVolumePricing = volume === VOLUME_PRICING_GATE;
const reset = () => {
setCredits(""); setUseCase(""); setVolume(""); setWorkEmail("");
setCompany(""); setCompanySize(""); setEmailError(null);
setCaptchaValid(false); setSubmitting(false);
};
const handleSubmit = async () => {
if (!credits.trim() || !useCase.trim() || !volume) {
toast.error("Please fill in all required fields");
return;
}
if (wantsVolumePricing) {
const err = validateWorkEmail(workEmail);
if (err) { setEmailError(err); return; }
if (!company.trim() || !companySize) {
toast.error("Please complete the volume-pricing details");
return;
}
}
if (!captchaValid) { toast.error("Please answer the quick check"); return; }
setSubmitting(true);
try {
await submitLead({
kind: "topup",
source,
payload: {
credits, useCase, volume, wantsVolumePricing,
...(wantsVolumePricing ? { workEmail, company, companySize } : {}),
},
});
toast.success("Thanks — we'll get your top-up sorted shortly.");
reset();
onOpenChange(false);
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Request a credit top-up</DialogTitle>
<DialogDescription>
Tell us how many credits you need and we&apos;ll sort you out.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="topup-credits">How many credits?</Label>
<Input
id="topup-credits"
inputMode="numeric"
value={credits}
onChange={(e) => setCredits(e.target.value)}
placeholder="e.g. 5000"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="topup-usecase">What&apos;s the use case?</Label>
<Input id="topup-usecase" value={useCase} onChange={(e) => setUseCase(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger><SelectValue placeholder="Select volume" /></SelectTrigger>
<SelectContent>
{TOPUP_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{wantsVolumePricing && (
<div className="space-y-3 rounded-md border-l-2 border-primary bg-muted/40 p-3">
<p className="text-sm font-medium text-primary">Talk to us about volume pricing</p>
<div className="space-y-1.5">
<Label htmlFor="topup-email">Work email</Label>
<Input
id="topup-email"
type="email"
value={workEmail}
onChange={(e) => { setWorkEmail(e.target.value); setEmailError(null); }}
placeholder="you@company.com"
/>
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="topup-company">Company name</Label>
<Input id="topup-company" value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Company size</Label>
<Select value={companySize} onValueChange={setCompanySize}>
<SelectTrigger><SelectValue placeholder="Select size" /></SelectTrigger>
<SelectContent>
{TOPUP_COMPANY_SIZE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<button
type="button"
onClick={onOpenEnterprise}
className="text-sm text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Need enterprise deployment? (SSO, on-prem, SOC2, data residency)
</button>
</div>
)}
<MathCaptcha id="topup-captcha" onValidChange={setCaptchaValid} />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit request"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -3,79 +3,73 @@
export type LeadSource =
| "sidebar"
| "billing_card"
| "billing_custom_pricing"
| "builder_nudge"
| "topup"
| "hire_expert";
| "hire_expert"
| "onboarding"
| "pricing_custom_volume"
| "landing_contact";
export type LeadKind = "topup" | "hire_expert" | "enterprise";
export type LeadKind = "hire_expert" | "enterprise";
// Top-up: expected monthly call volume. ">20k" unlocks the volume-pricing block.
export const TOPUP_VOLUME_OPTIONS = [
{ value: "0-5k", label: "05k calls/month" },
{ value: "5k-20k", label: "5k20k 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: "210" },
{ value: "10-100", label: "10100" },
{ value: "100-1000", label: "1001000" },
{ value: "1000+", label: "1000+" },
] as const;
// Hire-an-Expert timeline.
export const HIRE_TIMELINE_OPTIONS = [
{ value: "asap", label: "ASAP" },
{ value: "2-4_weeks", label: "24 weeks" },
{ value: "1-2_months", label: "12 months" },
{ value: "flexible", label: "Flexible" },
{ value: "exploring", label: "Exploring" },
] as const;
// Hire-an-Expert expected monthly call volume.
export const HIRE_VOLUME_OPTIONS = [
// 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 = [
{ value: "0-5k", label: "05k" },
{ value: "5k-100k", label: "5k100k" },
{ value: "100k+", label: "100k+" },
{ value: "not-sure", label: "Not sure" },
] as const;
// Hire-an-Expert expected monthly call volume (shared bucket set).
export const HIRE_VOLUME_OPTIONS = VOLUME_OPTIONS;
// Enterprise monthly call volume (shared bucket set).
export const ENTERPRISE_VOLUME_OPTIONS = VOLUME_OPTIONS;
// Lead sources for which the Enterprise modal surfaces the conditional
// "Need enterprise deployment (SSO, on-prem, data residency)?" question.
// Other entry points hide it and default the payload to "yes".
export const ENTERPRISE_DEPLOYMENT_SOURCES: readonly LeadSource[] = [
"billing_custom_pricing",
"pricing_custom_volume",
"landing_contact",
];
// Enterprise deployment need (conditional — see ENTERPRISE_DEPLOYMENT_SOURCES).
export const ENTERPRISE_DEPLOYMENT_OPTIONS = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
{ value: "maybe", label: "Maybe" },
] as const;
// ---------------------------------------------------------------------------
// 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" },
] as const;
// Onboarding: what best describes you.
export const ONBOARDING_PERSONA_OPTIONS = [
{ value: "enterprise_midmarket", label: "Enterprise / Mid-Market" },
{ value: "agency", label: "Agency / consultancy building for clients" },
{ value: "local_business", label: "Local business" },
{ value: "startup", label: "Startup" },
{ value: "solo", label: "Solo founder / builder" },
] as const;
// Persona values that unlock the on-prem conditional question.
export const ONBOARDING_ONPREM_PERSONAS: readonly string[] = ["enterprise_midmarket"];
// Onboarding: on-prem deployment need (conditional on Enterprise/Mid-Market).
export const ONBOARDING_ONPREM_OPTIONS = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
{ 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: "50200" },
{ value: "200-1000", label: "2001000" },
{ value: "1000-5000", label: "10005000" },
{ 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;

View file

@ -0,0 +1,47 @@
// 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.
// Base URL of the user_onboarding service; unset → calls are skipped (no-op).
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL;
// POST a JSON body to the onboarding service with the Dograh auth token attached.
async function post(path: string, token: string, body: unknown): Promise<void> {
if (!BASE_URL) return; // service not configured — skip silently
try {
await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
} catch {
// Best-effort: PostHog already captured the event; never block the user.
}
}
// Map a lead kind to its endpoint path on the onboarding service.
const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
hire_expert: "/api/v1/leads/hire-expert",
enterprise: "/api/v1/leads/enterprise",
};
// Persist a lead submission (hire-expert / enterprise).
export async function postLeadToService(
kind: "hire_expert" | "enterprise",
token: string,
body: Record<string, unknown>,
): Promise<void> {
await post(LEAD_PATH[kind], token, 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);
}

View file

@ -1,15 +1,16 @@
// 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.
// 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.
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import type { LeadKind, LeadSource } from "./leadFieldOptions";
import { postLeadToService } from "./onboardingServiceClient";
const SUBMIT_EVENT: Record<LeadKind, string> = {
topup: PostHogEvent.TOPUP_REQUESTED,
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED,
};
@ -19,13 +20,16 @@ export interface SubmitLeadArgs {
source: LeadSource;
// Field values, already validated by the caller. Non-sensitive lead data.
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 }: SubmitLeadArgs): Promise<void> {
// PostHog capture — the durable record until the backend endpoint lands.
export async function submitLead({ kind, source, payload, token }: SubmitLeadArgs): Promise<void> {
// PostHog capture — the durable record, always fired.
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"));
// Persist to the separate user_onboarding service (best-effort).
if (token) {
await postLeadToService(kind, token, { source, ...payload });
}
}

View file

@ -0,0 +1,34 @@
// 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 Dograh user-config by the caller
// (LeadFormsContext.completeOnboarding), not here — that needs the saveUserConfig hook.
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import { postOnboardingToService } from "./onboardingServiceClient";
export interface OnboardingAnswers {
companyName?: string;
usageContext?: string;
persona?: string;
// Only present when persona unlocks the on-prem question.
onPremNeed?: 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 });
}
}

View file

@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
className
)}
{...props}

View file

@ -13,12 +13,16 @@ export const PostHogEvent = {
SLACK_COMMUNITY_CLICKED: "slack_community_clicked",
HIRE_EXPERT_OPENED: "hire_expert_opened",
HIRE_EXPERT_SUBMITTED: "hire_expert_submitted",
TOPUP_REQUEST_OPENED: "topup_request_opened",
TOPUP_REQUESTED: "topup_requested",
BUY_CREDITS_CLICKED: "buy_credits_clicked",
BUY_CREDITS_AMOUNT_SELECTED: "buy_credits_amount_selected",
CUSTOM_PRICING_CLICKED: "custom_pricing_clicked",
ENTERPRISE_LEAD_OPENED: "enterprise_lead_opened",
ENTERPRISE_LEAD_SUBMITTED: "enterprise_lead_submitted",
HIRE_NUDGE_SHOWN: "hire_nudge_shown",
HIRE_NUDGE_CLICKED: "hire_nudge_clicked",
HIRE_NUDGE_DISMISSED: "hire_nudge_dismissed",
HIRE_NUDGE_EXPIRED: "hire_nudge_expired",
ONBOARDING_SHOWN: "onboarding_shown",
ONBOARDING_SUBMITTED: "onboarding_submitted",
ONBOARDING_SKIPPED: "onboarding_skipped",
} as const;

View file

@ -1,18 +1,27 @@
"use client";
import posthog from "posthog-js";
import { createContext, type ReactNode,useCallback, useContext, useMemo, useRef, useState } from "react";
import { createContext, type ReactNode,useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
import { EnterpriseModal } from "@/components/lead-forms/EnterpriseModal";
import { HireExpertModal } from "@/components/lead-forms/HireExpertModal";
import type { LeadSource } from "@/components/lead-forms/leadFieldOptions";
import { TopUpModal } from "@/components/lead-forms/TopUpModal";
import { OnboardingModal } from "@/components/lead-forms/OnboardingModal";
import { PostHogEvent } from "@/constants/posthog-events";
import { useUserConfig } from "@/context/UserConfigContext";
// The onboarding flag fields live on the Dograh user-config JSON blob. The
// generated client type may not include them until `npm run generate-client`
// is re-run against the updated backend, so read them through this shape.
type OnboardingFlags = {
onboarding_completed_at?: string | null;
onboarding_skipped?: boolean | null;
};
interface LeadFormsContextValue {
openHireExpert: (source: LeadSource) => void;
openTopUp: (source: LeadSource) => void;
openEnterprise: (source: LeadSource) => void;
openEnterprise: (source: LeadSource, prefill?: { company?: string }) => void;
// True once the hire modal has been opened this session (used to suppress the builder nudge).
hasOpenedHireRef: React.MutableRefObject<boolean>;
}
@ -21,14 +30,71 @@ const LeadFormsContext = createContext<LeadFormsContextValue | null>(null);
export function LeadFormsProvider({ children }: { children: ReactNode }) {
const [hireOpen, setHireOpen] = useState(false);
const [topUpOpen, setTopUpOpen] = useState(false);
const [enterpriseOpen, setEnterpriseOpen] = useState(false);
// Track the originating source so the *_OPENED and submit events agree.
const [hireSource, setHireSource] = useState<LeadSource>("sidebar");
const [topUpSource, setTopUpSource] = useState<LeadSource>("billing_card");
const [enterpriseSource, setEnterpriseSource] = useState<LeadSource>("topup");
const [enterpriseSource, setEnterpriseSource] = useState<LeadSource>("sidebar");
const [enterprisePrefill, setEnterprisePrefill] = useState<{ company?: string } | undefined>(undefined);
const hasOpenedHireRef = useRef(false);
// ---- Post-signup onboarding gate ----
// Show the onboarding form ONCE per user, and ONLY to genuinely new users:
// (a) the completion flag is unset (server-side, cross-device), AND
// (b) the user has zero workflows (grandfathers out all existing users —
// they already have workflows, so they never see this modal).
const { userConfig, loading: userConfigLoading, user, saveUserConfig } = useUserConfig();
const [onboardingOpen, setOnboardingOpen] = useState(false);
// Guard so the one-time workflow-count check runs at most once per mount.
const onboardingCheckedRef = useRef(false);
useEffect(() => {
if (userConfigLoading || !user || onboardingCheckedRef.current) return;
const flags = userConfig as OnboardingFlags | null;
const completed = Boolean(flags?.onboarding_completed_at) || Boolean(flags?.onboarding_skipped);
if (completed) {
onboardingCheckedRef.current = true; // already done — never show
return;
}
onboardingCheckedRef.current = true;
// Only brand-new users (no workflows yet) see the form. The count is
// org-scoped (the user's selected organization), so a new user joining an
// org that already has workflows is correctly grandfathered out. This costs
// one lightweight count query per session for users whose flag is still
// unset — an accepted trade for a server-authoritative, cross-device gate.
(async () => {
try {
const res = await getWorkflowCountApiV1WorkflowCountGet();
// Re-read the flag after the await: a config save elsewhere may have
// stamped completion while the count was in flight.
const latest = userConfig as OnboardingFlags | null;
const stillPending =
!latest?.onboarding_completed_at && !latest?.onboarding_skipped;
if (res.data?.total === 0 && stillPending) {
setOnboardingOpen(true);
posthog.capture(PostHogEvent.ONBOARDING_SHOWN);
}
} catch {
// If the count can't be fetched, do NOT show the modal — fail closed so
// existing users are never disrupted.
}
})();
}, [userConfigLoading, user, userConfig]);
const completeOnboarding = useCallback(() => {
// Dismiss immediately; stamp the server flag best-effort so it never re-shows
// (cross-device, and before the user has built a workflow). saveUserConfig
// already merges with the existing config, so only the new field is needed.
setOnboardingOpen(false);
void saveUserConfig({
onboarding_completed_at: new Date().toISOString(),
} as Parameters<typeof saveUserConfig>[0]).catch(() => {
// Best-effort: the user is already past the form; a failed stamp only risks
// a re-prompt on another device, which is acceptable.
});
}, [saveUserConfig]);
const openHireExpert = useCallback((source: LeadSource) => {
hasOpenedHireRef.current = true;
setHireSource(source);
@ -36,32 +102,21 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
posthog.capture(PostHogEvent.HIRE_EXPERT_OPENED, { source });
}, []);
const openTopUp = useCallback((source: LeadSource) => {
setTopUpSource(source);
setTopUpOpen(true);
posthog.capture(PostHogEvent.TOPUP_REQUEST_OPENED, { source });
}, []);
const openEnterprise = useCallback((source: LeadSource) => {
const openEnterprise = useCallback((source: LeadSource, prefill?: { company?: string }) => {
setEnterpriseSource(source);
setEnterprisePrefill(prefill);
setEnterpriseOpen(true);
posthog.capture(PostHogEvent.ENTERPRISE_LEAD_OPENED, { source });
}, []);
const value = useMemo(
() => ({ openHireExpert, openTopUp, openEnterprise, hasOpenedHireRef }),
[openHireExpert, openTopUp, openEnterprise],
() => ({ openHireExpert, openEnterprise, hasOpenedHireRef }),
[openHireExpert, openEnterprise],
);
return (
<LeadFormsContext.Provider value={value}>
{children}
<TopUpModal
open={topUpOpen}
onOpenChange={setTopUpOpen}
source={topUpSource}
onOpenEnterprise={() => openEnterprise("topup")}
/>
<HireExpertModal
open={hireOpen}
onOpenChange={setHireOpen}
@ -72,6 +127,12 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
open={enterpriseOpen}
onOpenChange={setEnterpriseOpen}
source={enterpriseSource}
prefill={enterprisePrefill}
/>
<OnboardingModal
open={onboardingOpen}
onComplete={completeOnboarding}
onOpenEnterprise={(prefill) => openEnterprise("onboarding", prefill)}
/>
</LeadFormsContext.Provider>
);

View file

@ -0,0 +1,18 @@
// Self-serve credit top-up seam. The real implementation (create a Razorpay
// order on the backend + open Razorpay checkout) lands on this branch as a
// separate concurrent task. Until then the seam throws so the UI can surface a
// friendly "not wired yet" message without any placeholder charge flow.
/** Starts a self-serve top-up for `amountUsd`. Implemented by the Razorpay integration. */
export async function startTopUp(amountUsd: number): Promise<void> {
// TODO(razorpay): create order on backend + open Razorpay checkout.
// Reference the amount so the signature is honoured before the impl lands.
void amountUsd;
throw new Error("Top-up not wired yet");
}
// Minimum self-serve top-up amount in USD.
export const MIN_TOPUP_USD = 5;
// Preset chip amounts (USD).
export const TOPUP_PRESETS = [5, 10, 25, 50, 100] as const;