diff --git a/.gitignore b/.gitignore index 8fa4c055..891e0344 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node_modules/ # Superpowers brainstorm mockups (local only) .superpowers/ +.gstack/ diff --git a/api/routes/user.py b/api/routes/user.py index 20d0a41e..a3c25da2 100644 --- a/api/routes/user.py +++ b/api/routes/user.py @@ -85,6 +85,9 @@ class UserConfigurationRequestResponseSchema(BaseModel): test_phone_number: str | None = None timezone: str | None = None organization_pricing: dict[str, Union[float, str, bool]] | None = None + # Post-signup onboarding gate (see UserConfiguration). Set once on submit/skip. + onboarding_completed_at: datetime | None = None + onboarding_skipped: bool | None = None @router.get("/configurations/user") diff --git a/api/schemas/user_configuration.py b/api/schemas/user_configuration.py index 2e62396a..15608760 100644 --- a/api/schemas/user_configuration.py +++ b/api/schemas/user_configuration.py @@ -21,6 +21,10 @@ class UserConfiguration(BaseModel): test_phone_number: str | None = None timezone: str | None = None last_validated_at: datetime | None = None + # Post-signup onboarding gate: set once the user submits or skips the + # onboarding form, so it shows only once per user (server-side, cross-device). + onboarding_completed_at: datetime | None = None + onboarding_skipped: bool = False @model_validator(mode="before") @classmethod diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index 877cad97..a16950c1 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -141,6 +141,10 @@ def mask_user_config(config: UserConfiguration) -> Dict[str, Any]: "is_realtime": config.is_realtime, "test_phone_number": config.test_phone_number, "timezone": config.timezone, + # Onboarding gate flags (not secrets) — surfaced so the UI can decide + # whether to show the post-signup onboarding form on boot. + "onboarding_completed_at": config.onboarding_completed_at, + "onboarding_skipped": config.onboarding_skipped, } diff --git a/api/services/configuration/merge.py b/api/services/configuration/merge.py index f421648f..094800d3 100644 --- a/api/services/configuration/merge.py +++ b/api/services/configuration/merge.py @@ -113,6 +113,13 @@ def merge_user_configurations( if "timezone" in incoming_partial: merged["timezone"] = incoming_partial["timezone"] + # Onboarding gate flags — overwrite only when supplied (set once on submit/skip). + if "onboarding_completed_at" in incoming_partial: + merged["onboarding_completed_at"] = incoming_partial["onboarding_completed_at"] + + if "onboarding_skipped" in incoming_partial: + merged["onboarding_skipped"] = incoming_partial["onboarding_skipped"] + return UserConfiguration.model_validate(merged) diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index f05c5f71..7f831cf9 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -257,12 +257,12 @@ SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config( ) AZURE_SPEECH_PROVIDER_MODEL_CONFIG = provider_model_config( "Azure Speech Services", - description="Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.", + description="Azure Cognitive Services Speech - TTS and STT via the Azure Speech SDK.", provider_docs_url="https://learn.microsoft.com/en-us/azure/ai-services/speech-service/", ) AZURE_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config( "Azure OpenAI Realtime", - description="Azure OpenAI Realtime API — low-latency speech-to-speech conversations.", + description="Azure OpenAI Realtime API - low-latency speech-to-speech conversations.", provider_docs_url="https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/realtime-audio-quickstart", ) @@ -360,7 +360,7 @@ class GoogleVertexLLMConfiguration(BaseLLMConfiguration): api_key: str | list[str] | None = Field( default=None, description=( - "Not used for Vertex AI — authentication is via the service account " + "Not used for Vertex AI - authentication is via the service account " "in `credentials` (or ADC). Leave blank." ), ) @@ -425,7 +425,7 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration): provider: Literal[ServiceProviders.AWS_BEDROCK] = ServiceProviders.AWS_BEDROCK model: str = Field( default="us.amazon.nova-pro-v1:0", - description="Bedrock model ID — include the region inference-profile prefix (e.g. 'us.').", + description="Bedrock model ID - include the region inference-profile prefix (e.g. 'us.').", json_schema_extra={"examples": AWS_BEDROCK_MODELS, "allow_custom_input": True}, ) aws_access_key: str = Field( @@ -442,7 +442,7 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration): ) api_key: str | list[str] | None = Field( default=None, - description="Not used for Bedrock — authentication is via the AWS credentials above. Leave blank.", + description="Not used for Bedrock - authentication is via the AWS credentials above. Leave blank.", ) @@ -682,7 +682,7 @@ class GoogleVertexRealtimeLLMConfiguration(BaseLLMConfiguration): api_key: str | list[str] | None = Field( default=None, description=( - "Not used for Vertex AI — authentication is via the service account " + "Not used for Vertex AI - authentication is via the service account " "in `credentials` (or ADC). Leave blank." ), ) diff --git a/api/services/workflow/dto.py b/api/services/workflow/dto.py index 60aad758..e63340a2 100644 --- a/api/services/workflow/dto.py +++ b/api/services/workflow/dto.py @@ -176,7 +176,7 @@ class _ToolDocumentRefsMixin(BaseModel): @node_spec( name="startCall", display_name="Start Call", - description="Entry point of the workflow — plays a greeting and opens the conversation.", + description="Entry point of the workflow - plays a greeting and opens the conversation.", llm_hint=( "The entry point of every workflow (exactly one required). Plays an " "optional greeting, can fetch context from an external API before the " @@ -344,7 +344,7 @@ class StartCallNodeData( @node_spec( name="agentNode", display_name="Agent Node", - description="Conversational step — the LLM runs one focused exchange.", + description="Conversational step - the LLM runs one focused exchange.", llm_hint=( "Mid-call step executed by the LLM. Most workflows are a chain of agent " "nodes connected by edges that describe transition conditions. Each agent " @@ -613,9 +613,9 @@ class GlobalNodeData(BaseNodeData, _PromptedNodeDataMixin): "description": ( "Path segment that uniquely identifies " "this trigger. Used in both URLs:\n" - " • Production: `/api/v1/public/agent/` — executes " + " • Production: `/api/v1/public/agent/` - executes " "the published agent.\n" - " • Test: `/api/v1/public/agent/test/` — executes " + " • Test: `/api/v1/public/agent/test/` - executes " "the latest draft.\n" "Can be customized to a descriptive value up to 36 characters " "using letters, numbers, hyphens, or underscores." @@ -708,7 +708,7 @@ class TriggerNodeData(BaseNodeData): "display_name": "Payload Template", "description": ( "JSON body of the request. Values are Jinja-rendered against the " - "run context — `{{workflow_run_id}}`, `{{gathered_context.foo}}`, " + "run context - `{{workflow_run_id}}`, `{{gathered_context.foo}}`, " "`{{annotations.qa_xxx}}`, etc." ), "ui_type": PropertyType.json, diff --git a/api/services/workflow/workflow_graph.py b/api/services/workflow/workflow_graph.py index a6268159..7e03d499 100644 --- a/api/services/workflow/workflow_graph.py +++ b/api/services/workflow/workflow_graph.py @@ -229,7 +229,7 @@ class WorkflowGraph: kind=ItemKind.workflow, id=None, field=None, - message="Workflow has no start node — exactly one is required", + message="Workflow has no start node - exactly one is required", ) ) elif len(start_nodes) > 1: @@ -239,7 +239,7 @@ class WorkflowGraph: id=None, field=None, message=( - f"Workflow has {len(start_nodes)} start nodes — " + f"Workflow has {len(start_nodes)} start nodes - " f"exactly one is required" ), ) diff --git a/ui/.env.example b/ui/.env.example index 741790e5..08da3a1c 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -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 diff --git a/ui/package-lock.json b/ui/package-lock.json index 9923bb0f..73681a43 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/public/dograh-logo-inverse.png b/ui/public/dograh-logo-inverse.png new file mode 100755 index 00000000..8415e795 Binary files /dev/null and b/ui/public/dograh-logo-inverse.png differ diff --git a/ui/public/dograh-logo.png b/ui/public/dograh-logo.png new file mode 100755 index 00000000..40165da8 Binary files /dev/null and b/ui/public/dograh-logo.png differ diff --git a/ui/public/dograh-mark.png b/ui/public/dograh-mark.png new file mode 100755 index 00000000..328f51f1 Binary files /dev/null and b/ui/public/dograh-mark.png differ diff --git a/ui/src/app/api-keys/page.tsx b/ui/src/app/api-keys/page.tsx index 2558889b..05aa7c33 100644 --- a/ui/src/app/api-keys/page.tsx +++ b/ui/src/app/api-keys/page.tsx @@ -304,7 +304,7 @@ export default function APIKeysPage() { // Don't render content until auth is loaded if (loading || !user) { return ( -
+
@@ -319,7 +319,7 @@ export default function APIKeysPage() { const showServiceKeyArchiveControls = !isOSS; return ( -
+
diff --git a/ui/src/app/auth/login/page.tsx b/ui/src/app/auth/login/page.tsx index 39c6ceb1..a1fef886 100644 --- a/ui/src/app/auth/login/page.tsx +++ b/ui/src/app/auth/login/page.tsx @@ -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 ( -
- - - Sign in - Enter your email and password to continue - - -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
- -
-

- Don't have an account?{" "} - - Sign up - -

-
-
-
+ }> +
+

Sign in

+

+ Enter your email and password to continue +

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+ +

+ Don't have an account?{" "} + + Sign up + +

+
); } diff --git a/ui/src/app/auth/signup/page.tsx b/ui/src/app/auth/signup/page.tsx index d9d98c18..ad43ec83 100644 --- a/ui/src/app/auth/signup/page.tsx +++ b/ui/src/app/auth/signup/page.tsx @@ -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 ( -
- - - Create an account - Enter your details to get started - - -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - minLength={8} - /> -
-
- - setConfirmPassword(e.target.value)} - required - minLength={8} - /> -
- -
-

- Already have an account?{" "} - - Sign in - -

-
-
-
+ }> +
+

Create an account

+

Enter your details to get started

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+ +

+ Already have an account?{" "} + + Sign in + +

+
); } diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index aab2adcf..c6499b4c 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -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' : ''} )) )} diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 087479fc..e4d83dca 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -184,4 +184,27 @@ @media (prefers-reduced-motion: reduce) { .auth-waveform span { animation: none; } } + + /* Atmospheric app background — premium dark depth instead of flat black. + Decorative only; applied to content areas via the .app-surface class so it + cascades to every page without per-page edits. Light mode stays clean. */ + .app-surface { + background-color: var(--background); + } + .dark .app-surface { + background-image: + radial-gradient(55rem 32rem at 100% 100%, color-mix(in oklch, var(--cta) 13%, transparent), transparent 55%), + radial-gradient(48rem 30rem at 0% 100%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 52%), + linear-gradient(0deg, color-mix(in oklch, var(--foreground) 4%, transparent), transparent 38%); + background-repeat: no-repeat; + } + + /* Faint warm wash at the bottom of the sidebar for subtle depth (dark only). */ + .dark .app-sidebar-surface { + background-image: linear-gradient( + 0deg, + color-mix(in oklch, var(--cta) 8%, transparent), + transparent 32% + ); + } } diff --git a/ui/src/app/handler/[...stack]/AuthShell.tsx b/ui/src/app/handler/[...stack]/AuthShell.tsx deleted file mode 100644 index b438ead1..00000000 --- a/ui/src/app/handler/[...stack]/AuthShell.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// Dark two-column auth shell. LEFT (lg+ only): a brand/value panel with a -// CSS-only audio-waveform motif, proof points, and a Bland-style enterprise CTA -// block at the bottom (passed in as `enterpriseSlot`). RIGHT: a centered -// zinc-900 card that wraps the Stack Auth form (`children`). Mobile collapses to -// the single card column. Palette is the app's blacks/greys with one warm CTA -// accent on the waveform + focus. - -import type { ReactNode } from "react"; - -const PROOF_POINTS = [ - "Open source", - "7+ telephony providers", - "Open architecture", -]; - -export function AuthShell({ - children, - enterpriseSlot, -}: { - children: ReactNode; - enterpriseSlot?: ReactNode; -}) { - return ( -
- {/* Brand / value panel — hidden on mobile */} - - - {/* Form column */} -
-
- {/* Mobile-only wordmark (brand panel is hidden) */} -
-
- -
- Dograh -
- {children} -
-
-
- ); -} diff --git a/ui/src/app/handler/[...stack]/BackButton.tsx b/ui/src/app/handler/[...stack]/BackButton.tsx index d36a4a03..5827c8ef 100644 --- a/ui/src/app/handler/[...stack]/BackButton.tsx +++ b/ui/src/app/handler/[...stack]/BackButton.tsx @@ -8,11 +8,22 @@ import { Button } from "@/components/ui/button"; export function BackButton() { const router = useRouter(); + // On a direct load (e.g. an OAuth redirect or a deep link to /handler/sign-in) + // there's no in-app history, so router.back() would bounce the user off-app. + // Fall back to the home route in that case. + const handleBack = () => { + if (typeof window !== "undefined" && window.history.length > 1) { + router.back(); + } else { + router.push("/"); + } + }; + return ( @@ -436,7 +436,7 @@ export function EmbedDialog({

Integration Instructions

  • • Add the embed script tag to your page (see below).
  • -
  • • The widget renders no UI — render your own buttons.
  • +
  • • The widget renders no UI - render your own buttons.
  • • Call window.DograhWidget.start() to begin a call.
  • • Call window.DograhWidget.end() to end it.
  • • Subscribe to onCallStart, onCallEnd, onStatusChange, onError to drive your UI.
  • @@ -445,12 +445,12 @@ export function EmbedDialog({
-

Example — track status in your own state

+

Example - track status in your own state

Mirror the call status into a variable you control, then render whatever UI you like from it. The status values are idle, connecting, connected, failed.

-                                                    {`// Vanilla JS — keep your own state, render however you want
+                                                    {`// Vanilla JS - keep your own state, render however you want
 let callStatus = 'idle';
 
 window.DograhWidget?.onStatusChange((status) => {
diff --git a/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx b/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx
index 5e737ae2..1d03c6d6 100644
--- a/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx
+++ b/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx
@@ -268,7 +268,7 @@ export const PhoneCallDialog = ({
                             {telephonyConfigs.map((config) => (
                                 
                                     {config.name} ({config.provider})
-                                    {config.is_default_outbound ? " — default" : ""}
+                                    {config.is_default_outbound ? " - default" : ""}
                                 
                             ))}
                         
@@ -294,8 +294,8 @@ export const PhoneCallDialog = ({
                             
                                 {fromPhoneNumbers.map((phone) => (
                                     
-                                        {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" : ""}
                                     
                                 ))}
                             
diff --git a/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx b/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx
index 645f8fb7..582c6d8f 100644
--- a/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx
+++ b/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx
@@ -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}`;
diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx
index 30eec85e..01fa7b41 100644
--- a/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx
+++ b/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx
@@ -305,7 +305,7 @@ export const WorkflowEditorHeader = ({
                     
- Viewing {activeVersionLabel} — Read only + Viewing {activeVersionLabel} - Read only
)} diff --git a/ui/src/app/workflow/[workflowId]/settings/page.tsx b/ui/src/app/workflow/[workflowId]/settings/page.tsx index b1bff348..d12ed8aa 100644 --- a/ui/src/app/workflow/[workflowId]/settings/page.tsx +++ b/ui/src/app/workflow/[workflowId]/settings/page.tsx @@ -583,7 +583,7 @@ function GeneralSection({

Context Compaction

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

diff --git a/ui/src/app/workflow/create/page.tsx b/ui/src/app/workflow/create/page.tsx index 1a11efff..73700745 100644 --- a/ui/src/app/workflow/create/page.tsx +++ b/ui/src/app/workflow/create/page.tsx @@ -80,7 +80,7 @@ export default function CreateWorkflowPage() { }; return ( -
+

Create Voice Agent

diff --git a/ui/src/components/BrandLogo.tsx b/ui/src/components/BrandLogo.tsx new file mode 100644 index 00000000..745f8155 --- /dev/null +++ b/ui/src/components/BrandLogo.tsx @@ -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 + Dograh + ); + } + if (inverse) { + return ( + // eslint-disable-next-line @next/next/no-img-element + Dograh + ); + } + return ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + Dograh + {/* eslint-disable-next-line @next/next/no-img-element */} + Dograh + + ); +} diff --git a/ui/src/app/handler/[...stack]/AuthEnterpriseCTA.tsx b/ui/src/components/auth/AuthEnterpriseCTA.tsx similarity index 87% rename from ui/src/app/handler/[...stack]/AuthEnterpriseCTA.tsx rename to ui/src/components/auth/AuthEnterpriseCTA.tsx index 3efeb0e9..3f507cf2 100644 --- a/ui/src/app/handler/[...stack]/AuthEnterpriseCTA.tsx +++ b/ui/src/components/auth/AuthEnterpriseCTA.tsx @@ -2,7 +2,8 @@ // Bland-style enterprise call-to-action rendered inside the auth brand panel. // Links out to the main marketing site's enterprise intake form rather than the -// in-app modal, since the visitor is not yet authenticated here. +// in-app modal, since the visitor is not yet authenticated here. Shared by the +// Stack Auth handler and the local/OSS auth pages. import { Button } from "@/components/ui/button"; @@ -18,7 +19,7 @@ export function AuthEnterpriseCTA() { variant="outline" className="w-full border-white/20 bg-white/5 text-zinc-100 hover:bg-white/10 hover:text-white" > - Talk to our team + Enterprise Enquiry ); diff --git a/ui/src/components/auth/AuthShell.tsx b/ui/src/components/auth/AuthShell.tsx new file mode 100644 index 00000000..5c472691 --- /dev/null +++ b/ui/src/components/auth/AuthShell.tsx @@ -0,0 +1,86 @@ +// Shared dark two-column auth shell, used by BOTH the Stack Auth handler +// (/handler/[...stack], cloud) and the local/OSS auth pages (/auth/login, +// /auth/signup). LEFT: a centered card that wraps the auth form (`children`). +// RIGHT (lg+ only): a brand/value panel with the Dograh logo, proof points, and +// a Bland-style enterprise CTA block at the bottom (passed in as `enterpriseSlot`). +// Mobile collapses to the single card column. The form column scrolls and stays +// centered so tall (sign-up) forms never clip on short viewports. Palette is the +// app's blacks/greys with one warm CTA accent. + +import type { ReactNode } from "react"; + +import { BrandLogo } from "@/components/BrandLogo"; + +const HIGHLIGHTS = [ + "Speech-to-speech", + "MCP-native", + "BYOK - any model", +]; + +export function AuthShell({ + children, + enterpriseSlot, +}: { + children: ReactNode; + enterpriseSlot?: ReactNode; +}) { + return ( +
+ {/* Form column (LEFT) — scrolls and stays centered so tall forms never clip. */} +
+
+
+ {/* Mobile-only wordmark (brand panel is hidden) */} +
+ +
+ {children} +
+
+
+ + {/* Brand / value panel (RIGHT) — hidden on mobile */} + +
+ ); +} diff --git a/ui/src/components/billing/BuyCreditsControl.tsx b/ui/src/components/billing/BuyCreditsControl.tsx index 608f58cf..2c6b15be 100644 --- a/ui/src/components/billing/BuyCreditsControl.tsx +++ b/ui/src/components/billing/BuyCreditsControl.tsx @@ -1,30 +1,40 @@ "use client"; -// Compact self-serve "Buy Credits" control for the billing card. Preset amount -// chips plus a custom amount (min $5) feed the Razorpay seam in -// @/lib/billing/topup. Analytics: chip selection and the buy click are captured -// for funnel analysis. The seam currently throws "not wired yet"; we surface -// that as a calm inline note rather than an error toast. +// Compact self-serve "Buy Credits" control. The amount chips + custom input live +// in a popover that only opens when the user clicks "Buy Credits" — so the +// billing card stays clean until they intend to top up. Presets + custom (min $5) +// feed the Razorpay seam in @/lib/billing/topup, which currently throws "not +// wired yet"; we surface that as a calm inline note rather than an error toast. import posthog from "posthog-js"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { PostHogEvent } from "@/constants/posthog-events"; -import { MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup"; +import { MAX_TOPUP_USD, MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup"; import { cn } from "@/lib/utils"; -export function BuyCreditsControl() { +// Round to whole cents and reject non-positive / non-finite input so a typo +// (e.g. "5.999", "-1", "abc") can't produce a NaN or fractional-cent order. +const parseAmount = (raw: string): number | null => { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.round(n * 100) / 100; +}; + +export function BuyCreditsControl({ className }: { className?: string }) { + const [open, setOpen] = useState(false); const [selected, setSelected] = useState(null); const [custom, setCustom] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // The effective amount: a parsed custom value takes precedence when present. - const customAmount = custom.trim() ? Number(custom) : null; + const customAmount = custom.trim() ? parseAmount(custom) : null; const amount = customAmount ?? selected; - const valid = amount != null && Number.isFinite(amount) && amount >= MIN_TOPUP_USD; + const valid = amount != null && amount >= MIN_TOPUP_USD && amount <= MAX_TOPUP_USD; const selectPreset = (value: number) => { setSelected(value); @@ -37,8 +47,8 @@ export function BuyCreditsControl() { setCustom(raw); setSelected(null); setError(null); - const parsed = Number(raw); - if (raw.trim() && Number.isFinite(parsed) && parsed >= MIN_TOPUP_USD) { + const parsed = parseAmount(raw); + if (parsed != null && parsed >= MIN_TOPUP_USD && parsed <= MAX_TOPUP_USD) { posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: parsed }); } }; @@ -59,52 +69,66 @@ export function BuyCreditsControl() { }; return ( -
-
- {TOPUP_PRESETS.map((value) => ( - - ))} -
- - $ - - onCustomChange(e.target.value)} - placeholder="Custom" - aria-label={`Custom amount (min $${MIN_TOPUP_USD})`} - className="h-9 w-28 pl-5" - /> + + + + + +
+

Top up credits

+

Pick an amount (min ${MIN_TOPUP_USD}).

-
- {error ? ( -

{error}

- ) : ( -

Minimum ${MIN_TOPUP_USD}.

- )} +
+ {TOPUP_PRESETS.map((value) => ( + + ))} +
+ + $ + + onCustomChange(e.target.value)} + placeholder="Custom" + aria-label={`Custom amount (min $${MIN_TOPUP_USD})`} + className="h-9 w-24 pl-5" + /> +
+
- -
+ {error &&

{error}

} + + + + ); } diff --git a/ui/src/components/billing/DograhCreditsCard.tsx b/ui/src/components/billing/DograhCreditsCard.tsx index f94f74ef..cdaf0419 100644 --- a/ui/src/components/billing/DograhCreditsCard.tsx +++ b/ui/src/components/billing/DograhCreditsCard.tsx @@ -24,7 +24,11 @@ export function DograhCreditsCard() { if (!auth.isAuthenticated) return; try { const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet(); - if (response.data) { + // The generated client resolves to { data, error } and does NOT throw on + // 4xx/5xx (see ui/AGENTS.md) — check error explicitly. + if (response.error) { + console.error("Failed to fetch MPS credits:", response.error); + } else if (response.data) { setMpsCredits(response.data); } } catch (error) { @@ -74,7 +78,7 @@ export function DograhCreditsCard() {
{mpsCredits.total_quota > 0 && ( - + )}
) : ( @@ -83,20 +87,23 @@ export function DograhCreditsCard() {

)} - {/* Footer CTAs — card ends with self-serve + done-for-you actions */} + {/* Footer CTAs — self-serve + done-for-you side by side, with the + custom-pricing link directly beneath. */}
-
-
-

Running low?

-

Top up instantly, or have us build it for you.

-
-
- - -
+
+

Running low?

+

Top up instantly, or have us build it for you.

+
+
+ +
diff --git a/ui/src/components/flow/ToolSelector.tsx b/ui/src/components/flow/ToolSelector.tsx index 86c300f2..00ffc458 100644 --- a/ui/src/components/flow/ToolSelector.tsx +++ b/ui/src/components/flow/ToolSelector.tsx @@ -280,7 +280,7 @@ export function ToolSelector({ )} {fns.length === 0 && !err && (

- No tools discovered — Refresh. + No tools discovered - Refresh.

)} {fns.map((fn) => { diff --git a/ui/src/components/layout/AppLayout.tsx b/ui/src/components/layout/AppLayout.tsx index 8b2b90c8..e2c67752 100644 --- a/ui/src/components/layout/AppLayout.tsx +++ b/ui/src/components/layout/AppLayout.tsx @@ -19,7 +19,7 @@ function AppHeader() { const { toggleSidebar } = useSidebar(); return ( -
+
+ +
+
+ ); +} diff --git a/ui/src/components/lead-forms/EnterpriseLeadFields.tsx b/ui/src/components/lead-forms/EnterpriseLeadFields.tsx new file mode 100644 index 00000000..067daa8b --- /dev/null +++ b/ui/src/components/lead-forms/EnterpriseLeadFields.tsx @@ -0,0 +1,150 @@ +"use client"; + +// Shared enterprise lead fields, rendered by BOTH the standalone EnterpriseModal +// and the inline on-prem expansion of the onboarding form. One source of truth so +// the two stay identical and submit through the same /api/v1/leads/enterprise +// path. Controlled: the parent owns the values + the submit/captcha flow. + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +import { + ENTERPRISE_DEPLOYMENT_OPTIONS, + ENTERPRISE_VOLUME_OPTIONS, +} from "./leadFieldOptions"; +import { PhoneField } from "./PhoneField"; + +export interface EnterpriseFieldsValue { + name: string; + company: string; + jobTitle: string; + workEmail: string; + phone: string; + volume: string; + deployment: string; + agentGoal: string; +} + +export const EMPTY_ENTERPRISE_FIELDS: EnterpriseFieldsValue = { + name: "", + company: "", + jobTitle: "", + workEmail: "", + phone: "", + volume: "", + deployment: "", + agentGoal: "", +}; + +interface EnterpriseLeadFieldsProps { + // Unique prefix for input ids/labels (e.g. "ent", "ob-op") so the two + // instances never collide when both exist in the DOM. + idPrefix: string; + value: EnterpriseFieldsValue; + onChange: (patch: Partial) => void; + // Work email is mandatory only when the visitor is logged out. + workEmailRequired: boolean; + // The deployment question is surfaced only for certain entry points; elsewhere + // it is hidden and the caller defaults the payload to "yes". + showDeployment: boolean; + emailError?: string | null; +} + +export function EnterpriseLeadFields({ + idPrefix: p, + value, + onChange, + workEmailRequired, + showDeployment, + emailError, +}: EnterpriseLeadFieldsProps) { + return ( +
+
+
+ + onChange({ name: e.target.value })} /> +
+
+ + onChange({ company: e.target.value })} /> +
+
+ +
+
+ + onChange({ jobTitle: e.target.value })} /> +
+
+ + onChange({ workEmail: e.target.value })} + /> + {emailError &&

{emailError}

} +
+
+ +
+
+ + onChange({ phone })} required /> +
+
+ + +
+
+ + {showDeployment && ( +
+ + +
+ )} + +
+ +