mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
feat: UI refresh and user onboarding (#430)
* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)
Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for user-onboarding lead-gen surfaces
14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): register PostHog events for lead-gen surfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared field options, work-email validation, and submit seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): inline math captcha field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): enterprise intake modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): hire-an-expert modal with enterprise link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): top-up modal with >20k volume-pricing gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared lead-forms context provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): mount LeadFormsProvider in app layout
Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): add Credits & Billing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(ui): add lint:lead-flow guard script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur
Revised lead-capture surfaces and credits bar:
- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
sticky footer, trust-line slot), PhoneField (react-international-phone,
dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
monthly volume. EnterpriseModal: + work email (required logged-out),
conditional deployment (yes/no/maybe, source-gated), agent goal.
OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
waveform motif + proof points + Bland-style enterprise CTA block on the
left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
(defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ui rezig, onboarding, billing, hire us & on prem cues
* ui changes
* chore: update comment
* chore: untrack docs/superpowers and gitignore it
* feat: refactor user configuration table
* feat(ui): 'check your email' confirmation on lead forms
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* added email and country in form submissions
* chore: update leads api
* fix: wrap dograh model config in card
---------
Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a2d9ed24ed
commit
00b35d6963
82 changed files with 3819 additions and 604 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
type ServiceSegment,
|
||||
} from "@/components/ServiceConfigurationForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -366,105 +367,107 @@ export function AIModelConfigurationV2Editor({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
{isCustomVoice ? (
|
||||
<Input
|
||||
placeholder="Enter voice"
|
||||
value={dograh.voice}
|
||||
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
{isCustomVoice ? (
|
||||
<Input
|
||||
placeholder="Enter voice"
|
||||
value={dograh.voice}
|
||||
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{allowCustomVoice && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dograh-custom-voice"
|
||||
checked={isCustomVoice}
|
||||
onCheckedChange={(checked) => {
|
||||
const custom = checked as boolean;
|
||||
setIsCustomVoice(custom);
|
||||
if (!custom) {
|
||||
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{allowCustomVoice && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dograh-custom-voice"
|
||||
checked={isCustomVoice}
|
||||
onCheckedChange={(checked) => {
|
||||
const custom = checked as boolean;
|
||||
setIsCustomVoice(custom);
|
||||
if (!custom) {
|
||||
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Speed</Label>
|
||||
<Select
|
||||
value={String(dograh.speed)}
|
||||
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select speed" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.speeds.map((speed) => (
|
||||
<SelectItem key={speed} value={String(speed)}>
|
||||
{speed}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSavingDograh ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
|
|
|
|||
38
ui/src/components/BrandLogo.tsx
Normal file
38
ui/src/components/BrandLogo.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Reusable Dograh wordmark. Theme-aware by default: the dark logo shows on light
|
||||
// surfaces and the light/cream logo shows on dark. Pass `inverse` to force the
|
||||
// light logo on an always-dark surface (e.g. the auth brand panel). Pass `mark`
|
||||
// to render the square logo mark instead of the full wordmark (e.g. the app
|
||||
// sidebar header). Height is controlled by the caller via className (e.g.
|
||||
// "h-7"); width stays auto so each lockup keeps its aspect ratio.
|
||||
export function BrandLogo({
|
||||
className,
|
||||
inverse = false,
|
||||
mark = false,
|
||||
}: {
|
||||
className?: string;
|
||||
inverse?: boolean;
|
||||
mark?: boolean;
|
||||
}) {
|
||||
if (mark) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src="/dograh-mark.png" alt="Dograh" className={cn("w-auto select-none", className)} />
|
||||
);
|
||||
}
|
||||
if (inverse) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src="/dograh-logo-inverse.png" alt="Dograh" className={cn("w-auto select-none", className)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/dograh-logo.png" alt="Dograh" className={cn("block w-auto select-none dark:hidden", className)} />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/dograh-logo-inverse.png" alt="Dograh" className={cn("hidden w-auto select-none dark:block", className)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/components/ThemeProvider.tsx
Normal file
12
ui/src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// Thin wrapper around next-themes so the root (server) layout can mount a theme
|
||||
// provider without pulling client-only code into the server module graph. Dark
|
||||
// is the locked default; the system preference is intentionally not consulted.
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
38
ui/src/components/auth/AuthEnterpriseCTA.tsx
Normal file
38
ui/src/components/auth/AuthEnterpriseCTA.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
// Enterprise call-to-action rendered inside the auth brand panel. Opens the
|
||||
// SAME in-app Enterprise lead modal used post-login (not the marketing site's
|
||||
// /contact page). The visitor is typically NOT authenticated here: the modal
|
||||
// requires a work email in that case, and submitLead persists the lead through
|
||||
// the user_onboarding service's public contact-sales endpoint instead of the
|
||||
// token-gated /leads/enterprise. Shared by the Stack Auth handler and the
|
||||
// local/OSS auth pages.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EnterpriseModal } from "@/components/lead-forms/EnterpriseModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
export function AuthEnterpriseCTA() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openModal = () => {
|
||||
setOpen(true);
|
||||
posthog.capture(PostHogEvent.ENTERPRISE_LEAD_OPENED, { source: "auth_page" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openModal}
|
||||
className="w-full border-white/20 bg-white/5 text-zinc-100 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Enterprise Enquiry
|
||||
</Button>
|
||||
<EnterpriseModal open={open} onOpenChange={setOpen} source="auth_page" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
ui/src/components/auth/AuthShell.tsx
Normal file
87
ui/src/components/auth/AuthShell.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Shared dark two-column auth shell, used by BOTH the Stack Auth handler
|
||||
// (/handler/[...stack], cloud) and the local/OSS auth pages (/auth/login,
|
||||
// /auth/signup). LEFT: a centered card that wraps the auth form (`children`).
|
||||
// RIGHT (lg+ only): a brand/value panel with the Dograh logo, proof points, and
|
||||
// a Bland-style enterprise CTA block at the bottom (passed in as `enterpriseSlot`).
|
||||
// Mobile collapses to the single card column. The form column scrolls and stays
|
||||
// centered so tall (sign-up) forms never clip on short viewports. Palette is the
|
||||
// app's blacks/greys with one warm CTA accent.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { BrandLogo } from "@/components/BrandLogo";
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
"Speech-to-speech",
|
||||
"MCP-native",
|
||||
"BYOK - any model",
|
||||
];
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
enterpriseSlot,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
enterpriseSlot?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-screen w-full bg-background lg:grid-cols-[55%_45%]">
|
||||
{/* Form column (LEFT) — scrolls and stays centered so tall forms never
|
||||
clip. Carries the giant faded "dograh" imprint along its bottom. */}
|
||||
<main className="auth-imprint flex min-h-screen flex-col overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-6 sm:p-10">
|
||||
<div className="w-full max-w-md space-y-6 rounded-2xl border border-border/60 bg-card p-6 shadow-lg sm:p-8">
|
||||
{/* Mobile-only wordmark (brand panel is hidden) */}
|
||||
<div className="lg:hidden">
|
||||
<BrandLogo className="h-7" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Brand / value panel (RIGHT) — hidden on mobile */}
|
||||
<aside className="relative hidden flex-col justify-between overflow-hidden border-l border-border/60 bg-zinc-950 p-10 lg:flex xl:p-14">
|
||||
{/* Ambient depth: soft radial glow behind the content */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 top-1/3 size-[28rem] rounded-full opacity-20 blur-3xl"
|
||||
style={{ background: "radial-gradient(circle, var(--cta), transparent 70%)" }}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<BrandLogo inverse className="h-8" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md space-y-5">
|
||||
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-50 xl:text-4xl">
|
||||
The open-source voice AI platform.
|
||||
</h1>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{HIGHLIGHTS.map((point) => (
|
||||
<li
|
||||
key={point}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-medium text-zinc-300"
|
||||
>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Enterprise CTA block (Bland-style) — bottom margin lifts it off the
|
||||
viewport edge while justify-between keeps the column layout */}
|
||||
<div className="relative mb-12 max-w-md space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-5 xl:mb-16">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">
|
||||
Need on-prem, data residency & a data perimeter?
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400">
|
||||
We deploy Dograh inside your environment for regulated and
|
||||
high-scale teams.
|
||||
</p>
|
||||
{enterpriseSlot}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
ui/src/components/billing/BuyCreditsControl.tsx
Normal file
134
ui/src/components/billing/BuyCreditsControl.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
// Compact self-serve "Buy Credits" control. The amount chips + custom input live
|
||||
// in a popover that only opens when the user clicks "Buy Credits" — so the
|
||||
// billing card stays clean until they intend to top up. Presets + custom (min $5)
|
||||
// feed the Razorpay seam in @/lib/billing/topup, which currently throws "not
|
||||
// wired yet"; we surface that as a calm inline note rather than an error toast.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { MAX_TOPUP_USD, MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Round to whole cents and reject non-positive / non-finite input so a typo
|
||||
// (e.g. "5.999", "-1", "abc") can't produce a NaN or fractional-cent order.
|
||||
const parseAmount = (raw: string): number | null => {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.round(n * 100) / 100;
|
||||
};
|
||||
|
||||
export function BuyCreditsControl({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [custom, setCustom] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The effective amount: a parsed custom value takes precedence when present.
|
||||
const customAmount = custom.trim() ? parseAmount(custom) : null;
|
||||
const amount = customAmount ?? selected;
|
||||
const valid = amount != null && amount >= MIN_TOPUP_USD && amount <= MAX_TOPUP_USD;
|
||||
|
||||
const selectPreset = (value: number) => {
|
||||
setSelected(value);
|
||||
setCustom("");
|
||||
setError(null);
|
||||
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: value });
|
||||
};
|
||||
|
||||
const onCustomChange = (raw: string) => {
|
||||
setCustom(raw);
|
||||
setSelected(null);
|
||||
setError(null);
|
||||
const parsed = parseAmount(raw);
|
||||
if (parsed != null && parsed >= MIN_TOPUP_USD && parsed <= MAX_TOPUP_USD) {
|
||||
posthog.capture(PostHogEvent.BUY_CREDITS_AMOUNT_SELECTED, { amount: parsed });
|
||||
}
|
||||
};
|
||||
|
||||
const onBuy = async () => {
|
||||
if (!valid || amount == null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
posthog.capture(PostHogEvent.BUY_CREDITS_CLICKED, { amount });
|
||||
try {
|
||||
await startTopUp(amount);
|
||||
} catch {
|
||||
// The seam is intentionally unimplemented until Razorpay lands.
|
||||
setError("Self-serve top-up is coming soon. Use \"Hire an Expert\" or contact us for now.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
"bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
Buy Credits
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Top up credits</p>
|
||||
<p className="text-xs text-muted-foreground">Pick an amount (min ${MIN_TOPUP_USD}).</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TOPUP_PRESETS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => selectPreset(value)}
|
||||
aria-pressed={selected === value}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
"border-input text-foreground hover:bg-accent",
|
||||
selected === value && "border-cta bg-cta/10 text-foreground ring-1 ring-cta/40",
|
||||
)}
|
||||
>
|
||||
${value}
|
||||
</button>
|
||||
))}
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={custom}
|
||||
onChange={(e) => onCustomChange(e.target.value)}
|
||||
placeholder="Custom"
|
||||
aria-label={`Custom amount (min $${MIN_TOPUP_USD})`}
|
||||
className="h-9 w-24 pl-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-muted-foreground">{error}</p>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onBuy}
|
||||
disabled={!valid || busy}
|
||||
className="w-full bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
|
||||
>
|
||||
{busy ? "Starting…" : valid && amount != null ? `Buy $${amount}` : "Buy Credits"}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
122
ui/src/components/billing/DograhCreditsCard.tsx
Normal file
122
ui/src/components/billing/DograhCreditsCard.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
|
||||
import { UserRound } from "lucide-react";
|
||||
import posthog from "posthog-js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet } from "@/client/sdk.gen";
|
||||
import type { MpsCreditsResponse } from "@/client/types.gen";
|
||||
import { BuyCreditsControl } from "@/components/billing/BuyCreditsControl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { useLeadForms } from "@/context/LeadFormsContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export function DograhCreditsCard() {
|
||||
const auth = useAuth();
|
||||
const { openHireExpert, openEnterprise } = useLeadForms();
|
||||
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
|
||||
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
|
||||
|
||||
const fetchMpsCredits = useCallback(async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
try {
|
||||
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
|
||||
// The generated client resolves to { data, error } and does NOT throw on
|
||||
// 4xx/5xx (see ui/AGENTS.md) — check error explicitly.
|
||||
if (response.error) {
|
||||
console.error("Failed to fetch MPS credits:", response.error);
|
||||
} else if (response.data) {
|
||||
setMpsCredits(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch MPS credits:", error);
|
||||
} finally {
|
||||
setIsLoadingCredits(false);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
fetchMpsCredits();
|
||||
}
|
||||
}, [auth.isAuthenticated, fetchMpsCredits]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Dograh Model Credits</CardTitle>
|
||||
<CardDescription>
|
||||
These track usage of Dograh models using Dograh Service Keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCredits ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-8 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
) : mpsCredits ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{mpsCredits.total_credits_used.toFixed(2)}{" "}
|
||||
<span className="text-lg font-normal text-muted-foreground">
|
||||
/ {mpsCredits.total_quota.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Credits Used</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
|
||||
<p className="text-sm text-muted-foreground">Remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mpsCredits.total_quota > 0 && (
|
||||
<Progress value={Math.min(100, (mpsCredits.total_credits_used / mpsCredits.total_quota) * 100)} className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
No Dograh service keys configured. Set up a service key in your model configuration to see usage.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer CTAs — self-serve + done-for-you side by side, with the
|
||||
custom-pricing link directly beneath. */}
|
||||
<div className="mt-6 space-y-4 border-t pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Running low?</p>
|
||||
<p className="text-sm text-muted-foreground">Top up instantly, or have us build it for you.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<BuyCreditsControl className="w-full sm:flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 sm:flex-1"
|
||||
onClick={() => openHireExpert("billing_card")}
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Hire an Expert
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
posthog.capture(PostHogEvent.CUSTOM_PRICING_CLICKED);
|
||||
openEnterprise("billing_custom_pricing");
|
||||
}}
|
||||
className="block text-xs text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Book a Strategy Call: custom pricing for committed volume
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -280,7 +280,7 @@ export function ToolSelector({
|
|||
)}
|
||||
{fns.length === 0 && !err && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tools discovered — Refresh.
|
||||
No tools discovered - Refresh.
|
||||
</p>
|
||||
)}
|
||||
{fns.map((fn) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { LeadFormsProvider } from "@/context/LeadFormsContext";
|
||||
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { GitHubStarBadge } from "./GitHubStarBadge";
|
||||
|
|
@ -18,7 +19,7 @@ function AppHeader() {
|
|||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-2">
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-border/60 bg-background/70 px-4 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/55">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} aria-label="Open menu" className="md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
|
|
@ -111,41 +112,43 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
|||
return (
|
||||
<SidebarProvider defaultOpen>
|
||||
{shouldShowSidebar ? (
|
||||
<div className="flex min-h-screen w-full">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex-1">
|
||||
<BackendStatusBanner />
|
||||
{!isWorkflowEditor && <AppHeader />}
|
||||
{/* Optional header area for specific pages */}
|
||||
{headerActions && (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
{headerActions}
|
||||
<LeadFormsProvider>
|
||||
<div className="flex min-h-screen w-full">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex-1">
|
||||
<BackendStatusBanner />
|
||||
{!isWorkflowEditor && <AppHeader />}
|
||||
{/* Optional header area for specific pages */}
|
||||
{headerActions && (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Optional sticky tabs */}
|
||||
{stickyTabs && (
|
||||
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center py-2">
|
||||
{stickyTabs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Optional sticky tabs */}
|
||||
{stickyTabs && (
|
||||
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center py-2">
|
||||
{stickyTabs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
{/* Main content area */}
|
||||
<main className="app-surface flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</LeadFormsProvider>
|
||||
) : (
|
||||
<div className="flex-1 w-full">
|
||||
<div className="app-surface w-full flex-1">
|
||||
<BackendStatusBanner />
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
Phone,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
UserRound,
|
||||
Workflow,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
|
@ -26,6 +27,7 @@ import Link from "next/link";
|
|||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
import { BrandLogo } from "@/components/BrandLogo";
|
||||
import ThemeToggle from "@/components/ThemeSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -52,6 +54,7 @@ import {
|
|||
} from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useLeadForms } from "@/context/LeadFormsContext";
|
||||
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
|
||||
import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion";
|
||||
import type { LocalUser } from "@/lib/auth";
|
||||
|
|
@ -129,7 +132,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
label: "OBSERVE",
|
||||
label: "MANAGE",
|
||||
items: [
|
||||
{
|
||||
title: "Agent Runs",
|
||||
|
|
@ -145,7 +148,7 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
title: "Reports",
|
||||
url: "/reports",
|
||||
icon: FileText,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -163,6 +166,7 @@ export function AppSidebar() {
|
|||
const { state, isMobile, setOpenMobile } = useSidebar();
|
||||
const { provider, getSelectedTeam, logout, user } = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
const { openHireExpert } = useLeadForms();
|
||||
const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings();
|
||||
const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0;
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
|
|
@ -223,8 +227,9 @@ export function AppSidebar() {
|
|||
asChild
|
||||
tooltip={tooltip}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isItemActive && "bg-accent text-accent-foreground"
|
||||
"rounded-xl transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isItemActive &&
|
||||
"bg-cta/15 font-semibold text-foreground hover:bg-cta/20 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
|
|
@ -233,7 +238,18 @@ export function AppSidebar() {
|
|||
className={cn("relative", isCollapsed && "justify-center")}
|
||||
translate="no"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{isItemActive && !isCollapsed && (
|
||||
<span
|
||||
className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-cta"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
isItemActive && "text-cta drop-shadow-[0_0_6px_rgba(240,170,70,0.8)]"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn("notranslate min-w-0 flex-1 truncate", isCollapsed && "sr-only")}
|
||||
translate="no"
|
||||
|
|
@ -259,17 +275,71 @@ export function AppSidebar() {
|
|||
);
|
||||
};
|
||||
|
||||
// Footer identity trigger: avatar initials only (no name), in a subtle
|
||||
// bordered circle. Same treatment expanded and collapsed.
|
||||
const displayIdentity =
|
||||
user?.displayName ||
|
||||
(user as { primaryEmail?: string } | undefined)?.primaryEmail ||
|
||||
(user as LocalUser | undefined)?.email ||
|
||||
"";
|
||||
const userInitials =
|
||||
displayIdentity
|
||||
.split(/[\s@]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s: string) => s[0]?.toUpperCase())
|
||||
.join("") || "U";
|
||||
|
||||
const userChipTrigger = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded-full border border-border/80 bg-muted/40 hover:bg-muted/60"
|
||||
>
|
||||
<span className="text-xs font-medium">{userInitials}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
// "Hire an Expert" CTA, rendered INSIDE the shared footer pill next to the
|
||||
// profile icon. Expanded: label pill filling the row. Collapsed: icon-only.
|
||||
const hireExpertButton = isCollapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={() => openHireExpert("sidebar")}
|
||||
aria-label="Hire an Expert"
|
||||
>
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Hire an Expert</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 rounded-full px-3 text-xs"
|
||||
onClick={() => openHireExpert("sidebar")}
|
||||
>
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
Hire an Expert
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="border-r">
|
||||
<SidebarHeader className="border-b px-2 py-3 notranslate" translate="no">
|
||||
<Sidebar collapsible="icon" variant="floating" className="app-sidebar-dock py-4">
|
||||
<SidebarHeader className="px-2 py-3 notranslate" translate="no">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={cn("flex items-center gap-2", isCollapsed && "hidden")}>
|
||||
<Link
|
||||
href="/"
|
||||
className="notranslate flex items-center gap-2 px-2 text-xl font-bold"
|
||||
className="notranslate flex items-center gap-2 px-1"
|
||||
translate="no"
|
||||
>
|
||||
Dograh
|
||||
<BrandLogo mark className="h-6" />
|
||||
{versionInfo && (
|
||||
<span
|
||||
className="notranslate text-xs font-normal text-muted-foreground"
|
||||
|
|
@ -293,7 +363,7 @@ export function AppSidebar() {
|
|||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Latest: {latestRelease} — click to see the update guide</p>
|
||||
<p>Latest: {latestRelease} - click to see the update guide</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -367,25 +437,20 @@ export function AppSidebar() {
|
|||
</SidebarContent>
|
||||
|
||||
<SidebarFooter
|
||||
className={cn("border-t p-4 notranslate", isCollapsed && "p-2")}
|
||||
className={cn("p-3 notranslate", isCollapsed && "p-2")}
|
||||
translate="no"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{provider !== "stack" && (
|
||||
<div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-1 rounded-full border border-border/60 bg-muted/30 p-1",
|
||||
isCollapsed && "flex-col"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
<span className="text-xs font-medium">
|
||||
{(user?.displayName || (user as LocalUser | undefined)?.email || "")
|
||||
.split(/[\s@]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s: string) => s[0]?.toUpperCase())
|
||||
.join("")
|
||||
|| "U"}
|
||||
</span>
|
||||
</Button>
|
||||
{userChipTrigger}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
|
|
@ -406,24 +471,20 @@ export function AppSidebar() {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{hireExpertButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "stack" && (
|
||||
<div className={cn("flex", isCollapsed ? "justify-center" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-1 rounded-full border border-border/60 bg-muted/30 p-1",
|
||||
isCollapsed && "flex-col"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
<span className="text-xs font-medium">
|
||||
{(user?.displayName || (user as { primaryEmail?: string })?.primaryEmail || "")
|
||||
.split(/[\s@]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s: string) => s[0]?.toUpperCase())
|
||||
.join("")
|
||||
|| "U"}
|
||||
</span>
|
||||
</Button>
|
||||
{userChipTrigger}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
|
|
@ -445,42 +506,30 @@ export function AppSidebar() {
|
|||
<Settings className="mr-2 h-4 w-4" />
|
||||
Platform Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/usage")} className="cursor-pointer">
|
||||
<CircleDollarSign className="mr-2 h-4 w-4" />
|
||||
Usage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{hireExpertButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn("mt-2 border-t pt-2", isCollapsed && "flex justify-center")}>
|
||||
{isCollapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="notranslate" translate="no">
|
||||
<ThemeToggle
|
||||
showLabel={false}
|
||||
className="hover:bg-accent hover:text-accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Toggle theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="notranslate" translate="no">
|
||||
<ThemeToggle
|
||||
showLabel={true}
|
||||
className="hover:bg-accent hover:text-accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="notranslate" translate="no">
|
||||
<ThemeToggle
|
||||
showLabel={false}
|
||||
className="rounded-full hover:bg-accent hover:text-accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={isCollapsed ? "right" : "top"}>
|
||||
<p>Toggle theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
|
|
|
|||
90
ui/src/components/lead-forms/CaptchaChallenge.tsx
Normal file
90
ui/src/components/lead-forms/CaptchaChallenge.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
// Anti-spam quick-check shown as a popup ON TOP of a lead form (via the
|
||||
// LeadModalShell `overlay` slot) so it can't be scrolled past or missed.
|
||||
// Generates a fresh sum each time it mounts; calls onVerified once the correct
|
||||
// answer is confirmed, onCancel to dismiss back to the form.
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function CaptchaChallenge({
|
||||
onVerified,
|
||||
onCancel,
|
||||
}: {
|
||||
onVerified: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [a, setA] = useState(0);
|
||||
const [b, setB] = useState(0);
|
||||
const [answer, setAnswer] = useState("");
|
||||
|
||||
// Fresh challenge whenever this mounts (the parent mounts it on demand).
|
||||
// Math.random is allowed in the browser runtime (not a workflow script).
|
||||
const regenerate = () => {
|
||||
setA(Math.floor(Math.random() * 8) + 1);
|
||||
setB(Math.floor(Math.random() * 8) + 1);
|
||||
setAnswer("");
|
||||
};
|
||||
useEffect(() => {
|
||||
regenerate();
|
||||
}, []);
|
||||
|
||||
const confirm = () => {
|
||||
if (answer.trim() !== "" && parseInt(answer, 10) === a + b) {
|
||||
onVerified();
|
||||
} else {
|
||||
toast.error("That's not quite right - try again.");
|
||||
regenerate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lead-form-slab relative w-full max-w-xs overflow-hidden rounded-xl border border-border/70 bg-card shadow-2xl">
|
||||
<div className="lead-form-underline relative space-y-4 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-cta/25 bg-cta/10 text-cta">
|
||||
<ShieldCheck className="size-4" />
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Quick check</p>
|
||||
<p className="text-xs text-muted-foreground">Confirm you're human before we send this.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="captcha-answer">
|
||||
What is {a} + {b}?
|
||||
</Label>
|
||||
<Input
|
||||
id="captcha-answer"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") confirm();
|
||||
}}
|
||||
placeholder="Answer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
className="bg-cta text-cta-foreground shadow-md shadow-cta/25 hover:bg-cta/90 hover:shadow-cta/35 focus-visible:ring-cta/50"
|
||||
>
|
||||
Confirm & submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
ui/src/components/lead-forms/EnterpriseLeadFields.tsx
Normal file
143
ui/src/components/lead-forms/EnterpriseLeadFields.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
// Shared enterprise lead fields, rendered by BOTH the standalone EnterpriseModal
|
||||
// and the inline on-prem expansion of the onboarding form. One source of truth so
|
||||
// the two stay identical and submit through the same /api/v1/leads/enterprise
|
||||
// path. Controlled: the parent owns the values + the submit/captcha flow.
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
ENTERPRISE_DEPLOYMENT_OPTIONS,
|
||||
ENTERPRISE_VOLUME_OPTIONS,
|
||||
} from "./leadFieldOptions";
|
||||
import { PhoneField } from "./PhoneField";
|
||||
|
||||
export interface EnterpriseFieldsValue {
|
||||
name: string;
|
||||
company: string;
|
||||
jobTitle: string;
|
||||
workEmail: string;
|
||||
phone: string;
|
||||
volume: string;
|
||||
deployment: string;
|
||||
agentGoal: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ENTERPRISE_FIELDS: EnterpriseFieldsValue = {
|
||||
name: "",
|
||||
company: "",
|
||||
jobTitle: "",
|
||||
workEmail: "",
|
||||
phone: "",
|
||||
volume: "",
|
||||
deployment: "",
|
||||
agentGoal: "",
|
||||
};
|
||||
|
||||
interface EnterpriseLeadFieldsProps {
|
||||
// Unique prefix for input ids/labels (e.g. "ent", "ob-op") so the two
|
||||
// instances never collide when both exist in the DOM.
|
||||
idPrefix: string;
|
||||
value: EnterpriseFieldsValue;
|
||||
onChange: (patch: Partial<EnterpriseFieldsValue>) => void;
|
||||
// The deployment question is surfaced only for certain entry points; elsewhere
|
||||
// it is hidden and the caller defaults the payload to "yes".
|
||||
showDeployment: boolean;
|
||||
emailError?: string | null;
|
||||
}
|
||||
|
||||
export function EnterpriseLeadFields({
|
||||
idPrefix: p,
|
||||
value,
|
||||
onChange,
|
||||
showDeployment,
|
||||
emailError,
|
||||
}: EnterpriseLeadFieldsProps) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-name`}>Name</Label>
|
||||
<Input id={`${p}-name`} placeholder="Your full name" value={value.name} onChange={(e) => onChange({ name: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-company`}>Company name</Label>
|
||||
<Input id={`${p}-company`} placeholder="Acme Inc." value={value.company} onChange={(e) => onChange({ company: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-title`}>Job title</Label>
|
||||
<Input id={`${p}-title`} placeholder="VP Operations" value={value.jobTitle} onChange={(e) => onChange({ jobTitle: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-email`}>Work email</Label>
|
||||
<Input
|
||||
id={`${p}-email`}
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
value={value.workEmail}
|
||||
onChange={(e) => onChange({ workEmail: e.target.value })}
|
||||
/>
|
||||
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-phone`}>Phone</Label>
|
||||
<PhoneField id={`${p}-phone`} value={value.phone} onChange={(phone) => onChange({ phone })} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-volume`}>Monthly call volume</Label>
|
||||
<Select value={value.volume} onValueChange={(v) => onChange({ volume: v })}>
|
||||
<SelectTrigger id={`${p}-volume`}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTERPRISE_VOLUME_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDeployment && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-deployment`}>Need enterprise deployment (SSO, on-prem, data residency)?</Label>
|
||||
<Select value={value.deployment} onValueChange={(v) => onChange({ deployment: v })}>
|
||||
<SelectTrigger id={`${p}-deployment`}><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTERPRISE_DEPLOYMENT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${p}-goal`}>
|
||||
What do you want the voice agent to do? <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`${p}-goal`}
|
||||
value={value.agentGoal}
|
||||
onChange={(e) => onChange({ agentGoal: e.target.value })}
|
||||
placeholder="Use case, regulatory context, current stack…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
ui/src/components/lead-forms/EnterpriseModal.tsx
Normal file
139
ui/src/components/lead-forms/EnterpriseModal.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import {
|
||||
EMPTY_ENTERPRISE_FIELDS,
|
||||
type EnterpriseFieldsValue,
|
||||
EnterpriseLeadFields,
|
||||
} from "./EnterpriseLeadFields";
|
||||
import { FormTrustLine } from "./FormTrustLine";
|
||||
import { validateWorkEmail } from "./isPersonalEmail";
|
||||
import { ENTERPRISE_DEPLOYMENT_SOURCES, type LeadSource } from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { submitLead } from "./submitLead";
|
||||
|
||||
interface EnterpriseModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
source: LeadSource;
|
||||
// Optional values to pre-fill when the modal opens (e.g. company name already
|
||||
// collected upstream). Backward-compatible: omitted = no prefill.
|
||||
prefill?: { company?: string };
|
||||
}
|
||||
|
||||
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
|
||||
const { config } = useAppConfig();
|
||||
// Deployment provenance (analytics only); OSS submits via the public contact-sales path.
|
||||
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
|
||||
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// The deployment question is only surfaced for custom-volume / Contact-Us /
|
||||
// pricing-custom-volume entry points; elsewhere it is hidden and the payload
|
||||
// defaults to "yes".
|
||||
const showDeployment = ENTERPRISE_DEPLOYMENT_SOURCES.includes(source);
|
||||
|
||||
const reset = () => {
|
||||
setValue(EMPTY_ENTERPRISE_FIELDS);
|
||||
setEmailError(null);
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const onFieldsChange = (patch: Partial<EnterpriseFieldsValue>) => {
|
||||
setValue((v) => ({ ...v, ...patch }));
|
||||
if ("workEmail" in patch) setEmailError(null);
|
||||
};
|
||||
|
||||
// Seed company from prefill when the modal opens (don't clobber edits).
|
||||
const prefillCompany = prefill?.company;
|
||||
useEffect(() => {
|
||||
if (open && prefillCompany) {
|
||||
setValue((v) => (v.company ? v : { ...v, company: prefillCompany }));
|
||||
}
|
||||
}, [open, prefillCompany]);
|
||||
|
||||
// Required fields, independent of the anti-spam check (revealed only after the
|
||||
// first submit click — see handleSubmit).
|
||||
const baseValid =
|
||||
Boolean(value.name.trim()) &&
|
||||
Boolean(value.company.trim()) &&
|
||||
Boolean(value.jobTitle.trim()) &&
|
||||
Boolean(value.workEmail.trim()) &&
|
||||
Boolean(value.phone.trim()) &&
|
||||
Boolean(value.volume);
|
||||
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
// Validate, then pop the anti-spam check on top of the modal.
|
||||
const handleSubmit = () => {
|
||||
const err = validateWorkEmail(value.workEmail);
|
||||
if (err) { setEmailError(err); return; }
|
||||
if (!value.name.trim() || !value.company.trim() || !value.jobTitle.trim() || !value.phone.trim() || !value.volume) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified.
|
||||
const doSubmit = async () => {
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitLead({
|
||||
kind: "enterprise",
|
||||
source,
|
||||
origin,
|
||||
payload: {
|
||||
name: value.name,
|
||||
company: value.company,
|
||||
jobTitle: value.jobTitle,
|
||||
workEmail: value.workEmail,
|
||||
phone: value.phone,
|
||||
volume: value.volume,
|
||||
// Hidden entry points imply enterprise intent — default to "yes".
|
||||
deployment: showDeployment ? value.deployment || "yes" : "yes",
|
||||
agentGoal: value.agentGoal,
|
||||
},
|
||||
});
|
||||
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
|
||||
icon={ShieldCheck}
|
||||
eyebrow="Enterprise"
|
||||
title="Book a Strategy Call"
|
||||
description="SSO, on-prem, data residency, committed volume. Tell us about your environment."
|
||||
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
|
||||
trustLine={<FormTrustLine />}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<EnterpriseLeadFields
|
||||
idPrefix="ent"
|
||||
value={value}
|
||||
onChange={onFieldsChange}
|
||||
showDeployment={showDeployment}
|
||||
emailError={emailError}
|
||||
/>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
10
ui/src/components/lead-forms/FormTrustLine.tsx
Normal file
10
ui/src/components/lead-forms/FormTrustLine.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Shared reassurance line shown beneath every lead-form submit. A small,
|
||||
// consistent trust signal — keeps the promise identical across all forms.
|
||||
|
||||
export function FormTrustLine() {
|
||||
return (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Average response: under 10 minutes during business hours.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
182
ui/src/components/lead-forms/HireExpertModal.tsx
Normal file
182
ui/src/components/lead-forms/HireExpertModal.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import { FormTrustLine } from "./FormTrustLine";
|
||||
import { isValidEmail } from "./isPersonalEmail";
|
||||
import { HIRE_VOLUME_OPTIONS, type LeadSource } from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { PhoneField } from "./PhoneField";
|
||||
import { submitLead } from "./submitLead";
|
||||
|
||||
interface HireExpertModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
source: LeadSource;
|
||||
onOpenEnterprise: () => void;
|
||||
}
|
||||
|
||||
export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }: HireExpertModalProps) {
|
||||
const { user } = useAuth(); // logged-in identity (prefills the email field)
|
||||
const { config } = useAppConfig();
|
||||
// Deployment provenance (analytics only): cloud → cloud_app, else oss_app. OSS submits the
|
||||
// lead anonymously (cloud can't verify its token), so the email field below is the identity.
|
||||
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
|
||||
// Logged-in user's email (Stack uses primaryEmail; local uses email) — prefilled, editable.
|
||||
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [agentGoal, setAgentGoal] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [volume, setVolume] = useState("");
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Prefill the email from the logged-in user when the modal opens (don't clobber edits).
|
||||
useEffect(() => {
|
||||
if (open && userEmail) setEmail((e) => e || userEmail);
|
||||
}, [open, userEmail]);
|
||||
|
||||
const reset = () => {
|
||||
setName(""); setCompany(""); setEmail(""); setJobTitle(""); setAgentGoal("");
|
||||
setPhone(""); setVolume(""); setCaptchaActive(false); setSubmitting(false);
|
||||
};
|
||||
|
||||
// Required fields, independent of the anti-spam check (which is revealed only
|
||||
// after the first submit click — see handleSubmit).
|
||||
const baseValid =
|
||||
Boolean(name.trim()) &&
|
||||
Boolean(company.trim()) &&
|
||||
isValidEmail(email) &&
|
||||
Boolean(jobTitle.trim()) &&
|
||||
Boolean(agentGoal.trim()) &&
|
||||
Boolean(phone.trim()) &&
|
||||
Boolean(volume);
|
||||
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
// Validate, then pop the anti-spam check on top of the modal.
|
||||
const handleSubmit = () => {
|
||||
if (!baseValid) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified.
|
||||
const doSubmit = async () => {
|
||||
setCaptchaActive(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitLead({
|
||||
kind: "hire_expert",
|
||||
source,
|
||||
origin,
|
||||
payload: { name, company, email, jobTitle, agentGoal, phone, volume },
|
||||
});
|
||||
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}
|
||||
icon={Sparkles}
|
||||
eyebrow="Done-for-you"
|
||||
title="Let us build your voice agent"
|
||||
description="Building good voice agents is nuanced. Tell us what you need and we'll take it end-to-end."
|
||||
primary={{ label: "Submit", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
secondary={{ label: "Cancel", onClick: () => onOpenChange(false), disabled: submitting }}
|
||||
helper={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenEnterprise}
|
||||
className="underline decoration-dashed underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Need enterprise deployment? (SSO, on-prem, data residency)
|
||||
</button>
|
||||
}
|
||||
trustLine={<FormTrustLine />}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={doSubmit} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-name">Name</Label>
|
||||
<Input id="hire-name" placeholder="Your full name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-company">Company name</Label>
|
||||
<Input id="hire-company" placeholder="Acme Inc." value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-email">Email</Label>
|
||||
<Input id="hire-email" type="email" placeholder="you@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-title">Job title</Label>
|
||||
<Input id="hire-title" placeholder="VP Operations" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-goal">What do you want the voice agent to do?</Label>
|
||||
<Textarea
|
||||
id="hire-goal"
|
||||
value={agentGoal}
|
||||
onChange={(e) => setAgentGoal(e.target.value)}
|
||||
placeholder="Use case, target outcomes, any remarks…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-phone">Phone</Label>
|
||||
<PhoneField id="hire-phone" value={phone} onChange={setPhone} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hire-volume">Expected monthly call volume</Label>
|
||||
<Select value={volume} onValueChange={setVolume}>
|
||||
<SelectTrigger id="hire-volume"><SelectValue placeholder="Select" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HIRE_VOLUME_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
93
ui/src/components/lead-forms/HireExpertNudge.tsx
Normal file
93
ui/src/components/lead-forms/HireExpertNudge.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { UserRound, X } from "lucide-react";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { useLeadForms } from "@/context/LeadFormsContext";
|
||||
|
||||
interface HireExpertNudgeProps {
|
||||
workflowId: number;
|
||||
}
|
||||
|
||||
// Timings. Override SHOW_DELAY_MS to a few seconds during manual testing.
|
||||
const SHOW_DELAY_MS = 5 * 60 * 1000; // 5 minutes on the builder
|
||||
const AUTO_FADE_MS = 30 * 1000; // visible for 30s
|
||||
|
||||
function nudgeDoneKey(workflowId: number) {
|
||||
return `dograh:hireNudge:${workflowId}`;
|
||||
}
|
||||
|
||||
export function HireExpertNudge({ workflowId }: HireExpertNudgeProps) {
|
||||
const { openHireExpert, hasOpenedHireRef } = useLeadForms();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Arm the 5-minute show timer (once per mount / workflow).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
// Already shown+consumed for this workflow → skip.
|
||||
if (localStorage.getItem(nudgeDoneKey(workflowId))) return;
|
||||
|
||||
const showTimer = setTimeout(() => {
|
||||
if (hasOpenedHireRef.current) return; // they engaged elsewhere; don't nag
|
||||
if (localStorage.getItem(nudgeDoneKey(workflowId))) return;
|
||||
setVisible(true);
|
||||
posthog.capture(PostHogEvent.HIRE_NUDGE_SHOWN, { workflowId });
|
||||
// Auto-fade after 30s. Auto-expiry does NOT mark done (per spec).
|
||||
fadeTimer.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
posthog.capture(PostHogEvent.HIRE_NUDGE_EXPIRED, { workflowId });
|
||||
}, AUTO_FADE_MS);
|
||||
}, SHOW_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(showTimer);
|
||||
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
||||
};
|
||||
}, [workflowId, hasOpenedHireRef]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const markDone = () => {
|
||||
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
||||
localStorage.setItem(nudgeDoneKey(workflowId), "1");
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
posthog.capture(PostHogEvent.HIRE_NUDGE_CLICKED, { workflowId });
|
||||
markDone();
|
||||
openHireExpert("builder_nudge");
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
posthog.capture(PostHogEvent.HIRE_NUDGE_DISMISSED, { workflowId });
|
||||
markDone();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-6 right-6 z-50 flex max-w-xs items-center gap-3 rounded-lg border border-primary bg-background p-3 shadow-lg animate-in fade-in slide-in-from-bottom-2"
|
||||
>
|
||||
<button type="button" onClick={handleClick} className="flex flex-1 items-center gap-3 text-left">
|
||||
<UserRound className="h-5 w-5 shrink-0 text-primary" />
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">Hire an Expert</span>
|
||||
<span className="block text-xs text-muted-foreground">We'll build your agent for you</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
ui/src/components/lead-forms/LeadModalShell.tsx
Normal file
136
ui/src/components/lead-forms/LeadModalShell.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
// Shared chrome for the lead dialogs (HireExpert, Enterprise, post-signup
|
||||
// Onboarding). Wraps the existing @/components/ui/dialog primitive (which already
|
||||
// supplies the blurred backdrop) and adds a consistent header band (eyebrow +
|
||||
// title + description), a scrollable body with underline fields, a footer
|
||||
// (primary CTA + optional ghost secondary + optional helper slot), and a bottom
|
||||
// trust-line slot. The visual language ("Ledger", user-approved): flat charcoal
|
||||
// slab where ONLY the header band is darker (footer matches the body), NO
|
||||
// gradients/glows/icons, Geist type only, one warm accent reserved for the
|
||||
// primary action and the focused-field underline (see .lead-form-* in
|
||||
// globals.css).
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LeadModalShellProps {
|
||||
// Accepted for caller compatibility; the Ledger design renders no icon.
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
// Primary action — rendered with the warm CTA accent.
|
||||
primary: { label: string; onClick: () => void; disabled?: boolean; loading?: boolean };
|
||||
// Optional ghost secondary (e.g. Cancel / Skip).
|
||||
secondary?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
// Optional helper rendered in the footer below the actions (e.g. a link).
|
||||
helper?: ReactNode;
|
||||
// Optional trust line beneath the footer (we pass <FormTrustLine/>).
|
||||
trustLine?: ReactNode;
|
||||
// Optional layer floated ON TOP of the whole modal (e.g. the captcha popup).
|
||||
overlay?: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// Forwarded to DialogContent so callers can lock dismissal (onboarding gate).
|
||||
contentProps?: React.ComponentProps<typeof DialogContent>;
|
||||
}
|
||||
|
||||
export function LeadModalShell({
|
||||
title,
|
||||
eyebrow,
|
||||
description,
|
||||
children,
|
||||
primary,
|
||||
secondary,
|
||||
helper,
|
||||
trustLine,
|
||||
overlay,
|
||||
open,
|
||||
onOpenChange,
|
||||
contentProps,
|
||||
}: LeadModalShellProps) {
|
||||
const { className: contentClassName, ...restContentProps } = contentProps ?? {};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"lead-form-slab max-h-[90vh] gap-0 overflow-hidden rounded-2xl border-border/70 bg-card p-0 shadow-2xl sm:max-w-[560px]",
|
||||
contentClassName,
|
||||
)}
|
||||
{...restContentProps}
|
||||
>
|
||||
{/* Header: a slightly darker band, separated by a hairline. */}
|
||||
<DialogHeader className="space-y-0 border-b border-border/40 bg-black/[0.04] px-8 pb-5 pt-6 text-left dark:bg-black/25">
|
||||
<div className="min-w-0">
|
||||
{eyebrow && (
|
||||
<span className="block text-[0.7rem] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="mt-1.5 text-2xl font-semibold leading-tight tracking-tight">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="mt-1.5 text-sm leading-snug">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Scrollable body: flat, compact underline fields. */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-8 py-6">
|
||||
<div className="lead-form-underline">{children}</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — same surface as the body (only the header band differs);
|
||||
actions first, then the optional helper line BELOW the buttons,
|
||||
then the trust line at the very bottom. */}
|
||||
<div className="space-y-3 border-t border-border/40 px-8 py-4">
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
{secondary && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={secondary.onClick}
|
||||
disabled={secondary.disabled}
|
||||
>
|
||||
{secondary.label}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={primary.onClick}
|
||||
disabled={primary.disabled || primary.loading}
|
||||
className="bg-cta text-cta-foreground shadow-md shadow-cta/25 hover:bg-cta/90 hover:shadow-cta/35 focus-visible:ring-cta/50"
|
||||
>
|
||||
{primary.loading ? "Submitting…" : primary.label}
|
||||
</Button>
|
||||
</div>
|
||||
{helper && <div className="text-center text-xs text-muted-foreground">{helper}</div>}
|
||||
{trustLine}
|
||||
</div>
|
||||
|
||||
{/* Optional popup floated on top of the entire modal (captcha, etc.). */}
|
||||
{overlay && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/70 p-6 backdrop-blur-md">
|
||||
{overlay}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/lead-forms/MathCaptcha.tsx
Normal file
45
ui/src/components/lead-forms/MathCaptcha.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface MathCaptchaProps {
|
||||
// Called whenever validity changes, so the parent can enable/disable submit.
|
||||
onValidChange: (valid: boolean) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Dead-simple anti-spam: "What is X + Y?". Generated client-side on mount.
|
||||
// Math.random is allowed in browser runtime (this is not a workflow script).
|
||||
export function MathCaptcha({ onValidChange, id = "math-captcha" }: MathCaptchaProps) {
|
||||
const [a, setA] = useState(0);
|
||||
const [b, setB] = useState(0);
|
||||
const [answer, setAnswer] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setA(Math.floor(Math.random() * 8) + 1);
|
||||
setB(Math.floor(Math.random() * 8) + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onValidChange(answer.trim() !== "" && parseInt(answer, 10) === a + b);
|
||||
}, [answer, a, b, onValidChange]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>
|
||||
Quick check: what is {a} + {b}?
|
||||
</Label>
|
||||
<Input
|
||||
id={id}
|
||||
inputMode="numeric"
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="Answer"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
357
ui/src/components/lead-forms/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import { CaptchaChallenge } from "./CaptchaChallenge";
|
||||
import {
|
||||
EMPTY_ENTERPRISE_FIELDS,
|
||||
type EnterpriseFieldsValue,
|
||||
EnterpriseLeadFields,
|
||||
} from "./EnterpriseLeadFields";
|
||||
import { validateWorkEmail } from "./isPersonalEmail";
|
||||
import {
|
||||
ONBOARDING_HEARD_OPTIONS,
|
||||
ONBOARDING_MIGRATION_OPTIONS,
|
||||
ONBOARDING_ONPREM_OPTIONS,
|
||||
ONBOARDING_ONPREM_PERSONAS,
|
||||
ONBOARDING_PERSONA_OPTIONS,
|
||||
ONBOARDING_VOLUME_OPTIONS,
|
||||
} from "./leadFieldOptions";
|
||||
import { LeadModalShell } from "./LeadModalShell";
|
||||
import { submitLead } from "./submitLead";
|
||||
import { type OnboardingAnswers, submitOnboarding } from "./submitOnboarding";
|
||||
|
||||
interface OnboardingModalProps {
|
||||
open: boolean;
|
||||
// Called after a tracked submit to dismiss the gate and stamp the server-side
|
||||
// "completed" flag. Onboarding is compulsory — `skipped` is always false now.
|
||||
onComplete: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
const { user } = useAuth(); // logged-in identity → onboarding email (sent silently)
|
||||
const { config } = useAppConfig();
|
||||
// Deployment provenance (analytics only).
|
||||
const origin = config?.deploymentMode === "cloud" ? "cloud_app" : "oss_app";
|
||||
// The logged-in user's email (Stack uses primaryEmail; local uses email). Sent in the
|
||||
// body — there is no visible email field on the onboarding form.
|
||||
const userEmail = user ? ("primaryEmail" in user ? user.primaryEmail ?? "" : user.email ?? "") : "";
|
||||
|
||||
const [persona, setPersona] = useState("");
|
||||
const [onPremNeed, setOnPremNeed] = useState("");
|
||||
const [migratingFrom, setMigratingFrom] = useState("");
|
||||
const [migratingOtherProvider, setMigratingOtherProvider] = useState("");
|
||||
const [switchReason, setSwitchReason] = useState("");
|
||||
const [howHeard, setHowHeard] = useState("");
|
||||
const [volume, setVolume] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Inline on-prem expansion: the FULL enterprise form, submitted through the same
|
||||
// /api/v1/leads/enterprise path as the standalone Enterprise modal.
|
||||
const [onPremExpanded, setOnPremExpanded] = useState(false);
|
||||
const [ef, setEf] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
|
||||
const [efEmailError, setEfEmailError] = useState<string | null>(null);
|
||||
const [captchaActive, setCaptchaActive] = useState(false);
|
||||
|
||||
const showOnPrem = ONBOARDING_ONPREM_PERSONAS.includes(persona);
|
||||
const showManagedNote = showOnPrem && onPremNeed === "yes";
|
||||
const wantsOnPrem = showManagedNote && onPremExpanded;
|
||||
const isOtherProvider = migratingFrom === "other";
|
||||
const isMigrating = Boolean(migratingFrom) && migratingFrom !== "no";
|
||||
|
||||
// All four questions are required (onboarding is compulsory). "Other" provider also
|
||||
// needs its free-text name; the "why switching" note is optional.
|
||||
const baseValid =
|
||||
Boolean(persona) &&
|
||||
Boolean(migratingFrom) &&
|
||||
(!isOtherProvider || Boolean(migratingOtherProvider.trim())) &&
|
||||
Boolean(howHeard) &&
|
||||
Boolean(volume);
|
||||
const canSubmit = baseValid && !submitting;
|
||||
|
||||
const answers = (): OnboardingAnswers => ({
|
||||
persona: persona || undefined,
|
||||
onPremNeed: showOnPrem ? onPremNeed || undefined : undefined,
|
||||
migratingFrom: migratingFrom || undefined,
|
||||
migratingOtherProvider: isOtherProvider ? migratingOtherProvider.trim() || undefined : undefined,
|
||||
switchReason: isMigrating ? switchReason.trim() || undefined : undefined,
|
||||
howHeard: howHeard || undefined,
|
||||
volume: volume || undefined,
|
||||
});
|
||||
|
||||
const onEfChange = (patch: Partial<EnterpriseFieldsValue>) => {
|
||||
setEf((v) => ({ ...v, ...patch }));
|
||||
if ("workEmail" in patch) setEfEmailError(null);
|
||||
};
|
||||
|
||||
const expandOnPrem = () => setOnPremExpanded(true);
|
||||
|
||||
const collapseOnPrem = () => {
|
||||
setOnPremExpanded(false);
|
||||
setCaptchaActive(false);
|
||||
setEfEmailError(null);
|
||||
};
|
||||
|
||||
// Best-effort persistence must never trap the user. Dismiss immediately, then fire
|
||||
// the network work in the background. `withEnterprise` = also send the on-prem lead.
|
||||
const finish = (withEnterprise: boolean) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
const data = answers();
|
||||
const efSnapshot = withEnterprise ? { ...ef } : null;
|
||||
onComplete(false); // compulsory — always "completed", never skipped
|
||||
void (async () => {
|
||||
try {
|
||||
await submitOnboarding(data, origin, userEmail);
|
||||
// Two distinct submissions on success: onboarding answers above, and the
|
||||
// enterprise on-prem lead here (same endpoint as the standalone form).
|
||||
if (efSnapshot) {
|
||||
await submitLead({
|
||||
kind: "enterprise",
|
||||
source: "onboarding",
|
||||
origin,
|
||||
payload: {
|
||||
name: efSnapshot.name,
|
||||
company: efSnapshot.company || undefined,
|
||||
jobTitle: efSnapshot.jobTitle,
|
||||
workEmail: efSnapshot.workEmail,
|
||||
phone: efSnapshot.phone,
|
||||
volume: efSnapshot.volume,
|
||||
// They already answered on-prem = yes; deployment intent is implied.
|
||||
deployment: "yes",
|
||||
agentGoal: efSnapshot.agentGoal,
|
||||
},
|
||||
});
|
||||
// Only the on-prem/enterprise lead path sends an email; plain onboarding
|
||||
// does not. Confirm the email just for this path.
|
||||
toast.success("Check your inbox — we just emailed you the next steps (give it a minute).");
|
||||
}
|
||||
} catch {
|
||||
// Swallowed — the user is already in the product; calls are timeout-bounded.
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!baseValid) {
|
||||
toast.error(
|
||||
isOtherProvider && !migratingOtherProvider.trim()
|
||||
? "Please tell us which provider you're migrating from"
|
||||
: "Please answer all the questions",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// If the user engaged the on-prem section, validate it + pop the anti-spam check.
|
||||
if (wantsOnPrem) {
|
||||
const err = validateWorkEmail(ef.workEmail);
|
||||
if (err) { setEfEmailError(err); return; }
|
||||
if (!ef.name.trim() || !ef.company.trim() || !ef.jobTitle.trim() || !ef.phone.trim() || !ef.volume) {
|
||||
toast.error("Please complete the on-prem details below, or remove that section.");
|
||||
return;
|
||||
}
|
||||
setCaptchaActive(true);
|
||||
return;
|
||||
}
|
||||
finish(false);
|
||||
};
|
||||
|
||||
// Runs once the captcha popup is verified (on-prem path).
|
||||
const submitWithOnPrem = () => {
|
||||
setCaptchaActive(false);
|
||||
finish(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<LeadModalShell
|
||||
open={open}
|
||||
// Hard gate: no outside/escape close, hide the built-in ×. Onboarding is
|
||||
// compulsory — the only exit is "Get started" once the questions are answered.
|
||||
onOpenChange={() => {}}
|
||||
contentProps={{
|
||||
className: "[&>button]:hidden",
|
||||
onEscapeKeyDown: (e) => e.preventDefault(),
|
||||
onPointerDownOutside: (e) => e.preventDefault(),
|
||||
onInteractOutside: (e) => e.preventDefault(),
|
||||
}}
|
||||
icon={Rocket}
|
||||
eyebrow="Welcome"
|
||||
title="Welcome to Dograh"
|
||||
description="A few quick questions so we can tailor your experience. Takes ~20 seconds."
|
||||
primary={{ label: "Get started", onClick: handleSubmit, disabled: !canSubmit, loading: submitting }}
|
||||
overlay={captchaActive ? <CaptchaChallenge onVerified={submitWithOnPrem} onCancel={() => setCaptchaActive(false)} /> : undefined}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-persona">What best describes you?</Label>
|
||||
<Select
|
||||
value={persona}
|
||||
onValueChange={(v) => {
|
||||
setPersona(v);
|
||||
// Leaving the on-prem-eligible persona resets the conditional answer
|
||||
// and any inline enterprise lead.
|
||||
if (!ONBOARDING_ONPREM_PERSONAS.includes(v)) {
|
||||
setOnPremNeed("");
|
||||
collapseOnPrem();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ob-persona"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_PERSONA_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showOnPrem && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-onprem">Do you need on-prem deployment for compliance & data residency?</Label>
|
||||
<Select
|
||||
value={onPremNeed}
|
||||
onValueChange={(v) => {
|
||||
setOnPremNeed(v);
|
||||
if (v !== "yes") collapseOnPrem();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ob-onprem"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_ONPREM_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showManagedNote && (
|
||||
<div className="mt-2 space-y-3 rounded-lg border border-border/60 bg-muted/30 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
We offer a <span className="font-medium text-foreground">Managed On-Prem</span> deployment
|
||||
for compliance and data residency.
|
||||
</p>
|
||||
{onPremExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={collapseOnPrem}
|
||||
className="shrink-0 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!onPremExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={expandOnPrem}
|
||||
className="text-xs font-medium text-cta underline-offset-4 hover:underline"
|
||||
>
|
||||
Talk to us about on-prem →
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<EnterpriseLeadFields
|
||||
idPrefix="ob-op"
|
||||
value={ef}
|
||||
onChange={onEfChange}
|
||||
showDeployment={false}
|
||||
emailError={efEmailError}
|
||||
/>
|
||||
<p className="text-[0.7rem] text-muted-foreground">
|
||||
Our team will reach out about on-prem. Prefer not to? Click “Remove”.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-volume">Expected monthly call volume</Label>
|
||||
<Select value={volume} onValueChange={setVolume}>
|
||||
<SelectTrigger id="ob-volume"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_VOLUME_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-migrating">Are you migrating from another provider?</Label>
|
||||
<Select
|
||||
value={migratingFrom}
|
||||
onValueChange={(v) => {
|
||||
setMigratingFrom(v);
|
||||
if (v !== "other") setMigratingOtherProvider("");
|
||||
if (v === "no") setSwitchReason("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ob-migrating"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_MIGRATION_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isOtherProvider && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Label htmlFor="ob-other-provider">Other provider</Label>
|
||||
<Input
|
||||
id="ob-other-provider"
|
||||
placeholder="Enter the provider here"
|
||||
value={migratingOtherProvider}
|
||||
onChange={(e) => setMigratingOtherProvider(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMigrating && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Label htmlFor="ob-switch-reason">
|
||||
Why are you switching? <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ob-switch-reason"
|
||||
rows={2}
|
||||
placeholder="e.g. cost, self-hosting, concurrency, data security, latency"
|
||||
value={switchReason}
|
||||
onChange={(e) => setSwitchReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ob-heard">How did you hear about us?</Label>
|
||||
<Select value={howHeard} onValueChange={setHowHeard}>
|
||||
<SelectTrigger id="ob-heard"><SelectValue placeholder="Select one" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ONBOARDING_HEARD_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</LeadModalShell>
|
||||
);
|
||||
}
|
||||
66
ui/src/components/lead-forms/PhoneField.tsx
Normal file
66
ui/src/components/lead-forms/PhoneField.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
// Dark-themed wrapper around react-international-phone's PhoneInput.
|
||||
// Emits a clean E.164 string (the backend geo/qualification rule keys off the
|
||||
// dial code). The library is styled with its own CSS variables, which we map to
|
||||
// our dark surface tokens so the field matches the rest of the form. Default
|
||||
// country is the US; the user can switch via the flag selector.
|
||||
|
||||
import "react-international-phone/style.css";
|
||||
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PhoneFieldProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Map the library's theming variables onto our dark surface tokens so the
|
||||
// control reads as one cohesive input rather than a third-party widget.
|
||||
const phoneThemeVars: React.CSSProperties = {
|
||||
["--react-international-phone-height" as string]: "2.25rem",
|
||||
["--react-international-phone-background-color" as string]: "transparent",
|
||||
["--react-international-phone-text-color" as string]: "var(--foreground)",
|
||||
["--react-international-phone-border-color" as string]: "var(--input)",
|
||||
["--react-international-phone-border-radius" as string]: "var(--radius-md)",
|
||||
["--react-international-phone-font-size" as string]: "0.875rem",
|
||||
["--react-international-phone-country-selector-background-color" as string]:
|
||||
"transparent",
|
||||
["--react-international-phone-country-selector-background-color-hover" as string]:
|
||||
"var(--accent)",
|
||||
["--react-international-phone-dropdown-item-background-color" as string]:
|
||||
"var(--popover)",
|
||||
["--react-international-phone-dropdown-item-text-color" as string]:
|
||||
"var(--popover-foreground)",
|
||||
["--react-international-phone-dropdown-item-background-color-hover" as string]:
|
||||
"var(--accent)",
|
||||
["--react-international-phone-selected-dropdown-item-background-color" as string]:
|
||||
"var(--accent)",
|
||||
};
|
||||
|
||||
export function PhoneField({ id, value, onChange, required, disabled }: PhoneFieldProps) {
|
||||
return (
|
||||
<div style={phoneThemeVars} className="phone-field-dark">
|
||||
<PhoneInput
|
||||
defaultCountry="us"
|
||||
value={value}
|
||||
onChange={(phone) => onChange(phone)}
|
||||
disabled={disabled}
|
||||
inputProps={{ id, required }}
|
||||
className="w-full"
|
||||
inputClassName={cn(
|
||||
"!w-full !bg-transparent !text-foreground placeholder:!text-muted-foreground",
|
||||
"focus-visible:!border-ring focus-visible:!ring-[3px] focus-visible:!ring-ring/50 !outline-none",
|
||||
)}
|
||||
countrySelectorStyleProps={{
|
||||
buttonClassName: "!h-9 !border-input !bg-transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
ui/src/components/lead-forms/detectCountry.ts
Normal file
84
ui/src/components/lead-forms/detectCountry.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Best-effort country detection for lead provenance — no permission prompt, no
|
||||
// network call, no precise geolocation. Primary signal: the browser's IANA timezone
|
||||
// → ISO 3166-1 country (location-based; timezones shared by several countries resolve
|
||||
// to the larger/likelier one). Fallback: the browser locale's region. Returns a
|
||||
// human-readable country name (e.g. "India") — it goes in the founders-email subject —
|
||||
// or undefined if nothing resolves. Sent silently in the form body; never shown.
|
||||
|
||||
// IANA timezone → ISO 3166-1 alpha-2. Curated to the common business regions; anything
|
||||
// not listed falls back to the locale region below. Shared zones → larger country.
|
||||
const TZ_TO_ISO: Record<string, string> = {
|
||||
// United States
|
||||
"America/New_York": "US", "America/Detroit": "US", "America/Chicago": "US",
|
||||
"America/Denver": "US", "America/Phoenix": "US", "America/Los_Angeles": "US",
|
||||
"America/Anchorage": "US", "America/Adak": "US", "America/Boise": "US",
|
||||
"America/Indiana/Indianapolis": "US", "Pacific/Honolulu": "US",
|
||||
// Canada
|
||||
"America/Toronto": "CA", "America/Montreal": "CA", "America/Vancouver": "CA",
|
||||
"America/Edmonton": "CA", "America/Winnipeg": "CA", "America/Halifax": "CA",
|
||||
"America/St_Johns": "CA", "America/Regina": "CA",
|
||||
// Mexico & Central/South America
|
||||
"America/Mexico_City": "MX", "America/Tijuana": "MX", "America/Monterrey": "MX",
|
||||
"America/Cancun": "MX", "America/Sao_Paulo": "BR", "America/Bahia": "BR",
|
||||
"America/Argentina/Buenos_Aires": "AR", "America/Santiago": "CL",
|
||||
"America/Bogota": "CO", "America/Lima": "PE", "America/Caracas": "VE",
|
||||
"America/Guayaquil": "EC", "America/Montevideo": "UY",
|
||||
// United Kingdom & Ireland
|
||||
"Europe/London": "GB", "Europe/Dublin": "IE",
|
||||
// Europe
|
||||
"Europe/Paris": "FR", "Europe/Berlin": "DE", "Europe/Madrid": "ES",
|
||||
"Europe/Rome": "IT", "Europe/Amsterdam": "NL", "Europe/Brussels": "BE",
|
||||
"Europe/Zurich": "CH", "Europe/Vienna": "AT", "Europe/Stockholm": "SE",
|
||||
"Europe/Oslo": "NO", "Europe/Copenhagen": "DK", "Europe/Helsinki": "FI",
|
||||
"Europe/Warsaw": "PL", "Europe/Prague": "CZ", "Europe/Budapest": "HU",
|
||||
"Europe/Bucharest": "RO", "Europe/Athens": "GR", "Europe/Lisbon": "PT",
|
||||
"Europe/Moscow": "RU", "Europe/Kiev": "UA", "Europe/Kyiv": "UA",
|
||||
"Europe/Istanbul": "TR",
|
||||
// South Asia
|
||||
"Asia/Kolkata": "IN", "Asia/Calcutta": "IN", "Asia/Karachi": "PK",
|
||||
"Asia/Dhaka": "BD", "Asia/Colombo": "LK", "Asia/Kathmandu": "NP",
|
||||
// Middle East
|
||||
"Asia/Dubai": "AE", "Asia/Riyadh": "SA", "Asia/Qatar": "QA",
|
||||
"Asia/Kuwait": "KW", "Asia/Jerusalem": "IL", "Asia/Tehran": "IR",
|
||||
"Asia/Baghdad": "IQ", "Asia/Amman": "JO",
|
||||
// East Asia
|
||||
"Asia/Shanghai": "CN", "Asia/Hong_Kong": "HK", "Asia/Taipei": "TW",
|
||||
"Asia/Tokyo": "JP", "Asia/Seoul": "KR",
|
||||
// Southeast Asia
|
||||
"Asia/Singapore": "SG", "Asia/Bangkok": "TH", "Asia/Jakarta": "ID",
|
||||
"Asia/Kuala_Lumpur": "MY", "Asia/Manila": "PH", "Asia/Ho_Chi_Minh": "VN",
|
||||
// Oceania
|
||||
"Australia/Sydney": "AU", "Australia/Melbourne": "AU", "Australia/Brisbane": "AU",
|
||||
"Australia/Perth": "AU", "Australia/Adelaide": "AU", "Pacific/Auckland": "NZ",
|
||||
// Africa
|
||||
"Africa/Johannesburg": "ZA", "Africa/Lagos": "NG", "Africa/Cairo": "EG",
|
||||
"Africa/Nairobi": "KE", "Africa/Casablanca": "MA", "Africa/Accra": "GH",
|
||||
};
|
||||
|
||||
// Resolve the browser locale's region (e.g. "en-GB" → "GB"), maximizing likely subtags
|
||||
// for bare languages (e.g. "en" → "US"). Returns an ISO alpha-2 or undefined.
|
||||
function localeRegion(): string | undefined {
|
||||
if (typeof navigator === "undefined" || !navigator.language) return undefined;
|
||||
try {
|
||||
return new Intl.Locale(navigator.language).maximize().region ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectCountry(): string | undefined {
|
||||
let iso: string | undefined;
|
||||
try {
|
||||
iso = TZ_TO_ISO[Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
} catch {
|
||||
// Intl unavailable — fall through to the locale region.
|
||||
}
|
||||
iso = iso || localeRegion();
|
||||
if (!iso) return undefined;
|
||||
try {
|
||||
// Human-readable name for the founders-email subject (e.g. "IN" → "India").
|
||||
return new Intl.DisplayNames(["en"], { type: "region" }).of(iso) ?? iso;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
49
ui/src/components/lead-forms/isPersonalEmail.ts
Normal file
49
ui/src/components/lead-forms/isPersonalEmail.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Returns true if the email uses a common free/personal domain.
|
||||
// Used to gate "work email" fields on lead forms.
|
||||
|
||||
const PERSONAL_EMAIL_DOMAINS = new Set([
|
||||
"gmail.com",
|
||||
"googlemail.com",
|
||||
"yahoo.com",
|
||||
"yahoo.co.in",
|
||||
"yahoo.co.uk",
|
||||
"ymail.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"hotmail.co.uk",
|
||||
"live.com",
|
||||
"msn.com",
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"mac.com",
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"pm.me",
|
||||
"aol.com",
|
||||
"gmx.com",
|
||||
"gmx.net",
|
||||
"mail.com",
|
||||
"zoho.com",
|
||||
"yandex.com",
|
||||
"fastmail.com",
|
||||
]);
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
// Pragmatic check — not RFC-perfect, but rejects obvious garbage.
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
||||
}
|
||||
|
||||
export function isPersonalEmail(email: string): boolean {
|
||||
const at = email.trim().toLowerCase().split("@");
|
||||
if (at.length !== 2) return false;
|
||||
return PERSONAL_EMAIL_DOMAINS.has(at[1]);
|
||||
}
|
||||
|
||||
// Convenience validator for work-email fields.
|
||||
// Returns an error string, or null if valid.
|
||||
export function validateWorkEmail(email: string): string | null {
|
||||
if (!email.trim()) return "Work email is required";
|
||||
if (!isValidEmail(email)) return "Please enter a valid email address";
|
||||
if (isPersonalEmail(email)) return "Please use your work email";
|
||||
return null;
|
||||
}
|
||||
106
ui/src/components/lead-forms/leadFieldOptions.ts
Normal file
106
ui/src/components/lead-forms/leadFieldOptions.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Shared dropdown options + lead source/kind types for the lead-gen forms.
|
||||
|
||||
export type LeadSource =
|
||||
| "sidebar"
|
||||
| "billing_card"
|
||||
| "billing_custom_pricing"
|
||||
| "builder_nudge"
|
||||
| "hire_expert"
|
||||
| "onboarding"
|
||||
| "pricing_custom_volume"
|
||||
| "landing_contact"
|
||||
| "auth_page";
|
||||
|
||||
export type LeadKind = "hire_expert" | "enterprise";
|
||||
|
||||
// Provenance stamped by the in-app forms (analytics only; the marketing site and
|
||||
// server use "website"). Derived from AppConfig deploymentMode: cloud → "cloud_app",
|
||||
// otherwise "oss_app". OSS submits via the public no-token endpoints.
|
||||
export type LeadOrigin = "cloud_app" | "oss_app";
|
||||
|
||||
// Monthly call-volume buckets. Values MUST match the backend qualifier enum
|
||||
// (user_onboarding flows): "0-5k" | "5k-100k" | "100k+" | "not-sure".
|
||||
export const VOLUME_OPTIONS = [
|
||||
{ value: "0-5k", label: "0–5k" },
|
||||
{ value: "5k-100k", label: "5k–100k" },
|
||||
{ value: "100k+", label: "100k+" },
|
||||
{ value: "not-sure", label: "Not sure" },
|
||||
] as const;
|
||||
|
||||
// Hire-an-Expert expected monthly call volume (shared bucket set).
|
||||
export const HIRE_VOLUME_OPTIONS = VOLUME_OPTIONS;
|
||||
|
||||
// Enterprise monthly call volume (shared bucket set).
|
||||
export const ENTERPRISE_VOLUME_OPTIONS = VOLUME_OPTIONS;
|
||||
|
||||
// Lead sources for which the Enterprise modal surfaces the conditional
|
||||
// "Need enterprise deployment (SSO, on-prem, data residency)?" question.
|
||||
// Other entry points hide it and default the payload to "yes".
|
||||
export const ENTERPRISE_DEPLOYMENT_SOURCES: readonly LeadSource[] = [
|
||||
"billing_custom_pricing",
|
||||
"pricing_custom_volume",
|
||||
"landing_contact",
|
||||
"auth_page",
|
||||
];
|
||||
|
||||
// Enterprise deployment need (conditional — see ENTERPRISE_DEPLOYMENT_SOURCES).
|
||||
export const ENTERPRISE_DEPLOYMENT_OPTIONS = [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
{ value: "maybe", label: "Maybe" },
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-signup onboarding form options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Onboarding: are you migrating from another provider? (a trimmed competitor list).
|
||||
// "no" → not migrating; "other" → reveals a free-text provider field.
|
||||
export const ONBOARDING_MIGRATION_OPTIONS = [
|
||||
{ value: "no", label: "No, I'm not migrating" },
|
||||
{ value: "vapi", label: "Vapi" },
|
||||
{ value: "retell", label: "Retell" },
|
||||
{ value: "bland", label: "Bland" },
|
||||
{ value: "elevenlabs", label: "ElevenLabs" },
|
||||
{ value: "synthflow", label: "Synthflow" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: how did you hear about us? (trimmed).
|
||||
export const ONBOARDING_HEARD_OPTIONS = [
|
||||
{ value: "github", label: "GitHub" },
|
||||
{ value: "search_engine", label: "Search engine" },
|
||||
{ value: "social_media", label: "Social media (Twitter, LinkedIn)" },
|
||||
{ value: "youtube", label: "YouTube" },
|
||||
{ value: "ai_tool", label: "AI tool (ChatGPT, Claude)" },
|
||||
{ value: "referral", label: "Someone told me about it" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: expected monthly call volume. Its own set — value "exploring" is NOT
|
||||
// the qualifier's "not-sure"; onboarding has no flow, so this is analytics-only.
|
||||
export const ONBOARDING_VOLUME_OPTIONS = [
|
||||
{ value: "0-5k", label: "0–5k" },
|
||||
{ value: "5k-100k", label: "5k–100k" },
|
||||
{ value: "100k+", label: "100k+" },
|
||||
{ value: "exploring", label: "Exploring" },
|
||||
] as const;
|
||||
|
||||
// Onboarding: what best describes you.
|
||||
export const ONBOARDING_PERSONA_OPTIONS = [
|
||||
{ value: "enterprise_midmarket", label: "Enterprise / Mid-Market" },
|
||||
{ value: "agency", label: "Agency / consultancy building for clients" },
|
||||
{ value: "local_business", label: "Local business" },
|
||||
{ value: "startup", label: "Startup" },
|
||||
{ value: "solo", label: "Solo founder / builder" },
|
||||
] as const;
|
||||
|
||||
// Persona values that unlock the on-prem conditional question.
|
||||
export const ONBOARDING_ONPREM_PERSONAS: readonly string[] = ["enterprise_midmarket"];
|
||||
|
||||
// Onboarding: on-prem deployment need (conditional on Enterprise/Mid-Market).
|
||||
export const ONBOARDING_ONPREM_OPTIONS = [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
{ value: "not_sure", label: "Not sure" },
|
||||
] as const;
|
||||
57
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
57
ui/src/components/lead-forms/onboardingServiceClient.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Thin client for the SEPARATE user_onboarding service (its own base URL).
|
||||
// Not part of the generated Dograh SDK — a different host. All endpoints are PUBLIC
|
||||
// (no auth token); identity is the email carried in the body. Every call is
|
||||
// BEST-EFFORT: failures are swallowed so a down/erroring service never blocks the user.
|
||||
|
||||
// Base URL of the user_onboarding service. Unset (the default for self-hosted OSS —
|
||||
// .env.example ships this commented out) → fall back to our cloud leads backend so we
|
||||
// still receive OSS form submissions. Override the env var to point elsewhere (or to a
|
||||
// local backend) to stop sending leads to us.
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL || "https://api-leads.dograh.com";
|
||||
|
||||
// Bound every call so a slow/hung service can never freeze the UI. Best-effort:
|
||||
// failures are surfaced via console.error (Sentry breadcrumbs) but never thrown.
|
||||
const TIMEOUT_MS = 6000;
|
||||
|
||||
// POST a JSON body to the onboarding service (public — no auth header).
|
||||
async function post(path: string, body: unknown): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
// fetch does not reject on 4xx/5xx — check explicitly so dropped leads are
|
||||
// at least observable.
|
||||
if (!res.ok) {
|
||||
console.error(`[onboarding] POST ${path} failed with HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Network error, or the timeout aborted the request. Never block the user.
|
||||
console.error(`[onboarding] POST ${path} did not complete:`, err);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Map a lead kind to its endpoint path on the onboarding service.
|
||||
const LEAD_PATH: Record<"hire_expert" | "enterprise", string> = {
|
||||
hire_expert: "/api/v1/leads/hire-expert",
|
||||
enterprise: "/api/v1/leads/enterprise",
|
||||
};
|
||||
|
||||
// Persist a lead submission (hire-expert / enterprise). Email is in the body.
|
||||
export async function postLeadToService(
|
||||
kind: "hire_expert" | "enterprise",
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await post(LEAD_PATH[kind], body);
|
||||
}
|
||||
|
||||
// Persist an onboarding submission (or skip — body carries `skipped`).
|
||||
export async function postOnboardingToService(body: Record<string, unknown>): Promise<void> {
|
||||
await post("/api/v1/onboarding", body);
|
||||
}
|
||||
36
ui/src/components/lead-forms/submitLead.ts
Normal file
36
ui/src/components/lead-forms/submitLead.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Single submission seam for all lead forms.
|
||||
// Fires a PostHog capture (the durable record) and POSTs to the separate, PUBLIC
|
||||
// user_onboarding service (best-effort — the user is never blocked if it's down).
|
||||
// No auth token: identity is the email in the payload.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
import { detectCountry } from "./detectCountry";
|
||||
import type { LeadKind, LeadOrigin, LeadSource } from "./leadFieldOptions";
|
||||
import { postLeadToService } from "./onboardingServiceClient";
|
||||
|
||||
const SUBMIT_EVENT: Record<LeadKind, string> = {
|
||||
hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED,
|
||||
enterprise: PostHogEvent.ENTERPRISE_LEAD_SUBMITTED,
|
||||
};
|
||||
|
||||
export interface SubmitLeadArgs {
|
||||
kind: LeadKind;
|
||||
source: LeadSource;
|
||||
// Deployment provenance (analytics only): "cloud_app" | "oss_app".
|
||||
origin: LeadOrigin;
|
||||
// Field values, already validated by the caller. Includes the contact email.
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function submitLead({ kind, source, origin, payload }: SubmitLeadArgs): Promise<void> {
|
||||
// `country` is detected silently (timezone/locale) and sent in the body — no visible
|
||||
// field. It feeds the founders-notification email subject server-side.
|
||||
const body = { source, origin, country: detectCountry(), ...payload };
|
||||
// PostHog capture — the durable record, always fired.
|
||||
posthog.capture(SUBMIT_EVENT[kind], body);
|
||||
// Persist to the separate user_onboarding service (best-effort, public).
|
||||
await postLeadToService(kind, body);
|
||||
}
|
||||
49
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
49
ui/src/components/lead-forms/submitOnboarding.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Submission seam for the post-signup onboarding form.
|
||||
// Fires a PostHog capture AND POSTs the answers to the separate, PUBLIC
|
||||
// user_onboarding service (best-effort). The "show once per user" flag is stamped
|
||||
// on the server-backed onboarding state by the caller, not here.
|
||||
//
|
||||
// No auth token. The logged-in user's email is passed in from the modal (available in
|
||||
// the frontend session for both cloud and OSS) and sent in the body — there is no
|
||||
// visible email field. `country` is detected silently and sent too. Onboarding is now
|
||||
// COMPULSORY (no skip).
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
|
||||
import { detectCountry } from "./detectCountry";
|
||||
import type { LeadOrigin } from "./leadFieldOptions";
|
||||
import { postOnboardingToService } from "./onboardingServiceClient";
|
||||
|
||||
export interface OnboardingAnswers {
|
||||
persona?: string;
|
||||
// Only present when persona unlocks the on-prem question.
|
||||
onPremNeed?: string;
|
||||
// Are you migrating from another provider? ("no" | a provider | "other").
|
||||
migratingFrom?: string;
|
||||
// Free-text provider name when migratingFrom === "other".
|
||||
migratingOtherProvider?: string;
|
||||
// Free-text "why are you switching?" (shown when migrating).
|
||||
switchReason?: string;
|
||||
// How did you hear about us?
|
||||
howHeard?: string;
|
||||
// Expected monthly call volume (0-5k | 5k-100k | 100k+ | exploring).
|
||||
volume?: string;
|
||||
}
|
||||
|
||||
export async function submitOnboarding(
|
||||
answers: OnboardingAnswers,
|
||||
origin: LeadOrigin,
|
||||
email?: string,
|
||||
): Promise<void> {
|
||||
posthog.capture(PostHogEvent.ONBOARDING_SUBMITTED, { ...answers, origin });
|
||||
await postOnboardingToService({
|
||||
source: "onboarding",
|
||||
origin,
|
||||
country: detectCountry(),
|
||||
...(email ? { email } : {}),
|
||||
...answers,
|
||||
skipped: false, // onboarding is compulsory now — kept for stored-shape continuity
|
||||
});
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"card-weave rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm dark:shadow-md dark:shadow-black/25",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function DialogOverlay({
|
|||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"focus-visible:border-cta/70 focus-visible:ring-cta/30 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@/client/sdk.gen';
|
||||
import type { FolderResponse } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -126,132 +127,134 @@ export function WorkflowTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Agent Name</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold text-center">Total Runs</TableHead>
|
||||
<TableHead className="font-semibold text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflows.map((workflow) => (
|
||||
<TableRow
|
||||
key={workflow.id}
|
||||
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{workflow.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{workflow.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(workflow.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
|
||||
{workflow.total_runs || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(workflow.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
{folders && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={movingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{movingWorkflowId === workflow.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<FolderInput size={16} />
|
||||
)}
|
||||
Move
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={currentFolderId === null}
|
||||
onClick={() => handleMove(workflow.id, null)}
|
||||
>
|
||||
<Inbox size={14} className="mr-2" />
|
||||
Uncategorized
|
||||
{currentFolderId === null && (
|
||||
<Check size={14} className="ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{folders.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
disabled={folder.id === currentFolderId}
|
||||
onClick={() => handleMove(workflow.id, folder.id)}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Agent Name</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold text-center">Total Runs</TableHead>
|
||||
<TableHead className="font-semibold text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflows.map((workflow) => (
|
||||
<TableRow
|
||||
key={workflow.id}
|
||||
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{workflow.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{workflow.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(workflow.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
|
||||
{workflow.total_runs || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(workflow.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Edit
|
||||
</Button>
|
||||
{folders && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={movingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FolderIcon size={14} className="mr-2" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.id === currentFolderId && (
|
||||
<Check size={14} className="ml-auto shrink-0" />
|
||||
{movingWorkflowId === workflow.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<FolderInput size={16} />
|
||||
)}
|
||||
Move
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={currentFolderId === null}
|
||||
onClick={() => handleMove(workflow.id, null)}
|
||||
>
|
||||
<Inbox size={14} className="mr-2" />
|
||||
Uncategorized
|
||||
{currentFolderId === null && (
|
||||
<Check size={14} className="ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
|
||||
disabled={loadingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{loadingWorkflowId === workflow.id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{showArchived ? 'Restoring...' : 'Archiving...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showArchived ? (
|
||||
<>
|
||||
<RotateCcw size={16} />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive size={16} />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{folders.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
disabled={folder.id === currentFolderId}
|
||||
onClick={() => handleMove(workflow.id, folder.id)}
|
||||
>
|
||||
<FolderIcon size={14} className="mr-2" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
{folder.id === currentFolderId && (
|
||||
<Check size={14} className="ml-auto shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleArchiveToggle(workflow.id, workflow.status)}
|
||||
disabled={loadingWorkflowId === workflow.id || isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{loadingWorkflowId === workflow.id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{showArchived ? 'Restoring...' : 'Archiving...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showArchived ? (
|
||||
<>
|
||||
<RotateCcw size={16} />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive size={16} />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export function FolderSection({
|
|||
<AlertDialogTitle>Delete “{folder.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The {count} agent{count === 1 ? '' : 's'} in this folder
|
||||
won’t be deleted — they’ll move to Uncategorized.
|
||||
won’t be deleted - they’ll move to Uncategorized.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue