diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 0c7bdf9c..087479fc 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -42,6 +42,8 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --color-cta: var(--cta); + --color-cta-foreground: var(--cta-foreground); } :root { @@ -77,6 +79,9 @@ --sidebar-ring: oklch(0.708 0 0); --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); + /* Single restrained warm accent — used only on primary CTAs + focus rings. */ + --cta: oklch(0.72 0.15 65); + --cta-foreground: oklch(0.16 0.02 60); } .dark { @@ -111,6 +116,9 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); + /* Warm accent, slightly brighter against the near-black surfaces. */ + --cta: oklch(0.78 0.16 67); + --cta-foreground: oklch(0.16 0.02 60); } @layer base { @@ -135,3 +143,45 @@ .animate-spin-slow { animation: spin-slow 3s linear infinite; } + +@layer components { + /* CSS-only audio-waveform motif for the auth brand panel. A row of bars that + breathe at staggered intervals, evoking live voice. Decorative only. */ + .auth-waveform { + display: flex; + align-items: center; + gap: 0.3rem; + height: 3.5rem; + } + + .auth-waveform span { + display: block; + width: 0.25rem; + border-radius: 9999px; + background: linear-gradient( + to top, + color-mix(in oklch, var(--cta) 70%, transparent), + color-mix(in oklch, var(--cta) 25%, transparent) + ); + animation: auth-wave 1.4s ease-in-out infinite; + transform-origin: center; + } + + .auth-waveform span:nth-child(1) { animation-delay: 0s; height: 35%; } + .auth-waveform span:nth-child(2) { animation-delay: 0.15s; height: 65%; } + .auth-waveform span:nth-child(3) { animation-delay: 0.3s; height: 100%; } + .auth-waveform span:nth-child(4) { animation-delay: 0.45s; height: 55%; } + .auth-waveform span:nth-child(5) { animation-delay: 0.6s; height: 80%; } + .auth-waveform span:nth-child(6) { animation-delay: 0.3s; height: 45%; } + .auth-waveform span:nth-child(7) { animation-delay: 0.15s; height: 70%; } + .auth-waveform span:nth-child(8) { animation-delay: 0s; height: 30%; } + + @keyframes auth-wave { + 0%, 100% { transform: scaleY(0.4); opacity: 0.7; } + 50% { transform: scaleY(1); opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .auth-waveform span { animation: none; } + } +} diff --git a/ui/src/components/billing/BuyCreditsControl.tsx b/ui/src/components/billing/BuyCreditsControl.tsx new file mode 100644 index 00000000..608f58cf --- /dev/null +++ b/ui/src/components/billing/BuyCreditsControl.tsx @@ -0,0 +1,110 @@ +"use client"; + +// Compact self-serve "Buy Credits" control for the billing card. Preset amount +// chips plus a custom amount (min $5) feed the Razorpay seam in +// @/lib/billing/topup. Analytics: chip selection and the buy click are captured +// for funnel analysis. The seam currently throws "not wired yet"; we surface +// that as a calm inline note rather than an error toast. + +import posthog from "posthog-js"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PostHogEvent } from "@/constants/posthog-events"; +import { MIN_TOPUP_USD, startTopUp, TOPUP_PRESETS } from "@/lib/billing/topup"; +import { cn } from "@/lib/utils"; + +export function BuyCreditsControl() { + const [selected, setSelected] = useState(null); + const [custom, setCustom] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // The effective amount: a parsed custom value takes precedence when present. + const customAmount = custom.trim() ? Number(custom) : null; + const amount = customAmount ?? selected; + const valid = amount != null && Number.isFinite(amount) && amount >= MIN_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 = Number(raw); + if (raw.trim() && Number.isFinite(parsed) && parsed >= MIN_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 ( +
+
+ {TOPUP_PRESETS.map((value) => ( + + ))} +
+ + $ + + onCustomChange(e.target.value)} + placeholder="Custom" + aria-label={`Custom amount (min $${MIN_TOPUP_USD})`} + className="h-9 w-28 pl-5" + /> +
+
+ + {error ? ( +

{error}

+ ) : ( +

Minimum ${MIN_TOPUP_USD}.

+ )} + + +
+ ); +} diff --git a/ui/src/components/billing/DograhCreditsCard.tsx b/ui/src/components/billing/DograhCreditsCard.tsx index 25a09a6e..f94f74ef 100644 --- a/ui/src/components/billing/DograhCreditsCard.tsx +++ b/ui/src/components/billing/DograhCreditsCard.tsx @@ -1,19 +1,22 @@ "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, openTopUp } = useLeadForms(); + const { openHireExpert, openEnterprise } = useLeadForms(); const [mpsCredits, setMpsCredits] = useState(null); const [isLoadingCredits, setIsLoadingCredits] = useState(true); @@ -80,16 +83,31 @@ export function DograhCreditsCard() {

)} - {/* Footer CTAs — card ends with an action */} -
- Running low? -
- - + {/* Footer CTAs — card ends with self-serve + done-for-you actions */} +
+
+
+

Running low?

+

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

+
+
+ + +
+
diff --git a/ui/src/components/lead-forms/EnterpriseModal.tsx b/ui/src/components/lead-forms/EnterpriseModal.tsx index 4ffb9ae6..f0d0f84a 100644 --- a/ui/src/components/lead-forms/EnterpriseModal.tsx +++ b/ui/src/components/lead-forms/EnterpriseModal.tsx @@ -1,16 +1,9 @@ "use client"; -import { useState } from "react"; +import { ShieldCheck } from "lucide-react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -21,45 +14,82 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/lib/auth"; +import { FormTrustLine } from "./FormTrustLine"; import { validateWorkEmail } from "./isPersonalEmail"; import { - ENTERPRISE_COMPANY_SIZE_OPTIONS, - ENTERPRISE_INDUSTRY_OPTIONS, - ENTERPRISE_TIMELINE_OPTIONS, + ENTERPRISE_DEPLOYMENT_OPTIONS, + ENTERPRISE_DEPLOYMENT_SOURCES, + ENTERPRISE_VOLUME_OPTIONS, type LeadSource, } from "./leadFieldOptions"; +import { LeadModalShell } from "./LeadModalShell"; import { MathCaptcha } from "./MathCaptcha"; +import { PhoneField } from "./PhoneField"; 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 in the onboarding form). Backward-compatible: omitted = no prefill. + prefill?: { company?: string }; } -export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalProps) { +export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) { + const { getAccessToken, isAuthenticated } = useAuth(); // Dograh token for the onboarding service + const [name, setName] = useState(""); const [company, setCompany] = useState(""); - const [industry, setIndustry] = useState(""); - const [companySize, setCompanySize] = useState(""); - const [timeline, setTimeline] = useState(""); + const [jobTitle, setJobTitle] = useState(""); const [workEmail, setWorkEmail] = useState(""); const [phone, setPhone] = useState(""); - const [notes, setNotes] = useState(""); + const [volume, setVolume] = useState(""); + const [deployment, setDeployment] = useState(""); + const [agentGoal, setAgentGoal] = useState(""); const [emailError, setEmailError] = useState(null); const [captchaValid, setCaptchaValid] = 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); + // Work email is mandatory only when the visitor is logged out (we already + // have the email for authenticated users via their Dograh token). + const workEmailRequired = !isAuthenticated; + const reset = () => { - setCompany(""); setIndustry(""); setCompanySize(""); setTimeline(""); - setWorkEmail(""); setPhone(""); setNotes(""); setEmailError(null); - setCaptchaValid(false); setSubmitting(false); + setName(""); setCompany(""); setJobTitle(""); setWorkEmail(""); + setPhone(""); setVolume(""); setDeployment(""); setAgentGoal(""); + setEmailError(null); setCaptchaValid(false); setSubmitting(false); }; + // Seed reusable fields from prefill when the modal opens, so we don't re-ask + // for info already captured upstream (e.g. company name from onboarding). + const prefillCompany = prefill?.company; + useEffect(() => { + if (open && prefillCompany) { + setCompany((prev) => prev || prefillCompany); + } + }, [open, prefillCompany]); + + const canSubmit = + Boolean(name.trim()) && + Boolean(company.trim()) && + Boolean(phone.trim()) && + Boolean(volume) && + (!workEmailRequired || Boolean(workEmail.trim())) && + captchaValid && + !submitting; + const handleSubmit = async () => { - const err = validateWorkEmail(workEmail); - if (err) { setEmailError(err); return; } - if (!company.trim() || !industry || !companySize || !timeline) { + if (workEmailRequired || workEmail.trim()) { + const err = validateWorkEmail(workEmail); + if (err) { setEmailError(err); return; } + } + if (!name.trim() || !company.trim() || !phone.trim() || !volume) { toast.error("Please fill in all required fields"); return; } @@ -67,10 +97,23 @@ export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalP setSubmitting(true); try { + // Resolve the token best-effort; submission still succeeds via PostHog if it fails. + const token = await getAccessToken().catch(() => undefined); await submitLead({ kind: "enterprise", source, - payload: { company, industry, companySize, timeline, workEmail, phone, notes }, + payload: { + name, + company, + jobTitle, + workEmail, + phone, + volume, + // Hidden entry points imply enterprise intent — default to "yes". + deployment: showDeployment ? deployment || "yes" : "yes", + agentGoal, + }, + token, }); toast.success("Thanks — our team will reach out about enterprise deployment."); reset(); @@ -82,59 +125,40 @@ export function EnterpriseModal({ open, onOpenChange, source }: EnterpriseModalP }; return ( - { if (!o) reset(); onOpenChange(o); }}> - - - Enterprise deployment - - SSO, on-prem, SOC2, data residency. Tell us about your environment. - - - -
+ { if (!o) reset(); onOpenChange(o); }} + icon={ShieldCheck} + eyebrow="Enterprise" + title="Talk to our team" + 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={} + > +
+
+
+ + setName(e.target.value)} /> +
setCompany(e.target.value)} />
+
+
- - + + setJobTitle(e.target.value)} />
-
- - -
- -
- - -
- -
- + {emailError &&

{emailError}

}
+
-
- - setPhone(e.target.value)} /> +
+
+ +
- -
- -