mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
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:
parent
39a5ae4966
commit
d694a81f0a
18 changed files with 1051 additions and 512 deletions
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
ui/src/components/billing/BuyCreditsControl.tsx
Normal file
110
ui/src/components/billing/BuyCreditsControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
10
ui/src/components/lead-forms/FormTrustLine.tsx
Normal file
10
ui/src/components/lead-forms/FormTrustLine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
126
ui/src/components/lead-forms/LeadModalShell.tsx
Normal file
126
ui/src/components/lead-forms/LeadModalShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
181
ui/src/components/lead-forms/OnboardingModal.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
66
ui/src/components/lead-forms/PhoneField.tsx
Normal file
66
ui/src/components/lead-forms/PhoneField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: "0–5k calls/month" },
|
||||
{ value: "5k-20k", label: "5k–20k calls/month" },
|
||||
{ value: ">20k", label: ">20k calls/month" },
|
||||
] as const;
|
||||
|
||||
// The value that gates the volume-pricing qualifier block.
|
||||
export const VOLUME_PRICING_GATE = ">20k";
|
||||
|
||||
// Top-up volume-pricing qualifier: company size (small-business scale).
|
||||
export const TOPUP_COMPANY_SIZE_OPTIONS = [
|
||||
{ value: "only_me", label: "Only me" },
|
||||
{ value: "2-10", label: "2–10" },
|
||||
{ value: "10-100", label: "10–100" },
|
||||
{ value: "100-1000", label: "100–1000" },
|
||||
{ value: "1000+", label: "1000+" },
|
||||
] as const;
|
||||
|
||||
// Hire-an-Expert timeline.
|
||||
export const HIRE_TIMELINE_OPTIONS = [
|
||||
{ value: "asap", label: "ASAP" },
|
||||
{ value: "2-4_weeks", label: "2–4 weeks" },
|
||||
{ value: "1-2_months", label: "1–2 months" },
|
||||
{ value: "flexible", label: "Flexible" },
|
||||
{ value: "exploring", label: "Exploring" },
|
||||
] as const;
|
||||
|
||||
// Hire-an-Expert expected monthly call volume.
|
||||
export const HIRE_VOLUME_OPTIONS = [
|
||||
// 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: "0–5k" },
|
||||
{ value: "5k-100k", label: "5k–100k" },
|
||||
{ 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: "50–200" },
|
||||
{ value: "200-1000", label: "200–1000" },
|
||||
{ value: "1000-5000", label: "1000–5000" },
|
||||
{ value: "5000+", label: "5000+" },
|
||||
] as const;
|
||||
|
||||
// Enterprise timeline.
|
||||
export const ENTERPRISE_TIMELINE_OPTIONS = [
|
||||
{ value: "this_quarter", label: "This quarter" },
|
||||
{ value: "next_quarter", label: "Next quarter" },
|
||||
{ value: "6_months", label: "6 months" },
|
||||
{ value: "exploring", label: "Exploring" },
|
||||
] as const;
|
||||
|
|
|
|||
47
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
47
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
34
ui/src/components/lead-forms/submitOnboarding.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
18
ui/src/lib/billing/topup.ts
Normal file
18
ui/src/lib/billing/topup.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue