mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
ui rezig, onboarding, billing, hire us & on prem cues
This commit is contained in:
parent
0662a1770f
commit
0eddce6c83
55 changed files with 1108 additions and 629 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ node_modules/
|
|||
|
||||
# Superpowers brainstorm mockups (local only)
|
||||
.superpowers/
|
||||
.gstack/
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
4
ui/package-lock.json
generated
|
|
@ -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
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
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
BIN
ui/public/dograh-mark.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'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't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
38
ui/src/components/BrandLogo.tsx
Normal file
38
ui/src/components/BrandLogo.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
86
ui/src/components/auth/AuthShell.tsx
Normal file
86
ui/src/components/auth/AuthShell.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
ui/src/components/lead-forms/CaptchaChallenge.tsx
Normal file
82
ui/src/components/lead-forms/CaptchaChallenge.tsx
Normal 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'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 & submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
ui/src/components/lead-forms/EnterpriseLeadFields.tsx
Normal file
150
ui/src/components/lead-forms/EnterpriseLeadFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 “Remove”.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export function FolderSection({
|
|||
<AlertDialogTitle>Delete “{folder.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The {count} agent{count === 1 ? '' : 's'} in this folder
|
||||
won’t be deleted — they’ll move to Uncategorized.
|
||||
won’t be deleted - they’ll move to Uncategorized.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)).*)',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue