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

This commit is contained in:
Pritesh 2026-06-11 10:37:43 +05:30
parent 0662a1770f
commit 0eddce6c83
55 changed files with 1108 additions and 629 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ node_modules/
# Superpowers brainstorm mockups (local only)
.superpowers/
.gstack/

View file

@ -85,6 +85,9 @@ class UserConfigurationRequestResponseSchema(BaseModel):
test_phone_number: str | None = None
timezone: str | None = None
organization_pricing: dict[str, Union[float, str, bool]] | None = None
# Post-signup onboarding gate (see UserConfiguration). Set once on submit/skip.
onboarding_completed_at: datetime | None = None
onboarding_skipped: bool | None = None
@router.get("/configurations/user")

View file

@ -21,6 +21,10 @@ class UserConfiguration(BaseModel):
test_phone_number: str | None = None
timezone: str | None = None
last_validated_at: datetime | None = None
# Post-signup onboarding gate: set once the user submits or skips the
# onboarding form, so it shows only once per user (server-side, cross-device).
onboarding_completed_at: datetime | None = None
onboarding_skipped: bool = False
@model_validator(mode="before")
@classmethod

View file

@ -141,6 +141,10 @@ def mask_user_config(config: UserConfiguration) -> Dict[str, Any]:
"is_realtime": config.is_realtime,
"test_phone_number": config.test_phone_number,
"timezone": config.timezone,
# Onboarding gate flags (not secrets) — surfaced so the UI can decide
# whether to show the post-signup onboarding form on boot.
"onboarding_completed_at": config.onboarding_completed_at,
"onboarding_skipped": config.onboarding_skipped,
}

View file

@ -113,6 +113,13 @@ def merge_user_configurations(
if "timezone" in incoming_partial:
merged["timezone"] = incoming_partial["timezone"]
# Onboarding gate flags — overwrite only when supplied (set once on submit/skip).
if "onboarding_completed_at" in incoming_partial:
merged["onboarding_completed_at"] = incoming_partial["onboarding_completed_at"]
if "onboarding_skipped" in incoming_partial:
merged["onboarding_skipped"] = incoming_partial["onboarding_skipped"]
return UserConfiguration.model_validate(merged)

View file

@ -257,12 +257,12 @@ SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config(
)
AZURE_SPEECH_PROVIDER_MODEL_CONFIG = provider_model_config(
"Azure Speech Services",
description="Azure Cognitive Services Speech TTS and STT via the Azure Speech SDK.",
description="Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.",
provider_docs_url="https://learn.microsoft.com/en-us/azure/ai-services/speech-service/",
)
AZURE_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config(
"Azure OpenAI Realtime",
description="Azure OpenAI Realtime API low-latency speech-to-speech conversations.",
description="Azure OpenAI Realtime API - low-latency speech-to-speech conversations.",
provider_docs_url="https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/realtime-audio-quickstart",
)
@ -360,7 +360,7 @@ class GoogleVertexLLMConfiguration(BaseLLMConfiguration):
api_key: str | list[str] | None = Field(
default=None,
description=(
"Not used for Vertex AI authentication is via the service account "
"Not used for Vertex AI - authentication is via the service account "
"in `credentials` (or ADC). Leave blank."
),
)
@ -425,7 +425,7 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration):
provider: Literal[ServiceProviders.AWS_BEDROCK] = ServiceProviders.AWS_BEDROCK
model: str = Field(
default="us.amazon.nova-pro-v1:0",
description="Bedrock model ID include the region inference-profile prefix (e.g. 'us.').",
description="Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').",
json_schema_extra={"examples": AWS_BEDROCK_MODELS, "allow_custom_input": True},
)
aws_access_key: str = Field(
@ -442,7 +442,7 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration):
)
api_key: str | list[str] | None = Field(
default=None,
description="Not used for Bedrock authentication is via the AWS credentials above. Leave blank.",
description="Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.",
)
@ -682,7 +682,7 @@ class GoogleVertexRealtimeLLMConfiguration(BaseLLMConfiguration):
api_key: str | list[str] | None = Field(
default=None,
description=(
"Not used for Vertex AI authentication is via the service account "
"Not used for Vertex AI - authentication is via the service account "
"in `credentials` (or ADC). Leave blank."
),
)

View file

@ -176,7 +176,7 @@ class _ToolDocumentRefsMixin(BaseModel):
@node_spec(
name="startCall",
display_name="Start Call",
description="Entry point of the workflow plays a greeting and opens the conversation.",
description="Entry point of the workflow - plays a greeting and opens the conversation.",
llm_hint=(
"The entry point of every workflow (exactly one required). Plays an "
"optional greeting, can fetch context from an external API before the "
@ -344,7 +344,7 @@ class StartCallNodeData(
@node_spec(
name="agentNode",
display_name="Agent Node",
description="Conversational step the LLM runs one focused exchange.",
description="Conversational step - the LLM runs one focused exchange.",
llm_hint=(
"Mid-call step executed by the LLM. Most workflows are a chain of agent "
"nodes connected by edges that describe transition conditions. Each agent "
@ -613,9 +613,9 @@ class GlobalNodeData(BaseNodeData, _PromptedNodeDataMixin):
"description": (
"Path segment that uniquely identifies "
"this trigger. Used in both URLs:\n"
" • Production: `/api/v1/public/agent/<trigger_path>` executes "
" • Production: `/api/v1/public/agent/<trigger_path>` - executes "
"the published agent.\n"
" • Test: `/api/v1/public/agent/test/<trigger_path>` executes "
" • Test: `/api/v1/public/agent/test/<trigger_path>` - executes "
"the latest draft.\n"
"Can be customized to a descriptive value up to 36 characters "
"using letters, numbers, hyphens, or underscores."
@ -708,7 +708,7 @@ class TriggerNodeData(BaseNodeData):
"display_name": "Payload Template",
"description": (
"JSON body of the request. Values are Jinja-rendered against the "
"run context `{{workflow_run_id}}`, `{{gathered_context.foo}}`, "
"run context - `{{workflow_run_id}}`, `{{gathered_context.foo}}`, "
"`{{annotations.qa_xxx}}`, etc."
),
"ui_type": PropertyType.json,

View file

@ -229,7 +229,7 @@ class WorkflowGraph:
kind=ItemKind.workflow,
id=None,
field=None,
message="Workflow has no start node exactly one is required",
message="Workflow has no start node - exactly one is required",
)
)
elif len(start_nodes) > 1:
@ -239,7 +239,7 @@ class WorkflowGraph:
id=None,
field=None,
message=(
f"Workflow has {len(start_nodes)} start nodes "
f"Workflow has {len(start_nodes)} start nodes - "
f"exactly one is required"
),
)

View file

@ -1,3 +1,6 @@
BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_NODE_ENV=development
# Base URL of the separate user_onboarding service (lead-gen + onboarding form
# submissions). Leave unset to disable those POSTs (PostHog capture still fires).
NEXT_PUBLIC_ONBOARDING_API_URL=http://localhost:8001

4
ui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "ui",
"version": "1.30.1",
"version": "1.33.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ui",
"version": "1.30.1",
"version": "1.33.0",
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@radix-ui/react-alert-dialog": "^1.1.15",

BIN
ui/public/dograh-logo-inverse.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
ui/public/dograh-logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
ui/public/dograh-mark.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -304,7 +304,7 @@ export default function APIKeysPage() {
// Don't render content until auth is loaded
if (loading || !user) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="min-h-screen flex items-center justify-center">
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-64 w-96" />
@ -319,7 +319,7 @@ export default function APIKeysPage() {
const showServiceKeyArchiveControls = !isOSS;
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<div className="mb-8">

View file

@ -5,8 +5,9 @@ import { useState } from "react";
import { toast } from "sonner";
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
import { AuthShell } from "@/components/auth/AuthShell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -46,48 +47,48 @@ export default function LoginPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>Enter your email and password to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
</div>
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<div className="space-y-1.5 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password to continue
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</AuthShell>
);
}

View file

@ -5,8 +5,9 @@ import { useState } from "react";
import { toast } from "sonner";
import { signupApiV1AuthSignupPost } from "@/client/sdk.gen";
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
import { AuthShell } from "@/components/auth/AuthShell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -58,61 +59,59 @@ export default function SignupPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>Enter your details to get started</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account..." : "Create account"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/auth/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<div className="space-y-1.5 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Create an account</h1>
<p className="text-sm text-muted-foreground">Enter your details to get started</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account..." : "Create account"}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/auth/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</AuthShell>
);
}

View file

@ -253,7 +253,7 @@ export default function NewCampaignPage() {
}
if (maxConcurrencyValue > effectiveLimit) {
if (availableFromNumbersCount > 0 && availableFromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) add more CLIs to increase concurrency.`);
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) - add more CLIs to increase concurrency.`);
} else {
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
}
@ -455,7 +455,7 @@ export default function NewCampaignPage() {
value={config.id.toString()}
>
{config.name} ({config.provider})
{config.is_default_outbound ? ' default' : ''}
{config.is_default_outbound ? ' - default' : ''}
</SelectItem>
))
)}

View file

@ -184,4 +184,27 @@
@media (prefers-reduced-motion: reduce) {
.auth-waveform span { animation: none; }
}
/* Atmospheric app background premium dark depth instead of flat black.
Decorative only; applied to content areas via the .app-surface class so it
cascades to every page without per-page edits. Light mode stays clean. */
.app-surface {
background-color: var(--background);
}
.dark .app-surface {
background-image:
radial-gradient(55rem 32rem at 100% 100%, color-mix(in oklch, var(--cta) 13%, transparent), transparent 55%),
radial-gradient(48rem 30rem at 0% 100%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 52%),
linear-gradient(0deg, color-mix(in oklch, var(--foreground) 4%, transparent), transparent 38%);
background-repeat: no-repeat;
}
/* Faint warm wash at the bottom of the sidebar for subtle depth (dark only). */
.dark .app-sidebar-surface {
background-image: linear-gradient(
0deg,
color-mix(in oklch, var(--cta) 8%, transparent),
transparent 32%
);
}
}

View file

@ -1,82 +0,0 @@
// Dark two-column auth shell. LEFT (lg+ only): a brand/value panel with a
// CSS-only audio-waveform motif, proof points, and a Bland-style enterprise CTA
// block at the bottom (passed in as `enterpriseSlot`). RIGHT: a centered
// zinc-900 card that wraps the Stack Auth form (`children`). Mobile collapses to
// the single card column. Palette is the app's blacks/greys with one warm CTA
// accent on the waveform + focus.
import type { ReactNode } from "react";
const PROOF_POINTS = [
"Open source",
"7+ telephony providers",
"Open architecture",
];
export function AuthShell({
children,
enterpriseSlot,
}: {
children: ReactNode;
enterpriseSlot?: ReactNode;
}) {
return (
<div className="grid min-h-screen w-full bg-background lg:grid-cols-[45%_55%]">
{/* Brand / value panel — hidden on mobile */}
<aside className="relative hidden flex-col justify-between overflow-hidden border-r 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 -left-24 top-1/3 size-[28rem] rounded-full opacity-20 blur-3xl"
style={{ background: "radial-gradient(circle, var(--cta), transparent 70%)" }}
/>
<div className="relative flex items-center gap-3">
<div className="auth-waveform" aria-hidden>
<span /><span /><span /><span /><span /><span /><span /><span />
</div>
<span className="text-xl font-semibold tracking-tight text-zinc-50">Dograh</span>
</div>
<div className="relative max-w-md space-y-6">
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-50 xl:text-4xl">
Voice AI for outbound calling, built in the open.
</h1>
<ul className="flex flex-wrap gap-x-3 gap-y-2 text-sm text-zinc-400">
{PROOF_POINTS.map((point, i) => (
<li key={point} className="flex items-center gap-3">
{i > 0 && <span aria-hidden className="text-zinc-700">·</span>}
<span>{point}</span>
</li>
))}
</ul>
</div>
{/* Enterprise CTA block (Bland-style) */}
<div className="relative max-w-md space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-5">
<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>
{/* Form column */}
<main className="flex 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="flex items-center gap-3 lg:hidden">
<div className="auth-waveform" aria-hidden>
<span /><span /><span /><span /><span /><span /><span /><span />
</div>
<span className="text-lg font-semibold tracking-tight">Dograh</span>
</div>
{children}
</div>
</main>
</div>
);
}

View file

@ -8,11 +8,22 @@ import { Button } from "@/components/ui/button";
export function BackButton() {
const router = useRouter();
// On a direct load (e.g. an OAuth redirect or a deep link to /handler/sign-in)
// there's no in-app history, so router.back() would bounce the user off-app.
// Fall back to the home route in that case.
const handleBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
} else {
router.push("/");
}
};
return (
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
onClick={handleBack}
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />

View file

@ -1,12 +1,28 @@
import { StackHandler, StackTheme } from "@stackframe/stack";
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
import { AuthShell } from "@/components/auth/AuthShell";
import { getAuthProvider } from "@/lib/auth/config";
import { AuthEnterpriseCTA } from "./AuthEnterpriseCTA";
import { AuthShell } from "./AuthShell";
import { BackButton } from "./BackButton";
import { stackAuthDarkTheme } from "./stack-theme";
// Stack Auth serves every auth page from this one catch-all. We give the brand
// split-screen shell to the user-facing FORM routes and render only the wide /
// interstitial "machine" routes full-page (so account-settings etc. aren't
// cramped into the narrow auth card). This is a BLOCKLIST, not an allowlist, so
// new or aliased form routes — Stack's `log-in`/`register` aliases, case/dash
// variants, email-verification, mfa, team-invitation — get the shell by default.
// Matching is normalized (lowercase, dashes stripped) to mirror Stack's own
// case- and dash-insensitive route resolution.
const FULL_PAGE_ROUTES = new Set([
"accountsettings",
"oauthcallback",
"magiclinkcallback",
"signout",
"error",
]);
export default async function Handler(props: unknown) {
const authProvider = await getAuthProvider();
@ -27,12 +43,34 @@ export default async function Handler(props: unknown) {
const { getStackServerApp } = await import("@/lib/auth/server");
const app = await getStackServerApp();
return (
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<BackButton />
<StackTheme theme={stackAuthDarkTheme}>
<StackHandler fullPage={false} app={app!} routeProps={props} />
</StackTheme>
</AuthShell>
// Resolve the first route segment to decide layout. `params` is async in
// Next 15; awaiting it here does not consume it for StackHandler below.
let segment = "";
try {
const { params } = props as { params?: Promise<{ stack?: string[] }> };
const resolved = params ? await params : undefined;
segment = resolved?.stack?.[0] ?? "";
} catch {
segment = "";
}
const normalizedSegment = segment.toLowerCase().replace(/-/g, "");
const isAuthForm = segment !== "" && !FULL_PAGE_ROUTES.has(normalizedSegment);
const handler = (
<StackTheme theme={stackAuthDarkTheme}>
<StackHandler fullPage={!isAuthForm} app={app!} routeProps={props} />
</StackTheme>
);
if (isAuthForm) {
return (
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<BackButton />
{handler}
</AuthShell>
);
}
// account-settings and machine routes render full-page (Stack's own layout).
return handler;
}

View file

@ -4,7 +4,7 @@ import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
export default function ServiceConfigurationPage() {
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<ServiceConfiguration docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides} />

View file

@ -136,7 +136,7 @@ export const RecordingsUploadDialog = ({
const valid: PendingFile[] = [];
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit skipped.`);
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit - skipped.`);
continue;
}
const id = `pending-${++pendingFileCounter}`;

View file

@ -148,7 +148,7 @@ export default function TelephonyConfigurationsPage() {
};
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="flex items-start justify-between gap-4 mb-6">
<div>

View file

@ -518,7 +518,7 @@ const data = await response.json();`;
if (loading || !user) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="min-h-screen flex items-center justify-center">
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-64 w-96" />
@ -529,7 +529,7 @@ const data = await response.json();`;
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-8 w-48" />
@ -542,7 +542,7 @@ const data = await response.json();`;
if (!tool) {
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-2xl font-bold mb-4">Tool not found</h1>
@ -563,7 +563,7 @@ const data = await response.json();`;
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Header */}

View file

@ -283,7 +283,7 @@ export default function ToolsPage() {
if (loading || !user) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="min-h-screen flex items-center justify-center">
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-64 w-96" />
@ -293,7 +293,7 @@ export default function ToolsPage() {
}
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<div className="mb-8">

View file

@ -323,7 +323,7 @@ export function EmbedDialog({
<div className="space-y-2">
<div className="font-medium">Headless (Bring Your Own UI)</div>
<div className="text-xs text-muted-foreground">
No UI drive calls from your own buttons via the JS API
No UI - drive calls from your own buttons via the JS API
</div>
</div>
</button>
@ -436,7 +436,7 @@ export function EmbedDialog({
<h4 className="font-medium mb-2">Integration Instructions</h4>
<ul className="text-sm space-y-2 text-muted-foreground">
<li> Add the embed script tag to your page (see below).</li>
<li> The widget renders no UI render your own buttons.</li>
<li> The widget renders no UI - render your own buttons.</li>
<li> Call <code className="text-xs">window.DograhWidget.start()</code> to begin a call.</li>
<li> Call <code className="text-xs">window.DograhWidget.end()</code> to end it.</li>
<li> Subscribe to <code className="text-xs">onCallStart</code>, <code className="text-xs">onCallEnd</code>, <code className="text-xs">onStatusChange</code>, <code className="text-xs">onError</code> to drive your UI.</li>
@ -445,12 +445,12 @@ export function EmbedDialog({
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/20 p-4 border border-blue-200 dark:border-blue-800">
<h4 className="font-medium mb-2 text-blue-900 dark:text-blue-100">Example track status in your own state</h4>
<h4 className="font-medium mb-2 text-blue-900 dark:text-blue-100">Example - track status in your own state</h4>
<p className="text-xs text-blue-900/80 dark:text-blue-100/80 mb-2">
Mirror the call status into a variable you control, then render whatever UI you like from it. The status values are <code className="text-xs">idle</code>, <code className="text-xs">connecting</code>, <code className="text-xs">connected</code>, <code className="text-xs">failed</code>.
</p>
<pre className="text-xs overflow-x-auto">
<code className="text-blue-800 dark:text-blue-200">{`// Vanilla JS keep your own state, render however you want
<code className="text-blue-800 dark:text-blue-200">{`// Vanilla JS - keep your own state, render however you want
let callStatus = 'idle';
window.DograhWidget?.onStatusChange((status) => {

View file

@ -268,7 +268,7 @@ export const PhoneCallDialog = ({
{telephonyConfigs.map((config) => (
<SelectItem key={config.id} value={String(config.id)}>
{config.name} ({config.provider})
{config.is_default_outbound ? " default" : ""}
{config.is_default_outbound ? " - default" : ""}
</SelectItem>
))}
</SelectContent>
@ -294,8 +294,8 @@ export const PhoneCallDialog = ({
<SelectContent>
{fromPhoneNumbers.map((phone) => (
<SelectItem key={phone.id} value={String(phone.id)}>
{phone.label ? `${phone.label} ${phone.address}` : phone.address}
{phone.is_default_caller_id ? " default" : ""}
{phone.label ? `${phone.label} - ${phone.address}` : phone.address}
{phone.is_default_caller_id ? " - default" : ""}
</SelectItem>
))}
</SelectContent>

View file

@ -181,7 +181,7 @@ export const RecordingsDialog = ({
const valid: PendingFile[] = [];
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit skipped.`);
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit - skipped.`);
continue;
}
const id = `pending-${++pendingFileCounter}`;

View file

@ -305,7 +305,7 @@ export const WorkflowEditorHeader = ({
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-blue-500/30 bg-blue-500/10">
<Eye className="w-4 h-4 text-blue-400" />
<span className="text-sm text-blue-400">
Viewing {activeVersionLabel} Read only
Viewing {activeVersionLabel} - Read only
</span>
</div>
)}

View file

@ -583,7 +583,7 @@ function GeneralSection({
<div>
<h3 className="text-sm font-medium">Context Compaction</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Automatically summarize conversation context when transitioning between nodes. Not applicable in Realtime mode the speech-to-speech service manages its own conversation state and this setting is ignored.
Automatically summarize conversation context when transitioning between nodes. Not applicable in Realtime mode - the speech-to-speech service manages its own conversation state and this setting is ignored.
</p>
</div>
<div className="flex items-center justify-between">

View file

@ -80,7 +80,7 @@ export default function CreateWorkflowPage() {
};
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Create Voice Agent</h1>

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

@ -2,7 +2,8 @@
// Bland-style enterprise call-to-action rendered inside the auth brand panel.
// Links out to the main marketing site's enterprise intake form rather than the
// in-app modal, since the visitor is not yet authenticated here.
// in-app modal, since the visitor is not yet authenticated here. Shared by the
// Stack Auth handler and the local/OSS auth pages.
import { Button } from "@/components/ui/button";
@ -18,7 +19,7 @@ export function AuthEnterpriseCTA() {
variant="outline"
className="w-full border-white/20 bg-white/5 text-zinc-100 hover:bg-white/10 hover:text-white"
>
Talk to our team
Enterprise Enquiry
</Button>
</a>
);

View file

@ -0,0 +1,86 @@
// 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. */}
<main className="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

@ -1,30 +1,40 @@
"use client";
// Compact self-serve "Buy Credits" control for the billing card. Preset amount
// chips plus a custom amount (min $5) feed the Razorpay seam in
// @/lib/billing/topup. Analytics: chip selection and the buy click are captured
// for funnel analysis. The seam currently throws "not wired yet"; we surface
// that as a calm inline note rather than an error toast.
// 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 { MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup";
import { MAX_TOPUP_USD, MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup";
import { cn } from "@/lib/utils";
export function BuyCreditsControl() {
// 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() ? Number(custom) : null;
const customAmount = custom.trim() ? parseAmount(custom) : null;
const amount = customAmount ?? selected;
const valid = amount != null && Number.isFinite(amount) && amount >= MIN_TOPUP_USD;
const valid = amount != null && amount >= MIN_TOPUP_USD && amount <= MAX_TOPUP_USD;
const selectPreset = (value: number) => {
setSelected(value);
@ -37,8 +47,8 @@ export function BuyCreditsControl() {
setCustom(raw);
setSelected(null);
setError(null);
const parsed = Number(raw);
if (raw.trim() && Number.isFinite(parsed) && parsed >= MIN_TOPUP_USD) {
const parsed = parseAmount(raw);
if (parsed != null && parsed >= MIN_TOPUP_USD && parsed <= MAX_TOPUP_USD) {
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: parsed });
}
};
@ -59,52 +69,66 @@ export function BuyCreditsControl() {
};
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{TOPUP_PRESETS.map((value) => (
<button
key={value}
type="button"
onClick={() => selectPreset(value)}
aria-pressed={selected === value}
className={cn(
"rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
"border-input text-foreground hover:bg-accent",
selected === value && "border-cta bg-cta/10 text-foreground ring-1 ring-cta/40",
)}
>
${value}
</button>
))}
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
inputMode="decimal"
value={custom}
onChange={(e) => onCustomChange(e.target.value)}
placeholder="Custom"
aria-label={`Custom amount (min $${MIN_TOPUP_USD})`}
className="h-9 w-28 pl-5"
/>
<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>
{error ? (
<p className="text-xs text-muted-foreground">{error}</p>
) : (
<p className="text-xs text-muted-foreground">Minimum ${MIN_TOPUP_USD}.</p>
)}
<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>
<Button
type="button"
onClick={onBuy}
disabled={!valid || busy}
className="bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
{busy ? "Starting…" : "Buy Credits"}
</Button>
</div>
{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

@ -24,7 +24,11 @@ export function DograhCreditsCard() {
if (!auth.isAuthenticated) return;
try {
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
if (response.data) {
// 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) {
@ -74,7 +78,7 @@ export function DograhCreditsCard() {
</div>
{mpsCredits.total_quota > 0 && (
<Progress value={(mpsCredits.total_credits_used / mpsCredits.total_quota) * 100} className="h-3" />
<Progress value={Math.min(100, (mpsCredits.total_credits_used / mpsCredits.total_quota) * 100)} className="h-3" />
)}
</div>
) : (
@ -83,20 +87,23 @@ export function DograhCreditsCard() {
</p>
)}
{/* Footer CTAs — card ends with self-serve + done-for-you actions */}
{/* 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="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Running low?</p>
<p className="text-sm text-muted-foreground">Top up instantly, or have us build it for you.</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:items-end">
<BuyCreditsControl />
<Button variant="outline" className="gap-2" onClick={() => openHireExpert("billing_card")}>
<UserRound className="h-4 w-4" />
Hire an Expert
</Button>
</div>
<div 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"
@ -104,9 +111,9 @@ export function DograhCreditsCard() {
posthog.capture(PostHogEvent.CUSTOM_PRICING_CLICKED);
openEnterprise("billing_custom_pricing");
}}
className="text-xs text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
className="block text-xs text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Contact Us: Custom pricing for committed monthly volume
Book a Strategy Call: custom pricing for committed volume
</button>
</div>
</CardContent>

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

@ -19,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" />
@ -120,7 +120,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
{!isWorkflowEditor && <AppHeader />}
{/* Optional header area for specific pages */}
{headerActions && (
<header className="sticky top-0 z-50 w-full border-b bg-background">
<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}
@ -141,14 +141,14 @@ const AppLayout: React.FC<AppLayoutProps> = ({
)}
{/* Main content area */}
<main className="flex-1">
<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

@ -27,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 {
@ -226,8 +227,8 @@ export function AppSidebar() {
asChild
tooltip={tooltip}
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-accent text-accent-foreground"
"transition-colors hover:bg-accent hover:text-accent-foreground",
isItemActive && "bg-cta/10 font-medium text-foreground hover:bg-cta/15"
)}
>
<Link
@ -236,7 +237,10 @@ export function AppSidebar() {
className={cn("relative", isCollapsed && "justify-center")}
translate="no"
>
<Icon className="h-4 w-4 shrink-0" />
{isItemActive && !isCollapsed && (
<span className="absolute inset-y-1 left-0 w-0.5 rounded-full bg-cta" aria-hidden />
)}
<Icon className={cn("h-4 w-4 shrink-0", isItemActive && "text-cta")} />
<span
className={cn("notranslate min-w-0 flex-1 truncate", isCollapsed && "sr-only")}
translate="no"
@ -288,16 +292,16 @@ export function AppSidebar() {
);
return (
<Sidebar collapsible="icon" className="border-r">
<Sidebar collapsible="icon" className="app-sidebar-surface border-r border-border/60">
<SidebarHeader className="border-b 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"
@ -321,7 +325,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>
)}
@ -474,10 +478,6 @@ 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

View file

@ -0,0 +1,82 @@
"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 { 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="w-full max-w-xs space-y-4 rounded-xl border border-border/60 bg-card p-5 shadow-xl">
<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 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-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
Confirm &amp; submit
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,150 @@
"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;
// Work email is mandatory only when the visitor is logged out.
workEmailRequired: boolean;
// 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,
workEmailRequired,
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`} 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`} 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 <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id={`${p}-title`} value={value.jobTitle} onChange={(e) => onChange({ jobTitle: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor={`${p}-email`}>
Work email{!workEmailRequired && <span className="text-muted-foreground"> (optional)</span>}
</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

@ -4,29 +4,18 @@ import { ShieldCheck } 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 { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
import {
EMPTY_ENTERPRISE_FIELDS,
type EnterpriseFieldsValue,
EnterpriseLeadFields,
} from "./EnterpriseLeadFields";
import { FormTrustLine } from "./FormTrustLine";
import { validateWorkEmail } from "./isPersonalEmail";
import {
ENTERPRISE_DEPLOYMENT_OPTIONS,
ENTERPRISE_DEPLOYMENT_SOURCES,
ENTERPRISE_VOLUME_OPTIONS,
type LeadSource,
} from "./leadFieldOptions";
import { ENTERPRISE_DEPLOYMENT_SOURCES, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { MathCaptcha } from "./MathCaptcha";
import { PhoneField } from "./PhoneField";
import { submitLead } from "./submitLead";
interface EnterpriseModalProps {
@ -34,67 +23,71 @@ interface EnterpriseModalProps {
onOpenChange: (open: boolean) => void;
source: LeadSource;
// Optional values to pre-fill when the modal opens (e.g. company name already
// collected in the onboarding form). Backward-compatible: omitted = no prefill.
// collected upstream). Backward-compatible: omitted = no prefill.
prefill?: { company?: string };
}
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
const { getAccessToken, isAuthenticated } = useAuth(); // Dograh token for the onboarding service
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [workEmail, setWorkEmail] = useState("");
const [phone, setPhone] = useState("");
const [volume, setVolume] = useState("");
const [deployment, setDeployment] = useState("");
const [agentGoal, setAgentGoal] = useState("");
const { getAccessToken, isAuthenticated } = useAuth(); // Dograh token for the onboarding service
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaValid, setCaptchaValid] = useState(false);
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);
// Work email is mandatory only when the visitor is logged out (we already
// have the email for authenticated users via their Dograh token).
// Work email is mandatory only when the visitor is logged out.
const workEmailRequired = !isAuthenticated;
const reset = () => {
setName(""); setCompany(""); setJobTitle(""); setWorkEmail("");
setPhone(""); setVolume(""); setDeployment(""); setAgentGoal("");
setEmailError(null); setCaptchaValid(false); setSubmitting(false);
setValue(EMPTY_ENTERPRISE_FIELDS);
setEmailError(null);
setCaptchaActive(false);
setSubmitting(false);
};
// Seed reusable fields from prefill when the modal opens, so we don't re-ask
// for info already captured upstream (e.g. company name from onboarding).
const 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) {
setCompany((prev) => prev || prefillCompany);
setValue((v) => (v.company ? v : { ...v, company: prefillCompany }));
}
}, [open, prefillCompany]);
const canSubmit =
Boolean(name.trim()) &&
Boolean(company.trim()) &&
Boolean(phone.trim()) &&
Boolean(volume) &&
(!workEmailRequired || Boolean(workEmail.trim())) &&
captchaValid &&
!submitting;
// 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.phone.trim()) &&
Boolean(value.volume) &&
(!workEmailRequired || Boolean(value.workEmail.trim()));
const handleSubmit = async () => {
if (workEmailRequired || workEmail.trim()) {
const err = validateWorkEmail(workEmail);
const canSubmit = baseValid && !submitting;
// Validate, then pop the anti-spam check on top of the modal.
const handleSubmit = () => {
if (workEmailRequired || value.workEmail.trim()) {
const err = validateWorkEmail(value.workEmail);
if (err) { setEmailError(err); return; }
}
if (!name.trim() || !company.trim() || !phone.trim() || !volume) {
if (!value.name.trim() || !value.company.trim() || !value.phone.trim() || !value.volume) {
toast.error("Please fill in all required fields");
return;
}
if (!captchaValid) { toast.error("Please answer the quick check"); return; }
setCaptchaActive(true);
};
// Runs once the captcha popup is verified.
const doSubmit = async () => {
setCaptchaActive(false);
setSubmitting(true);
try {
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
@ -103,19 +96,19 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
kind: "enterprise",
source,
payload: {
name,
company,
jobTitle,
workEmail,
phone,
volume,
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 ? deployment || "yes" : "yes",
agentGoal,
deployment: showDeployment ? value.deployment || "yes" : "yes",
agentGoal: value.agentGoal,
},
token,
});
toast.success("Thanks our team will reach out about enterprise deployment.");
toast.success("Thanks - our team will reach out about enterprise deployment.");
reset();
onOpenChange(false);
} catch {
@ -130,93 +123,21 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
icon={ShieldCheck}
eyebrow="Enterprise"
title="Talk to our team"
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}
>
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ent-name">Name</Label>
<Input id="ent-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ent-company">Company name</Label>
<Input id="ent-company" value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ent-title">
Job title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ent-title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ent-email">
Work email{!workEmailRequired && <span className="text-muted-foreground"> (optional)</span>}
</Label>
<Input
id="ent-email"
type="email"
value={workEmail}
onChange={(e) => { setWorkEmail(e.target.value); setEmailError(null); }}
placeholder="you@company.com"
/>
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ent-phone">Phone</Label>
<PhoneField id="ent-phone" value={phone} onChange={setPhone} required />
</div>
<div className="space-y-1.5">
<Label>Monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{showDeployment && (
<div className="space-y-1.5">
<Label>Need enterprise deployment (SSO, on-prem, data residency)?</Label>
<Select value={deployment} onValueChange={setDeployment}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{ENTERPRISE_DEPLOYMENT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ent-goal">
What do you want the voice agent to do? <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="ent-goal"
value={agentGoal}
onChange={(e) => setAgentGoal(e.target.value)}
placeholder="Use case, regulatory context, current stack…"
rows={3}
/>
</div>
<MathCaptcha id="ent-captcha" onValidChange={setCaptchaValid} />
</div>
<EnterpriseLeadFields
idPrefix="ent"
value={value}
onChange={onFieldsChange}
workEmailRequired={workEmailRequired}
showDeployment={showDeployment}
emailError={emailError}
/>
</LeadModalShell>
);
}

View file

@ -16,10 +16,10 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
import { FormTrustLine } from "./FormTrustLine";
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { MathCaptcha } from "./MathCaptcha";
import { PhoneField } from "./PhoneField";
import { submitLead } from "./submitLead";
@ -38,30 +38,37 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
const [agentGoal, setAgentGoal] = useState("");
const [phone, setPhone] = useState("");
const [volume, setVolume] = useState("");
const [captchaValid, setCaptchaValid] = useState(false);
const [captchaActive, setCaptchaActive] = useState(false);
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setName(""); setCompany(""); setJobTitle(""); setAgentGoal("");
setPhone(""); setVolume(""); setCaptchaValid(false); setSubmitting(false);
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
};
const canSubmit =
// 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()) &&
Boolean(agentGoal.trim()) &&
Boolean(phone.trim()) &&
Boolean(volume) &&
captchaValid &&
!submitting;
Boolean(volume);
const handleSubmit = async () => {
if (!name.trim() || !company.trim() || !agentGoal.trim() || !phone.trim() || !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;
}
if (!captchaValid) { toast.error("Please answer the quick check"); return; }
setCaptchaActive(true);
};
// Runs once the captcha popup is verified.
const doSubmit = async () => {
setCaptchaActive(false);
setSubmitting(true);
try {
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
@ -72,7 +79,7 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
payload: { name, company, jobTitle, agentGoal, phone, volume },
token,
});
toast.success("Thanks we'll reach out about building your agent.");
toast.success("Thanks - we'll reach out about building your agent.");
reset();
onOpenChange(false);
} catch {
@ -101,6 +108,7 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
</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">
@ -138,9 +146,9 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
</div>
<div className="space-y-1.5">
<Label>Expected monthly call volume</Label>
<Label htmlFor="hire-volume">Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectTrigger id="hire-volume"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{HIRE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
@ -150,7 +158,6 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
</div>
</div>
<MathCaptcha id="hire-captcha" onValidChange={setCaptchaValid} />
</div>
</LeadModalShell>
);

View file

@ -68,7 +68,11 @@ export function HireExpertNudge({ workflowId }: HireExpertNudgeProps) {
};
return (
<div 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">
<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>

View file

@ -31,10 +31,12 @@ interface LeadModalShellProps {
primary: { label: string; onClick: () => void; disabled?: boolean; loading?: boolean };
// Optional ghost secondary (e.g. Cancel / Skip).
secondary?: { label: string; onClick: () => void; disabled?: boolean };
// Optional helper rendered in the footer beside the actions (e.g. a link).
// 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).
@ -51,6 +53,7 @@ export function LeadModalShell({
secondary,
helper,
trustLine,
overlay,
open,
onOpenChange,
contentProps,
@ -93,33 +96,39 @@ export function LeadModalShell({
{/* Scrollable body */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-5">{children}</div>
{/* Sticky footer */}
{/* Sticky footer 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/60 bg-background/80 px-6 py-4 backdrop-blur-sm">
<div className="flex flex-col-reverse items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">{helper}</div>
<div className="flex items-center justify-end gap-2">
{secondary && (
<Button
type="button"
variant="ghost"
onClick={secondary.onClick}
disabled={secondary.disabled}
>
{secondary.label}
</Button>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{secondary && (
<Button
type="button"
onClick={primary.onClick}
disabled={primary.disabled || primary.loading}
className="bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
variant="ghost"
onClick={secondary.onClick}
disabled={secondary.disabled}
>
{primary.loading ? "Submitting…" : primary.label}
{secondary.label}
</Button>
</div>
)}
<Button
type="button"
onClick={primary.onClick}
disabled={primary.disabled || primary.loading}
className="bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
{primary.loading ? "Submitting…" : primary.label}
</Button>
</div>
{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/85 p-6 backdrop-blur-sm">
{overlay}
</div>
)}
</DialogContent>
</Dialog>
);

View file

@ -1,15 +1,9 @@
"use client";
import { Rocket } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -21,32 +15,47 @@ import {
} from "@/components/ui/select";
import { useAuth } from "@/lib/auth";
import { CaptchaChallenge } from "./CaptchaChallenge";
import {
EMPTY_ENTERPRISE_FIELDS,
type EnterpriseFieldsValue,
EnterpriseLeadFields,
} from "./EnterpriseLeadFields";
import { validateWorkEmail } from "./isPersonalEmail";
import {
ONBOARDING_ONPREM_OPTIONS,
ONBOARDING_ONPREM_PERSONAS,
ONBOARDING_PERSONA_OPTIONS,
ONBOARDING_USAGE_CONTEXT_OPTIONS,
} from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { submitLead } from "./submitLead";
import { type OnboardingAnswers, skipOnboarding, submitOnboarding } from "./submitOnboarding";
interface OnboardingModalProps {
open: boolean;
// Called after a tracked outcome (submit or skip) to dismiss the gate.
onComplete: () => void;
// Opens the existing EnterpriseModal, prefilled with what we already collected.
onOpenEnterprise: (prefill: { company?: string }) => void;
}
export function OnboardingModal({ open, onComplete, onOpenEnterprise }: OnboardingModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [companyName, setCompanyName] = useState("");
const [usageContext, setUsageContext] = useState("");
const [persona, setPersona] = useState("");
const [onPremNeed, setOnPremNeed] = useState("");
const [submitting, setSubmitting] = useState(false);
// 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 answers = (): OnboardingAnswers => ({
companyName: companyName.trim() || undefined,
@ -55,127 +64,215 @@ export function OnboardingModal({ open, onComplete, onOpenEnterprise }: Onboardi
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
});
const handleSubmit = async () => {
setSubmitting(true);
try {
const token = await getAccessToken().catch(() => undefined);
await submitOnboarding(answers(), token);
onComplete();
} catch {
// Submission is best-effort. Never block the user from reaching the
// product — treat a failure as complete.
onComplete();
}
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
setEf((v) => ({ ...v, ...patch }));
if ("workEmail" in patch) setEfEmailError(null);
};
const handleSkip = async () => {
// Skipping is itself signal — capture whatever was filled.
try {
const token = await getAccessToken().catch(() => undefined);
await skipOnboarding(answers(), token);
} finally {
onComplete();
}
const expandOnPrem = () => {
setOnPremExpanded(true);
// Seed company from what we already collected (don't clobber edits).
setEf((v) => (v.company ? v : { ...v, company: companyName.trim() }));
};
const collapseOnPrem = () => {
setOnPremExpanded(false);
setCaptchaActive(false);
setEfEmailError(null);
};
// Best-effort persistence must never trap the user behind this hard gate.
// Dismiss immediately, then fire the token + network work in the background.
const finish = (skipped: boolean, withEnterprise: boolean) => {
if (submitting) return;
setSubmitting(true);
const data = answers();
const efSnapshot = withEnterprise ? { ...ef } : null;
onComplete();
void (async () => {
const token = await getAccessToken().catch(() => undefined);
try {
if (skipped) await skipOnboarding(data, token);
else await submitOnboarding(data, token);
// 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",
payload: {
name: efSnapshot.name,
company: efSnapshot.company || companyName.trim() || 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,
},
token,
});
}
} catch {
// Swallowed — the user is already in the product; network calls are
// bounded by a timeout in onboardingServiceClient.
}
})();
};
const handleSubmit = () => {
// Onboarding answers are all optional, so we only gate on the enterprise
// fields when the user has actually engaged the on-prem section.
if (wantsOnPrem) {
const err = validateWorkEmail(ef.workEmail);
if (err) { setEfEmailError(err); return; }
if (!ef.name.trim() || !ef.company.trim() || !ef.phone.trim() || !ef.volume) {
toast.error("Please complete the on-prem details below, or remove that section.");
return;
}
// Pop the anti-spam check on top of the modal before sending the lead.
setCaptchaActive(true);
return;
}
finish(false, false);
};
// Runs once the captcha popup is verified (on-prem path).
const submitWithOnPrem = () => {
setCaptchaActive(false);
finish(false, true);
};
const handleSkip = () => finish(true, false);
return (
<Dialog open={open}>
<DialogContent
// No tracked-outcome-free exits: block Escape, outside-click, and hide
// the built-in close (×). The only ways out are Skip or Get started.
className="max-w-md [&>button]:hidden"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Welcome to Dograh</DialogTitle>
<DialogDescription>
A few quick questions so we can tailor your experience. Takes ~20 seconds.
</DialogDescription>
</DialogHeader>
<LeadModalShell
open={open}
// Hard gate: no outside/escape close, hide the built-in ×. The only exits
// are Skip or Get started.
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: submitting }}
secondary={{ label: "Skip for now", onClick: handleSkip, disabled: submitting }}
overlay={captchaActive ? <CaptchaChallenge onVerified={submitWithOnPrem} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="ob-company">
Company name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ob-company" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="grid gap-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="ob-company">
Company name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ob-company" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ob-usage">Where do you plan to use this?</Label>
<Select value={usageContext} onValueChange={setUsageContext}>
<SelectTrigger id="ob-usage"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_USAGE_CONTEXT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Where do you plan to use this?</Label>
<Select value={usageContext} onValueChange={setUsageContext}>
<SelectTrigger><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_USAGE_CONTEXT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label 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>What best describes you?</Label>
<Label htmlFor="ob-onprem">Do you need on-prem deployment for compliance &amp; data residency?</Label>
<Select
value={persona}
value={onPremNeed}
onValueChange={(v) => {
setPersona(v);
// Reset the conditional answer if they leave the on-prem-eligible persona.
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) setOnPremNeed("");
setOnPremNeed(v);
if (v !== "yes") collapseOnPrem();
}}
>
<SelectTrigger><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectTrigger id="ob-onprem"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_PERSONA_OPTIONS.map((o) => (
{ONBOARDING_ONPREM_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showOnPrem && (
<div className="space-y-1.5 rounded-md border-l-2 border-primary bg-muted/40 p-3">
<Label>Do you need on-prem deployment for compliance &amp; data residency?</Label>
<Select value={onPremNeed} onValueChange={setOnPremNeed}>
<SelectTrigger><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_ONPREM_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{showManagedNote && (
<div className="mt-2 space-y-2">
<p className="text-sm text-muted-foreground">
We provide a <span className="font-medium text-foreground">Managed On-Prem solution</span> for
enterprises to ensure compliance and data security. Share your contact and our team will reach out.
{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>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenEnterprise({ company: companyName.trim() || undefined })}
>
Talk to us about on-prem
</Button>
<p className="text-xs text-muted-foreground">Optional you can skip and continue.</p>
{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>
)}
</div>
)}
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" onClick={handleSkip} disabled={submitting}>
Skip for now
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving…" : "Get started"}
</Button>
</div>
</DialogContent>
</Dialog>
{!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}
workEmailRequired
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>
</LeadModalShell>
);
}

View file

@ -6,20 +6,46 @@
// Base URL of the user_onboarding service; unset → calls are skipped (no-op).
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL;
// Bound every call so a slow/hung service can never freeze the UI (the onboarding
// modal used to await this with no timeout). Best-effort: failures are surfaced
// via console.error (captured as Sentry breadcrumbs) but never thrown.
const TIMEOUT_MS = 6000;
// POST a JSON body to the onboarding service with the Dograh auth token attached.
async function post(path: string, token: string, body: unknown): Promise<void> {
if (!BASE_URL) return; // service not configured — skip silently
if (!BASE_URL) {
// Misconfig would otherwise be invisible: a token-bearing submit dropped on
// the floor while PostHog still records the event as "submitted".
if (token) {
console.error(
`[onboarding] NEXT_PUBLIC_ONBOARDING_API_URL is unset — "${path}" not persisted to the onboarding service`,
);
}
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
await fetch(`${BASE_URL}${path}`, {
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
} catch {
// Best-effort: PostHog already captured the event; never block the user.
// 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);
}
}

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",
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm dark:shadow-md dark:shadow-black/25",
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

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

View file

@ -9,6 +9,7 @@ import { HireExpertModal } from "@/components/lead-forms/HireExpertModal";
import type { LeadSource } from "@/components/lead-forms/leadFieldOptions";
import { OnboardingModal } from "@/components/lead-forms/OnboardingModal";
import { PostHogEvent } from "@/constants/posthog-events";
import { useOnboarding } from "@/context/OnboardingContext";
import { useUserConfig } from "@/context/UserConfigContext";
// The onboarding flag fields live on the Dograh user-config JSON blob. The
@ -43,6 +44,10 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
// (b) the user has zero workflows (grandfathers out all existing users —
// they already have workflows, so they never see this modal).
const { userConfig, loading: userConfigLoading, user, saveUserConfig } = useUserConfig();
// Same-browser "show once" backstop, shared with the rest of onboarding
// (tooltips/actions) via OnboardingProvider. Complements the server-side flag
// so an instant reload before the async save round-trips can't re-show the gate.
const { hasCompletedAction, markActionCompleted } = useOnboarding();
const [onboardingOpen, setOnboardingOpen] = useState(false);
// Guard so the one-time workflow-count check runs at most once per mount.
const onboardingCheckedRef = useRef(false);
@ -51,7 +56,10 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
if (userConfigLoading || !user || onboardingCheckedRef.current) return;
const flags = userConfig as OnboardingFlags | null;
const completed = Boolean(flags?.onboarding_completed_at) || Boolean(flags?.onboarding_skipped);
const completed =
hasCompletedAction("welcome_form_completed") ||
Boolean(flags?.onboarding_completed_at) ||
Boolean(flags?.onboarding_skipped);
if (completed) {
onboardingCheckedRef.current = true; // already done — never show
return;
@ -80,20 +88,24 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
// existing users are never disrupted.
}
})();
}, [userConfigLoading, user, userConfig]);
}, [userConfigLoading, user, userConfig, hasCompletedAction]);
const completeOnboarding = useCallback(() => {
// Dismiss immediately; stamp the server flag best-effort so it never re-shows
// (cross-device, and before the user has built a workflow). saveUserConfig
// already merges with the existing config, so only the new field is needed.
// Dismiss immediately. Mark the same-browser backstop synchronously via
// OnboardingProvider (same store as the one-time tooltips/actions) so an
// instant reload can't re-show the gate, then best-effort persist the server
// flag (cross-device source of truth). saveUserConfig merges with the existing
// config, so only the new field is needed.
setOnboardingOpen(false);
markActionCompleted("welcome_form_completed");
void saveUserConfig({
onboarding_completed_at: new Date().toISOString(),
} as Parameters<typeof saveUserConfig>[0]).catch(() => {
// Best-effort: the user is already past the form; a failed stamp only risks
// a re-prompt on another device, which is acceptable.
// The local backstop already prevents a same-browser re-prompt; a failed
// server stamp only risks a re-prompt on another device.
console.error("[onboarding] failed to persist completion flag to user-config");
});
}, [saveUserConfig]);
}, [saveUserConfig, markActionCompleted]);
const openHireExpert = useCallback((source: LeadSource) => {
hasOpenedHireRef.current = true;
@ -132,7 +144,6 @@ export function LeadFormsProvider({ children }: { children: ReactNode }) {
<OnboardingModal
open={onboardingOpen}
onComplete={completeOnboarding}
onOpenEnterprise={(prefill) => openEnterprise("onboarding", prefill)}
/>
</LeadFormsContext.Provider>
);

View file

@ -3,7 +3,7 @@
import { createContext, useContext, useEffect, useState } from 'react';
export type TooltipKey = 'web_call' | 'customize_workflow';
export type OnboardingActionKey = 'web_call_started';
export type OnboardingActionKey = 'web_call_started' | 'welcome_form_completed';
interface OnboardingState {
seenTooltips: TooltipKey[];

View file

@ -14,5 +14,9 @@ export async function startTopUp(amountUsd: number): Promise<void> {
// Minimum self-serve top-up amount in USD.
export const MIN_TOPUP_USD = 5;
// Maximum self-serve top-up amount in USD (guards against fat-finger typos
// before the real Razorpay order is created).
export const MAX_TOPUP_USD = 10000;
// Preset chip amounts (USD).
export const TOPUP_PRESETS = [5, 10, 25, 50, 100] as const;

View file

@ -63,8 +63,8 @@ export const config = {
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (public folder)
* - public static assets (anything with a file extension, e.g. /dograh-logo.png)
*/
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf)).*)',
],
};