feat: UI refresh and user onboarding (#430)

* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)

Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for user-onboarding lead-gen surfaces

14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): register PostHog events for lead-gen surfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): shared field options, work-email validation, and submit seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): inline math captcha field

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): enterprise intake modal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): hire-an-expert modal with enterprise link

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): top-up modal with >20k volume-pricing gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): shared lead-forms context provider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): mount LeadFormsProvider in app layout

Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): add Credits & Billing page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(ui): add lint:lead-flow guard script

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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>

* feat(ui): split-screen auth + enterprise CTA + dark theme default

- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
  waveform motif + proof points + Bland-style enterprise CTA block on the
  left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
  local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
  (defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ui rezig, onboarding, billing, hire us & on prem cues

* ui changes

* chore: update comment

* chore: untrack docs/superpowers and gitignore it

* feat: refactor user configuration table

* feat(ui): 'check your email' confirmation on lead forms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* added email and country in form submissions

* chore: update leads api

* fix: wrap dograh model config in card

---------

Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-06-17 19:49:33 +05:30 committed by GitHub
parent a2d9ed24ed
commit 00b35d6963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 3819 additions and 604 deletions

View file

@ -11,6 +11,7 @@ import {
type ServiceSegment,
} from "@/components/ServiceConfigurationForm";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -366,105 +367,107 @@ export function AIModelConfigurationV2Editor({
</TabsContent>
<TabsContent value="dograh" className="mt-0">
<div className="rounded-lg border p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Voice</Label>
{isCustomVoice ? (
<Input
placeholder="Enter voice"
value={dograh.voice}
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
/>
) : (
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<Card>
<CardContent className="pt-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Voice</Label>
{isCustomVoice ? (
<Input
placeholder="Enter voice"
value={dograh.voice}
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
/>
) : (
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{allowCustomVoice && (
<div className="flex items-center space-x-2">
<Checkbox
id="dograh-custom-voice"
checked={isCustomVoice}
onCheckedChange={(checked) => {
const custom = checked as boolean;
setIsCustomVoice(custom);
if (!custom) {
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
}
}}
/>
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
)}
</div>
<div className="space-y-2">
<Label>Speed</Label>
<Select
value={String(dograh.speed)}
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
<SelectValue placeholder="Select speed" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
{defaults.dograh.speeds.map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
)}
{allowCustomVoice && (
<div className="flex items-center space-x-2">
<Checkbox
id="dograh-custom-voice"
checked={isCustomVoice}
onCheckedChange={(checked) => {
const custom = checked as boolean;
setIsCustomVoice(custom);
if (!custom) {
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
}
}}
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
)}
</div>
<div className="space-y-2">
<Label>Speed</Label>
<Select
value={String(dograh.speed)}
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select speed" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.speeds.map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
</div>
</div>
</div>
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
<Save className="mr-2 h-4 w-4" />
{isSavingDograh ? "Saving..." : submitLabel}
</Button>
</div>
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
<Save className="mr-2 h-4 w-4" />
{isSavingDograh ? "Saving..." : submitLabel}
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="byok" className="mt-0">

View file

@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
// Reusable Dograh wordmark. Theme-aware by default: the dark logo shows on light
// surfaces and the light/cream logo shows on dark. Pass `inverse` to force the
// light logo on an always-dark surface (e.g. the auth brand panel). Pass `mark`
// to render the square logo mark instead of the full wordmark (e.g. the app
// sidebar header). Height is controlled by the caller via className (e.g.
// "h-7"); width stays auto so each lockup keeps its aspect ratio.
export function BrandLogo({
className,
inverse = false,
mark = false,
}: {
className?: string;
inverse?: boolean;
mark?: boolean;
}) {
if (mark) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src="/dograh-mark.png" alt="Dograh" className={cn("w-auto select-none", className)} />
);
}
if (inverse) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src="/dograh-logo-inverse.png" alt="Dograh" className={cn("w-auto select-none", className)} />
);
}
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/dograh-logo.png" alt="Dograh" className={cn("block w-auto select-none dark:hidden", className)} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/dograh-logo-inverse.png" alt="Dograh" className={cn("hidden w-auto select-none dark:block", className)} />
</>
);
}

View file

@ -0,0 +1,12 @@
"use client";
// Thin wrapper around next-themes so the root (server) layout can mount a theme
// provider without pulling client-only code into the server module graph. Dark
// is the locked default; the system preference is intentionally not consulted.
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,38 @@
"use client";
// Enterprise call-to-action rendered inside the auth brand panel. Opens the
// SAME in-app Enterprise lead modal used post-login (not the marketing site's
// /contact page). The visitor is typically NOT authenticated here: the modal
// requires a work email in that case, and submitLead persists the lead through
// the user_onboarding service's public contact-sales endpoint instead of the
// token-gated /leads/enterprise. Shared by the Stack Auth handler and the
// local/OSS auth pages.
import posthog from "posthog-js";
import { useState } from "react";
import { EnterpriseModal } from "@/components/lead-forms/EnterpriseModal";
import { Button } from "@/components/ui/button";
import { PostHogEvent } from "@/constants/posthog-events";
export function AuthEnterpriseCTA() {
const [open, setOpen] = useState(false);
const openModal = () => {
setOpen(true);
posthog.capture(PostHogEvent.ENTERPRISE_LEAD_OPENED, { source: "auth_page" });
};
return (
<>
<Button
variant="outline"
onClick={openModal}
className="w-full border-white/20 bg-white/5 text-zinc-100 hover:bg-white/10 hover:text-white"
>
Enterprise Enquiry
</Button>
<EnterpriseModal open={open} onOpenChange={setOpen} source="auth_page" />
</>
);
}

View file

@ -0,0 +1,87 @@
// Shared dark two-column auth shell, used by BOTH the Stack Auth handler
// (/handler/[...stack], cloud) and the local/OSS auth pages (/auth/login,
// /auth/signup). LEFT: a centered card that wraps the auth form (`children`).
// RIGHT (lg+ only): a brand/value panel with the Dograh logo, proof points, and
// a Bland-style enterprise CTA block at the bottom (passed in as `enterpriseSlot`).
// Mobile collapses to the single card column. The form column scrolls and stays
// centered so tall (sign-up) forms never clip on short viewports. Palette is the
// app's blacks/greys with one warm CTA accent.
import type { ReactNode } from "react";
import { BrandLogo } from "@/components/BrandLogo";
const HIGHLIGHTS = [
"Speech-to-speech",
"MCP-native",
"BYOK - any model",
];
export function AuthShell({
children,
enterpriseSlot,
}: {
children: ReactNode;
enterpriseSlot?: ReactNode;
}) {
return (
<div className="grid min-h-screen w-full bg-background lg:grid-cols-[55%_45%]">
{/* Form column (LEFT) scrolls and stays centered so tall forms never
clip. Carries the giant faded "dograh" imprint along its bottom. */}
<main className="auth-imprint flex min-h-screen flex-col overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-6 sm:p-10">
<div className="w-full max-w-md space-y-6 rounded-2xl border border-border/60 bg-card p-6 shadow-lg sm:p-8">
{/* Mobile-only wordmark (brand panel is hidden) */}
<div className="lg:hidden">
<BrandLogo className="h-7" />
</div>
{children}
</div>
</div>
</main>
{/* Brand / value panel (RIGHT) — hidden on mobile */}
<aside className="relative hidden flex-col justify-between overflow-hidden border-l border-border/60 bg-zinc-950 p-10 lg:flex xl:p-14">
{/* Ambient depth: soft radial glow behind the content */}
<div
aria-hidden
className="pointer-events-none absolute -right-24 top-1/3 size-[28rem] rounded-full opacity-20 blur-3xl"
style={{ background: "radial-gradient(circle, var(--cta), transparent 70%)" }}
/>
<div className="relative">
<BrandLogo inverse className="h-8" />
</div>
<div className="relative max-w-md space-y-5">
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-50 xl:text-4xl">
The open-source voice AI platform.
</h1>
<ul className="flex flex-wrap gap-2">
{HIGHLIGHTS.map((point) => (
<li
key={point}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-medium text-zinc-300"
>
{point}
</li>
))}
</ul>
</div>
{/* Enterprise CTA block (Bland-style) bottom margin lifts it off the
viewport edge while justify-between keeps the column layout */}
<div className="relative mb-12 max-w-md space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-5 xl:mb-16">
<h2 className="text-sm font-semibold text-zinc-100">
Need on-prem, data residency &amp; a data perimeter?
</h2>
<p className="text-sm text-zinc-400">
We deploy Dograh inside your environment for regulated and
high-scale teams.
</p>
{enterpriseSlot}
</div>
</aside>
</div>
);
}

View file

@ -0,0 +1,134 @@
"use client";
// Compact self-serve "Buy Credits" control. The amount chips + custom input live
// in a popover that only opens when the user clicks "Buy Credits" — so the
// billing card stays clean until they intend to top up. Presets + custom (min $5)
// feed the Razorpay seam in @/lib/billing/topup, which 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { PostHogEvent } from "@/constants/posthog-events";
import { MAX_TOPUP_USD, MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup";
import { cn } from "@/lib/utils";
// Round to whole cents and reject non-positive / non-finite input so a typo
// (e.g. "5.999", "-1", "abc") can't produce a NaN or fractional-cent order.
const parseAmount = (raw: string): number | null => {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 100) / 100;
};
export function BuyCreditsControl({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
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() ? parseAmount(custom) : null;
const amount = customAmount ?? selected;
const valid = amount != null && amount >= MIN_TOPUP_USD && amount <= MAX_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 = parseAmount(raw);
if (parsed != null && parsed >= MIN_TOPUP_USD && parsed <= MAX_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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
className={cn(
"bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50",
className,
)}
>
Buy Credits
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 space-y-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">Top up credits</p>
<p className="text-xs text-muted-foreground">Pick an amount (min ${MIN_TOPUP_USD}).</p>
</div>
<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-24 pl-5"
/>
</div>
</div>
{error && <p className="text-xs text-muted-foreground">{error}</p>}
<Button
type="button"
onClick={onBuy}
disabled={!valid || busy}
className="w-full bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
{busy ? "Starting…" : valid && amount != null ? `Buy $${amount}` : "Buy Credits"}
</Button>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,122 @@
"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, openEnterprise } = useLeadForms();
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
const fetchMpsCredits = useCallback(async () => {
if (!auth.isAuthenticated) return;
try {
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
// The generated client resolves to { data, error } and does NOT throw on
// 4xx/5xx (see ui/AGENTS.md) — check error explicitly.
if (response.error) {
console.error("Failed to fetch MPS credits:", response.error);
} else if (response.data) {
setMpsCredits(response.data);
}
} catch (error) {
console.error("Failed to fetch MPS credits:", error);
} finally {
setIsLoadingCredits(false);
}
}, [auth.isAuthenticated]);
useEffect(() => {
if (auth.isAuthenticated) {
fetchMpsCredits();
}
}, [auth.isAuthenticated, fetchMpsCredits]);
return (
<Card className="mb-6">
<CardHeader>
<CardTitle>Dograh Model Credits</CardTitle>
<CardDescription>
These track usage of Dograh models using Dograh Service Keys.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingCredits ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/4"></div>
<div className="h-8 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
) : mpsCredits ? (
<div className="space-y-4">
<div className="flex justify-between items-baseline">
<div>
<p className="text-2xl font-bold">
{mpsCredits.total_credits_used.toFixed(2)}{" "}
<span className="text-lg font-normal text-muted-foreground">
/ {mpsCredits.total_quota.toFixed(2)}
</span>
</p>
<p className="text-sm text-muted-foreground">Credits Used</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
<p className="text-sm text-muted-foreground">Remaining</p>
</div>
</div>
{mpsCredits.total_quota > 0 && (
<Progress value={Math.min(100, (mpsCredits.total_credits_used / mpsCredits.total_quota) * 100)} className="h-3" />
)}
</div>
) : (
<p className="text-muted-foreground">
No Dograh service keys configured. Set up a service key in your model configuration to see usage.
</p>
)}
{/* Footer CTAs self-serve + done-for-you side by side, with the
custom-pricing link directly beneath. */}
<div className="mt-6 space-y-4 border-t pt-4">
<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 gap-2 sm:flex-row">
<BuyCreditsControl className="w-full sm:flex-1" />
<Button
variant="outline"
className="w-full gap-2 sm:flex-1"
onClick={() => openHireExpert("billing_card")}
>
<UserRound className="h-4 w-4" />
Hire an Expert
</Button>
</div>
<button
type="button"
onClick={() => {
posthog.capture(PostHogEvent.CUSTOM_PRICING_CLICKED);
openEnterprise("billing_custom_pricing");
}}
className="block text-xs text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Book a Strategy Call: custom pricing for committed volume
</button>
</div>
</CardContent>
</Card>
);
}

View file

@ -280,7 +280,7 @@ export function ToolSelector({
)}
{fns.length === 0 && !err && (
<p className="text-xs text-muted-foreground">
No tools discovered Refresh.
No tools discovered - Refresh.
</p>
)}
{fns.map((fn) => {

View file

@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar";
import { PostHogEvent } from "@/constants/posthog-events";
import { useAppConfig } from "@/context/AppConfigContext";
import { LeadFormsProvider } from "@/context/LeadFormsContext";
import { AppSidebar } from "./AppSidebar";
import { GitHubStarBadge } from "./GitHubStarBadge";
@ -18,7 +19,7 @@ function AppHeader() {
const { toggleSidebar } = useSidebar();
return (
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-2">
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-border/60 bg-background/70 px-4 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/55">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={toggleSidebar} aria-label="Open menu" className="md:hidden">
<Menu className="h-5 w-5" />
@ -111,41 +112,43 @@ const AppLayout: React.FC<AppLayoutProps> = ({
return (
<SidebarProvider defaultOpen>
{shouldShowSidebar ? (
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">
<BackendStatusBanner />
{!isWorkflowEditor && <AppHeader />}
{/* Optional header area for specific pages */}
{headerActions && (
<header className="sticky top-0 z-50 w-full border-b bg-background">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
{headerActions}
<LeadFormsProvider>
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">
<BackendStatusBanner />
{!isWorkflowEditor && <AppHeader />}
{/* Optional header area for specific pages */}
{headerActions && (
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
{headerActions}
</div>
</div>
</header>
)}
{/* Optional sticky tabs */}
{stickyTabs && (
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
</header>
)}
)}
{/* Optional sticky tabs */}
{stickyTabs && (
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
)}
{/* Main content area */}
<main className="flex-1">
{children}
</main>
</SidebarInset>
</div>
{/* Main content area */}
<main className="app-surface flex-1">
{children}
</main>
</SidebarInset>
</div>
</LeadFormsProvider>
) : (
<div className="flex-1 w-full">
<div className="app-surface w-full flex-1">
<BackendStatusBanner />
{children}
</div>

View file

@ -19,6 +19,7 @@ import {
Phone,
Settings,
TrendingUp,
UserRound,
Workflow,
Wrench,
} from "lucide-react";
@ -26,6 +27,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useRef } from "react";
import { BrandLogo } from "@/components/BrandLogo";
import ThemeToggle from "@/components/ThemeSwitcher";
import { Button } from "@/components/ui/button";
import {
@ -52,6 +54,7 @@ import {
} from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppConfig } from "@/context/AppConfigContext";
import { useLeadForms } from "@/context/LeadFormsContext";
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion";
import type { LocalUser } from "@/lib/auth";
@ -129,7 +132,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
],
},
{
label: "OBSERVE",
label: "MANAGE",
items: [
{
title: "Agent Runs",
@ -145,7 +148,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
title: "Reports",
url: "/reports",
icon: FileText,
},
}
],
},
];
@ -163,6 +166,7 @@ export function AppSidebar() {
const { state, isMobile, setOpenMobile } = useSidebar();
const { provider, getSelectedTeam, logout, user } = useAuth();
const { config } = useAppConfig();
const { openHireExpert } = useLeadForms();
const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings();
const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0;
const isCollapsed = !isMobile && state === "collapsed";
@ -223,8 +227,9 @@ export function AppSidebar() {
asChild
tooltip={tooltip}
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground"
"rounded-xl transition-colors hover:bg-accent hover:text-accent-foreground",
isItemActive &&
"bg-cta/15 font-semibold text-foreground hover:bg-cta/20 hover:text-foreground"
)}
>
<Link
@ -233,7 +238,18 @@ export function AppSidebar() {
className={cn("relative", isCollapsed && "justify-center")}
translate="no"
>
<Icon className="h-4 w-4 shrink-0" />
{isItemActive && !isCollapsed && (
<span
className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-cta"
aria-hidden
/>
)}
<Icon
className={cn(
"h-4 w-4 shrink-0",
isItemActive && "text-cta drop-shadow-[0_0_6px_rgba(240,170,70,0.8)]"
)}
/>
<span
className={cn("notranslate min-w-0 flex-1 truncate", isCollapsed && "sr-only")}
translate="no"
@ -259,17 +275,71 @@ export function AppSidebar() {
);
};
// Footer identity trigger: avatar initials only (no name), in a subtle
// bordered circle. Same treatment expanded and collapsed.
const displayIdentity =
user?.displayName ||
(user as { primaryEmail?: string } | undefined)?.primaryEmail ||
(user as LocalUser | undefined)?.email ||
"";
const userInitials =
displayIdentity
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0]?.toUpperCase())
.join("") || "U";
const userChipTrigger = (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 cursor-pointer rounded-full border border-border/80 bg-muted/40 hover:bg-muted/60"
>
<span className="text-xs font-medium">{userInitials}</span>
</Button>
);
// "Hire an Expert" CTA, rendered INSIDE the shared footer pill next to the
// profile icon. Expanded: label pill filling the row. Collapsed: icon-only.
const hireExpertButton = isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="h-7 w-7 rounded-full"
onClick={() => openHireExpert("sidebar")}
aria-label="Hire an Expert"
>
<UserRound className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Hire an Expert</p>
</TooltipContent>
</Tooltip>
) : (
<Button
size="sm"
className="h-7 gap-1.5 rounded-full px-3 text-xs"
onClick={() => openHireExpert("sidebar")}
>
<UserRound className="h-3.5 w-3.5" />
Hire an Expert
</Button>
);
return (
<Sidebar collapsible="icon" className="border-r">
<SidebarHeader className="border-b px-2 py-3 notranslate" translate="no">
<Sidebar collapsible="icon" variant="floating" className="app-sidebar-dock py-4">
<SidebarHeader className="px-2 py-3 notranslate" translate="no">
<div className="flex items-center justify-between">
<div className={cn("flex items-center gap-2", isCollapsed && "hidden")}>
<Link
href="/"
className="notranslate flex items-center gap-2 px-2 text-xl font-bold"
className="notranslate flex items-center gap-2 px-1"
translate="no"
>
Dograh
<BrandLogo mark className="h-6" />
{versionInfo && (
<span
className="notranslate text-xs font-normal text-muted-foreground"
@ -293,7 +363,7 @@ export function AppSidebar() {
</a>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Latest: {latestRelease} click to see the update guide</p>
<p>Latest: {latestRelease} - click to see the update guide</p>
</TooltipContent>
</Tooltip>
)}
@ -367,25 +437,20 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter
className={cn("border-t p-4 notranslate", isCollapsed && "p-2")}
className={cn("p-3 notranslate", isCollapsed && "p-2")}
translate="no"
>
<div className="space-y-2">
{provider !== "stack" && (
<div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
<div
className={cn(
"flex items-center justify-between gap-1 rounded-full border border-border/60 bg-muted/30 p-1",
isCollapsed && "flex-col"
)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<span className="text-xs font-medium">
{(user?.displayName || (user as LocalUser | undefined)?.email || "")
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0]?.toUpperCase())
.join("")
|| "U"}
</span>
</Button>
{userChipTrigger}
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-56">
<DropdownMenuLabel className="font-normal">
@ -406,24 +471,20 @@ export function AppSidebar() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hireExpertButton}
</div>
)}
{provider === "stack" && (
<div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
<div
className={cn(
"flex items-center justify-between gap-1 rounded-full border border-border/60 bg-muted/30 p-1",
isCollapsed && "flex-col"
)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<span className="text-xs font-medium">
{(user?.displayName || (user as { primaryEmail?: string })?.primaryEmail || "")
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0]?.toUpperCase())
.join("")
|| "U"}
</span>
</Button>
{userChipTrigger}
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-56">
<DropdownMenuLabel className="font-normal">
@ -445,42 +506,30 @@ export function AppSidebar() {
<Settings className="mr-2 h-4 w-4" />
Platform Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/usage")} className="cursor-pointer">
<CircleDollarSign className="mr-2 h-4 w-4" />
Usage
</DropdownMenuItem>
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hireExpertButton}
</div>
)}
<div className={cn("mt-2 border-t pt-2", isCollapsed && "flex justify-center")}>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="notranslate" translate="no">
<ThemeToggle
showLabel={false}
className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>Toggle theme</p>
</TooltipContent>
</Tooltip>
) : (
<div className="notranslate" translate="no">
<ThemeToggle
showLabel={true}
className="hover:bg-accent hover:text-accent-foreground"
/>
</div>
)}
<div className="mt-1 flex justify-center">
<Tooltip>
<TooltipTrigger asChild>
<div className="notranslate" translate="no">
<ThemeToggle
showLabel={false}
className="rounded-full hover:bg-accent hover:text-accent-foreground"
/>
</div>
</TooltipTrigger>
<TooltipContent side={isCollapsed ? "right" : "top"}>
<p>Toggle theme</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</SidebarFooter>

View file

@ -0,0 +1,90 @@
"use client";
// Anti-spam quick-check shown as a popup ON TOP of a lead form (via the
// LeadModalShell `overlay` slot) so it can't be scrolled past or missed.
// Generates a fresh sum each time it mounts; calls onVerified once the correct
// answer is confirmed, onCancel to dismiss back to the form.
import { ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function CaptchaChallenge({
onVerified,
onCancel,
}: {
onVerified: () => void;
onCancel: () => void;
}) {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const [answer, setAnswer] = useState("");
// Fresh challenge whenever this mounts (the parent mounts it on demand).
// Math.random is allowed in the browser runtime (not a workflow script).
const regenerate = () => {
setA(Math.floor(Math.random() * 8) + 1);
setB(Math.floor(Math.random() * 8) + 1);
setAnswer("");
};
useEffect(() => {
regenerate();
}, []);
const confirm = () => {
if (answer.trim() !== "" && parseInt(answer, 10) === a + b) {
onVerified();
} else {
toast.error("That's not quite right - try again.");
regenerate();
}
};
return (
<div className="lead-form-slab relative w-full max-w-xs overflow-hidden rounded-xl border border-border/70 bg-card shadow-2xl">
<div className="lead-form-underline relative space-y-4 p-5">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-cta/25 bg-cta/10 text-cta">
<ShieldCheck className="size-4" />
</span>
<div className="space-y-1">
<p className="text-sm font-semibold">Quick check</p>
<p className="text-xs text-muted-foreground">Confirm you&apos;re human before we send this.</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="captcha-answer">
What is {a} + {b}?
</Label>
<Input
id="captcha-answer"
inputMode="numeric"
autoFocus
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") confirm();
}}
placeholder="Answer"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
type="button"
onClick={confirm}
className="bg-cta text-cta-foreground shadow-md shadow-cta/25 hover:bg-cta/90 hover:shadow-cta/35 focus-visible:ring-cta/50"
>
Confirm &amp; submit
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
"use client";
// Shared enterprise lead fields, rendered by BOTH the standalone EnterpriseModal
// and the inline on-prem expansion of the onboarding form. One source of truth so
// the two stay identical and submit through the same /api/v1/leads/enterprise
// path. Controlled: the parent owns the values + the submit/captcha flow.
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
ENTERPRISE_DEPLOYMENT_OPTIONS,
ENTERPRISE_VOLUME_OPTIONS,
} from "./leadFieldOptions";
import { PhoneField } from "./PhoneField";
export interface EnterpriseFieldsValue {
name: string;
company: string;
jobTitle: string;
workEmail: string;
phone: string;
volume: string;
deployment: string;
agentGoal: string;
}
export const EMPTY_ENTERPRISE_FIELDS: EnterpriseFieldsValue = {
name: "",
company: "",
jobTitle: "",
workEmail: "",
phone: "",
volume: "",
deployment: "",
agentGoal: "",
};
interface EnterpriseLeadFieldsProps {
// Unique prefix for input ids/labels (e.g. "ent", "ob-op") so the two
// instances never collide when both exist in the DOM.
idPrefix: string;
value: EnterpriseFieldsValue;
onChange: (patch: Partial<EnterpriseFieldsValue>) => void;
// The deployment question is surfaced only for certain entry points; elsewhere
// it is hidden and the caller defaults the payload to "yes".
showDeployment: boolean;
emailError?: string | null;
}
export function EnterpriseLeadFields({
idPrefix: p,
value,
onChange,
showDeployment,
emailError,
}: EnterpriseLeadFieldsProps) {
return (
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${p}-name`}>Name</Label>
<Input id={`${p}-name`} placeholder="Your full name" value={value.name} onChange={(e) => onChange({ name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor={`${p}-company`}>Company name</Label>
<Input id={`${p}-company`} placeholder="Acme Inc." value={value.company} onChange={(e) => onChange({ company: e.target.value })} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${p}-title`}>Job title</Label>
<Input id={`${p}-title`} placeholder="VP Operations" value={value.jobTitle} onChange={(e) => onChange({ jobTitle: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor={`${p}-email`}>Work email</Label>
<Input
id={`${p}-email`}
type="email"
placeholder="you@company.com"
value={value.workEmail}
onChange={(e) => onChange({ workEmail: e.target.value })}
/>
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${p}-phone`}>Phone</Label>
<PhoneField id={`${p}-phone`} value={value.phone} onChange={(phone) => onChange({ phone })} required />
</div>
<div className="space-y-1.5">
<Label htmlFor={`${p}-volume`}>Monthly call volume</Label>
<Select value={value.volume} onValueChange={(v) => onChange({ volume: v })}>
<SelectTrigger id={`${p}-volume`}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{showDeployment && (
<div className="space-y-1.5">
<Label htmlFor={`${p}-deployment`}>Need enterprise deployment (SSO, on-prem, data residency)?</Label>
<Select value={value.deployment} onValueChange={(v) => onChange({ deployment: v })}>
<SelectTrigger id={`${p}-deployment`}><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={`${p}-goal`}>
What do you want the voice agent to do? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id={`${p}-goal`}
value={value.agentGoal}
onChange={(e) => onChange({ agentGoal: e.target.value })}
placeholder="Use case, regulatory context, current stack…"
rows={3}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,139 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useAppConfig } from "@/context/AppConfigContext";
import { CaptchaChallenge } from "./CaptchaChallenge";
import {
EMPTY_ENTERPRISE_FIELDS,
type EnterpriseFieldsValue,
EnterpriseLeadFields,
} from "./EnterpriseLeadFields";
import { FormTrustLine } from "./FormTrustLine";
import { validateWorkEmail } from "./isPersonalEmail";
import { ENTERPRISE_DEPLOYMENT_SOURCES, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
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 upstream). Backward-compatible: omitted = no prefill.
prefill?: { company?: string };
}
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
const { config } = useAppConfig();
// Deployment provenance (analytics only); OSS submits via the public contact-sales path.
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaActive, setCaptchaActive] = useState(false);
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);
const reset = () => {
setValue(EMPTY_ENTERPRISE_FIELDS);
setEmailError(null);
setCaptchaActive(false);
setSubmitting(false);
};
const onFieldsChange = (patch: Partial<EnterpriseFieldsValue>) => {
setValue((v) => ({ ...v, ...patch }));
if ("workEmail" in patch) setEmailError(null);
};
// Seed company from prefill when the modal opens (don't clobber edits).
const prefillCompany = prefill?.company;
useEffect(() => {
if (open && prefillCompany) {
setValue((v) => (v.company ? v : { ...v, company: prefillCompany }));
}
}, [open, prefillCompany]);
// Required fields, independent of the anti-spam check (revealed only after the
// first submit click — see handleSubmit).
const baseValid =
Boolean(value.name.trim()) &&
Boolean(value.company.trim()) &&
Boolean(value.jobTitle.trim()) &&
Boolean(value.workEmail.trim()) &&
Boolean(value.phone.trim()) &&
Boolean(value.volume);
const canSubmit = baseValid && !submitting;
// Validate, then pop the anti-spam check on top of the modal.
const handleSubmit = () => {
const err = validateWorkEmail(value.workEmail);
if (err) { setEmailError(err); return; }
if (!value.name.trim() || !value.company.trim() || !value.jobTitle.trim() || !value.phone.trim() || !value.volume) {
toast.error("Please fill in all required fields");
return;
}
setCaptchaActive(true);
};
// Runs once the captcha popup is verified.
const doSubmit = async () => {
setCaptchaActive(false);
setSubmitting(true);
try {
await submitLead({
kind: "enterprise",
source,
origin,
payload: {
name: value.name,
company: value.company,
jobTitle: value.jobTitle,
workEmail: value.workEmail,
phone: value.phone,
volume: value.volume,
// Hidden entry points imply enterprise intent — default to "yes".
deployment: showDeployment ? value.deployment || "yes" : "yes",
agentGoal: value.agentGoal,
},
});
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
reset();
onOpenChange(false);
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
return (
<LeadModalShell
open={open}
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
icon={ShieldCheck}
eyebrow="Enterprise"
title="Book a Strategy Call"
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 />}
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<EnterpriseLeadFields
idPrefix="ent"
value={value}
onChange={onFieldsChange}
showDeployment={showDeployment}
emailError={emailError}
/>
</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

@ -0,0 +1,182 @@
"use client";
import { Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useAppConfig } from "@/context/AppConfigContext";
import { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
import { FormTrustLine } from "./FormTrustLine";
import { isValidEmail } from "./isPersonalEmail";
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { PhoneField } from "./PhoneField";
import { submitLead } from "./submitLead";
interface HireExpertModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
source: LeadSource;
onOpenEnterprise: () => void;
}
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
const { user } = useAuth(); // logged-in identity (prefills the email field)
const { config } = useAppConfig();
// Deployment provenance (analytics only): cloud → cloud_app, else oss_app. OSS submits the
// lead anonymously (cloud can't verify its token), so the email field below is the identity.
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
// Logged-in user's email (Stack uses primaryEmail; local uses email) — prefilled, editable.
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [email, setEmail] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [agentGoal, setAgentGoal] = useState("");
const [phone, setPhone] = useState("");
const [volume, setVolume] = useState("");
const [captchaActive, setCaptchaActive] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Prefill the email from the logged-in user when the modal opens (don't clobber edits).
useEffect(() => {
if (open && userEmail) setEmail((e) => e || userEmail);
}, [open, userEmail]);
const reset = () => {
setName(""); setCompany(""); setEmail(""); setJobTitle(""); setAgentGoal("");
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
};
// Required fields, independent of the anti-spam check (which is revealed only
// after the first submit click — see handleSubmit).
const baseValid =
Boolean(name.trim()) &&
Boolean(company.trim()) &&
isValidEmail(email) &&
Boolean(jobTitle.trim()) &&
Boolean(agentGoal.trim()) &&
Boolean(phone.trim()) &&
Boolean(volume);
const canSubmit = baseValid && !submitting;
// Validate, then pop the anti-spam check on top of the modal.
const handleSubmit = () => {
if (!baseValid) {
toast.error("Please fill in all required fields");
return;
}
setCaptchaActive(true);
};
// Runs once the captcha popup is verified.
const doSubmit = async () => {
setCaptchaActive(false);
setSubmitting(true);
try {
await submitLead({
kind: "hire_expert",
source,
origin,
payload: { name, company, email, jobTitle, agentGoal, phone, volume },
});
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
reset();
onOpenChange(false);
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
return (
<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 />}
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<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" placeholder="Your full 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" placeholder="Acme Inc." value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-email">Email</Label>
<Input id="hire-email" type="email" placeholder="you@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-title">Job title</Label>
<Input id="hire-title" placeholder="VP Operations" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
</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-phone">Phone</Label>
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-volume">Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger id="hire-volume"><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>
</LeadModalShell>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { UserRound, X } from "lucide-react";
import posthog from "posthog-js";
import { useEffect, useRef, useState } from "react";
import { PostHogEvent } from "@/constants/posthog-events";
import { useLeadForms } from "@/context/LeadFormsContext";
interface HireExpertNudgeProps {
workflowId: number;
}
// Timings. Override SHOW_DELAY_MS to a few seconds during manual testing.
const SHOW_DELAY_MS = 5 * 60 * 1000; // 5 minutes on the builder
const AUTO_FADE_MS = 30 * 1000; // visible for 30s
function nudgeDoneKey(workflowId: number) {
return `dograh:hireNudge:${workflowId}`;
}
export function HireExpertNudge({ workflowId }: HireExpertNudgeProps) {
const { openHireExpert, hasOpenedHireRef } = useLeadForms();
const [visible, setVisible] = useState(false);
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Arm the 5-minute show timer (once per mount / workflow).
useEffect(() => {
if (typeof window === "undefined") return;
// Already shown+consumed for this workflow → skip.
if (localStorage.getItem(nudgeDoneKey(workflowId))) return;
const showTimer = setTimeout(() => {
if (hasOpenedHireRef.current) return; // they engaged elsewhere; don't nag
if (localStorage.getItem(nudgeDoneKey(workflowId))) return;
setVisible(true);
posthog.capture(PostHogEvent.HIRE_NUDGE_SHOWN, { workflowId });
// Auto-fade after 30s. Auto-expiry does NOT mark done (per spec).
fadeTimer.current = setTimeout(() => {
setVisible(false);
posthog.capture(PostHogEvent.HIRE_NUDGE_EXPIRED, { workflowId });
}, AUTO_FADE_MS);
}, SHOW_DELAY_MS);
return () => {
clearTimeout(showTimer);
if (fadeTimer.current) clearTimeout(fadeTimer.current);
};
}, [workflowId, hasOpenedHireRef]);
if (!visible) return null;
const markDone = () => {
if (fadeTimer.current) clearTimeout(fadeTimer.current);
localStorage.setItem(nudgeDoneKey(workflowId), "1");
setVisible(false);
};
const handleClick = () => {
posthog.capture(PostHogEvent.HIRE_NUDGE_CLICKED, { workflowId });
markDone();
openHireExpert("builder_nudge");
};
const handleDismiss = () => {
posthog.capture(PostHogEvent.HIRE_NUDGE_DISMISSED, { workflowId });
markDone();
};
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-6 right-6 z-50 flex max-w-xs items-center gap-3 rounded-lg border border-primary bg-background p-3 shadow-lg animate-in fade-in slide-in-from-bottom-2"
>
<button type="button" onClick={handleClick} className="flex flex-1 items-center gap-3 text-left">
<UserRound className="h-5 w-5 shrink-0 text-primary" />
<span>
<span className="block text-sm font-semibold">Hire an Expert</span>
<span className="block text-xs text-muted-foreground">We&apos;ll build your agent for you</span>
</span>
</button>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

View file

@ -0,0 +1,136 @@
"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 band (eyebrow +
// title + description), a scrollable body with underline fields, a footer
// (primary CTA + optional ghost secondary + optional helper slot), and a bottom
// trust-line slot. The visual language ("Ledger", user-approved): flat charcoal
// slab where ONLY the header band is darker (footer matches the body), NO
// gradients/glows/icons, Geist type only, one warm accent reserved for the
// primary action and the focused-field underline (see .lead-form-* in
// globals.css).
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 {
// Accepted for caller compatibility; the Ledger design renders no icon.
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 below the actions (e.g. a link).
helper?: ReactNode;
// Optional trust line beneath the footer (we pass <FormTrustLine/>).
trustLine?: ReactNode;
// Optional layer floated ON TOP of the whole modal (e.g. the captcha popup).
overlay?: 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({
title,
eyebrow,
description,
children,
primary,
secondary,
helper,
trustLine,
overlay,
open,
onOpenChange,
contentProps,
}: LeadModalShellProps) {
const { className: contentClassName, ...restContentProps } = contentProps ?? {};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"lead-form-slab max-h-[90vh] gap-0 overflow-hidden rounded-2xl border-border/70 bg-card p-0 shadow-2xl sm:max-w-[560px]",
contentClassName,
)}
{...restContentProps}
>
{/* Header: a slightly darker band, separated by a hairline. */}
<DialogHeader className="space-y-0 border-b border-border/40 bg-black/[0.04] px-8 pb-5 pt-6 text-left dark:bg-black/25">
<div className="min-w-0">
{eyebrow && (
<span className="block text-[0.7rem] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{eyebrow}
</span>
)}
<DialogTitle className="mt-1.5 text-2xl font-semibold leading-tight tracking-tight">
{title}
</DialogTitle>
{description && (
<DialogDescription className="mt-1.5 text-sm leading-snug">
{description}
</DialogDescription>
)}
</div>
</DialogHeader>
{/* Scrollable body: flat, compact underline fields. */}
<div className="max-h-[60vh] overflow-y-auto px-8 py-6">
<div className="lead-form-underline">{children}</div>
</div>
{/* Footer same surface as the body (only the header band differs);
actions first, then the optional helper line BELOW the buttons,
then the trust line at the very bottom. */}
<div className="space-y-3 border-t border-border/40 px-8 py-4">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{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-md shadow-cta/25 hover:bg-cta/90 hover:shadow-cta/35 focus-visible:ring-cta/50"
>
{primary.loading ? "Submitting…" : primary.label}
</Button>
</div>
{helper && <div className="text-center text-xs text-muted-foreground">{helper}</div>}
{trustLine}
</div>
{/* Optional popup floated on top of the entire modal (captcha, etc.). */}
{overlay && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/70 p-6 backdrop-blur-md">
{overlay}
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface MathCaptchaProps {
// Called whenever validity changes, so the parent can enable/disable submit.
onValidChange: (valid: boolean) => void;
id?: string;
}
// Dead-simple anti-spam: "What is X + Y?". Generated client-side on mount.
// Math.random is allowed in browser runtime (this is not a workflow script).
export function MathCaptcha({ onValidChange, id = "math-captcha" }: MathCaptchaProps) {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const [answer, setAnswer] = useState("");
useEffect(() => {
setA(Math.floor(Math.random() * 8) + 1);
setB(Math.floor(Math.random() * 8) + 1);
}, []);
useEffect(() => {
onValidChange(answer.trim() !== "" && parseInt(answer, 10) === a + b);
}, [answer, a, b, onValidChange]);
return (
<div className="space-y-1.5">
<Label htmlFor={id}>
Quick check: what is {a} + {b}?
</Label>
<Input
id={id}
inputMode="numeric"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Answer"
className="w-32"
/>
</div>
);
}

View file

@ -0,0 +1,357 @@
"use client";
import { Rocket } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useAppConfig } from "@/context/AppConfigContext";
import { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
import {
EMPTY_ENTERPRISE_FIELDS,
type EnterpriseFieldsValue,
EnterpriseLeadFields,
} from "./EnterpriseLeadFields";
import { validateWorkEmail } from "./isPersonalEmail";
import {
ONBOARDING_HEARD_OPTIONS,
ONBOARDING_MIGRATION_OPTIONS,
ONBOARDING_ONPREM_OPTIONS,
ONBOARDING_ONPREM_PERSONAS,
ONBOARDING_PERSONA_OPTIONS,
ONBOARDING_VOLUME_OPTIONS,
} from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { submitLead } from "./submitLead";
import { type OnboardingAnswers, submitOnboarding } from "./submitOnboarding";
interface OnboardingModalProps {
open: boolean;
// Called after a tracked submit to dismiss the gate and stamp the server-side
// "completed" flag. Onboarding is compulsory — `skipped` is always false now.
onComplete: (skipped: boolean) => void;
}
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const { user } = useAuth(); // logged-in identity → onboarding email (sent silently)
const { config } = useAppConfig();
// Deployment provenance (analytics only).
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
// The logged-in user's email (Stack uses primaryEmail; local uses email). Sent in the
// body — there is no visible email field on the onboarding form.
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
const [persona, setPersona] = useState("");
const [onPremNeed, setOnPremNeed] = useState("");
const [migratingFrom, setMigratingFrom] = useState("");
const [migratingOtherProvider, setMigratingOtherProvider] = useState("");
const [switchReason, setSwitchReason] = useState("");
const [howHeard, setHowHeard] = useState("");
const [volume, setVolume] = useState("");
const [submitting, setSubmitting] = useState(false);
// Inline on-prem expansion: the FULL enterprise form, submitted through the same
// /api/v1/leads/enterprise path as the standalone Enterprise modal.
const [onPremExpanded, setOnPremExpanded] = useState(false);
const [ef, setEf] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [efEmailError, setEfEmailError] = useState<string | null>(null);
const [captchaActive, setCaptchaActive] = useState(false);
const showOnPrem = ONBOARDING_ONPREM_PERSONAS.includes(persona);
const showManagedNote = showOnPrem && onPremNeed === "yes";
const wantsOnPrem = showManagedNote && onPremExpanded;
const isOtherProvider = migratingFrom === "other";
const isMigrating = Boolean(migratingFrom) && migratingFrom !== "no";
// All four questions are required (onboarding is compulsory). "Other" provider also
// needs its free-text name; the "why switching" note is optional.
const baseValid =
Boolean(persona) &&
Boolean(migratingFrom) &&
(!isOtherProvider || Boolean(migratingOtherProvider.trim())) &&
Boolean(howHeard) &&
Boolean(volume);
const canSubmit = baseValid && !submitting;
const answers = (): OnboardingAnswers => ({
persona: persona || undefined,
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
migratingFrom: migratingFrom || undefined,
migratingOtherProvider: isOtherProvider ? migratingOtherProvider.trim() || undefined : undefined,
switchReason: isMigrating ? switchReason.trim() || undefined : undefined,
howHeard: howHeard || undefined,
volume: volume || undefined,
});
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
setEf((v) => ({ ...v, ...patch }));
if ("workEmail" in patch) setEfEmailError(null);
};
const expandOnPrem = () => setOnPremExpanded(true);
const collapseOnPrem = () => {
setOnPremExpanded(false);
setCaptchaActive(false);
setEfEmailError(null);
};
// Best-effort persistence must never trap the user. Dismiss immediately, then fire
// the network work in the background. `withEnterprise` = also send the on-prem lead.
const finish = (withEnterprise: boolean) => {
if (submitting) return;
setSubmitting(true);
const data = answers();
const efSnapshot = withEnterprise ? { ...ef } : null;
onComplete(false); // compulsory — always "completed", never skipped
void (async () => {
try {
await submitOnboarding(data, origin, userEmail);
// Two distinct submissions on success: onboarding answers above, and the
// enterprise on-prem lead here (same endpoint as the standalone form).
if (efSnapshot) {
await submitLead({
kind: "enterprise",
source: "onboarding",
origin,
payload: {
name: efSnapshot.name,
company: efSnapshot.company || undefined,
jobTitle: efSnapshot.jobTitle,
workEmail: efSnapshot.workEmail,
phone: efSnapshot.phone,
volume: efSnapshot.volume,
// They already answered on-prem = yes; deployment intent is implied.
deployment: "yes",
agentGoal: efSnapshot.agentGoal,
},
});
// Only the on-prem/enterprise lead path sends an email; plain onboarding
// does not. Confirm the email just for this path.
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
}
} catch {
// Swallowed — the user is already in the product; calls are timeout-bounded.
}
})();
};
const handleSubmit = () => {
if (!baseValid) {
toast.error(
isOtherProvider && !migratingOtherProvider.trim()
? "Please tell us which provider you're migrating from"
: "Please answer all the questions",
);
return;
}
// If the user engaged the on-prem section, validate it + pop the anti-spam check.
if (wantsOnPrem) {
const err = validateWorkEmail(ef.workEmail);
if (err) { setEfEmailError(err); return; }
if (!ef.name.trim() || !ef.company.trim() || !ef.jobTitle.trim() || !ef.phone.trim() || !ef.volume) {
toast.error("Please complete the on-prem details below, or remove that section.");
return;
}
setCaptchaActive(true);
return;
}
finish(false);
};
// Runs once the captcha popup is verified (on-prem path).
const submitWithOnPrem = () => {
setCaptchaActive(false);
finish(true);
};
return (
<LeadModalShell
open={open}
// Hard gate: no outside/escape close, hide the built-in ×. Onboarding is
// compulsory — the only exit is "Get started" once the questions are answered.
onOpenChange={() => {}}
contentProps={{
className: "[&>button]:hidden",
onEscapeKeyDown: (e) => e.preventDefault(),
onPointerDownOutside: (e) => e.preventDefault(),
onInteractOutside: (e) => e.preventDefault(),
}}
icon={Rocket}
eyebrow="Welcome"
title="Welcome to Dograh"
description="A few quick questions so we can tailor your experience. Takes ~20 seconds."
primary={{ label: "Get started", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
overlay={captchaActive ? <CaptchaChallenge onVerified={submitWithOnPrem} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="ob-persona">What best describes you?</Label>
<Select
value={persona}
onValueChange={(v) => {
setPersona(v);
// Leaving the on-prem-eligible persona resets the conditional answer
// and any inline enterprise lead.
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) {
setOnPremNeed("");
collapseOnPrem();
}
}}
>
<SelectTrigger id="ob-persona"><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">
<Label htmlFor="ob-onprem">Do you need on-prem deployment for compliance &amp; data residency?</Label>
<Select
value={onPremNeed}
onValueChange={(v) => {
setOnPremNeed(v);
if (v !== "yes") collapseOnPrem();
}}
>
<SelectTrigger id="ob-onprem"><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-3 rounded-lg border border-border/60 bg-muted/30 p-3">
<div className="flex items-start justify-between gap-2">
<p className="text-xs leading-relaxed text-muted-foreground">
We offer a <span className="font-medium text-foreground">Managed On-Prem</span> deployment
for compliance and data residency.
</p>
{onPremExpanded && (
<button
type="button"
onClick={collapseOnPrem}
className="shrink-0 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Remove
</button>
)}
</div>
{!onPremExpanded ? (
<button
type="button"
onClick={expandOnPrem}
className="text-xs font-medium text-cta underline-offset-4 hover:underline"
>
Talk to us about on-prem
</button>
) : (
<div className="space-y-3">
<EnterpriseLeadFields
idPrefix="ob-op"
value={ef}
onChange={onEfChange}
showDeployment={false}
emailError={efEmailError}
/>
<p className="text-[0.7rem] text-muted-foreground">
Our team will reach out about on-prem. Prefer not to? Click &ldquo;Remove&rdquo;.
</p>
</div>
)}
</div>
)}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ob-volume">Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger id="ob-volume"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-migrating">Are you migrating from another provider?</Label>
<Select
value={migratingFrom}
onValueChange={(v) => {
setMigratingFrom(v);
if (v !== "other") setMigratingOtherProvider("");
if (v === "no") setSwitchReason("");
}}
>
<SelectTrigger id="ob-migrating"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_MIGRATION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{isOtherProvider && (
<div className="mt-2 space-y-1.5">
<Label htmlFor="ob-other-provider">Other provider</Label>
<Input
id="ob-other-provider"
placeholder="Enter the provider here"
value={migratingOtherProvider}
onChange={(e) => setMigratingOtherProvider(e.target.value)}
/>
</div>
)}
{isMigrating && (
<div className="mt-2 space-y-1.5">
<Label htmlFor="ob-switch-reason">
Why are you switching? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="ob-switch-reason"
rows={2}
placeholder="e.g. cost, self-hosting, concurrency, data security, latency"
value={switchReason}
onChange={(e) => setSwitchReason(e.target.value)}
/>
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-heard">How did you hear about us?</Label>
<Select value={howHeard} onValueChange={setHowHeard}>
<SelectTrigger id="ob-heard"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_HEARD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</LeadModalShell>
);
}

View file

@ -0,0 +1,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

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

View file

@ -0,0 +1,49 @@
// Returns true if the email uses a common free/personal domain.
// Used to gate "work email" fields on lead forms.
const PERSONAL_EMAIL_DOMAINS = new Set([
"gmail.com",
"googlemail.com",
"yahoo.com",
"yahoo.co.in",
"yahoo.co.uk",
"ymail.com",
"outlook.com",
"hotmail.com",
"hotmail.co.uk",
"live.com",
"msn.com",
"icloud.com",
"me.com",
"mac.com",
"proton.me",
"protonmail.com",
"pm.me",
"aol.com",
"gmx.com",
"gmx.net",
"mail.com",
"zoho.com",
"yandex.com",
"fastmail.com",
]);
export function isValidEmail(email: string): boolean {
// Pragmatic check — not RFC-perfect, but rejects obvious garbage.
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
}
export function isPersonalEmail(email: string): boolean {
const at = email.trim().toLowerCase().split("@");
if (at.length !== 2) return false;
return PERSONAL_EMAIL_DOMAINS.has(at[1]);
}
// Convenience validator for work-email fields.
// Returns an error string, or null if valid.
export function validateWorkEmail(email: string): string | null {
if (!email.trim()) return "Work email is required";
if (!isValidEmail(email)) return "Please enter a valid email address";
if (isPersonalEmail(email)) return "Please use your work email";
return null;
}

View file

@ -0,0 +1,106 @@
// Shared dropdown options + lead source/kind types for the lead-gen forms.
export type LeadSource =
| "sidebar"
| "billing_card"
| "billing_custom_pricing"
| "builder_nudge"
| "hire_expert"
| "onboarding"
| "pricing_custom_volume"
| "landing_contact"
| "auth_page";
export type LeadKind = "hire_expert" | "enterprise";
// Provenance stamped by the in-app forms (analytics only; the marketing site and
// server use "website"). Derived from AppConfig deploymentMode: cloud → "cloud_app",
// otherwise "oss_app". OSS submits via the public no-token endpoints.
export type LeadOrigin = "cloud_app" | "oss_app";
// Monthly call-volume buckets. Values MUST match the backend qualifier enum
// (user_onboarding flows): "0-5k" | "5k-100k" | "100k+" | "not-sure".
export const VOLUME_OPTIONS = [
{ 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",
"auth_page",
];
// 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: are you migrating from another provider? (a trimmed competitor list).
// "no" → not migrating; "other" → reveals a free-text provider field.
export const ONBOARDING_MIGRATION_OPTIONS = [
{ value: "no", label: "No, I'm not migrating" },
{ value: "vapi", label: "Vapi" },
{ value: "retell", label: "Retell" },
{ value: "bland", label: "Bland" },
{ value: "elevenlabs", label: "ElevenLabs" },
{ value: "synthflow", label: "Synthflow" },
{ value: "other", label: "Other" },
] as const;
// Onboarding: how did you hear about us? (trimmed).
export const ONBOARDING_HEARD_OPTIONS = [
{ value: "github", label: "GitHub" },
{ value: "search_engine", label: "Search engine" },
{ value: "social_media", label: "Social media (Twitter, LinkedIn)" },
{ value: "youtube", label: "YouTube" },
{ value: "ai_tool", label: "AI tool (ChatGPT, Claude)" },
{ value: "referral", label: "Someone told me about it" },
{ value: "other", label: "Other" },
] as const;
// Onboarding: expected monthly call volume. Its own set — value "exploring" is NOT
// the qualifier's "not-sure"; onboarding has no flow, so this is analytics-only.
export const ONBOARDING_VOLUME_OPTIONS = [
{ value: "0-5k", label: "05k" },
{ value: "5k-100k", label: "5k100k" },
{ value: "100k+", label: "100k+" },
{ value: "exploring", label: "Exploring" },
] as const;
// Onboarding: what best describes you.
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;

View file

@ -0,0 +1,57 @@
// Thin client for the SEPARATE user_onboarding service (its own base URL).
// Not part of the generated Dograh SDK — a different host. All endpoints are PUBLIC
// (no auth token); identity is the email carried in the body. Every call is
// BEST-EFFORT: failures are swallowed so a down/erroring service never blocks the user.
// Base URL of the user_onboarding service. Unset (the default for self-hosted OSS —
// .env.example ships this commented out) → fall back to our cloud leads backend so we
// still receive OSS form submissions. Override the env var to point elsewhere (or to a
// local backend) to stop sending leads to us.
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL || "https://api-leads.dograh.com";
// Bound every call so a slow/hung service can never freeze the UI. Best-effort:
// failures are surfaced via console.error (Sentry breadcrumbs) but never thrown.
const TIMEOUT_MS = 6000;
// POST a JSON body to the onboarding service (public — no auth header).
async function post(path: string, body: unknown): Promise<void> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
// fetch does not reject on 4xx/5xx — check explicitly so dropped leads are
// at least observable.
if (!res.ok) {
console.error(`[onboarding] POST ${path} failed with HTTP ${res.status}`);
}
} catch (err) {
// Network error, or the timeout aborted the request. Never block the user.
console.error(`[onboarding] POST ${path} did not complete:`, err);
} finally {
clearTimeout(timer);
}
}
// 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). Email is in the body.
export async function postLeadToService(
kind: "hire_expert" | "enterprise",
body: Record<string, unknown>,
): Promise<void> {
await post(LEAD_PATH[kind], body);
}
// Persist an onboarding submission (or skip — body carries `skipped`).
export async function postOnboardingToService(body: Record<string, unknown>): Promise<void> {
await post("/api/v1/onboarding", body);
}

View file

@ -0,0 +1,36 @@
// Single submission seam for all lead forms.
// Fires a PostHog capture (the durable record) and POSTs to the separate, PUBLIC
// user_onboarding service (best-effort — the user is never blocked if it's down).
// No auth token: identity is the email in the payload.
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import { detectCountry } from "./detectCountry";
import type { LeadKind, LeadOrigin, LeadSource } from "./leadFieldOptions";
import { postLeadToService } from "./onboardingServiceClient";
const SUBMIT_EVENT: Record<LeadKind, string> = {
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED,
};
export interface SubmitLeadArgs {
kind: LeadKind;
source: LeadSource;
// Deployment provenance (analytics only): "cloud_app" | "oss_app".
origin: LeadOrigin;
// Field values, already validated by the caller. Includes the contact email.
payload: Record<string, unknown>;
}
export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise<void> {
// `country` is detected silently (timezone/locale) and sent in the body — no visible
// field. It feeds the founders-notification email subject server-side.
const body = { source, origin, country: detectCountry(), ...payload };
// PostHog capture — the durable record, always fired.
posthog.capture(SUBMIT_EVENT[kind], body);
// Persist to the separate user_onboarding service (best-effort, public).
await postLeadToService(kind, body);
}

View file

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

View file

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"card-weave rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm dark:shadow-md dark:shadow-black/25",
className
)}
{...props}

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

@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"focus-visible:border-cta/70 focus-visible:ring-cta/30 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}

View file

@ -19,6 +19,7 @@ import {
} from '@/client/sdk.gen';
import type { FolderResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
@ -126,132 +127,134 @@ export function WorkflowTable({
};
return (
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold text-center">Total Runs</TableHead>
<TableHead className="font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((workflow) => (
<TableRow
key={workflow.id}
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
>
<TableCell className="text-muted-foreground">
{workflow.id}
</TableCell>
<TableCell className="font-medium">
{workflow.name}
</TableCell>
<TableCell>
{new Date(workflow.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</TableCell>
<TableCell className="text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
{workflow.total_runs || 0}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(workflow.id)}
className="flex items-center gap-2"
>
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
<Card className="overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Agent Name</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold text-center">Total Runs</TableHead>
<TableHead className="font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((workflow) => (
<TableRow
key={workflow.id}
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
>
<TableCell className="text-muted-foreground">
{workflow.id}
</TableCell>
<TableCell className="font-medium">
{workflow.name}
</TableCell>
<TableCell>
{new Date(workflow.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</TableCell>
<TableCell className="text-center">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
{workflow.total_runs || 0}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(workflow.id)}
className="flex items-center gap-2"
>
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant={showArchived ? "default" : "outline"}
size="sm"
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
disabled={loadingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{loadingWorkflowId === workflow.id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{showArchived ? 'Restoring...' : 'Archiving...'}
</>
) : (
<>
{showArchived ? (
<>
<RotateCcw size={16} />
Restore
</>
) : (
<>
<Archive size={16} />
Archive
</>
)}
</>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button
variant={showArchived ? "default" : "outline"}
size="sm"
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
disabled={loadingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{loadingWorkflowId === workflow.id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{showArchived ? 'Restoring...' : 'Archiving...'}
</>
) : (
<>
{showArchived ? (
<>
<RotateCcw size={16} />
Restore
</>
) : (
<>
<Archive size={16} />
Archive
</>
)}
</>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View file

@ -228,7 +228,7 @@ export function FolderSection({
<AlertDialogTitle>Delete {folder.name}?</AlertDialogTitle>
<AlertDialogDescription>
The {count} agent{count === 1 ? '' : 's'} in this folder
wont be deleted theyll move to Uncategorized.
wont be deleted - theyll move to Uncategorized.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>