mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
Merge e5cc1308ed into 281656b960
This commit is contained in:
commit
b92e0a519e
81 changed files with 3536 additions and 589 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: 384be6596b36
|
||||
Create Date: 2026-06-12 21:04:25.561529
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "91cc6ba3e1c7"
|
||||
down_revision: Union[str, None] = "384be6596b36"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Backfill existing rows (all legacy model-config blobs) via the server
|
||||
# default, then drop the default — application code always supplies key.
|
||||
op.add_column(
|
||||
"user_configurations",
|
||||
sa.Column(
|
||||
"key",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="MODEL_CONFIGURATION",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_unique_constraint(
|
||||
"_user_configuration_key_uc", "user_configurations", ["user_id", "key"]
|
||||
)
|
||||
op.alter_column("user_configurations", "key", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"_user_configuration_key_uc", "user_configurations", type_="unique"
|
||||
)
|
||||
# Non-model-config rows (e.g. ONBOARDING) have no meaning in the old
|
||||
# single-blob schema; the old code would read them as the user's model
|
||||
# config, so they must not survive the downgrade.
|
||||
op.execute("DELETE FROM user_configurations WHERE key != 'MODEL_CONFIGURATION'")
|
||||
op.drop_column("user_configurations", "key")
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -343,7 +343,9 @@ class OrganizationUsageClient(BaseDBClient):
|
|||
if user_id:
|
||||
config_result = await session.execute(
|
||||
select(UserConfigurationModel).where(
|
||||
UserConfigurationModel.user_id == user_id
|
||||
UserConfigurationModel.user_id == user_id,
|
||||
UserConfigurationModel.key
|
||||
== UserConfigurationKey.MODEL_CONFIGURATION.value,
|
||||
)
|
||||
)
|
||||
config_obj = config_result.scalar_one_or_none()
|
||||
|
|
|
|||
|
|
@ -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.mKgFDhNhca
|
||||
# timestamp: 2026-06-09T10:10:10+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.w5T4z8AeiH
|
||||
# timestamp: 2026-06-12T16:16:24+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
BACKEND_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_NODE_ENV=development
|
||||
# Base URL of the separate user_onboarding service (lead-gen + onboarding form
|
||||
# submissions). Leave unset to disable those POSTs (PostHog capture still fires).
|
||||
NEXT_PUBLIC_ONBOARDING_API_URL=http://localhost:8001
|
||||
|
|
|
|||
4
ui/package-lock.json
generated
4
ui/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.30.1",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"version": "1.30.1",
|
||||
"version": "1.33.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -147,13 +147,13 @@ export type AwsBedrockLlmConfiguration = {
|
|||
/**
|
||||
* Api Key
|
||||
*
|
||||
* Not used for Bedrock — authentication is via the AWS credentials above. Leave blank.
|
||||
* Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.
|
||||
*/
|
||||
api_key?: string | Array<string> | null;
|
||||
/**
|
||||
* Model
|
||||
*
|
||||
* Bedrock model ID — include the region inference-profile prefix (e.g. 'us.').
|
||||
* Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
|
|
@ -344,7 +344,7 @@ export type AzureOpenAiEmbeddingsConfiguration = {
|
|||
/**
|
||||
* Azure OpenAI Realtime
|
||||
*
|
||||
* Azure OpenAI Realtime API — low-latency speech-to-speech conversations.
|
||||
* Azure OpenAI Realtime API - low-latency speech-to-speech conversations.
|
||||
*/
|
||||
export type AzureRealtimeLlmConfiguration = {
|
||||
/**
|
||||
|
|
@ -384,7 +384,7 @@ export type AzureRealtimeLlmConfiguration = {
|
|||
/**
|
||||
* Azure Speech Services
|
||||
*
|
||||
* Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.
|
||||
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
|
||||
*/
|
||||
export type AzureSpeechSttConfiguration = {
|
||||
/**
|
||||
|
|
@ -418,7 +418,7 @@ export type AzureSpeechSttConfiguration = {
|
|||
/**
|
||||
* Azure Speech Services
|
||||
*
|
||||
* Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.
|
||||
* Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.
|
||||
*/
|
||||
export type AzureSpeechTtsConfiguration = {
|
||||
/**
|
||||
|
|
@ -2627,7 +2627,7 @@ export type GoogleVertexLlmConfiguration = {
|
|||
/**
|
||||
* Api Key
|
||||
*
|
||||
* Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
*/
|
||||
api_key?: string | Array<string> | null;
|
||||
/**
|
||||
|
|
@ -2667,7 +2667,7 @@ export type GoogleVertexRealtimeLlmConfiguration = {
|
|||
/**
|
||||
* Api Key
|
||||
*
|
||||
* Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
* Not used for Vertex AI - authentication is via the service account in `credentials` (or ADC). Leave blank.
|
||||
*/
|
||||
api_key?: string | Array<string> | null;
|
||||
/**
|
||||
|
|
@ -3537,6 +3537,61 @@ export type NodeTypesResponse = {
|
|||
node_types: Array<NodeSpec>;
|
||||
};
|
||||
|
||||
/**
|
||||
* OnboardingState
|
||||
*
|
||||
* Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
|
||||
*
|
||||
* Server-authoritative replacement for the browser-localStorage onboarding
|
||||
* store, so the post-signup gate and one-time tooltips hold across devices.
|
||||
*/
|
||||
export type OnboardingState = {
|
||||
/**
|
||||
* Completed At
|
||||
*/
|
||||
completed_at?: string | null;
|
||||
/**
|
||||
* Skipped
|
||||
*/
|
||||
skipped?: boolean;
|
||||
/**
|
||||
* Seen Tooltips
|
||||
*/
|
||||
seen_tooltips?: Array<string>;
|
||||
/**
|
||||
* Completed Actions
|
||||
*/
|
||||
completed_actions?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* OnboardingStateUpdate
|
||||
*
|
||||
* Partial update merged into the stored state.
|
||||
*
|
||||
* Scalars overwrite when supplied; list entries are unioned into the stored
|
||||
* lists, so concurrent updates (e.g. two tabs marking different tooltips)
|
||||
* don't drop each other's items.
|
||||
*/
|
||||
export type OnboardingStateUpdate = {
|
||||
/**
|
||||
* Completed At
|
||||
*/
|
||||
completed_at?: string | null;
|
||||
/**
|
||||
* Skipped
|
||||
*/
|
||||
skipped?: boolean | null;
|
||||
/**
|
||||
* Seen Tooltips
|
||||
*/
|
||||
seen_tooltips?: Array<string> | null;
|
||||
/**
|
||||
* Completed Actions
|
||||
*/
|
||||
completed_actions?: Array<string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI
|
||||
*/
|
||||
|
|
@ -8563,6 +8618,84 @@ export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses = {
|
|||
|
||||
export type UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse = UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses[keyof UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses];
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/user/onboarding-state';
|
||||
};
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetError = GetUserOnboardingStateApiV1UserOnboardingStateGetErrors[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetErrors];
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: OnboardingState;
|
||||
};
|
||||
|
||||
export type GetUserOnboardingStateApiV1UserOnboardingStateGetResponse = GetUserOnboardingStateApiV1UserOnboardingStateGetResponses[keyof GetUserOnboardingStateApiV1UserOnboardingStateGetResponses];
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutData = {
|
||||
body: OnboardingStateUpdate;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/user/onboarding-state';
|
||||
};
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutError = UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutErrors];
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: OnboardingState;
|
||||
};
|
||||
|
||||
export type UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponse = UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses[keyof UpdateUserOnboardingStateApiV1UserOnboardingStatePutResponses];
|
||||
|
||||
export type ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type ServiceSegment,
|
||||
} from "@/components/ServiceConfigurationForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -359,79 +360,81 @@ export function AIModelConfigurationV2Editor({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
|
|
|
|||
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 { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import {
|
||||
EMPTY_ENTERPRISE_FIELDS,
|
||||
type EnterpriseFieldsValue,
|
||||
EnterpriseLeadFields,
|
||||
} from "./EnterpriseLeadFields";
|
||||
import { FormTrustLine } from "./FormTrustLine";
|
||||
import { validateWorkEmail } from "./isPersonalEmail";
|
||||
import { ENTERPRISE_DEPLOYMENT_SOURCES, type LeadSource } from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { submitLead } from "./submitLead";
|
||||
|
||||
interface EnterpriseModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
source: LeadSource;
|
||||
// Optional values to pre-fill when the modal opens (e.g. company name already
|
||||
// collected upstream). Backward-compatible: omitted = no prefill.
|
||||
prefill?: { company?: string };
|
||||
}
|
||||
|
||||
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
|
||||
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
|
||||
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// The deployment question is only surfaced for custom-volume / Contact-Us /
|
||||
// pricing-custom-volume entry points; elsewhere it is hidden and the payload
|
||||
// defaults to "yes".
|
||||
const showDeployment = ENTERPRISE_DEPLOYMENT_SOURCES.includes(source);
|
||||
|
||||
const reset = () => {
|
||||
setValue(EMPTY_ENTERPRISE_FIELDS);
|
||||
setEmailError(null);
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const onFieldsChange = (patch: Partial<EnterpriseFieldsValue>) => {
|
||||
setValue((v) => ({ ...v, ...patch }));
|
||||
if ("workEmail" in patch) setEmailError(null);
|
||||
};
|
||||
|
||||
// Seed company from prefill when the modal opens (don't clobber edits).
|
||||
const prefillCompany = prefill?.company;
|
||||
useEffect(() => {
|
||||
if (open && prefillCompany) {
|
||||
setValue((v) => (v.company ? v : { ...v, company: prefillCompany }));
|
||||
}
|
||||
}, [open, prefillCompany]);
|
||||
|
||||
// Required fields, independent of the anti-spam check (revealed only after the
|
||||
// first submit click — see handleSubmit).
|
||||
const baseValid =
|
||||
Boolean(value.name.trim()) &&
|
||||
Boolean(value.company.trim()) &&
|
||||
Boolean(value.jobTitle.trim()) &&
|
||||
Boolean(value.workEmail.trim()) &&
|
||||
Boolean(value.phone.trim()) &&
|
||||
Boolean(value.volume);
|
||||
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
// Validate, then pop the anti-spam check on top of the modal.
|
||||
const handleSubmit = () => {
|
||||
const err = validateWorkEmail(value.workEmail);
|
||||
if (err) { setEmailError(err); return; }
|
||||
if (!value.name.trim() || !value.company.trim() || !value.jobTitle.trim() || !value.phone.trim() || !value.volume) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified.
|
||||
const doSubmit = async () => {
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
|
||||
const token = await getAccessToken().catch(() => undefined);
|
||||
await submitLead({
|
||||
kind: "enterprise",
|
||||
source,
|
||||
payload: {
|
||||
name: value.name,
|
||||
company: value.company,
|
||||
jobTitle: value.jobTitle,
|
||||
workEmail: value.workEmail,
|
||||
phone: value.phone,
|
||||
volume: value.volume,
|
||||
// Hidden entry points imply enterprise intent — default to "yes".
|
||||
deployment: showDeployment ? value.deployment || "yes" : "yes",
|
||||
agentGoal: value.agentGoal,
|
||||
},
|
||||
token,
|
||||
});
|
||||
toast.success("Thanks - our team will reach out about enterprise deployment.");
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
|
||||
icon={ShieldCheck}
|
||||
eyebrow="Enterprise"
|
||||
title="Book a Strategy Call"
|
||||
description="SSO, on-prem, data residency, committed volume. Tell us about your environment."
|
||||
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
|
||||
trustLine={<FormTrustLine />}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<EnterpriseLeadFields
|
||||
idPrefix="ent"
|
||||
value={value}
|
||||
onChange={onFieldsChange}
|
||||
showDeployment={showDeployment}
|
||||
emailError={emailError}
|
||||
/>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
163
ui/src/components/lead-forms/HireExpertModal.tsx
Normal file
163
ui/src/components/lead-forms/HireExpertModal.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import { FormTrustLine } from "./FormTrustLine";
|
||||
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { PhoneField } from "./PhoneField";
|
||||
import { submitLead } from "./submitLead";
|
||||
|
||||
interface HireExpertModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
source: LeadSource;
|
||||
onOpenEnterprise: () => void;
|
||||
}
|
||||
|
||||
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
|
||||
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
|
||||
const [name, setName] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [agentGoal, setAgentGoal] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [volume, setVolume] = useState("");
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setName(""); setCompany(""); setJobTitle(""); setAgentGoal("");
|
||||
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
|
||||
};
|
||||
|
||||
// Required fields, independent of the anti-spam check (which is revealed only
|
||||
// after the first submit click — see handleSubmit).
|
||||
const baseValid =
|
||||
Boolean(name.trim()) &&
|
||||
Boolean(company.trim()) &&
|
||||
Boolean(jobTitle.trim()) &&
|
||||
Boolean(agentGoal.trim()) &&
|
||||
Boolean(phone.trim()) &&
|
||||
Boolean(volume);
|
||||
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
// Validate, then pop the anti-spam check on top of the modal.
|
||||
const handleSubmit = () => {
|
||||
if (!baseValid) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified.
|
||||
const doSubmit = async () => {
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Resolve the token best-effort; submission still succeeds via PostHog if it fails.
|
||||
const token = await getAccessToken().catch(() => undefined);
|
||||
await submitLead({
|
||||
kind: "hire_expert",
|
||||
source,
|
||||
payload: { name, company, jobTitle, agentGoal, phone, volume },
|
||||
token,
|
||||
});
|
||||
toast.success("Thanks - we'll reach out about building your agent.");
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
|
||||
icon={Sparkles}
|
||||
eyebrow="Done-for-you"
|
||||
title="Let us build your voice agent"
|
||||
description="Building good voice agents is nuanced. Tell us what you need and we'll take it end-to-end."
|
||||
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
|
||||
helper={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEnterprise}
|
||||
className="underline decoration-dashed underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Need enterprise deployment? (SSO, on-prem, data residency)
|
||||
</button>
|
||||
}
|
||||
trustLine={<FormTrustLine />}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-name">Name</Label>
|
||||
<Input id="hire-name" placeholder="Your full name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-company">Company name</Label>
|
||||
<Input id="hire-company" placeholder="Acme Inc." value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-title">Job title</Label>
|
||||
<Input id="hire-title" placeholder="VP Operations" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-goal">What do you want the voice agent to do?</Label>
|
||||
<Textarea
|
||||
id="hire-goal"
|
||||
value={agentGoal}
|
||||
onChange={(e) => setAgentGoal(e.target.value)}
|
||||
placeholder="Use case, target outcomes, any remarks…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-phone">Phone</Label>
|
||||
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-volume">Expected monthly call volume</Label>
|
||||
<Select value={volume} onValueChange={setVolume}>
|
||||
<SelectTrigger id="hire-volume"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HIRE_VOLUME_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
278
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
278
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import {
|
||||
EMPTY_ENTERPRISE_FIELDS,
|
||||
type EnterpriseFieldsValue,
|
||||
EnterpriseLeadFields,
|
||||
} from "./EnterpriseLeadFields";
|
||||
import { validateWorkEmail } from "./isPersonalEmail";
|
||||
import {
|
||||
ONBOARDING_ONPREM_OPTIONS,
|
||||
ONBOARDING_ONPREM_PERSONAS,
|
||||
ONBOARDING_PERSONA_OPTIONS,
|
||||
ONBOARDING_USAGE_CONTEXT_OPTIONS,
|
||||
} from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { submitLead } from "./submitLead";
|
||||
import { type OnboardingAnswers, skipOnboarding, submitOnboarding } from "./submitOnboarding";
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean;
|
||||
// Called after a tracked outcome (submit or skip) to dismiss the gate and
|
||||
// stamp the matching server-side flag (completed_at vs skipped).
|
||||
onComplete: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [usageContext, setUsageContext] = useState("");
|
||||
const [persona, setPersona] = useState("");
|
||||
const [onPremNeed, setOnPremNeed] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Inline on-prem expansion: the FULL enterprise form, submitted through the
|
||||
// same /api/v1/leads/enterprise path as the standalone Enterprise modal.
|
||||
const [onPremExpanded, setOnPremExpanded] = useState(false);
|
||||
const [ef, setEf] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
|
||||
const [efEmailError, setEfEmailError] = useState<string | null>(null);
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
|
||||
const showOnPrem = ONBOARDING_ONPREM_PERSONAS.includes(persona);
|
||||
const showManagedNote = showOnPrem && onPremNeed === "yes";
|
||||
const wantsOnPrem = showManagedNote && onPremExpanded;
|
||||
|
||||
const answers = (): OnboardingAnswers => ({
|
||||
companyName: companyName.trim() || undefined,
|
||||
usageContext: usageContext || undefined,
|
||||
persona: persona || undefined,
|
||||
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
|
||||
});
|
||||
|
||||
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
|
||||
setEf((v) => ({ ...v, ...patch }));
|
||||
if ("workEmail" in patch) setEfEmailError(null);
|
||||
};
|
||||
|
||||
const expandOnPrem = () => {
|
||||
setOnPremExpanded(true);
|
||||
// Seed company from what we already collected (don't clobber edits).
|
||||
setEf((v) => (v.company ? v : { ...v, company: companyName.trim() }));
|
||||
};
|
||||
|
||||
const collapseOnPrem = () => {
|
||||
setOnPremExpanded(false);
|
||||
setCaptchaActive(false);
|
||||
setEfEmailError(null);
|
||||
};
|
||||
|
||||
// Best-effort persistence must never trap the user behind this hard gate.
|
||||
// Dismiss immediately, then fire the token + network work in the background.
|
||||
const finish = (skipped: boolean, withEnterprise: boolean) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
const data = answers();
|
||||
const efSnapshot = withEnterprise ? { ...ef } : null;
|
||||
onComplete(skipped);
|
||||
void (async () => {
|
||||
const token = await getAccessToken().catch(() => undefined);
|
||||
try {
|
||||
if (skipped) await skipOnboarding(data, token);
|
||||
else await submitOnboarding(data, token);
|
||||
// Two distinct submissions on success: onboarding answers above, and the
|
||||
// enterprise on-prem lead here (same endpoint as the standalone form).
|
||||
if (efSnapshot) {
|
||||
await submitLead({
|
||||
kind: "enterprise",
|
||||
source: "onboarding",
|
||||
payload: {
|
||||
name: efSnapshot.name,
|
||||
company: efSnapshot.company || companyName.trim() || undefined,
|
||||
jobTitle: efSnapshot.jobTitle,
|
||||
workEmail: efSnapshot.workEmail,
|
||||
phone: efSnapshot.phone,
|
||||
volume: efSnapshot.volume,
|
||||
// They already answered on-prem = yes; deployment intent is implied.
|
||||
deployment: "yes",
|
||||
agentGoal: efSnapshot.agentGoal,
|
||||
},
|
||||
token,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Swallowed — the user is already in the product; network calls are
|
||||
// bounded by a timeout in onboardingServiceClient.
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Onboarding answers are all optional, so we only gate on the enterprise
|
||||
// fields when the user has actually engaged the on-prem section.
|
||||
if (wantsOnPrem) {
|
||||
const err = validateWorkEmail(ef.workEmail);
|
||||
if (err) { setEfEmailError(err); return; }
|
||||
if (!ef.name.trim() || !ef.company.trim() || !ef.jobTitle.trim() || !ef.phone.trim() || !ef.volume) {
|
||||
toast.error("Please complete the on-prem details below, or remove that section.");
|
||||
return;
|
||||
}
|
||||
// Pop the anti-spam check on top of the modal before sending the lead.
|
||||
setCaptchaActive(true);
|
||||
return;
|
||||
}
|
||||
finish(false, false);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified (on-prem path).
|
||||
const submitWithOnPrem = () => {
|
||||
setCaptchaActive(false);
|
||||
finish(false, true);
|
||||
};
|
||||
|
||||
const handleSkip = () => finish(true, false);
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
// Hard gate: no outside/escape close, hide the built-in ×. The only exits
|
||||
// are Skip or Get started.
|
||||
onOpenChange={() => {}}
|
||||
contentProps={{
|
||||
className: "[&>button]:hidden",
|
||||
onEscapeKeyDown: (e) => e.preventDefault(),
|
||||
onPointerDownOutside: (e) => e.preventDefault(),
|
||||
onInteractOutside: (e) => e.preventDefault(),
|
||||
}}
|
||||
icon={Rocket}
|
||||
eyebrow="Welcome"
|
||||
title="Welcome to Dograh"
|
||||
description="A few quick questions so we can tailor your experience. Takes ~20 seconds."
|
||||
primary={{ label: "Get started", onClick: handleSubmit, disabled: submitting }}
|
||||
secondary={{ label: "Skip for now", onClick: handleSkip, disabled: submitting }}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={submitWithOnPrem} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-company">
|
||||
Company name <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input id="ob-company" placeholder="Acme Inc." value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-usage">Where do you plan to use this?</Label>
|
||||
<Select value={usageContext} onValueChange={setUsageContext}>
|
||||
<SelectTrigger id="ob-usage"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_USAGE_CONTEXT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-persona">What best describes you?</Label>
|
||||
<Select
|
||||
value={persona}
|
||||
onValueChange={(v) => {
|
||||
setPersona(v);
|
||||
// Leaving the on-prem-eligible persona resets the conditional answer
|
||||
// and any inline enterprise lead.
|
||||
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) {
|
||||
setOnPremNeed("");
|
||||
collapseOnPrem();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ob-persona"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_PERSONA_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showOnPrem && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-onprem">Do you need on-prem deployment for compliance & 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
77
ui/src/components/lead-forms/leadFieldOptions.ts
Normal file
77
ui/src/components/lead-forms/leadFieldOptions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Shared dropdown options + lead source/kind types for the lead-gen forms.
|
||||
|
||||
export type LeadSource =
|
||||
| "sidebar"
|
||||
| "billing_card"
|
||||
| "billing_custom_pricing"
|
||||
| "builder_nudge"
|
||||
| "hire_expert"
|
||||
| "onboarding"
|
||||
| "pricing_custom_volume"
|
||||
| "landing_contact"
|
||||
| "auth_page";
|
||||
|
||||
export type LeadKind = "hire_expert" | "enterprise";
|
||||
|
||||
// Monthly call-volume buckets. Values MUST match the backend qualifier enum
|
||||
// (user_onboarding flows): "0-5k" | "5k-100k" | "100k+" | "not-sure".
|
||||
export const VOLUME_OPTIONS = [
|
||||
{ value: "0-5k", label: "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: where do you plan to use this (highest-signal question — keep exact).
|
||||
export const ONBOARDING_USAGE_CONTEXT_OPTIONS = [
|
||||
{ value: "for_my_clients", label: "For my clients" },
|
||||
{ value: "for_my_company", label: "For my company" },
|
||||
{ value: "personal", label: "Personal use case" },
|
||||
{ value: "exploring", label: "Just exploring" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: what best describes you.
|
||||
export const ONBOARDING_PERSONA_OPTIONS = [
|
||||
{ value: "enterprise_midmarket", label: "Enterprise / Mid-Market" },
|
||||
{ value: "agency", label: "Agency / consultancy building for clients" },
|
||||
{ value: "local_business", label: "Local business" },
|
||||
{ value: "startup", label: "Startup" },
|
||||
{ value: "solo", label: "Solo founder / builder" },
|
||||
] as const;
|
||||
|
||||
// Persona values that unlock the on-prem conditional question.
|
||||
export const ONBOARDING_ONPREM_PERSONAS: readonly string[] = ["enterprise_midmarket"];
|
||||
|
||||
// Onboarding: on-prem deployment need (conditional on Enterprise/Mid-Market).
|
||||
export const ONBOARDING_ONPREM_OPTIONS = [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
{ value: "not_sure", label: "Not sure" },
|
||||
] as const;
|
||||
81
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
81
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Thin client for the SEPARATE user_onboarding service (its own base URL).
|
||||
// Not part of the generated Dograh SDK — a different host. Sends the SAME Dograh
|
||||
// Bearer token the browser already holds. All calls are BEST-EFFORT: failures are
|
||||
// swallowed so a down/erroring service never blocks the user from the product.
|
||||
|
||||
// Base URL of the user_onboarding service; unset → calls are skipped (no-op).
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL;
|
||||
|
||||
// Bound every call so a slow/hung service can never freeze the UI (the onboarding
|
||||
// modal used to await this with no timeout). Best-effort: failures are surfaced
|
||||
// via console.error (captured as Sentry breadcrumbs) but never thrown.
|
||||
const TIMEOUT_MS = 6000;
|
||||
|
||||
// POST a JSON body to the onboarding service. The Dograh auth token is attached
|
||||
// when supplied; public endpoints (contact-sales) are called without one.
|
||||
async function post(path: string, token: string | undefined, body: unknown): Promise<void> {
|
||||
if (!BASE_URL) {
|
||||
// Misconfig would otherwise be invisible: a submit dropped on the floor
|
||||
// while PostHog still records the event as "submitted".
|
||||
console.error(
|
||||
`[onboarding] NEXT_PUBLIC_ONBOARDING_API_URL is unset — "${path}" not persisted to the onboarding service`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
// fetch does not reject on 4xx/5xx — check explicitly so dropped leads are
|
||||
// at least observable.
|
||||
if (!res.ok) {
|
||||
console.error(`[onboarding] POST ${path} failed with HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error, or the timeout aborted the request. Never block the user.
|
||||
console.error(`[onboarding] POST ${path} did not complete:`, err);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Map a lead kind to its endpoint path on the onboarding service.
|
||||
const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
|
||||
hire_expert: "/api/v1/leads/hire-expert",
|
||||
enterprise: "/api/v1/leads/enterprise",
|
||||
};
|
||||
|
||||
// Persist a lead submission (hire-expert / enterprise).
|
||||
export async function postLeadToService(
|
||||
kind: "hire_expert" | "enterprise",
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await post(LEAD_PATH[kind], token, body);
|
||||
}
|
||||
|
||||
// Persist a logged-out enterprise lead via the PUBLIC contact-sales endpoint
|
||||
// (no auth; the service applies a honeypot + per-IP rate limit). It runs the
|
||||
// same unified enterprise flow as the authenticated /leads/enterprise path.
|
||||
export async function postContactSalesToService(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await post("/api/v1/contact-sales", undefined, body);
|
||||
}
|
||||
|
||||
// Persist an onboarding submission (or skip — body carries `skipped`).
|
||||
export async function postOnboardingToService(
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await post("/api/v1/onboarding", token, body);
|
||||
}
|
||||
41
ui/src/components/lead-forms/submitLead.ts
Normal file
41
ui/src/components/lead-forms/submitLead.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Single submission seam for all lead forms.
|
||||
// Fires a PostHog capture, and (when a token is supplied) POSTs to the separate
|
||||
// user_onboarding service. The service call is best-effort — PostHog is the
|
||||
// durable record and the user is never blocked if the service is down.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
import type { LeadKind, LeadSource } from "./leadFieldOptions";
|
||||
import { postContactSalesToService, postLeadToService } from "./onboardingServiceClient";
|
||||
|
||||
const SUBMIT_EVENT: Record<LeadKind, string> = {
|
||||
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
|
||||
enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED,
|
||||
};
|
||||
|
||||
export interface SubmitLeadArgs {
|
||||
kind: LeadKind;
|
||||
source: LeadSource;
|
||||
// Field values, already validated by the caller. Non-sensitive lead data.
|
||||
payload: Record<string, unknown>;
|
||||
// Dograh auth token; when present the lead is also persisted to the service.
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export async function submitLead({ kind, source, payload, token }: SubmitLeadArgs): Promise<void> {
|
||||
// PostHog capture — the durable record, always fired.
|
||||
posthog.capture(SUBMIT_EVENT[kind], { source, ...payload });
|
||||
|
||||
// Persist to the separate user_onboarding service (best-effort).
|
||||
if (token) {
|
||||
await postLeadToService(kind, token, { source, ...payload });
|
||||
} else if (kind === "enterprise") {
|
||||
// Logged-out visitor (e.g. the auth-page Enterprise Enquiry CTA): the
|
||||
// public contact-sales endpoint persists the lead and runs the same
|
||||
// unified enterprise flow server-side, keyed off `workEmail` (which the
|
||||
// form requires when unauthenticated).
|
||||
await postContactSalesToService({ source, ...payload });
|
||||
}
|
||||
}
|
||||
34
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
34
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Submission seam for the post-signup onboarding form.
|
||||
// Fires a PostHog capture (submit or skip) AND, when a token is supplied, POSTs
|
||||
// the answers to the separate user_onboarding service (best-effort). The "show
|
||||
// once per user" flag itself is stamped on the server-backed onboarding state
|
||||
// by the caller (LeadFormsContext.completeOnboarding → OnboardingContext), not here.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
import { postOnboardingToService } from "./onboardingServiceClient";
|
||||
|
||||
export interface OnboardingAnswers {
|
||||
companyName?: string;
|
||||
usageContext?: string;
|
||||
persona?: string;
|
||||
// Only present when persona unlocks the on-prem question.
|
||||
onPremNeed?: string;
|
||||
}
|
||||
|
||||
export async function submitOnboarding(answers: OnboardingAnswers, token?: string): Promise<void> {
|
||||
posthog.capture(PostHogEvent.ONBOARDING_SUBMITTED, { ...answers });
|
||||
if (token) {
|
||||
await postOnboardingToService(token, { source: "onboarding", ...answers, skipped: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipOnboarding(answers: OnboardingAnswers, token?: string): Promise<void> {
|
||||
// Skipping is itself signal — capture whatever was filled before the skip.
|
||||
posthog.capture(PostHogEvent.ONBOARDING_SKIPPED, { ...answers });
|
||||
if (token) {
|
||||
await postOnboardingToService(token, { source: "onboarding", ...answers, skipped: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -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