This commit is contained in:
Abhishek 2026-06-12 16:33:55 +00:00 committed by GitHub
commit b92e0a519e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 3536 additions and 589 deletions

5
.gitignore vendored
View file

@ -19,3 +19,8 @@ coturn/
*.wav
dograh_pcm_cache/
node_modules/
# Superpowers brainstorm mockups (local only)
.superpowers/
docs/superpowers/
.gstack/

View file

@ -0,0 +1,52 @@
"""add key to user_configurations
Turns user_configurations into a per-user keyed JSON store mirroring
organization_configurations. Existing rows (the legacy v1 AI model
configuration blob) are backfilled with key MODEL_CONFIGURATION.
Revision ID: 91cc6ba3e1c7
Revises: 384be6596b36
Create Date: 2026-06-12 21:04:25.561529
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "91cc6ba3e1c7"
down_revision: Union[str, None] = "384be6596b36"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Backfill existing rows (all legacy model-config blobs) via the server
# default, then drop the default — application code always supplies key.
op.add_column(
"user_configurations",
sa.Column(
"key",
sa.String(),
nullable=False,
server_default="MODEL_CONFIGURATION",
),
)
op.create_unique_constraint(
"_user_configuration_key_uc", "user_configurations", ["user_id", "key"]
)
op.alter_column("user_configurations", "key", server_default=None)
def downgrade() -> None:
op.drop_constraint(
"_user_configuration_key_uc", "user_configurations", type_="unique"
)
# Non-model-config rows (e.g. ONBOARDING) have no meaning in the old
# single-blob schema; the old code would read them as the user's model
# config, so they must not survive the downgrade.
op.execute("DELETE FROM user_configurations WHERE key != 'MODEL_CONFIGURATION'")
op.drop_column("user_configurations", "key")

View file

@ -82,12 +82,24 @@ class UserModel(Base):
class UserConfigurationModel(Base):
"""Per-user keyed JSON store, mirroring organization_configurations.
Keys are defined in UserConfigurationKey. The legacy v1 AI model
configuration lives under MODEL_CONFIGURATION; last_validated_at is only
meaningful for that key.
"""
__tablename__ = "user_configurations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
key = Column(String, nullable=False)
configuration = Column(JSON, nullable=False, default=dict)
last_validated_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "key", name="_user_configuration_key_uc"),
)
# New Organization model
class OrganizationModel(Base):

View file

@ -18,7 +18,7 @@ from api.db.models import (
WorkflowModel,
WorkflowRunModel,
)
from api.enums import OrganizationConfigurationKey
from api.enums import OrganizationConfigurationKey, UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
@ -343,7 +343,9 @@ class OrganizationUsageClient(BaseDBClient):
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key
== UserConfigurationKey.MODEL_CONFIGURATION.value,
)
)
config_obj = config_result.scalar_one_or_none()

View file

@ -8,6 +8,7 @@ from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import UserConfigurationModel, UserModel
from api.enums import UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
@ -65,16 +66,51 @@ class UserClient(BaseDBClient):
)
return result.scalars().first()
async def _get_user_configuration_row(
self, session, user_id: int, key: str
) -> UserConfigurationModel | None:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key == key,
)
)
return result.scalars().first()
async def get_user_configuration_value(self, user_id: int, key: str) -> dict | None:
"""Get the JSON value stored for a user under `key`, or None."""
async with self.async_session() as session:
row = await self._get_user_configuration_row(session, user_id, key)
return row.configuration if row else None
async def upsert_user_configuration_value(
self, user_id: int, key: str, value: dict
) -> dict:
"""Create or update the JSON value stored for a user under `key`."""
async with self.async_session() as session:
row = await self._get_user_configuration_row(session, user_id, key)
if row:
row.configuration = value
else:
row = UserConfigurationModel(
user_id=user_id, key=key, configuration=value
)
session.add(row)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(row)
return row.configuration
async def get_user_configurations(
self, user_id: int
) -> EffectiveAIModelConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
configuration_obj = await self._get_user_configuration_row(
session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value
)
configuration_obj = result.scalars().first()
if not configuration_obj:
return EffectiveAIModelConfiguration()
@ -97,38 +133,18 @@ class UserClient(BaseDBClient):
async def update_user_configuration(
self, user_id: int, configuration: EffectiveAIModelConfiguration
) -> EffectiveAIModelConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
configuration_obj = result.scalars().first()
if not configuration_obj:
configuration_obj = UserConfigurationModel(
user_id=user_id, configuration=configuration.model_dump()
)
session.add(configuration_obj)
else:
configuration_obj.configuration = configuration.model_dump()
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(configuration_obj)
return EffectiveAIModelConfiguration.model_validate(
configuration_obj.configuration
value = await self.upsert_user_configuration_value(
user_id,
UserConfigurationKey.MODEL_CONFIGURATION.value,
configuration.model_dump(),
)
return EffectiveAIModelConfiguration.model_validate(value)
async def update_user_configuration_last_validated_at(self, user_id: int) -> None:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
configuration_obj = await self._get_user_configuration_row(
session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value
)
configuration_obj = result.scalars().first()
if not configuration_obj:
raise ValueError(f"User configuration with ID {user_id} not found")
configuration_obj.last_validated_at = datetime.now()

View file

@ -96,6 +96,15 @@ class OrganizationConfigurationKey(Enum):
MODEL_CONFIGURATION_PREFERENCES = "MODEL_CONFIGURATION_PREFERENCES" # Deprecated; read fallback for old org preferences
class UserConfigurationKey(Enum):
"""Keys for the per-user keyed JSON store (user_configurations)."""
MODEL_CONFIGURATION = (
"MODEL_CONFIGURATION" # Legacy per-user v1 AI model configuration
)
ONBOARDING = "ONBOARDING" # Post-signup onboarding state (gate, tooltips, actions)
class WorkflowStatus(Enum):
"""Workflow status values"""

View file

@ -9,6 +9,7 @@ from api.db import db_client
from api.db.models import (
UserModel,
)
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
get_resolved_ai_model_configuration,
@ -26,6 +27,10 @@ from api.services.organization_preferences import (
get_organization_preferences,
upsert_organization_preferences,
)
from api.services.user_onboarding import (
get_onboarding_state,
update_onboarding_state,
)
router = APIRouter(prefix="/user")
@ -203,6 +208,21 @@ async def update_user_configurations(
return masked_config
@router.get("/onboarding-state")
async def get_user_onboarding_state(
user: UserModel = Depends(get_user),
) -> OnboardingState:
return await get_onboarding_state(user.id)
@router.put("/onboarding-state")
async def update_user_onboarding_state(
request: OnboardingStateUpdate,
user: UserModel = Depends(get_user),
) -> OnboardingState:
return await update_onboarding_state(user.id, request)
@router.get("/configurations/user/validate")
async def validate_user_configurations(
validity_ttl_seconds: int = Query(default=60, ge=0, le=86400),

View file

@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, Field
class OnboardingState(BaseModel):
"""Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
Server-authoritative replacement for the browser-localStorage onboarding
store, so the post-signup gate and one-time tooltips hold across devices.
"""
# Post-signup onboarding form gate: set once on submit/skip.
completed_at: datetime | None = None
skipped: bool = False
# One-time UI affordances (tooltip keys, milestone action keys). Kept as
# free-form strings — the UI owns the vocabulary.
seen_tooltips: list[str] = Field(default_factory=list)
completed_actions: list[str] = Field(default_factory=list)
class OnboardingStateUpdate(BaseModel):
"""Partial update merged into the stored state.
Scalars overwrite when supplied; list entries are unioned into the stored
lists, so concurrent updates (e.g. two tabs marking different tooltips)
don't drop each other's items.
"""
completed_at: datetime | None = None
skipped: bool | None = None
seen_tooltips: list[str] | None = None
completed_actions: list[str] | None = None
def apply_to(self, state: OnboardingState) -> OnboardingState:
merged = state.model_copy(deep=True)
if self.completed_at is not None:
merged.completed_at = self.completed_at
if self.skipped is not None:
merged.skipped = self.skipped
for tooltip in self.seen_tooltips or []:
if tooltip not in merged.seen_tooltips:
merged.seen_tooltips.append(tooltip)
for action in self.completed_actions or []:
if action not in merged.completed_actions:
merged.completed_actions.append(action)
return merged

View file

@ -0,0 +1,37 @@
from loguru import logger
from pydantic import ValidationError
from api.db import db_client
from api.enums import UserConfigurationKey
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
async def get_onboarding_state(user_id: int) -> OnboardingState:
value = await db_client.get_user_configuration_value(
user_id, UserConfigurationKey.ONBOARDING.value
)
return _parse_state(value, user_id)
async def update_onboarding_state(
user_id: int, update: OnboardingStateUpdate
) -> OnboardingState:
state = update.apply_to(await get_onboarding_state(user_id))
await db_client.upsert_user_configuration_value(
user_id,
UserConfigurationKey.ONBOARDING.value,
state.model_dump(mode="json", exclude_none=True),
)
return state
def _parse_state(value, user_id: int) -> OnboardingState:
if not value or not isinstance(value, dict):
return OnboardingState()
try:
return OnboardingState.model_validate(value)
except ValidationError as exc:
logger.warning(
f"Invalid onboarding state for user {user_id}: {exc}. Returning defaults."
)
return OnboardingState()

View file

@ -0,0 +1,131 @@
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.user import router
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.services.auth.depends import get_user
def _make_test_app():
app = FastAPI()
app.include_router(router)
mock_user = MagicMock()
mock_user.id = 1
mock_user.is_superuser = False
mock_user.selected_organization_id = None
app.dependency_overrides[get_user] = lambda: mock_user
return app
class TestOnboardingStateUpdateMerge:
def test_lists_union_without_duplicates(self):
state = OnboardingState(
seen_tooltips=["web_call"], completed_actions=["web_call_started"]
)
update = OnboardingStateUpdate(
seen_tooltips=["web_call", "customize_workflow"],
completed_actions=["welcome_form_completed"],
)
merged = update.apply_to(state)
assert merged.seen_tooltips == ["web_call", "customize_workflow"]
assert merged.completed_actions == [
"web_call_started",
"welcome_form_completed",
]
def test_omitted_fields_preserve_existing_state(self):
completed_at = datetime(2026, 6, 12, tzinfo=UTC)
state = OnboardingState(
completed_at=completed_at, skipped=True, seen_tooltips=["web_call"]
)
merged = OnboardingStateUpdate().apply_to(state)
assert merged.completed_at == completed_at
assert merged.skipped is True
assert merged.seen_tooltips == ["web_call"]
def test_scalars_overwrite_when_supplied(self):
state = OnboardingState()
completed_at = datetime(2026, 6, 12, tzinfo=UTC)
merged = OnboardingStateUpdate(
completed_at=completed_at, skipped=True
).apply_to(state)
assert merged.completed_at == completed_at
assert merged.skipped is True
class TestOnboardingStateRoutes:
def test_get_returns_defaults_when_no_row(self):
app = _make_test_app()
client = TestClient(app)
with patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value=None),
):
response = client.get("/user/onboarding-state")
assert response.status_code == 200
body = response.json()
assert body["completed_at"] is None
assert body["skipped"] is False
assert body["seen_tooltips"] == []
assert body["completed_actions"] == []
def test_get_returns_defaults_on_invalid_stored_value(self):
app = _make_test_app()
client = TestClient(app)
with patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value={"skipped": "not-a-bool"}),
):
response = client.get("/user/onboarding-state")
assert response.status_code == 200
assert response.json()["skipped"] is False
def test_put_merges_into_stored_state_and_persists(self):
app = _make_test_app()
client = TestClient(app)
existing = {"seen_tooltips": ["web_call"]}
upsert = AsyncMock(side_effect=lambda user_id, key, value: value)
with (
patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value=existing),
),
patch(
"api.services.user_onboarding.db_client.upsert_user_configuration_value",
new=upsert,
),
):
response = client.put(
"/user/onboarding-state",
json={
"completed_at": "2026-06-12T00:00:00Z",
"seen_tooltips": ["customize_workflow"],
},
)
assert response.status_code == 200
body = response.json()
assert body["seen_tooltips"] == ["web_call", "customize_workflow"]
assert body["completed_at"] is not None
upsert.assert_awaited_once()
user_id, key, stored = upsert.await_args.args
assert user_id == 1
assert key == "ONBOARDING"
assert stored["seen_tooltips"] == ["web_call", "customize_workflow"]

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.mKgFDhNhca
# timestamp: 2026-06-09T10:10:10+00:00
# filename: dograh-openapi-XXXXXX.json.w5T4z8AeiH
# timestamp: 2026-06-12T16:16:24+00:00
from __future__ import annotations

View file

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

4
ui/package-lock.json generated
View file

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

View file

@ -9,7 +9,8 @@
"lint": "next lint",
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'",
"generate-client": "openapi-ts",
"test:display-options": "node scripts/test-display-options.mts"
"test:display-options": "node scripts/test-display-options.mts",
"lint:lead-flow": "bash ../../user_onboarding/scripts/check_lead_flow.sh"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

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

View file

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

View file

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

View file

@ -42,6 +42,8 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-cta: var(--cta);
--color-cta-foreground: var(--cta-foreground);
}
:root {
@ -77,6 +79,14 @@
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* Single restrained warm accent — used only on primary CTAs + focus rings. */
--cta: oklch(0.72 0.15 65);
--cta-foreground: oklch(0.16 0.02 60);
/* Giant faded "dograh" wordmark (authentic Proxima Nova letterforms traced
from the brand logo PNG the font is commercial, so the lettering ships
as static artwork in /public; fill + 0.9% opacity are baked into the
files). Theme-switched here; consumed by .app-surface and .auth-imprint. */
--brand-imprint: url("/brand-imprint-light.svg");
}
.dark {
@ -111,6 +121,10 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* Warm accent, slightly brighter against the near-black surfaces. */
--cta: oklch(0.78 0.16 67);
--cta-foreground: oklch(0.16 0.02 60);
--brand-imprint: url("/brand-imprint-dark.svg");
}
@layer base {
@ -135,3 +149,177 @@
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
@layer components {
/* CSS-only audio-waveform motif for the auth brand panel. A row of bars that
breathe at staggered intervals, evoking live voice. Decorative only. */
.auth-waveform {
display: flex;
align-items: center;
gap: 0.3rem;
height: 3.5rem;
}
.auth-waveform span {
display: block;
width: 0.25rem;
border-radius: 9999px;
background: linear-gradient(
to top,
color-mix(in oklch, var(--cta) 70%, transparent),
color-mix(in oklch, var(--cta) 25%, transparent)
);
animation: auth-wave 1.4s ease-in-out infinite;
transform-origin: center;
}
.auth-waveform span:nth-child(1) { animation-delay: 0s; height: 35%; }
.auth-waveform span:nth-child(2) { animation-delay: 0.15s; height: 65%; }
.auth-waveform span:nth-child(3) { animation-delay: 0.3s; height: 100%; }
.auth-waveform span:nth-child(4) { animation-delay: 0.45s; height: 55%; }
.auth-waveform span:nth-child(5) { animation-delay: 0.6s; height: 80%; }
.auth-waveform span:nth-child(6) { animation-delay: 0.3s; height: 45%; }
.auth-waveform span:nth-child(7) { animation-delay: 0.15s; height: 70%; }
.auth-waveform span:nth-child(8) { animation-delay: 0s; height: 30%; }
@keyframes auth-wave {
0%, 100% { transform: scaleY(0.4); opacity: 0.7; }
50% { transform: scaleY(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.auth-waveform span { animation: none; }
}
/* Matte app background flat charcoal (dark) / soft paper (light), NO
gradients, with one subtle graphic in BOTH themes: the giant faded
"dograh" wordmark (--brand-imprint, defined in :root/.dark) pinned to
the bottom of the viewport, echoing the dograh.com footer. */
/* NOTE: background-attachment: fixed positions in VIEWPORT space but only
paints inside .app-surface, which starts right of the ~270px sidebar
the +135px x-shift recentres the wordmark on the VISIBLE canvas. */
.app-surface {
background-color: oklch(0.984 0.001 80);
background-image: var(--brand-imprint);
background-size: min(68vw, 980px) auto;
background-position: calc(50% + 135px) calc(100% - 24px);
background-repeat: no-repeat;
background-attachment: fixed;
}
/* Sidebar is offcanvas on small screens — true centre there. */
@media (max-width: 767px) {
.app-surface {
background-position: center calc(100% - 24px);
}
}
.dark .app-surface {
background-color: oklch(0.165 0.002 80);
}
/* Giant faded "dograh" imprint for the auth pages (applied to the AuthShell
form column, shared by Stack + OSS login/signup). Same --brand-imprint as
.app-surface; element-relative here (no fixed attachment), so it centers
and scales to whatever element carries the class. */
.auth-imprint {
background-image: var(--brand-imprint);
background-size: min(86%, 920px) auto;
background-position: center calc(100% - 32px);
background-repeat: no-repeat;
}
}
/* ---------------------------------------------------------------------------
UN-LAYERED overrides. These intentionally live OUTSIDE @layer blocks:
they restyle elements that carry Tailwind utility classes (bg-sidebar,
rounded-lg, shadow-sm, border-*) and utilities sit in a later cascade
layer than @layer components un-layered author CSS beats both.
--------------------------------------------------------------------------- */
/* Floating-dock sidebar: detached rounded panel. Targets the shadcn sidebar's
inner panel; applied via .app-sidebar-dock on <Sidebar variant="floating">. */
.app-sidebar-dock [data-slot="sidebar-inner"] {
border-radius: 1.25rem;
overflow: hidden;
}
/* Flat carbon-charcoal panel with a soft light glow along the LEFT edge:
a 1px highlight line plus an inner bloom fading rightwards. */
.dark .app-sidebar-dock [data-slot="sidebar-inner"] {
border-color: rgb(255 255 255 / 0.1);
background-color: oklch(0.18 0.002 80);
box-shadow:
inset 1px 0 0 rgb(255 255 255 / 0.1),
inset 3px 0 6px -4px rgb(255 255 255 / 0.08),
0 24px 50px -14px rgb(0 0 0 / 0.85);
}
/* Card surface ("Crosshatch + Top-Lit Edge", user-approved 2026-06-11 after a
3-round design board): a 45° hairline twill weave at 1% laid over the panel
colour, plus dark mode only a brighter SOLID top border, like light
catching the machined top edge of the panel. Applied app-wide by the Card
primitive (components/ui/card.tsx). Un-layered so border-top-color beats
the border-border/60 utility. No gradients (user constraint). */
.card-weave {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23000000' stroke-opacity='.015' fill='none'/%3E%3C/svg%3E");
background-repeat: repeat;
}
.dark .card-weave {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23ffffff' stroke-opacity='.01' fill='none'/%3E%3C/svg%3E");
border-top-color: rgb(255 255 255 / 0.2);
}
/* Lead-form shell ("Ledger" treatment, user-approved 2026-06-11): neutral
charcoal slab where ONLY the header band is darker (body and footer share
the slab colour), muted compact labels, and underline-only fields with an
amber underline on focus. Applied by LeadModalShell; CaptchaChallenge
reuses slab + underline. */
.dark .lead-form-slab {
background-color: oklch(0.215 0 0);
border-color: rgb(255 255 255 / 0.1);
}
/* Muted, compact labels — the big white default labels read amateurish. */
.lead-form-underline label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--muted-foreground);
}
/* Ghost placeholders: present on every field, but barely-there. */
.lead-form-underline :is(input, textarea)::placeholder {
color: var(--muted-foreground);
opacity: 0.14;
}
.lead-form-underline [data-slot="select-trigger"][data-placeholder] {
color: color-mix(in oklab, var(--muted-foreground) 17%, transparent);
}
/* Underline-only fields: transparent box, hairline bottom border, amber
underline on the focused control. Compact heights keep rows tight. */
.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]) {
background-color: transparent;
border-top: 0;
border-left: 0;
border-right: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
box-shadow: none;
padding-left: 2px;
padding-right: 2px;
}
.lead-form-underline :is(input, [data-slot="select-trigger"]) {
height: 2.125rem;
}
.lead-form-underline textarea {
min-height: 3.25rem;
}
/* The phone country selector ships its own box — flatten it to match. */
.lead-form-underline .react-international-phone-country-selector-button {
border: 0 !important;
border-bottom: 1px solid var(--border) !important;
border-radius: 0 !important;
background: transparent !important;
}
.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]):focus-visible,
.lead-form-underline [data-slot="select-trigger"][data-state="open"] {
outline: none;
box-shadow: none;
border-bottom-color: var(--cta);
}

View file

@ -8,17 +8,26 @@ 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 (
<header className="flex items-center border-b px-4 py-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
</header>
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
);
}

View file

@ -1,18 +1,41 @@
import { StackHandler } from "@stackframe/stack";
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 { 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();
if (authProvider === "local") {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>Local Auth Mode</h1>
<p>Stack Auth handler is disabled when using local authentication.</p>
</div>
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
<div className="space-y-2 text-center text-zinc-200">
<h1 className="text-xl font-semibold">Local Auth Mode</h1>
<p className="text-sm text-muted-foreground">
Stack Auth handler is disabled when using local authentication.
</p>
</div>
</AuthShell>
);
}
@ -20,16 +43,35 @@ export default async function Handler(props: unknown) {
const { getStackServerApp } = await import("@/lib/auth/server");
const app = await getStackServerApp();
return (
<div className="flex flex-col h-screen">
<BackButton />
<div className="flex-1 overflow-auto">
<StackHandler
fullPage
app={app!}
routeProps={props}
/>
</div>
</div>
// 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 showBackButton = !new Set(["signin", "login"]).has(normalizedSegment);
const handler = (
<StackTheme theme={stackAuthDarkTheme}>
<StackHandler fullPage={!isAuthForm} app={app!} routeProps={props} />
</StackTheme>
);
if (isAuthForm) {
return (
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
{showBackButton && <BackButton />}
{handler}
</AuthShell>
);
}
// account-settings and machine routes render full-page (Stack's own layout).
return handler;
}

View file

@ -0,0 +1,34 @@
// Dark token overrides for the embedded Stack Auth form so it blends into the
// auth card surface (zinc-900 background, zinc-100 foreground, the warm CTA
// accent on the primary button, zinc-800 borders/inputs). Stack's theme parser
// does not accept OKLCH strings, so keep these values in hex.
import type { StackTheme } from "@stackframe/stack";
import type { ComponentProps } from "react";
type ThemeConfig = NonNullable<ComponentProps<typeof StackTheme>["theme"]>;
export const stackAuthDarkTheme: ThemeConfig = {
dark: {
background: "#27272a",
foreground: "#fafafa",
card: "#27272a",
cardForeground: "#fafafa",
popover: "#27272a",
popoverForeground: "#fafafa",
primary: "#fbbf24",
primaryForeground: "#422006",
secondary: "#3f3f46",
secondaryForeground: "#fafafa",
muted: "#3f3f46",
mutedForeground: "#a1a1aa",
accent: "#3f3f46",
accentForeground: "#fafafa",
destructive: "#ef4444",
destructiveForeground: "#fafafa",
border: "#3f3f46",
input: "#3f3f46",
ring: "#fbbf24",
},
radius: "0.625rem",
};

View file

@ -9,6 +9,7 @@ import AppLayout from "@/components/layout/AppLayout";
import PostHogIdentify from "@/components/PostHogIdentify";
import { SentryErrorBoundary } from "@/components/SentryErrorBoundary";
import SpinLoader from "@/components/SpinLoader";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Toaster } from "@/components/ui/sonner";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { OnboardingProvider } from "@/context/OnboardingContext";
@ -39,21 +40,24 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" className="dark" suppressHydrationWarning>
<head>
{/* Inline script to prevent flash of light theme - runs before React hydrates */}
{/* Inline script to prevent flash of light theme - runs before React hydrates.
Dark is the locked default: only an explicit stored 'light' opts out. */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
if (theme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
} catch (e) {}
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
@ -61,26 +65,28 @@ export default function RootLayout({
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<SentryErrorBoundary>
<AuthProvider>
<AppConfigProvider>
<Suspense fallback={<SpinLoader />}>
<OrgConfigProvider>
<TelephonyConfigWarningsProvider>
<OnboardingProvider>
<PostHogIdentify />
<AppLayout>
{children}
</AppLayout>
<Toaster />
<ChatwootWidget />
</OnboardingProvider>
</TelephonyConfigWarningsProvider>
</OrgConfigProvider>
</Suspense>
</AppConfigProvider>
</AuthProvider>
</SentryErrorBoundary>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<SentryErrorBoundary>
<AuthProvider>
<AppConfigProvider>
<Suspense fallback={<SpinLoader />}>
<OrgConfigProvider>
<TelephonyConfigWarningsProvider>
<OnboardingProvider>
<PostHogIdentify />
<AppLayout>
{children}
</AppLayout>
<Toaster />
<ChatwootWidget />
</OnboardingProvider>
</TelephonyConfigWarningsProvider>
</OrgConfigProvider>
</Suspense>
</AppConfigProvider>
</AuthProvider>
</SentryErrorBoundary>
</ThemeProvider>
</body>
</html>
);

View file

@ -13,7 +13,7 @@ export default async function ServiceConfigurationPage({ searchParams }: Service
const action = Array.isArray(params.action) ? params.action[0] : params.action;
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">
<ModelConfigurationV2

View file

@ -2,7 +2,6 @@ import { isNextRouterError } from "next/dist/client/components/is-next-router-er
import { redirect } from "next/navigation";
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
import SignInClient from "@/components/SignInClient";
import { getServerAccessToken,getServerAuthProvider,getServerUser } from "@/lib/auth/server";
import logger from '@/lib/logger';
import { getRedirectUrl } from "@/lib/utils";
@ -92,16 +91,6 @@ export default async function Home() {
}
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<SignInClient />
</div>
);
logger.debug('[HomePage] Redirecting unauthenticated Stack user to /handler/sign-in');
redirect('/handler/sign-in');
}

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowV
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen';
import { useNodeSpecs } from "@/components/flow/renderer";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { HireExpertNudge } from "@/components/lead-forms/HireExpertNudge";
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@ -481,6 +482,7 @@ function RenderWorkflow({
return (
<WorkflowProvider value={workflowContextValue}>
<div className="flex flex-col h-screen min-w-fit">
<HireExpertNudge workflowId={workflowId} />
{/* New Workflow Editor Header */}
<WorkflowEditorHeader
workflowName={workflowName}

View file

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

View file

@ -330,7 +330,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>
@ -356,8 +356,8 @@ export const PhoneCallDialog = ({
<SelectContent>
{fromPhoneNumbers.map((phone) => (
<SelectItem key={phone.id} value={String(phone.id)}>
{phone.label ? `${phone.label} ${phone.address}` : phone.address}
{phone.is_default_caller_id ? " default" : ""}
{phone.label ? `${phone.label} - ${phone.address}` : phone.address}
{phone.is_default_caller_id ? " - default" : ""}
</SelectItem>
))}
</SelectContent>

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet, listFoldersApiV1FolderGet } from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { Card, CardContent } from '@/components/ui/card';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { AgentFolderView } from '@/components/workflow/folders/AgentFolderView';
import { CreateFolderButton } from '@/components/workflow/folders/CreateFolderButton';
@ -78,9 +79,11 @@ async function WorkflowList() {
{activeWorkflows.length > 0 || folders.length > 0 ? (
<AgentFolderView workflows={activeWorkflows} folders={folders} />
) : (
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
</div>
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
No active workflows found. Create your first workflow to get started.
</CardContent>
</Card>
)}
</div>
@ -132,7 +135,11 @@ function WorkflowsLoading() {
<div className="h-8 w-48 bg-muted rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-muted rounded-lg h-40"></div>
<Card key={i}>
<CardContent className="p-0">
<div className="h-40 bg-muted/70" />
</CardContent>
</Card>
))}
</div>
</div>
@ -143,7 +150,11 @@ function WorkflowsLoading() {
<div className="h-8 w-48 bg-muted rounded"></div>
<div className="h-10 w-32 bg-muted rounded"></div>
</div>
<div className="bg-muted rounded-lg h-96"></div>
<Card>
<CardContent className="p-0">
<div className="h-96 bg-muted/70" />
</CardContent>
</Card>
</div>
</div>
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -147,13 +147,13 @@ export type AwsBedrockLlmConfiguration = {
/**
* Api Key
*
* Not used for Bedrock authentication is via the AWS credentials above. Leave blank.
* Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.
*/
api_key?: string | Array<string> | null;
/**
* Model
*
* Bedrock model ID include the region inference-profile prefix (e.g. 'us.').
* Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').
*/
model?: string;
/**
@ -344,7 +344,7 @@ export type AzureOpenAiEmbeddingsConfiguration = {
/**
* Azure OpenAI Realtime
*
* Azure OpenAI Realtime API low-latency speech-to-speech conversations.
* Azure OpenAI Realtime API - low-latency speech-to-speech conversations.
*/
export type AzureRealtimeLlmConfiguration = {
/**
@ -384,7 +384,7 @@ export type AzureRealtimeLlmConfiguration = {
/**
* Azure Speech Services
*
* Azure Cognitive Services Speech TTS and STT via the Azure Speech SDK.
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
*/
export type AzureSpeechSttConfiguration = {
/**
@ -418,7 +418,7 @@ export type AzureSpeechSttConfiguration = {
/**
* Azure Speech Services
*
* Azure Cognitive Services Speech TTS and STT via the Azure Speech SDK.
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
*/
export type AzureSpeechTtsConfiguration = {
/**
@ -2627,7 +2627,7 @@ export type GoogleVertexLlmConfiguration = {
/**
* Api Key
*
* Not used for Vertex AI authentication is via the service account in `credentials` (or ADC). Leave blank.
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
*/
api_key?: string | Array<string> | null;
/**
@ -2667,7 +2667,7 @@ export type GoogleVertexRealtimeLlmConfiguration = {
/**
* Api Key
*
* Not used for Vertex AI authentication is via the service account in `credentials` (or ADC). Leave blank.
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
*/
api_key?: string | Array<string> | null;
/**
@ -3537,6 +3537,61 @@ export type NodeTypesResponse = {
node_types: Array<NodeSpec>;
};
/**
* OnboardingState
*
* Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
*
* Server-authoritative replacement for the browser-localStorage onboarding
* store, so the post-signup gate and one-time tooltips hold across devices.
*/
export type OnboardingState = {
/**
* Completed At
*/
completed_at?: string | null;
/**
* Skipped
*/
skipped?: boolean;
/**
* Seen Tooltips
*/
seen_tooltips?: Array<string>;
/**
* Completed Actions
*/
completed_actions?: Array<string>;
};
/**
* OnboardingStateUpdate
*
* Partial update merged into the stored state.
*
* Scalars overwrite when supplied; list entries are unioned into the stored
* lists, so concurrent updates (e.g. two tabs marking different tooltips)
* don't drop each other's items.
*/
export type OnboardingStateUpdate = {
/**
* Completed At
*/
completed_at?: string | null;
/**
* Skipped
*/
skipped?: boolean | null;
/**
* Seen Tooltips
*/
seen_tooltips?: Array<string> | null;
/**
* Completed Actions
*/
completed_actions?: Array<string> | null;
};
/**
* OpenAI
*/
@ -8563,6 +8618,84 @@ export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses = {
export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse = UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses[keyof UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses];
export type GetUserOnboardingStateApiV1UserOnboardingStateGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/user/onboarding-state';
};
export type GetUserOnboardingStateApiV1UserOnboardingStateGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetUserOnboardingStateApiV1UserOnboardingStateGetError = GetUserOnboardingStateApiV1UserOnboardingStateGetErrors[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetErrors];
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponses = {
/**
* Successful Response
*/
200: OnboardingState;
};
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponse = GetUserOnboardingStateApiV1UserOnboardingStateGetResponses[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetResponses];
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutData = {
body: OnboardingStateUpdate;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/user/onboarding-state';
};
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutError = UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors];
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses = {
/**
* Successful Response
*/
200: OnboardingState;
};
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponse = UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses];
export type ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData = {
body?: never;
headers?: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,134 @@
"use client";
// Compact self-serve "Buy Credits" control. The amount chips + custom input live
// in a popover that only opens when the user clicks "Buy Credits" — so the
// billing card stays clean until they intend to top up. Presets + custom (min $5)
// feed the Razorpay seam in @/lib/billing/topup, which currently throws "not
// wired yet"; we surface that as a calm inline note rather than an error toast.
import posthog from "posthog-js";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { PostHogEvent } from "@/constants/posthog-events";
import { MAX_TOPUP_USD, MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup";
import { cn } from "@/lib/utils";
// Round to whole cents and reject non-positive / non-finite input so a typo
// (e.g. "5.999", "-1", "abc") can't produce a NaN or fractional-cent order.
const parseAmount = (raw: string): number | null => {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 100) / 100;
};
export function BuyCreditsControl({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<number | null>(null);
const [custom, setCustom] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The effective amount: a parsed custom value takes precedence when present.
const customAmount = custom.trim() ? parseAmount(custom) : null;
const amount = customAmount ?? selected;
const valid = amount != null && amount >= MIN_TOPUP_USD && amount <= MAX_TOPUP_USD;
const selectPreset = (value: number) => {
setSelected(value);
setCustom("");
setError(null);
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: value });
};
const onCustomChange = (raw: string) => {
setCustom(raw);
setSelected(null);
setError(null);
const parsed = parseAmount(raw);
if (parsed != null && parsed >= MIN_TOPUP_USD && parsed <= MAX_TOPUP_USD) {
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: parsed });
}
};
const onBuy = async () => {
if (!valid || amount == null) return;
setBusy(true);
setError(null);
posthog.capture(PostHogEvent.BUY_CREDITS_CLICKED, { amount });
try {
await startTopUp(amount);
} catch {
// The seam is intentionally unimplemented until Razorpay lands.
setError("Self-serve top-up is coming soon. Use \"Hire an Expert\" or contact us for now.");
} finally {
setBusy(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
className={cn(
"bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50",
className,
)}
>
Buy Credits
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 space-y-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">Top up credits</p>
<p className="text-xs text-muted-foreground">Pick an amount (min ${MIN_TOPUP_USD}).</p>
</div>
<div className="flex flex-wrap gap-2">
{TOPUP_PRESETS.map((value) => (
<button
key={value}
type="button"
onClick={() => selectPreset(value)}
aria-pressed={selected === value}
className={cn(
"rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
"border-input text-foreground hover:bg-accent",
selected === value && "border-cta bg-cta/10 text-foreground ring-1 ring-cta/40",
)}
>
${value}
</button>
))}
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
inputMode="decimal"
value={custom}
onChange={(e) => onCustomChange(e.target.value)}
placeholder="Custom"
aria-label={`Custom amount (min $${MIN_TOPUP_USD})`}
className="h-9 w-24 pl-5"
/>
</div>
</div>
{error && <p className="text-xs text-muted-foreground">{error}</p>}
<Button
type="button"
onClick={onBuy}
disabled={!valid || busy}
className="w-full bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
{busy ? "Starting…" : valid && amount != null ? `Buy $${amount}` : "Buy Credits"}
</Button>
</PopoverContent>
</Popover>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,139 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { 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_SOURCES, type LeadSource } from "./leadFieldOptions";
import { LeadModalShell } from "./LeadModalShell";
import { submitLead } from "./submitLead";
interface EnterpriseModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
source: LeadSource;
// Optional values to pre-fill when the modal opens (e.g. company name already
// collected upstream). Backward-compatible: omitted = no prefill.
prefill?: { company?: string };
}
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaActive, setCaptchaActive] = useState(false);
const [submitting, setSubmitting] = useState(false);
// The deployment question is only surfaced for custom-volume / Contact-Us /
// pricing-custom-volume entry points; elsewhere it is hidden and the payload
// defaults to "yes".
const showDeployment = ENTERPRISE_DEPLOYMENT_SOURCES.includes(source);
const reset = () => {
setValue(EMPTY_ENTERPRISE_FIELDS);
setEmailError(null);
setCaptchaActive(false);
setSubmitting(false);
};
const onFieldsChange = (patch: Partial<EnterpriseFieldsValue>) => {
setValue((v) => ({ ...v, ...patch }));
if ("workEmail" in patch) setEmailError(null);
};
// Seed company from prefill when the modal opens (don't clobber edits).
const prefillCompany = prefill?.company;
useEffect(() => {
if (open && prefillCompany) {
setValue((v) => (v.company ? v : { ...v, company: prefillCompany }));
}
}, [open, prefillCompany]);
// Required fields, independent of the anti-spam check (revealed only after the
// first submit click — see handleSubmit).
const baseValid =
Boolean(value.name.trim()) &&
Boolean(value.company.trim()) &&
Boolean(value.jobTitle.trim()) &&
Boolean(value.workEmail.trim()) &&
Boolean(value.phone.trim()) &&
Boolean(value.volume);
const canSubmit = baseValid && !submitting;
// Validate, then pop the anti-spam check on top of the modal.
const handleSubmit = () => {
const err = validateWorkEmail(value.workEmail);
if (err) { setEmailError(err); return; }
if (!value.name.trim() || !value.company.trim() || !value.jobTitle.trim() || !value.phone.trim() || !value.volume) {
toast.error("Please fill in all required fields");
return;
}
setCaptchaActive(true);
};
// Runs once the captcha popup is verified.
const doSubmit = async () => {
setCaptchaActive(false);
setSubmitting(true);
try {
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
const token = await getAccessToken().catch(() => undefined);
await submitLead({
kind: "enterprise",
source,
payload: {
name: value.name,
company: value.company,
jobTitle: value.jobTitle,
workEmail: value.workEmail,
phone: value.phone,
volume: value.volume,
// Hidden entry points imply enterprise intent — default to "yes".
deployment: showDeployment ? value.deployment || "yes" : "yes",
agentGoal: value.agentGoal,
},
token,
});
toast.success("Thanks - our team will reach out about enterprise deployment.");
reset();
onOpenChange(false);
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
return (
<LeadModalShell
open={open}
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
icon={ShieldCheck}
eyebrow="Enterprise"
title="Book a Strategy Call"
description="SSO, on-prem, data residency, committed volume. Tell us about your environment."
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
trustLine={<FormTrustLine />}
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<EnterpriseLeadFields
idPrefix="ent"
value={value}
onChange={onFieldsChange}
showDeployment={showDeployment}
emailError={emailError}
/>
</LeadModalShell>
);
}

View file

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

View file

@ -0,0 +1,163 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { 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 { PhoneField } from "./PhoneField";
import { submitLead } from "./submitLead";
interface HireExpertModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
source: LeadSource;
onOpenEnterprise: () => void;
}
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [name, setName] = useState("");
const [company, setCompany] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [agentGoal, setAgentGoal] = useState("");
const [phone, setPhone] = useState("");
const [volume, setVolume] = useState("");
const [captchaActive, setCaptchaActive] = useState(false);
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setName(""); setCompany(""); setJobTitle(""); setAgentGoal("");
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
};
// Required fields, independent of the anti-spam check (which is revealed only
// after the first submit click — see handleSubmit).
const baseValid =
Boolean(name.trim()) &&
Boolean(company.trim()) &&
Boolean(jobTitle.trim()) &&
Boolean(agentGoal.trim()) &&
Boolean(phone.trim()) &&
Boolean(volume);
const canSubmit = baseValid && !submitting;
// Validate, then pop the anti-spam check on top of the modal.
const handleSubmit = () => {
if (!baseValid) {
toast.error("Please fill in all required fields");
return;
}
setCaptchaActive(true);
};
// Runs once the captcha popup is verified.
const doSubmit = async () => {
setCaptchaActive(false);
setSubmitting(true);
try {
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
const token = await getAccessToken().catch(() => undefined);
await submitLead({
kind: "hire_expert",
source,
payload: { name, company, jobTitle, agentGoal, phone, volume },
token,
});
toast.success("Thanks - we'll reach out about building your agent.");
reset();
onOpenChange(false);
} catch {
toast.error("Something went wrong. Please try again.");
setSubmitting(false);
}
};
return (
<LeadModalShell
open={open}
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
icon={Sparkles}
eyebrow="Done-for-you"
title="Let us build your voice agent"
description="Building good voice agents is nuanced. Tell us what you need and we'll take it end-to-end."
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
helper={
<button
type="button"
onClick={onOpenEnterprise}
className="underline decoration-dashed underline-offset-4 hover:text-foreground"
>
Need enterprise deployment? (SSO, on-prem, data residency)
</button>
}
trustLine={<FormTrustLine />}
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
>
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="hire-name">Name</Label>
<Input id="hire-name" placeholder="Your full name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-company">Company name</Label>
<Input id="hire-company" placeholder="Acme Inc." value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-title">Job title</Label>
<Input id="hire-title" placeholder="VP Operations" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-goal">What do you want the voice agent to do?</Label>
<Textarea
id="hire-goal"
value={agentGoal}
onChange={(e) => setAgentGoal(e.target.value)}
placeholder="Use case, target outcomes, any remarks…"
rows={3}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="hire-phone">Phone</Label>
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-volume">Expected monthly call volume</Label>
<Select value={volume} onValueChange={setVolume}>
<SelectTrigger id="hire-volume"><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{HIRE_VOLUME_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</LeadModalShell>
);
}

View file

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

View file

@ -0,0 +1,136 @@
"use client";
// Shared chrome for the lead dialogs (HireExpert, Enterprise, post-signup
// Onboarding). Wraps the existing @/components/ui/dialog primitive (which already
// supplies the blurred backdrop) and adds a consistent header band (eyebrow +
// title + description), a scrollable body with underline fields, a footer
// (primary CTA + optional ghost secondary + optional helper slot), and a bottom
// trust-line slot. The visual language ("Ledger", user-approved): flat charcoal
// slab where ONLY the header band is darker (footer matches the body), NO
// gradients/glows/icons, Geist type only, one warm accent reserved for the
// primary action and the focused-field underline (see .lead-form-* in
// globals.css).
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface LeadModalShellProps {
// Accepted for caller compatibility; the Ledger design renders no icon.
icon?: LucideIcon;
title: string;
eyebrow?: string;
description?: string;
children: ReactNode;
// Primary action — rendered with the warm CTA accent.
primary: { label: string; onClick: () => void; disabled?: boolean; loading?: boolean };
// Optional ghost secondary (e.g. Cancel / Skip).
secondary?: { label: string; onClick: () => void; disabled?: boolean };
// Optional helper rendered in the footer below the actions (e.g. a link).
helper?: ReactNode;
// Optional trust line beneath the footer (we pass <FormTrustLine/>).
trustLine?: ReactNode;
// Optional layer floated ON TOP of the whole modal (e.g. the captcha popup).
overlay?: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
// Forwarded to DialogContent so callers can lock dismissal (onboarding gate).
contentProps?: React.ComponentProps<typeof DialogContent>;
}
export function LeadModalShell({
title,
eyebrow,
description,
children,
primary,
secondary,
helper,
trustLine,
overlay,
open,
onOpenChange,
contentProps,
}: LeadModalShellProps) {
const { className: contentClassName, ...restContentProps } = contentProps ?? {};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"lead-form-slab max-h-[90vh] gap-0 overflow-hidden rounded-2xl border-border/70 bg-card p-0 shadow-2xl sm:max-w-[560px]",
contentClassName,
)}
{...restContentProps}
>
{/* Header: a slightly darker band, separated by a hairline. */}
<DialogHeader className="space-y-0 border-b border-border/40 bg-black/[0.04] px-8 pb-5 pt-6 text-left dark:bg-black/25">
<div className="min-w-0">
{eyebrow && (
<span className="block text-[0.7rem] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{eyebrow}
</span>
)}
<DialogTitle className="mt-1.5 text-2xl font-semibold leading-tight tracking-tight">
{title}
</DialogTitle>
{description && (
<DialogDescription className="mt-1.5 text-sm leading-snug">
{description}
</DialogDescription>
)}
</div>
</DialogHeader>
{/* Scrollable body: flat, compact underline fields. */}
<div className="max-h-[60vh] overflow-y-auto px-8 py-6">
<div className="lead-form-underline">{children}</div>
</div>
{/* Footer same surface as the body (only the header band differs);
actions first, then the optional helper line BELOW the buttons,
then the trust line at the very bottom. */}
<div className="space-y-3 border-t border-border/40 px-8 py-4">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{secondary && (
<Button
type="button"
variant="ghost"
onClick={secondary.onClick}
disabled={secondary.disabled}
>
{secondary.label}
</Button>
)}
<Button
type="button"
onClick={primary.onClick}
disabled={primary.disabled || primary.loading}
className="bg-cta text-cta-foreground shadow-md shadow-cta/25 hover:bg-cta/90 hover:shadow-cta/35 focus-visible:ring-cta/50"
>
{primary.loading ? "Submitting…" : primary.label}
</Button>
</div>
{helper && <div className="text-center text-xs text-muted-foreground">{helper}</div>}
{trustLine}
</div>
{/* Optional popup floated on top of the entire modal (captcha, etc.). */}
{overlay && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/70 p-6 backdrop-blur-md">
{overlay}
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

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

View file

@ -0,0 +1,278 @@
"use client";
import { Rocket } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { 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 and
// stamp the matching server-side flag (completed_at vs skipped).
onComplete: (skipped: boolean) => void;
}
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,
usageContext: usageContext || undefined,
persona: persona || undefined,
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
});
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
setEf((v) => ({ ...v, ...patch }));
if ("workEmail" in patch) setEfEmailError(null);
};
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(skipped);
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.jobTitle.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 (
<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" placeholder="Acme Inc." 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 htmlFor="ob-persona">What best describes you?</Label>
<Select
value={persona}
onValueChange={(v) => {
setPersona(v);
// Leaving the on-prem-eligible persona resets the conditional answer
// and any inline enterprise lead.
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) {
setOnPremNeed("");
collapseOnPrem();
}
}}
>
<SelectTrigger id="ob-persona"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_PERSONA_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showOnPrem && (
<div className="space-y-1.5">
<Label htmlFor="ob-onprem">Do you need on-prem deployment for compliance &amp; data residency?</Label>
<Select
value={onPremNeed}
onValueChange={(v) => {
setOnPremNeed(v);
if (v !== "yes") collapseOnPrem();
}}
>
<SelectTrigger id="ob-onprem"><SelectValue placeholder="Select one" /></SelectTrigger>
<SelectContent>
{ONBOARDING_ONPREM_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{showManagedNote && (
<div className="mt-2 space-y-3 rounded-lg border border-border/60 bg-muted/30 p-3">
<div className="flex items-start justify-between gap-2">
<p className="text-xs leading-relaxed text-muted-foreground">
We offer a <span className="font-medium text-foreground">Managed On-Prem</span> deployment
for compliance and data residency.
</p>
{onPremExpanded && (
<button
type="button"
onClick={collapseOnPrem}
className="shrink-0 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Remove
</button>
)}
</div>
{!onPremExpanded ? (
<button
type="button"
onClick={expandOnPrem}
className="text-xs font-medium text-cta underline-offset-4 hover:underline"
>
Talk to us about on-prem
</button>
) : (
<div className="space-y-3">
<EnterpriseLeadFields
idPrefix="ob-op"
value={ef}
onChange={onEfChange}
showDeployment={false}
emailError={efEmailError}
/>
<p className="text-[0.7rem] text-muted-foreground">
Our team will reach out about on-prem. Prefer not to? Click &ldquo;Remove&rdquo;.
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
</LeadModalShell>
);
}

View file

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

View file

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

View file

@ -0,0 +1,77 @@
// Shared dropdown options + lead source/kind types for the lead-gen forms.
export type LeadSource =
| "sidebar"
| "billing_card"
| "billing_custom_pricing"
| "builder_nudge"
| "hire_expert"
| "onboarding"
| "pricing_custom_volume"
| "landing_contact"
| "auth_page";
export type LeadKind = "hire_expert" | "enterprise";
// Monthly call-volume buckets. Values MUST match the backend qualifier enum
// (user_onboarding flows): "0-5k" | "5k-100k" | "100k+" | "not-sure".
export const VOLUME_OPTIONS = [
{ value: "0-5k", label: "05k" },
{ value: "5k-100k", label: "5k100k" },
{ value: "100k+", label: "100k+" },
{ value: "not-sure", label: "Not sure" },
] as const;
// Hire-an-Expert expected monthly call volume (shared bucket set).
export const HIRE_VOLUME_OPTIONS = VOLUME_OPTIONS;
// Enterprise monthly call volume (shared bucket set).
export const ENTERPRISE_VOLUME_OPTIONS = VOLUME_OPTIONS;
// Lead sources for which the Enterprise modal surfaces the conditional
// "Need enterprise deployment (SSO, on-prem, data residency)?" question.
// Other entry points hide it and default the payload to "yes".
export const ENTERPRISE_DEPLOYMENT_SOURCES: readonly LeadSource[] = [
"billing_custom_pricing",
"pricing_custom_volume",
"landing_contact",
"auth_page",
];
// Enterprise deployment need (conditional — see ENTERPRISE_DEPLOYMENT_SOURCES).
export const ENTERPRISE_DEPLOYMENT_OPTIONS = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
{ value: "maybe", label: "Maybe" },
] as const;
// ---------------------------------------------------------------------------
// Post-signup onboarding form options
// ---------------------------------------------------------------------------
// Onboarding: where do you plan to use this (highest-signal question — keep exact).
export const ONBOARDING_USAGE_CONTEXT_OPTIONS = [
{ value: "for_my_clients", label: "For my clients" },
{ value: "for_my_company", label: "For my company" },
{ value: "personal", label: "Personal use case" },
{ value: "exploring", label: "Just exploring" },
] as const;
// Onboarding: what best describes you.
export const ONBOARDING_PERSONA_OPTIONS = [
{ value: "enterprise_midmarket", label: "Enterprise / Mid-Market" },
{ value: "agency", label: "Agency / consultancy building for clients" },
{ value: "local_business", label: "Local business" },
{ value: "startup", label: "Startup" },
{ value: "solo", label: "Solo founder / builder" },
] as const;
// Persona values that unlock the on-prem conditional question.
export const ONBOARDING_ONPREM_PERSONAS: readonly string[] = ["enterprise_midmarket"];
// Onboarding: on-prem deployment need (conditional on Enterprise/Mid-Market).
export const ONBOARDING_ONPREM_OPTIONS = [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
{ value: "not_sure", label: "Not sure" },
] as const;

View file

@ -0,0 +1,81 @@
// Thin client for the SEPARATE user_onboarding service (its own base URL).
// Not part of the generated Dograh SDK — a different host. Sends the SAME Dograh
// Bearer token the browser already holds. All calls are BEST-EFFORT: failures are
// swallowed so a down/erroring service never blocks the user from the product.
// Base URL of the user_onboarding service; unset → calls are skipped (no-op).
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL;
// 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. The Dograh auth token is attached
// when supplied; public endpoints (contact-sales) are called without one.
async function post(path: string, token: string | undefined, body: unknown): Promise<void> {
if (!BASE_URL) {
// Misconfig would otherwise be invisible: a submit dropped on the floor
// while PostHog still records the event as "submitted".
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 {
const res = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
});
// fetch does not reject on 4xx/5xx — check explicitly so dropped leads are
// at least observable.
if (!res.ok) {
console.error(`[onboarding] POST ${path} failed with HTTP ${res.status}`);
}
} catch (err) {
// Network error, or the timeout aborted the request. Never block the user.
console.error(`[onboarding] POST ${path} did not complete:`, err);
} finally {
clearTimeout(timer);
}
}
// Map a lead kind to its endpoint path on the onboarding service.
const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
hire_expert: "/api/v1/leads/hire-expert",
enterprise: "/api/v1/leads/enterprise",
};
// Persist a lead submission (hire-expert / enterprise).
export async function postLeadToService(
kind: "hire_expert" | "enterprise",
token: string,
body: Record<string, unknown>,
): Promise<void> {
await post(LEAD_PATH[kind], token, body);
}
// Persist a logged-out enterprise lead via the PUBLIC contact-sales endpoint
// (no auth; the service applies a honeypot + per-IP rate limit). It runs the
// same unified enterprise flow as the authenticated /leads/enterprise path.
export async function postContactSalesToService(
body: Record<string, unknown>,
): Promise<void> {
await post("/api/v1/contact-sales", undefined, body);
}
// Persist an onboarding submission (or skip — body carries `skipped`).
export async function postOnboardingToService(
token: string,
body: Record<string, unknown>,
): Promise<void> {
await post("/api/v1/onboarding", token, body);
}

View file

@ -0,0 +1,41 @@
// Single submission seam for all lead forms.
// Fires a PostHog capture, and (when a token is supplied) POSTs to the separate
// user_onboarding service. The service call is best-effort — PostHog is the
// durable record and the user is never blocked if the service is down.
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import type { LeadKind, LeadSource } from "./leadFieldOptions";
import { postContactSalesToService, postLeadToService } from "./onboardingServiceClient";
const SUBMIT_EVENT: Record<LeadKind, string> = {
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED,
};
export interface SubmitLeadArgs {
kind: LeadKind;
source: LeadSource;
// Field values, already validated by the caller. Non-sensitive lead data.
payload: Record<string, unknown>;
// Dograh auth token; when present the lead is also persisted to the service.
token?: string;
}
export async function submitLead({ kind, source, payload, token }: SubmitLeadArgs): Promise<void> {
// PostHog capture — the durable record, always fired.
posthog.capture(SUBMIT_EVENT[kind], { source, ...payload });
// Persist to the separate user_onboarding service (best-effort).
if (token) {
await postLeadToService(kind, token, { source, ...payload });
} else if (kind === "enterprise") {
// Logged-out visitor (e.g. the auth-page Enterprise Enquiry CTA): the
// public contact-sales endpoint persists the lead and runs the same
// unified enterprise flow server-side, keyed off `workEmail` (which the
// form requires when unauthenticated).
await postContactSalesToService({ source, ...payload });
}
}

View file

@ -0,0 +1,34 @@
// Submission seam for the post-signup onboarding form.
// Fires a PostHog capture (submit or skip) AND, when a token is supplied, POSTs
// the answers to the separate user_onboarding service (best-effort). The "show
// once per user" flag itself is stamped on the server-backed onboarding state
// by the caller (LeadFormsContext.completeOnboarding → OnboardingContext), not here.
import posthog from "posthog-js";
import { PostHogEvent } from "@/constants/posthog-events";
import { postOnboardingToService } from "./onboardingServiceClient";
export interface OnboardingAnswers {
companyName?: string;
usageContext?: string;
persona?: string;
// Only present when persona unlocks the on-prem question.
onPremNeed?: string;
}
export async function submitOnboarding(answers: OnboardingAnswers, token?: string): Promise<void> {
posthog.capture(PostHogEvent.ONBOARDING_SUBMITTED, { ...answers });
if (token) {
await postOnboardingToService(token, { source: "onboarding", ...answers, skipped: false });
}
}
export async function skipOnboarding(answers: OnboardingAnswers, token?: string): Promise<void> {
// Skipping is itself signal — capture whatever was filled before the skip.
posthog.capture(PostHogEvent.ONBOARDING_SKIPPED, { ...answers });
if (token) {
await postOnboardingToService(token, { source: "onboarding", ...answers, skipped: true });
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,4 +11,18 @@ export const PostHogEvent = {
SIGNED_IN: "signed_in",
GITHUB_STAR_CLICKED: "github_star_clicked",
SLACK_COMMUNITY_CLICKED: "slack_community_clicked",
HIRE_EXPERT_OPENED: "hire_expert_opened",
HIRE_EXPERT_SUBMITTED: "hire_expert_submitted",
BUY_CREDITS_CLICKED: "buy_credits_clicked",
BUY_CREDITS_AMOUNT_SELECTED: "buy_credits_amount_selected",
CUSTOM_PRICING_CLICKED: "custom_pricing_clicked",
ENTERPRISE_LEAD_OPENED: "enterprise_lead_opened",
ENTERPRISE_LEAD_SUBMITTED: "enterprise_lead_submitted",
HIRE_NUDGE_SHOWN: "hire_nudge_shown",
HIRE_NUDGE_CLICKED: "hire_nudge_clicked",
HIRE_NUDGE_DISMISSED: "hire_nudge_dismissed",
HIRE_NUDGE_EXPIRED: "hire_nudge_expired",
ONBOARDING_SHOWN: "onboarding_shown",
ONBOARDING_SUBMITTED: "onboarding_submitted",
ONBOARDING_SKIPPED: "onboarding_skipped",
} as const;

View file

@ -0,0 +1,136 @@
"use client";
import posthog from "posthog-js";
import { createContext, type ReactNode,useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
import { EnterpriseModal } from "@/components/lead-forms/EnterpriseModal";
import { HireExpertModal } from "@/components/lead-forms/HireExpertModal";
import type { LeadSource } from "@/components/lead-forms/leadFieldOptions";
import { OnboardingModal } from "@/components/lead-forms/OnboardingModal";
import { PostHogEvent } from "@/constants/posthog-events";
import { useOnboarding } from "@/context/OnboardingContext";
import { useAuth } from "@/lib/auth";
interface LeadFormsContextValue {
openHireExpert: (source: LeadSource) => void;
openEnterprise: (source: LeadSource, prefill?: { company?: string }) => void;
// True once the hire modal has been opened this session (used to suppress the builder nudge).
hasOpenedHireRef: React.MutableRefObject<boolean>;
}
const LeadFormsContext = createContext<LeadFormsContextValue | null>(null);
export function LeadFormsProvider({ children }: { children: ReactNode }) {
const [hireOpen, setHireOpen] = useState(false);
const [enterpriseOpen, setEnterpriseOpen] = useState(false);
// Track the originating source so the *_OPENED and submit events agree.
const [hireSource, setHireSource] = useState<LeadSource>("sidebar");
const [enterpriseSource, setEnterpriseSource] = useState<LeadSource>("sidebar");
const [enterprisePrefill, setEnterprisePrefill] = useState<{ company?: string } | undefined>(undefined);
const hasOpenedHireRef = useRef(false);
// ---- Post-signup onboarding gate ----
// Show the onboarding form ONCE per user, and ONLY to genuinely new users:
// (a) the completion/skip flag is unset (server-backed onboarding state,
// cross-device), AND
// (b) the user has zero workflows (grandfathers out all existing users —
// they already have workflows, so they never see this modal).
const { user, loading: authLoading } = useAuth();
const {
loading: onboardingLoading,
onboardingCompletedAt,
onboardingSkipped,
markOnboardingCompleted,
} = useOnboarding();
const [onboardingOpen, setOnboardingOpen] = useState(false);
// Guard so the one-time workflow-count check runs at most once per mount.
const onboardingCheckedRef = useRef(false);
// Live view of the gate for the post-await re-check below.
const onboardingDoneRef = useRef(false);
onboardingDoneRef.current = Boolean(onboardingCompletedAt) || onboardingSkipped;
useEffect(() => {
if (authLoading || onboardingLoading || !user || onboardingCheckedRef.current) {
return;
}
onboardingCheckedRef.current = true;
if (onboardingDoneRef.current) return; // already done — never show
// Only brand-new users (no workflows yet) see the form. The count is
// org-scoped (the user's selected organization), so a new user joining an
// org that already has workflows is correctly grandfathered out. This costs
// one lightweight count query per session for users whose flag is still
// unset — an accepted trade for a server-authoritative, cross-device gate.
(async () => {
try {
const res = await getWorkflowCountApiV1WorkflowCountGet();
// Re-check the flag after the await: a completion elsewhere (another
// tab) may have stamped it while the count was in flight.
if (res.data?.total === 0 && !onboardingDoneRef.current) {
setOnboardingOpen(true);
posthog.capture(PostHogEvent.ONBOARDING_SHOWN);
}
} catch {
// If the count can't be fetched, do NOT show the modal — fail closed so
// existing users are never disrupted.
}
})();
}, [authLoading, onboardingLoading, user]);
const completeOnboarding = useCallback((skipped: boolean) => {
// Dismiss immediately, then persist the flag through OnboardingContext
// (optimistic local state closes the gate even if the server write lags;
// the write itself is best-effort and cross-device).
setOnboardingOpen(false);
markOnboardingCompleted({ skipped });
}, [markOnboardingCompleted]);
const openHireExpert = useCallback((source: LeadSource) => {
hasOpenedHireRef.current = true;
setHireSource(source);
setHireOpen(true);
posthog.capture(PostHogEvent.HIRE_EXPERT_OPENED, { source });
}, []);
const openEnterprise = useCallback((source: LeadSource, prefill?: { company?: string }) => {
setEnterpriseSource(source);
setEnterprisePrefill(prefill);
setEnterpriseOpen(true);
posthog.capture(PostHogEvent.ENTERPRISE_LEAD_OPENED, { source });
}, []);
const value = useMemo(
() => ({ openHireExpert, openEnterprise, hasOpenedHireRef }),
[openHireExpert, openEnterprise],
);
return (
<LeadFormsContext.Provider value={value}>
{children}
<HireExpertModal
open={hireOpen}
onOpenChange={setHireOpen}
source={hireSource}
onOpenEnterprise={() => openEnterprise("hire_expert")}
/>
<EnterpriseModal
open={enterpriseOpen}
onOpenChange={setEnterpriseOpen}
source={enterpriseSource}
prefill={enterprisePrefill}
/>
<OnboardingModal
open={onboardingOpen}
onComplete={completeOnboarding}
/>
</LeadFormsContext.Provider>
);
}
export function useLeadForms(): LeadFormsContextValue {
const ctx = useContext(LeadFormsContext);
if (!ctx) throw new Error("useLeadForms must be used within a LeadFormsProvider");
return ctx;
}

View file

@ -1,95 +1,168 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import {
getUserOnboardingStateApiV1UserOnboardingStateGet,
updateUserOnboardingStateApiV1UserOnboardingStatePut,
} from '@/client/sdk.gen';
import type { OnboardingStateUpdate } from '@/client/types.gen';
import { useAuth } from '@/lib/auth';
export type TooltipKey = 'web_call' | 'customize_workflow';
export type OnboardingActionKey = 'web_call_started';
// Server-backed onboarding state (GET/PUT /user/onboarding-state), stored
// per-user under the ONBOARDING user-configuration key — deliberately
// independent of the AI model configuration. Replaces the old
// localStorage-only store so one-time UI (post-signup gate, tooltips,
// milestone actions) holds across devices and browsers.
interface OnboardingState {
seenTooltips: TooltipKey[];
completedActions: OnboardingActionKey[];
completed_at: string | null;
skipped: boolean;
seen_tooltips: string[];
completed_actions: string[];
}
interface OnboardingContextType {
// True until the server state has been fetched. While loading, the
// has* checks report "already seen/done" so one-time UI never flashes
// for users who have in fact seen it.
loading: boolean;
// Post-signup onboarding form gate (set once on submit/skip).
onboardingCompletedAt: string | null;
onboardingSkipped: boolean;
markOnboardingCompleted: (opts?: { skipped?: boolean }) => void;
hasSeenTooltip: (key: TooltipKey) => boolean;
markTooltipSeen: (key: TooltipKey) => void;
hasCompletedAction: (key: OnboardingActionKey) => boolean;
markActionCompleted: (key: OnboardingActionKey) => void;
resetOnboarding: () => void;
}
const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
const defaultState: OnboardingState = {
seenTooltips: [],
completedActions: [],
completed_at: null,
skipped: false,
seen_tooltips: [],
completed_actions: [],
};
const union = (a: string[], b: string[] | null | undefined) =>
[...a, ...(b ?? []).filter((item) => !a.includes(item))];
// Merge a server response into local state monotonically: flags only ever
// advance, so a response that raced a newer optimistic mark can't revert it.
const absorb = (prev: OnboardingState, server: Partial<OnboardingState>): OnboardingState => ({
completed_at: prev.completed_at ?? server.completed_at ?? null,
skipped: prev.skipped || Boolean(server.skipped),
seen_tooltips: union(prev.seen_tooltips, server.seen_tooltips),
completed_actions: union(prev.completed_actions, server.completed_actions),
});
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
const [onboardingState, setOnboardingState] = useState<OnboardingState>(() => {
// Initialize state from localStorage on first render
if (typeof window !== 'undefined') {
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
return { ...defaultState, ...parsed };
} catch (error) {
console.error('Failed to parse onboarding state:', error);
}
}
}
return defaultState;
});
const [state, setState] = useState<OnboardingState>(defaultState);
const [loaded, setLoaded] = useState(false);
const auth = useAuth();
const authRef = useRef(auth);
authRef.current = auth;
const hasFetched = useRef(false);
// Save state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
if (auth.loading || hasFetched.current) return;
if (!auth.isAuthenticated) {
// Unauthenticated pages (login/signup) have no onboarding state;
// unblock consumers with defaults.
setLoaded(true);
return;
}
}, [onboardingState]);
hasFetched.current = true;
const hasSeenTooltip = (key: TooltipKey): boolean => {
return onboardingState.seenTooltips.includes(key);
};
(async () => {
const res = await getUserOnboardingStateApiV1UserOnboardingStateGet().catch(() => null);
if (res?.data) {
const data = res.data as Partial<OnboardingState>;
setState((prev) => absorb(prev, data));
setLoaded(true);
} else {
// Fetch failed: stay in loading so one-time UI stays suppressed
// (fail closed — never re-show onboarding to an onboarded user).
console.error('[onboarding] failed to fetch onboarding state', res?.error);
}
})();
}, [auth.loading, auth.isAuthenticated]);
const markTooltipSeen = (key: TooltipKey) => {
setOnboardingState(prev => ({
// Best-effort server write. Only the delta is sent; the server unions list
// fields into the stored state, so concurrent tabs don't drop each other's
// updates. The response is the merged state — use it to reconcile.
const persist = useCallback((update: OnboardingStateUpdate) => {
if (!authRef.current.isAuthenticated) return;
void updateUserOnboardingStateApiV1UserOnboardingStatePut({ body: update })
.then((res) => {
if (res.error) {
console.error('[onboarding] failed to persist onboarding state', res.error);
} else if (res.data) {
const data = res.data as Partial<OnboardingState>;
setState((prev) => absorb(prev, data));
}
})
.catch(() => {
console.error('[onboarding] failed to persist onboarding state');
});
}, []);
const markOnboardingCompleted = useCallback((opts?: { skipped?: boolean }) => {
const skipped = opts?.skipped ?? false;
const completedAt = new Date().toISOString();
// Optimistic: the gate must close immediately and never re-open.
setState((prev) => ({
...prev,
seenTooltips: prev.seenTooltips.includes(key)
? prev.seenTooltips
: [...prev.seenTooltips, key]
skipped: prev.skipped || skipped,
completed_at: prev.completed_at ?? (skipped ? null : completedAt),
}));
};
persist(skipped ? { skipped: true } : { completed_at: completedAt });
}, [persist]);
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
return onboardingState.completedActions.includes(key);
};
const hasSeenTooltip = useCallback(
(key: TooltipKey) => !loaded || state.seen_tooltips.includes(key),
[loaded, state.seen_tooltips],
);
const markActionCompleted = (key: OnboardingActionKey) => {
setOnboardingState(prev => ({
...prev,
completedActions: prev.completedActions.includes(key)
? prev.completedActions
: [...prev.completedActions, key]
}));
};
const markTooltipSeen = useCallback((key: TooltipKey) => {
setState((prev) =>
prev.seen_tooltips.includes(key)
? prev
: { ...prev, seen_tooltips: [...prev.seen_tooltips, key] }
);
persist({ seen_tooltips: [key] });
}, [persist]);
const resetOnboarding = () => {
setOnboardingState(defaultState);
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
};
const hasCompletedAction = useCallback(
(key: OnboardingActionKey) => !loaded || state.completed_actions.includes(key),
[loaded, state.completed_actions],
);
const markActionCompleted = useCallback((key: OnboardingActionKey) => {
setState((prev) =>
prev.completed_actions.includes(key)
? prev
: { ...prev, completed_actions: [...prev.completed_actions, key] }
);
persist({ completed_actions: [key] });
}, [persist]);
return (
<OnboardingContext.Provider
value={{
loading: !loaded,
onboardingCompletedAt: state.completed_at,
onboardingSkipped: state.skipped,
markOnboardingCompleted,
hasSeenTooltip,
markTooltipSeen,
hasCompletedAction,
markActionCompleted,
resetOnboarding
}}
>
{children}

View file

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

View file

@ -65,8 +65,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)).*)',
],
};