mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
feat: UI refresh and user onboarding (#430)
* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)
Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for user-onboarding lead-gen surfaces
14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): register PostHog events for lead-gen surfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared field options, work-email validation, and submit seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): inline math captcha field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): enterprise intake modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): hire-an-expert modal with enterprise link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): top-up modal with >20k volume-pricing gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared lead-forms context provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): mount LeadFormsProvider in app layout
Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): add Credits & Billing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(ui): add lint:lead-flow guard script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur
Revised lead-capture surfaces and credits bar:
- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
sticky footer, trust-line slot), PhoneField (react-international-phone,
dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
monthly volume. EnterpriseModal: + work email (required logged-out),
conditional deployment (yes/no/maybe, source-gated), agent goal.
OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
waveform motif + proof points + Bland-style enterprise CTA block on the
left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
(defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ui rezig, onboarding, billing, hire us & on prem cues
* ui changes
* chore: update comment
* chore: untrack docs/superpowers and gitignore it
* feat: refactor user configuration table
* feat(ui): 'check your email' confirmation on lead forms
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* added email and country in form submissions
* chore: update leads api
* fix: wrap dograh model config in card
---------
Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a2d9ed24ed
commit
00b35d6963
82 changed files with 3819 additions and 604 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -19,3 +19,8 @@ coturn/
|
|||
*.wav
|
||||
dograh_pcm_cache/
|
||||
node_modules/
|
||||
|
||||
# Superpowers brainstorm mockups (local only)
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.gstack/
|
||||
|
|
|
|||
|
|
@ -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: efe356f488f9
|
||||
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] = "efe356f488f9"
|
||||
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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from api.utils.recording_artifacts import get_recording_storage_key
|
||||
|
||||
|
|
@ -347,7 +347,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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
47
api/schemas/onboarding_state.py
Normal file
47
api/schemas/onboarding_state.py
Normal 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
|
||||
37
api/services/user_onboarding.py
Normal file
37
api/services/user_onboarding.py
Normal 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()
|
||||
131
api/tests/test_onboarding_state.py
Normal file
131
api/tests/test_onboarding_state.py
Normal 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
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.CuRRn2w89H
|
||||
# timestamp: 2026-06-16T06:27:36+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.lMzKvoOMbD
|
||||
# timestamp: 2026-06-17T13:44:53+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
BACKEND_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_NODE_ENV=development
|
||||
# form submissions backend
|
||||
# NEXT_PUBLIC_ONBOARDING_API_URL=http://localhost:8001
|
||||
|
|
|
|||
4
ui/package-lock.json
generated
4
ui/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.30.1",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"version": "1.30.1",
|
||||
"version": "1.33.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
ui/public/brand-imprint-dark.svg
Normal file
1
ui/public/brand-imprint-dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
1
ui/public/brand-imprint-light.svg
Normal file
1
ui/public/brand-imprint-light.svg
Normal file
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
BIN
ui/public/dograh-logo-inverse.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
ui/public/dograh-logo.png
Executable file
BIN
ui/public/dograh-logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
ui/public/dograh-mark.png
Executable file
BIN
ui/public/dograh-mark.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -304,7 +304,7 @@ export default function APIKeysPage() {
|
|||
// Don't render content until auth is loaded
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
|
|
@ -319,7 +319,7 @@ export default function APIKeysPage() {
|
|||
const showServiceKeyArchiveControls = !isOSS;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
|
||||
import { loginApiV1AuthLoginPost } from "@/client/sdk.gen";
|
||||
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
|
|
@ -46,48 +47,48 @@ export default function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Sign in</CardTitle>
|
||||
<CardDescription>Enter your email and password to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email and password to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
|
||||
import { signupApiV1AuthSignupPost } from "@/client/sdk.gen";
|
||||
import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
|
|
@ -58,61 +59,59 @@ export default function SignupPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||
<CardDescription>Enter your details to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Create an account</h1>
|
||||
<p className="text-sm text-muted-foreground">Enter your details to get started</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ export default function NewCampaignPage() {
|
|||
}
|
||||
if (maxConcurrencyValue > effectiveLimit) {
|
||||
if (availableFromNumbersCount > 0 && availableFromNumbersCount < orgConcurrentLimit) {
|
||||
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) — add more CLIs to increase concurrency.`);
|
||||
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) - add more CLIs to increase concurrency.`);
|
||||
} else {
|
||||
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
|
||||
}
|
||||
|
|
@ -455,7 +455,7 @@ export default function NewCampaignPage() {
|
|||
value={config.id.toString()}
|
||||
>
|
||||
{config.name} ({config.provider})
|
||||
{config.is_default_outbound ? ' — default' : ''}
|
||||
{config.is_default_outbound ? ' - default' : ''}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
34
ui/src/app/handler/[...stack]/stack-theme.ts
Normal file
34
ui/src/app/handler/[...stack]/stack-theme.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const RecordingsUploadDialog = ({
|
|||
const valid: PendingFile[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit — skipped.`);
|
||||
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit - skipped.`);
|
||||
continue;
|
||||
}
|
||||
const id = `pending-${++pendingFileCounter}`;
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ export default function TelephonyConfigurationsPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -518,7 +518,7 @@ const data = await response.json();`;
|
|||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
|
|
@ -529,7 +529,7 @@ const data = await response.json();`;
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
|
|
@ -542,7 +542,7 @@ const data = await response.json();`;
|
|||
|
||||
if (!tool) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Tool not found</h1>
|
||||
|
|
@ -563,7 +563,7 @@ const data = await response.json();`;
|
|||
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export default function ToolsPage() {
|
|||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
|
|
@ -293,7 +293,7 @@ export default function ToolsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ export const RecordingsDialog = ({
|
|||
const valid: PendingFile[] = [];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit — skipped.`);
|
||||
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit - skipped.`);
|
||||
continue;
|
||||
}
|
||||
const id = `pending-${++pendingFileCounter}`;
|
||||
|
|
|
|||
|
|
@ -305,7 +305,7 @@ export const WorkflowEditorHeader = ({
|
|||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-blue-500/30 bg-blue-500/10">
|
||||
<Eye className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-blue-400">
|
||||
Viewing {activeVersionLabel} — Read only
|
||||
Viewing {activeVersionLabel} - Read only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -532,7 +532,9 @@ export type ByokPipelineAiModelConfiguration = {
|
|||
provider: 'minimax';
|
||||
} & MiniMaxTtsConfiguration) | ({
|
||||
provider: 'azure_speech';
|
||||
} & AzureSpeechTtsConfiguration);
|
||||
} & AzureSpeechTtsConfiguration) | ({
|
||||
provider: 'smallest';
|
||||
} & SmallestAittsConfiguration);
|
||||
/**
|
||||
* Stt
|
||||
*/
|
||||
|
|
@ -560,7 +562,9 @@ export type ByokPipelineAiModelConfiguration = {
|
|||
provider: 'gladia';
|
||||
} & GladiaSttConfiguration) | ({
|
||||
provider: 'azure_speech';
|
||||
} & AzureSpeechSttConfiguration);
|
||||
} & AzureSpeechSttConfiguration) | ({
|
||||
provider: 'smallest';
|
||||
} & SmallestAisttConfiguration);
|
||||
/**
|
||||
* Embeddings
|
||||
*/
|
||||
|
|
@ -3617,6 +3621,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
|
||||
*/
|
||||
|
|
@ -4854,6 +4913,74 @@ export type SignupRequest = {
|
|||
name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Smallest AI
|
||||
*
|
||||
* Smallest AI ultralow-latency TTS (Waves) and STT (Pulse) APIs.
|
||||
*/
|
||||
export type SmallestAisttConfiguration = {
|
||||
/**
|
||||
* Provider
|
||||
*/
|
||||
provider?: 'smallest';
|
||||
/**
|
||||
* Api Key
|
||||
*/
|
||||
api_key: string | Array<string>;
|
||||
/**
|
||||
* Model
|
||||
*
|
||||
* Smallest AI STT model. Supports 38 languages with real-time streaming.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Language
|
||||
*
|
||||
* ISO 639-1 language code for transcription.
|
||||
*/
|
||||
language?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Smallest AI
|
||||
*
|
||||
* Smallest AI ultralow-latency TTS (Waves) and STT (Pulse) APIs.
|
||||
*/
|
||||
export type SmallestAittsConfiguration = {
|
||||
/**
|
||||
* Provider
|
||||
*/
|
||||
provider?: 'smallest';
|
||||
/**
|
||||
* Api Key
|
||||
*/
|
||||
api_key: string | Array<string>;
|
||||
/**
|
||||
* Model
|
||||
*
|
||||
* Smallest AI TTS model. lightning_v3.1_pro is the premium pool (American, British, Indian accents); lightning_v3.1 is the standard pool with 217 voices across 12 languages.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Voice
|
||||
*
|
||||
* Smallest AI voice ID.
|
||||
*/
|
||||
voice?: string;
|
||||
/**
|
||||
* Language
|
||||
*
|
||||
* ISO 639-1 language code for synthesis.
|
||||
*/
|
||||
language?: string;
|
||||
/**
|
||||
* Speed
|
||||
*
|
||||
* Speech speed multiplier (0.5 to 2.0).
|
||||
*/
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Local Models (Speaches)
|
||||
*
|
||||
|
|
@ -8681,6 +8808,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?: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type ServiceSegment,
|
||||
} from "@/components/ServiceConfigurationForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -366,105 +367,107 @@ export function AIModelConfigurationV2Editor({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
{isCustomVoice ? (
|
||||
<Input
|
||||
placeholder="Enter voice"
|
||||
value={dograh.voice}
|
||||
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
{isCustomVoice ? (
|
||||
<Input
|
||||
placeholder="Enter voice"
|
||||
value={dograh.voice}
|
||||
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{allowCustomVoice && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dograh-custom-voice"
|
||||
checked={isCustomVoice}
|
||||
onCheckedChange={(checked) => {
|
||||
const custom = checked as boolean;
|
||||
setIsCustomVoice(custom);
|
||||
if (!custom) {
|
||||
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{allowCustomVoice && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dograh-custom-voice"
|
||||
checked={isCustomVoice}
|
||||
onCheckedChange={(checked) => {
|
||||
const custom = checked as boolean;
|
||||
setIsCustomVoice(custom);
|
||||
if (!custom) {
|
||||
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
|
|
|
|||
38
ui/src/components/BrandLogo.tsx
Normal file
38
ui/src/components/BrandLogo.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Reusable Dograh wordmark. Theme-aware by default: the dark logo shows on light
|
||||
// surfaces and the light/cream logo shows on dark. Pass `inverse` to force the
|
||||
// light logo on an always-dark surface (e.g. the auth brand panel). Pass `mark`
|
||||
// to render the square logo mark instead of the full wordmark (e.g. the app
|
||||
// sidebar header). Height is controlled by the caller via className (e.g.
|
||||
// "h-7"); width stays auto so each lockup keeps its aspect ratio.
|
||||
export function BrandLogo({
|
||||
className,
|
||||
inverse = false,
|
||||
mark = false,
|
||||
}: {
|
||||
className?: string;
|
||||
inverse?: boolean;
|
||||
mark?: boolean;
|
||||
}) {
|
||||
if (mark) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src="/dograh-mark.png" alt="Dograh" className={cn("w-auto select-none", className)} />
|
||||
);
|
||||
}
|
||||
if (inverse) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src="/dograh-logo-inverse.png" alt="Dograh" className={cn("w-auto select-none", className)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/dograh-logo.png" alt="Dograh" className={cn("block w-auto select-none dark:hidden", className)} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/dograh-logo-inverse.png" alt="Dograh" className={cn("hidden w-auto select-none dark:block", className)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/components/ThemeProvider.tsx
Normal file
12
ui/src/components/ThemeProvider.tsx
Normal 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>;
|
||||
}
|
||||
38
ui/src/components/auth/AuthEnterpriseCTA.tsx
Normal file
38
ui/src/components/auth/AuthEnterpriseCTA.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
ui/src/components/auth/AuthShell.tsx
Normal file
87
ui/src/components/auth/AuthShell.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
134
ui/src/components/billing/BuyCreditsControl.tsx
Normal file
134
ui/src/components/billing/BuyCreditsControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
ui/src/components/billing/DograhCreditsCard.tsx
Normal file
122
ui/src/components/billing/DograhCreditsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
90
ui/src/components/lead-forms/CaptchaChallenge.tsx
Normal file
90
ui/src/components/lead-forms/CaptchaChallenge.tsx
Normal 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'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 & submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
ui/src/components/lead-forms/EnterpriseLeadFields.tsx
Normal file
143
ui/src/components/lead-forms/EnterpriseLeadFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
ui/src/components/lead-forms/EnterpriseModal.tsx
Normal file
139
ui/src/components/lead-forms/EnterpriseModal.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import {
|
||||
EMPTY_ENTERPRISE_FIELDS,
|
||||
type EnterpriseFieldsValue,
|
||||
EnterpriseLeadFields,
|
||||
} from "./EnterpriseLeadFields";
|
||||
import { FormTrustLine } from "./FormTrustLine";
|
||||
import { validateWorkEmail } from "./isPersonalEmail";
|
||||
import { ENTERPRISE_DEPLOYMENT_SOURCES, type LeadSource } from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { submitLead } from "./submitLead";
|
||||
|
||||
interface EnterpriseModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
source: LeadSource;
|
||||
// Optional values to pre-fill when the modal opens (e.g. company name already
|
||||
// collected upstream). Backward-compatible: omitted = no prefill.
|
||||
prefill?: { company?: string };
|
||||
}
|
||||
|
||||
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
|
||||
const { config } = useAppConfig();
|
||||
// Deployment provenance (analytics only); OSS submits via the public contact-sales path.
|
||||
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
|
||||
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// The deployment question is only surfaced for custom-volume / Contact-Us /
|
||||
// pricing-custom-volume entry points; elsewhere it is hidden and the payload
|
||||
// defaults to "yes".
|
||||
const showDeployment = ENTERPRISE_DEPLOYMENT_SOURCES.includes(source);
|
||||
|
||||
const reset = () => {
|
||||
setValue(EMPTY_ENTERPRISE_FIELDS);
|
||||
setEmailError(null);
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const onFieldsChange = (patch: Partial<EnterpriseFieldsValue>) => {
|
||||
setValue((v) => ({ ...v, ...patch }));
|
||||
if ("workEmail" in patch) setEmailError(null);
|
||||
};
|
||||
|
||||
// Seed company from prefill when the modal opens (don't clobber edits).
|
||||
const prefillCompany = prefill?.company;
|
||||
useEffect(() => {
|
||||
if (open && prefillCompany) {
|
||||
setValue((v) => (v.company ? v : { ...v, company: prefillCompany }));
|
||||
}
|
||||
}, [open, prefillCompany]);
|
||||
|
||||
// Required fields, independent of the anti-spam check (revealed only after the
|
||||
// first submit click — see handleSubmit).
|
||||
const baseValid =
|
||||
Boolean(value.name.trim()) &&
|
||||
Boolean(value.company.trim()) &&
|
||||
Boolean(value.jobTitle.trim()) &&
|
||||
Boolean(value.workEmail.trim()) &&
|
||||
Boolean(value.phone.trim()) &&
|
||||
Boolean(value.volume);
|
||||
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
// Validate, then pop the anti-spam check on top of the modal.
|
||||
const handleSubmit = () => {
|
||||
const err = validateWorkEmail(value.workEmail);
|
||||
if (err) { setEmailError(err); return; }
|
||||
if (!value.name.trim() || !value.company.trim() || !value.jobTitle.trim() || !value.phone.trim() || !value.volume) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified.
|
||||
const doSubmit = async () => {
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitLead({
|
||||
kind: "enterprise",
|
||||
source,
|
||||
origin,
|
||||
payload: {
|
||||
name: value.name,
|
||||
company: value.company,
|
||||
jobTitle: value.jobTitle,
|
||||
workEmail: value.workEmail,
|
||||
phone: value.phone,
|
||||
volume: value.volume,
|
||||
// Hidden entry points imply enterprise intent — default to "yes".
|
||||
deployment: showDeployment ? value.deployment || "yes" : "yes",
|
||||
agentGoal: value.agentGoal,
|
||||
},
|
||||
});
|
||||
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
|
||||
icon={ShieldCheck}
|
||||
eyebrow="Enterprise"
|
||||
title="Book a Strategy Call"
|
||||
description="SSO, on-prem, data residency, committed volume. Tell us about your environment."
|
||||
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
|
||||
trustLine={<FormTrustLine />}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<EnterpriseLeadFields
|
||||
idPrefix="ent"
|
||||
value={value}
|
||||
onChange={onFieldsChange}
|
||||
showDeployment={showDeployment}
|
||||
emailError={emailError}
|
||||
/>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
10
ui/src/components/lead-forms/FormTrustLine.tsx
Normal file
10
ui/src/components/lead-forms/FormTrustLine.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Shared reassurance line shown beneath every lead-form submit. A small,
|
||||
// consistent trust signal — keeps the promise identical across all forms.
|
||||
|
||||
export function FormTrustLine() {
|
||||
return (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Average response: under 10 minutes during business hours.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
182
ui/src/components/lead-forms/HireExpertModal.tsx
Normal file
182
ui/src/components/lead-forms/HireExpertModal.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import { FormTrustLine } from "./FormTrustLine";
|
||||
import { isValidEmail } from "./isPersonalEmail";
|
||||
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { PhoneField } from "./PhoneField";
|
||||
import { submitLead } from "./submitLead";
|
||||
|
||||
interface HireExpertModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
source: LeadSource;
|
||||
onOpenEnterprise: () => void;
|
||||
}
|
||||
|
||||
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
|
||||
const { user } = useAuth(); // logged-in identity (prefills the email field)
|
||||
const { config } = useAppConfig();
|
||||
// Deployment provenance (analytics only): cloud → cloud_app, else oss_app. OSS submits the
|
||||
// lead anonymously (cloud can't verify its token), so the email field below is the identity.
|
||||
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
|
||||
// Logged-in user's email (Stack uses primaryEmail; local uses email) — prefilled, editable.
|
||||
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [agentGoal, setAgentGoal] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [volume, setVolume] = useState("");
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Prefill the email from the logged-in user when the modal opens (don't clobber edits).
|
||||
useEffect(() => {
|
||||
if (open && userEmail) setEmail((e) => e || userEmail);
|
||||
}, [open, userEmail]);
|
||||
|
||||
const reset = () => {
|
||||
setName(""); setCompany(""); setEmail(""); setJobTitle(""); setAgentGoal("");
|
||||
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
|
||||
};
|
||||
|
||||
// Required fields, independent of the anti-spam check (which is revealed only
|
||||
// after the first submit click — see handleSubmit).
|
||||
const baseValid =
|
||||
Boolean(name.trim()) &&
|
||||
Boolean(company.trim()) &&
|
||||
isValidEmail(email) &&
|
||||
Boolean(jobTitle.trim()) &&
|
||||
Boolean(agentGoal.trim()) &&
|
||||
Boolean(phone.trim()) &&
|
||||
Boolean(volume);
|
||||
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
// Validate, then pop the anti-spam check on top of the modal.
|
||||
const handleSubmit = () => {
|
||||
if (!baseValid) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified.
|
||||
const doSubmit = async () => {
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitLead({
|
||||
kind: "hire_expert",
|
||||
source,
|
||||
origin,
|
||||
payload: { name, company, email, jobTitle, agentGoal, phone, volume },
|
||||
});
|
||||
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
|
||||
icon={Sparkles}
|
||||
eyebrow="Done-for-you"
|
||||
title="Let us build your voice agent"
|
||||
description="Building good voice agents is nuanced. Tell us what you need and we'll take it end-to-end."
|
||||
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
|
||||
helper={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEnterprise}
|
||||
className="underline decoration-dashed underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Need enterprise deployment? (SSO, on-prem, data residency)
|
||||
</button>
|
||||
}
|
||||
trustLine={<FormTrustLine />}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-name">Name</Label>
|
||||
<Input id="hire-name" placeholder="Your full name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-company">Company name</Label>
|
||||
<Input id="hire-company" placeholder="Acme Inc." value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-email">Email</Label>
|
||||
<Input id="hire-email" type="email" placeholder="you@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-title">Job title</Label>
|
||||
<Input id="hire-title" placeholder="VP Operations" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-goal">What do you want the voice agent to do?</Label>
|
||||
<Textarea
|
||||
id="hire-goal"
|
||||
value={agentGoal}
|
||||
onChange={(e) => setAgentGoal(e.target.value)}
|
||||
placeholder="Use case, target outcomes, any remarks…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-phone">Phone</Label>
|
||||
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-volume">Expected monthly call volume</Label>
|
||||
<Select value={volume} onValueChange={setVolume}>
|
||||
<SelectTrigger id="hire-volume"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HIRE_VOLUME_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
93
ui/src/components/lead-forms/HireExpertNudge.tsx
Normal file
93
ui/src/components/lead-forms/HireExpertNudge.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
136
ui/src/components/lead-forms/LeadModalShell.tsx
Normal file
136
ui/src/components/lead-forms/LeadModalShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/lead-forms/MathCaptcha.tsx
Normal file
45
ui/src/components/lead-forms/MathCaptcha.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
357
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import {
|
||||
EMPTY_ENTERPRISE_FIELDS,
|
||||
type EnterpriseFieldsValue,
|
||||
EnterpriseLeadFields,
|
||||
} from "./EnterpriseLeadFields";
|
||||
import { validateWorkEmail } from "./isPersonalEmail";
|
||||
import {
|
||||
ONBOARDING_HEARD_OPTIONS,
|
||||
ONBOARDING_MIGRATION_OPTIONS,
|
||||
ONBOARDING_ONPREM_OPTIONS,
|
||||
ONBOARDING_ONPREM_PERSONAS,
|
||||
ONBOARDING_PERSONA_OPTIONS,
|
||||
ONBOARDING_VOLUME_OPTIONS,
|
||||
} from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { submitLead } from "./submitLead";
|
||||
import { type OnboardingAnswers, submitOnboarding } from "./submitOnboarding";
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean;
|
||||
// Called after a tracked submit to dismiss the gate and stamp the server-side
|
||||
// "completed" flag. Onboarding is compulsory — `skipped` is always false now.
|
||||
onComplete: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
const { user } = useAuth(); // logged-in identity → onboarding email (sent silently)
|
||||
const { config } = useAppConfig();
|
||||
// Deployment provenance (analytics only).
|
||||
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
|
||||
// The logged-in user's email (Stack uses primaryEmail; local uses email). Sent in the
|
||||
// body — there is no visible email field on the onboarding form.
|
||||
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
|
||||
|
||||
const [persona, setPersona] = useState("");
|
||||
const [onPremNeed, setOnPremNeed] = useState("");
|
||||
const [migratingFrom, setMigratingFrom] = useState("");
|
||||
const [migratingOtherProvider, setMigratingOtherProvider] = useState("");
|
||||
const [switchReason, setSwitchReason] = useState("");
|
||||
const [howHeard, setHowHeard] = useState("");
|
||||
const [volume, setVolume] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Inline on-prem expansion: the FULL enterprise form, submitted through the same
|
||||
// /api/v1/leads/enterprise path as the standalone Enterprise modal.
|
||||
const [onPremExpanded, setOnPremExpanded] = useState(false);
|
||||
const [ef, setEf] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
|
||||
const [efEmailError, setEfEmailError] = useState<string | null>(null);
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
|
||||
const showOnPrem = ONBOARDING_ONPREM_PERSONAS.includes(persona);
|
||||
const showManagedNote = showOnPrem && onPremNeed === "yes";
|
||||
const wantsOnPrem = showManagedNote && onPremExpanded;
|
||||
const isOtherProvider = migratingFrom === "other";
|
||||
const isMigrating = Boolean(migratingFrom) && migratingFrom !== "no";
|
||||
|
||||
// All four questions are required (onboarding is compulsory). "Other" provider also
|
||||
// needs its free-text name; the "why switching" note is optional.
|
||||
const baseValid =
|
||||
Boolean(persona) &&
|
||||
Boolean(migratingFrom) &&
|
||||
(!isOtherProvider || Boolean(migratingOtherProvider.trim())) &&
|
||||
Boolean(howHeard) &&
|
||||
Boolean(volume);
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
const answers = (): OnboardingAnswers => ({
|
||||
persona: persona || undefined,
|
||||
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
|
||||
migratingFrom: migratingFrom || undefined,
|
||||
migratingOtherProvider: isOtherProvider ? migratingOtherProvider.trim() || undefined : undefined,
|
||||
switchReason: isMigrating ? switchReason.trim() || undefined : undefined,
|
||||
howHeard: howHeard || undefined,
|
||||
volume: volume || undefined,
|
||||
});
|
||||
|
||||
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
|
||||
setEf((v) => ({ ...v, ...patch }));
|
||||
if ("workEmail" in patch) setEfEmailError(null);
|
||||
};
|
||||
|
||||
const expandOnPrem = () => setOnPremExpanded(true);
|
||||
|
||||
const collapseOnPrem = () => {
|
||||
setOnPremExpanded(false);
|
||||
setCaptchaActive(false);
|
||||
setEfEmailError(null);
|
||||
};
|
||||
|
||||
// Best-effort persistence must never trap the user. Dismiss immediately, then fire
|
||||
// the network work in the background. `withEnterprise` = also send the on-prem lead.
|
||||
const finish = (withEnterprise: boolean) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
const data = answers();
|
||||
const efSnapshot = withEnterprise ? { ...ef } : null;
|
||||
onComplete(false); // compulsory — always "completed", never skipped
|
||||
void (async () => {
|
||||
try {
|
||||
await submitOnboarding(data, origin, userEmail);
|
||||
// Two distinct submissions on success: onboarding answers above, and the
|
||||
// enterprise on-prem lead here (same endpoint as the standalone form).
|
||||
if (efSnapshot) {
|
||||
await submitLead({
|
||||
kind: "enterprise",
|
||||
source: "onboarding",
|
||||
origin,
|
||||
payload: {
|
||||
name: efSnapshot.name,
|
||||
company: efSnapshot.company || undefined,
|
||||
jobTitle: efSnapshot.jobTitle,
|
||||
workEmail: efSnapshot.workEmail,
|
||||
phone: efSnapshot.phone,
|
||||
volume: efSnapshot.volume,
|
||||
// They already answered on-prem = yes; deployment intent is implied.
|
||||
deployment: "yes",
|
||||
agentGoal: efSnapshot.agentGoal,
|
||||
},
|
||||
});
|
||||
// Only the on-prem/enterprise lead path sends an email; plain onboarding
|
||||
// does not. Confirm the email just for this path.
|
||||
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
|
||||
}
|
||||
} catch {
|
||||
// Swallowed — the user is already in the product; calls are timeout-bounded.
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!baseValid) {
|
||||
toast.error(
|
||||
isOtherProvider && !migratingOtherProvider.trim()
|
||||
? "Please tell us which provider you're migrating from"
|
||||
: "Please answer all the questions",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// If the user engaged the on-prem section, validate it + pop the anti-spam check.
|
||||
if (wantsOnPrem) {
|
||||
const err = validateWorkEmail(ef.workEmail);
|
||||
if (err) { setEfEmailError(err); return; }
|
||||
if (!ef.name.trim() || !ef.company.trim() || !ef.jobTitle.trim() || !ef.phone.trim() || !ef.volume) {
|
||||
toast.error("Please complete the on-prem details below, or remove that section.");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
return;
|
||||
}
|
||||
finish(false);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified (on-prem path).
|
||||
const submitWithOnPrem = () => {
|
||||
setCaptchaActive(false);
|
||||
finish(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
// Hard gate: no outside/escape close, hide the built-in ×. Onboarding is
|
||||
// compulsory — the only exit is "Get started" once the questions are answered.
|
||||
onOpenChange={() => {}}
|
||||
contentProps={{
|
||||
className: "[&>button]:hidden",
|
||||
onEscapeKeyDown: (e) => e.preventDefault(),
|
||||
onPointerDownOutside: (e) => e.preventDefault(),
|
||||
onInteractOutside: (e) => e.preventDefault(),
|
||||
}}
|
||||
icon={Rocket}
|
||||
eyebrow="Welcome"
|
||||
title="Welcome to Dograh"
|
||||
description="A few quick questions so we can tailor your experience. Takes ~20 seconds."
|
||||
primary={{ label: "Get started", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={submitWithOnPrem} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-persona">What best describes you?</Label>
|
||||
<Select
|
||||
value={persona}
|
||||
onValueChange={(v) => {
|
||||
setPersona(v);
|
||||
// Leaving the on-prem-eligible persona resets the conditional answer
|
||||
// and any inline enterprise lead.
|
||||
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) {
|
||||
setOnPremNeed("");
|
||||
collapseOnPrem();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ob-persona"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_PERSONA_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showOnPrem && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-onprem">Do you need on-prem deployment for compliance & 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 “Remove”.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-volume">Expected monthly call volume</Label>
|
||||
<Select value={volume} onValueChange={setVolume}>
|
||||
<SelectTrigger id="ob-volume"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_VOLUME_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-migrating">Are you migrating from another provider?</Label>
|
||||
<Select
|
||||
value={migratingFrom}
|
||||
onValueChange={(v) => {
|
||||
setMigratingFrom(v);
|
||||
if (v !== "other") setMigratingOtherProvider("");
|
||||
if (v === "no") setSwitchReason("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ob-migrating"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_MIGRATION_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isOtherProvider && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Label htmlFor="ob-other-provider">Other provider</Label>
|
||||
<Input
|
||||
id="ob-other-provider"
|
||||
placeholder="Enter the provider here"
|
||||
value={migratingOtherProvider}
|
||||
onChange={(e) => setMigratingOtherProvider(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMigrating && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Label htmlFor="ob-switch-reason">
|
||||
Why are you switching? <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ob-switch-reason"
|
||||
rows={2}
|
||||
placeholder="e.g. cost, self-hosting, concurrency, data security, latency"
|
||||
value={switchReason}
|
||||
onChange={(e) => setSwitchReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-heard">How did you hear about us?</Label>
|
||||
<Select value={howHeard} onValueChange={setHowHeard}>
|
||||
<SelectTrigger id="ob-heard"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_HEARD_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
66
ui/src/components/lead-forms/PhoneField.tsx
Normal file
66
ui/src/components/lead-forms/PhoneField.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
// Dark-themed wrapper around react-international-phone's PhoneInput.
|
||||
// Emits a clean E.164 string (the backend geo/qualification rule keys off the
|
||||
// dial code). The library is styled with its own CSS variables, which we map to
|
||||
// our dark surface tokens so the field matches the rest of the form. Default
|
||||
// country is the US; the user can switch via the flag selector.
|
||||
|
||||
import "react-international-phone/style.css";
|
||||
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PhoneFieldProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Map the library's theming variables onto our dark surface tokens so the
|
||||
// control reads as one cohesive input rather than a third-party widget.
|
||||
const phoneThemeVars: React.CSSProperties = {
|
||||
["--react-international-phone-height" as string]: "2.25rem",
|
||||
["--react-international-phone-background-color" as string]: "transparent",
|
||||
["--react-international-phone-text-color" as string]: "var(--foreground)",
|
||||
["--react-international-phone-border-color" as string]: "var(--input)",
|
||||
["--react-international-phone-border-radius" as string]: "var(--radius-md)",
|
||||
["--react-international-phone-font-size" as string]: "0.875rem",
|
||||
["--react-international-phone-country-selector-background-color" as string]:
|
||||
"transparent",
|
||||
["--react-international-phone-country-selector-background-color-hover" as string]:
|
||||
"var(--accent)",
|
||||
["--react-international-phone-dropdown-item-background-color" as string]:
|
||||
"var(--popover)",
|
||||
["--react-international-phone-dropdown-item-text-color" as string]:
|
||||
"var(--popover-foreground)",
|
||||
["--react-international-phone-dropdown-item-background-color-hover" as string]:
|
||||
"var(--accent)",
|
||||
["--react-international-phone-selected-dropdown-item-background-color" as string]:
|
||||
"var(--accent)",
|
||||
};
|
||||
|
||||
export function PhoneField({ id, value, onChange, required, disabled }: PhoneFieldProps) {
|
||||
return (
|
||||
<div style={phoneThemeVars} className="phone-field-dark">
|
||||
<PhoneInput
|
||||
defaultCountry="us"
|
||||
value={value}
|
||||
onChange={(phone) => onChange(phone)}
|
||||
disabled={disabled}
|
||||
inputProps={{ id, required }}
|
||||
className="w-full"
|
||||
inputClassName={cn(
|
||||
"!w-full !bg-transparent !text-foreground placeholder:!text-muted-foreground",
|
||||
"focus-visible:!border-ring focus-visible:!ring-[3px] focus-visible:!ring-ring/50 !outline-none",
|
||||
)}
|
||||
countrySelectorStyleProps={{
|
||||
buttonClassName: "!h-9 !border-input !bg-transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
ui/src/components/lead-forms/detectCountry.ts
Normal file
84
ui/src/components/lead-forms/detectCountry.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Best-effort country detection for lead provenance — no permission prompt, no
|
||||
// network call, no precise geolocation. Primary signal: the browser's IANA timezone
|
||||
// → ISO 3166-1 country (location-based; timezones shared by several countries resolve
|
||||
// to the larger/likelier one). Fallback: the browser locale's region. Returns a
|
||||
// human-readable country name (e.g. "India") — it goes in the founders-email subject —
|
||||
// or undefined if nothing resolves. Sent silently in the form body; never shown.
|
||||
|
||||
// IANA timezone → ISO 3166-1 alpha-2. Curated to the common business regions; anything
|
||||
// not listed falls back to the locale region below. Shared zones → larger country.
|
||||
const TZ_TO_ISO: Record<string, string> = {
|
||||
// United States
|
||||
"America/New_York": "US", "America/Detroit": "US", "America/Chicago": "US",
|
||||
"America/Denver": "US", "America/Phoenix": "US", "America/Los_Angeles": "US",
|
||||
"America/Anchorage": "US", "America/Adak": "US", "America/Boise": "US",
|
||||
"America/Indiana/Indianapolis": "US", "Pacific/Honolulu": "US",
|
||||
// Canada
|
||||
"America/Toronto": "CA", "America/Montreal": "CA", "America/Vancouver": "CA",
|
||||
"America/Edmonton": "CA", "America/Winnipeg": "CA", "America/Halifax": "CA",
|
||||
"America/St_Johns": "CA", "America/Regina": "CA",
|
||||
// Mexico & Central/South America
|
||||
"America/Mexico_City": "MX", "America/Tijuana": "MX", "America/Monterrey": "MX",
|
||||
"America/Cancun": "MX", "America/Sao_Paulo": "BR", "America/Bahia": "BR",
|
||||
"America/Argentina/Buenos_Aires": "AR", "America/Santiago": "CL",
|
||||
"America/Bogota": "CO", "America/Lima": "PE", "America/Caracas": "VE",
|
||||
"America/Guayaquil": "EC", "America/Montevideo": "UY",
|
||||
// United Kingdom & Ireland
|
||||
"Europe/London": "GB", "Europe/Dublin": "IE",
|
||||
// Europe
|
||||
"Europe/Paris": "FR", "Europe/Berlin": "DE", "Europe/Madrid": "ES",
|
||||
"Europe/Rome": "IT", "Europe/Amsterdam": "NL", "Europe/Brussels": "BE",
|
||||
"Europe/Zurich": "CH", "Europe/Vienna": "AT", "Europe/Stockholm": "SE",
|
||||
"Europe/Oslo": "NO", "Europe/Copenhagen": "DK", "Europe/Helsinki": "FI",
|
||||
"Europe/Warsaw": "PL", "Europe/Prague": "CZ", "Europe/Budapest": "HU",
|
||||
"Europe/Bucharest": "RO", "Europe/Athens": "GR", "Europe/Lisbon": "PT",
|
||||
"Europe/Moscow": "RU", "Europe/Kiev": "UA", "Europe/Kyiv": "UA",
|
||||
"Europe/Istanbul": "TR",
|
||||
// South Asia
|
||||
"Asia/Kolkata": "IN", "Asia/Calcutta": "IN", "Asia/Karachi": "PK",
|
||||
"Asia/Dhaka": "BD", "Asia/Colombo": "LK", "Asia/Kathmandu": "NP",
|
||||
// Middle East
|
||||
"Asia/Dubai": "AE", "Asia/Riyadh": "SA", "Asia/Qatar": "QA",
|
||||
"Asia/Kuwait": "KW", "Asia/Jerusalem": "IL", "Asia/Tehran": "IR",
|
||||
"Asia/Baghdad": "IQ", "Asia/Amman": "JO",
|
||||
// East Asia
|
||||
"Asia/Shanghai": "CN", "Asia/Hong_Kong": "HK", "Asia/Taipei": "TW",
|
||||
"Asia/Tokyo": "JP", "Asia/Seoul": "KR",
|
||||
// Southeast Asia
|
||||
"Asia/Singapore": "SG", "Asia/Bangkok": "TH", "Asia/Jakarta": "ID",
|
||||
"Asia/Kuala_Lumpur": "MY", "Asia/Manila": "PH", "Asia/Ho_Chi_Minh": "VN",
|
||||
// Oceania
|
||||
"Australia/Sydney": "AU", "Australia/Melbourne": "AU", "Australia/Brisbane": "AU",
|
||||
"Australia/Perth": "AU", "Australia/Adelaide": "AU", "Pacific/Auckland": "NZ",
|
||||
// Africa
|
||||
"Africa/Johannesburg": "ZA", "Africa/Lagos": "NG", "Africa/Cairo": "EG",
|
||||
"Africa/Nairobi": "KE", "Africa/Casablanca": "MA", "Africa/Accra": "GH",
|
||||
};
|
||||
|
||||
// Resolve the browser locale's region (e.g. "en-GB" → "GB"), maximizing likely subtags
|
||||
// for bare languages (e.g. "en" → "US"). Returns an ISO alpha-2 or undefined.
|
||||
function localeRegion(): string | undefined {
|
||||
if (typeof navigator === "undefined" || !navigator.language) return undefined;
|
||||
try {
|
||||
return new Intl.Locale(navigator.language).maximize().region ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectCountry(): string | undefined {
|
||||
let iso: string | undefined;
|
||||
try {
|
||||
iso = TZ_TO_ISO[Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
} catch {
|
||||
// Intl unavailable — fall through to the locale region.
|
||||
}
|
||||
iso = iso || localeRegion();
|
||||
if (!iso) return undefined;
|
||||
try {
|
||||
// Human-readable name for the founders-email subject (e.g. "IN" → "India").
|
||||
return new Intl.DisplayNames(["en"], { type: "region" }).of(iso) ?? iso;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
49
ui/src/components/lead-forms/isPersonalEmail.ts
Normal file
49
ui/src/components/lead-forms/isPersonalEmail.ts
Normal 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;
|
||||
}
|
||||
106
ui/src/components/lead-forms/leadFieldOptions.ts
Normal file
106
ui/src/components/lead-forms/leadFieldOptions.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Shared dropdown options + lead source/kind types for the lead-gen forms.
|
||||
|
||||
export type LeadSource =
|
||||
| "sidebar"
|
||||
| "billing_card"
|
||||
| "billing_custom_pricing"
|
||||
| "builder_nudge"
|
||||
| "hire_expert"
|
||||
| "onboarding"
|
||||
| "pricing_custom_volume"
|
||||
| "landing_contact"
|
||||
| "auth_page";
|
||||
|
||||
export type LeadKind = "hire_expert" | "enterprise";
|
||||
|
||||
// Provenance stamped by the in-app forms (analytics only; the marketing site and
|
||||
// server use "website"). Derived from AppConfig deploymentMode: cloud → "cloud_app",
|
||||
// otherwise "oss_app". OSS submits via the public no-token endpoints.
|
||||
export type LeadOrigin = "cloud_app" | "oss_app";
|
||||
|
||||
// Monthly call-volume buckets. Values MUST match the backend qualifier enum
|
||||
// (user_onboarding flows): "0-5k" | "5k-100k" | "100k+" | "not-sure".
|
||||
export const VOLUME_OPTIONS = [
|
||||
{ value: "0-5k", label: "0–5k" },
|
||||
{ value: "5k-100k", label: "5k–100k" },
|
||||
{ value: "100k+", label: "100k+" },
|
||||
{ value: "not-sure", label: "Not sure" },
|
||||
] as const;
|
||||
|
||||
// Hire-an-Expert expected monthly call volume (shared bucket set).
|
||||
export const HIRE_VOLUME_OPTIONS = VOLUME_OPTIONS;
|
||||
|
||||
// Enterprise monthly call volume (shared bucket set).
|
||||
export const ENTERPRISE_VOLUME_OPTIONS = VOLUME_OPTIONS;
|
||||
|
||||
// Lead sources for which the Enterprise modal surfaces the conditional
|
||||
// "Need enterprise deployment (SSO, on-prem, data residency)?" question.
|
||||
// Other entry points hide it and default the payload to "yes".
|
||||
export const ENTERPRISE_DEPLOYMENT_SOURCES: readonly LeadSource[] = [
|
||||
"billing_custom_pricing",
|
||||
"pricing_custom_volume",
|
||||
"landing_contact",
|
||||
"auth_page",
|
||||
];
|
||||
|
||||
// Enterprise deployment need (conditional — see ENTERPRISE_DEPLOYMENT_SOURCES).
|
||||
export const ENTERPRISE_DEPLOYMENT_OPTIONS = [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
{ value: "maybe", label: "Maybe" },
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-signup onboarding form options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Onboarding: are you migrating from another provider? (a trimmed competitor list).
|
||||
// "no" → not migrating; "other" → reveals a free-text provider field.
|
||||
export const ONBOARDING_MIGRATION_OPTIONS = [
|
||||
{ value: "no", label: "No, I'm not migrating" },
|
||||
{ value: "vapi", label: "Vapi" },
|
||||
{ value: "retell", label: "Retell" },
|
||||
{ value: "bland", label: "Bland" },
|
||||
{ value: "elevenlabs", label: "ElevenLabs" },
|
||||
{ value: "synthflow", label: "Synthflow" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: how did you hear about us? (trimmed).
|
||||
export const ONBOARDING_HEARD_OPTIONS = [
|
||||
{ value: "github", label: "GitHub" },
|
||||
{ value: "search_engine", label: "Search engine" },
|
||||
{ value: "social_media", label: "Social media (Twitter, LinkedIn)" },
|
||||
{ value: "youtube", label: "YouTube" },
|
||||
{ value: "ai_tool", label: "AI tool (ChatGPT, Claude)" },
|
||||
{ value: "referral", label: "Someone told me about it" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: expected monthly call volume. Its own set — value "exploring" is NOT
|
||||
// the qualifier's "not-sure"; onboarding has no flow, so this is analytics-only.
|
||||
export const ONBOARDING_VOLUME_OPTIONS = [
|
||||
{ value: "0-5k", label: "0–5k" },
|
||||
{ value: "5k-100k", label: "5k–100k" },
|
||||
{ value: "100k+", label: "100k+" },
|
||||
{ value: "exploring", label: "Exploring" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: what best describes you.
|
||||
export const ONBOARDING_PERSONA_OPTIONS = [
|
||||
{ value: "enterprise_midmarket", label: "Enterprise / Mid-Market" },
|
||||
{ value: "agency", label: "Agency / consultancy building for clients" },
|
||||
{ value: "local_business", label: "Local business" },
|
||||
{ value: "startup", label: "Startup" },
|
||||
{ value: "solo", label: "Solo founder / builder" },
|
||||
] as const;
|
||||
|
||||
// Persona values that unlock the on-prem conditional question.
|
||||
export const ONBOARDING_ONPREM_PERSONAS: readonly string[] = ["enterprise_midmarket"];
|
||||
|
||||
// Onboarding: on-prem deployment need (conditional on Enterprise/Mid-Market).
|
||||
export const ONBOARDING_ONPREM_OPTIONS = [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
{ value: "not_sure", label: "Not sure" },
|
||||
] as const;
|
||||
57
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
57
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Thin client for the SEPARATE user_onboarding service (its own base URL).
|
||||
// Not part of the generated Dograh SDK — a different host. All endpoints are PUBLIC
|
||||
// (no auth token); identity is the email carried in the body. Every call is
|
||||
// BEST-EFFORT: failures are swallowed so a down/erroring service never blocks the user.
|
||||
|
||||
// Base URL of the user_onboarding service. Unset (the default for self-hosted OSS —
|
||||
// .env.example ships this commented out) → fall back to our cloud leads backend so we
|
||||
// still receive OSS form submissions. Override the env var to point elsewhere (or to a
|
||||
// local backend) to stop sending leads to us.
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL || "https://api-leads.dograh.com";
|
||||
|
||||
// Bound every call so a slow/hung service can never freeze the UI. Best-effort:
|
||||
// failures are surfaced via console.error (Sentry breadcrumbs) but never thrown.
|
||||
const TIMEOUT_MS = 6000;
|
||||
|
||||
// POST a JSON body to the onboarding service (public — no auth header).
|
||||
async function post(path: string, body: unknown): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
// fetch does not reject on 4xx/5xx — check explicitly so dropped leads are
|
||||
// at least observable.
|
||||
if (!res.ok) {
|
||||
console.error(`[onboarding] POST ${path} failed with HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error, or the timeout aborted the request. Never block the user.
|
||||
console.error(`[onboarding] POST ${path} did not complete:`, err);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Map a lead kind to its endpoint path on the onboarding service.
|
||||
const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
|
||||
hire_expert: "/api/v1/leads/hire-expert",
|
||||
enterprise: "/api/v1/leads/enterprise",
|
||||
};
|
||||
|
||||
// Persist a lead submission (hire-expert / enterprise). Email is in the body.
|
||||
export async function postLeadToService(
|
||||
kind: "hire_expert" | "enterprise",
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await post(LEAD_PATH[kind], body);
|
||||
}
|
||||
|
||||
// Persist an onboarding submission (or skip — body carries `skipped`).
|
||||
export async function postOnboardingToService(body: Record<string, unknown>): Promise<void> {
|
||||
await post("/api/v1/onboarding", body);
|
||||
}
|
||||
36
ui/src/components/lead-forms/submitLead.ts
Normal file
36
ui/src/components/lead-forms/submitLead.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Single submission seam for all lead forms.
|
||||
// Fires a PostHog capture (the durable record) and POSTs to the separate, PUBLIC
|
||||
// user_onboarding service (best-effort — the user is never blocked if it's down).
|
||||
// No auth token: identity is the email in the payload.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
import { detectCountry } from "./detectCountry";
|
||||
import type { LeadKind, LeadOrigin, LeadSource } from "./leadFieldOptions";
|
||||
import { postLeadToService } from "./onboardingServiceClient";
|
||||
|
||||
const SUBMIT_EVENT: Record<LeadKind, string> = {
|
||||
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
|
||||
enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED,
|
||||
};
|
||||
|
||||
export interface SubmitLeadArgs {
|
||||
kind: LeadKind;
|
||||
source: LeadSource;
|
||||
// Deployment provenance (analytics only): "cloud_app" | "oss_app".
|
||||
origin: LeadOrigin;
|
||||
// Field values, already validated by the caller. Includes the contact email.
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise<void> {
|
||||
// `country` is detected silently (timezone/locale) and sent in the body — no visible
|
||||
// field. It feeds the founders-notification email subject server-side.
|
||||
const body = { source, origin, country: detectCountry(), ...payload };
|
||||
// PostHog capture — the durable record, always fired.
|
||||
posthog.capture(SUBMIT_EVENT[kind], body);
|
||||
// Persist to the separate user_onboarding service (best-effort, public).
|
||||
await postLeadToService(kind, body);
|
||||
}
|
||||
49
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
49
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Submission seam for the post-signup onboarding form.
|
||||
// Fires a PostHog capture AND POSTs the answers to the separate, PUBLIC
|
||||
// user_onboarding service (best-effort). The "show once per user" flag is stamped
|
||||
// on the server-backed onboarding state by the caller, not here.
|
||||
//
|
||||
// No auth token. The logged-in user's email is passed in from the modal (available in
|
||||
// the frontend session for both cloud and OSS) and sent in the body — there is no
|
||||
// visible email field. `country` is detected silently and sent too. Onboarding is now
|
||||
// COMPULSORY (no skip).
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
import { detectCountry } from "./detectCountry";
|
||||
import type { LeadOrigin } from "./leadFieldOptions";
|
||||
import { postOnboardingToService } from "./onboardingServiceClient";
|
||||
|
||||
export interface OnboardingAnswers {
|
||||
persona?: string;
|
||||
// Only present when persona unlocks the on-prem question.
|
||||
onPremNeed?: string;
|
||||
// Are you migrating from another provider? ("no" | a provider | "other").
|
||||
migratingFrom?: string;
|
||||
// Free-text provider name when migratingFrom === "other".
|
||||
migratingOtherProvider?: string;
|
||||
// Free-text "why are you switching?" (shown when migrating).
|
||||
switchReason?: string;
|
||||
// How did you hear about us?
|
||||
howHeard?: string;
|
||||
// Expected monthly call volume (0-5k | 5k-100k | 100k+ | exploring).
|
||||
volume?: string;
|
||||
}
|
||||
|
||||
export async function submitOnboarding(
|
||||
answers: OnboardingAnswers,
|
||||
origin: LeadOrigin,
|
||||
email?: string,
|
||||
): Promise<void> {
|
||||
posthog.capture(PostHogEvent.ONBOARDING_SUBMITTED, { ...answers, origin });
|
||||
await postOnboardingToService({
|
||||
source: "onboarding",
|
||||
origin,
|
||||
country: detectCountry(),
|
||||
...(email ? { email } : {}),
|
||||
...answers,
|
||||
skipped: false, // onboarding is compulsory now — kept for stored-shape continuity
|
||||
});
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export function FolderSection({
|
|||
<AlertDialogTitle>Delete “{folder.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The {count} agent{count === 1 ? '' : 's'} in this folder
|
||||
won’t be deleted — they’ll move to Uncategorized.
|
||||
won’t be deleted - they’ll move to Uncategorized.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
136
ui/src/context/LeadFormsContext.tsx
Normal file
136
ui/src/context/LeadFormsContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
22
ui/src/lib/billing/topup.ts
Normal file
22
ui/src/lib/billing/topup.ts
Normal 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;
|
||||
|
|
@ -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)).*)',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue