From 9fe73c2ae236f0bdc295cadda53f532f88272aa7 Mon Sep 17 00:00:00 2001 From: Pritesh Date: Fri, 12 Jun 2026 17:11:37 +0530 Subject: [PATCH] ui changes --- ui/public/brand-imprint-dark.svg | 1 + ui/public/brand-imprint-light.svg | 1 + ui/src/app/globals.css | 147 ++++++++++++++++-- ui/src/components/auth/AuthEnterpriseCTA.tsx | 34 ++-- ui/src/components/auth/AuthShell.tsx | 5 +- ui/src/components/layout/AppSidebar.tsx | 139 ++++++++++------- .../lead-forms/CaptchaChallenge.tsx | 72 +++++---- .../lead-forms/EnterpriseLeadFields.tsx | 17 +- .../components/lead-forms/EnterpriseModal.tsx | 18 +-- .../components/lead-forms/HireExpertModal.tsx | 11 +- .../components/lead-forms/LeadModalShell.tsx | 73 ++++----- .../components/lead-forms/OnboardingModal.tsx | 5 +- .../components/lead-forms/leadFieldOptions.ts | 4 +- .../lead-forms/onboardingServiceClient.ts | 28 ++-- ui/src/components/lead-forms/submitLead.ts | 8 +- ui/src/components/ui/card.tsx | 2 +- 16 files changed, 363 insertions(+), 202 deletions(-) create mode 100644 ui/public/brand-imprint-dark.svg create mode 100644 ui/public/brand-imprint-light.svg diff --git a/ui/public/brand-imprint-dark.svg b/ui/public/brand-imprint-dark.svg new file mode 100644 index 00000000..6d44ab46 --- /dev/null +++ b/ui/public/brand-imprint-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/brand-imprint-light.svg b/ui/public/brand-imprint-light.svg new file mode 100644 index 00000000..a22a0c3b --- /dev/null +++ b/ui/public/brand-imprint-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index e4d83dca..cc929a23 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -82,6 +82,11 @@ /* Single restrained warm accent — used only on primary CTAs + focus rings. */ --cta: oklch(0.72 0.15 65); --cta-foreground: oklch(0.16 0.02 60); + /* Giant faded "dograh" wordmark (authentic Proxima Nova letterforms traced + from the brand logo PNG — the font is commercial, so the lettering ships + as static artwork in /public; fill + 0.9% opacity are baked into the + files). Theme-switched here; consumed by .app-surface and .auth-imprint. */ + --brand-imprint: url("/brand-imprint-light.svg"); } .dark { @@ -119,6 +124,7 @@ /* Warm accent, slightly brighter against the near-black surfaces. */ --cta: oklch(0.78 0.16 67); --cta-foreground: oklch(0.16 0.02 60); + --brand-imprint: url("/brand-imprint-dark.svg"); } @layer base { @@ -185,26 +191,135 @@ .auth-waveform span { animation: none; } } - /* Atmospheric app background — premium dark depth instead of flat black. - Decorative only; applied to content areas via the .app-surface class so it - cascades to every page without per-page edits. Light mode stays clean. */ + /* Matte app background — flat charcoal (dark) / soft paper (light), NO + gradients, with one subtle graphic in BOTH themes: the giant faded + "dograh" wordmark (--brand-imprint, defined in :root/.dark) pinned to + the bottom of the viewport, echoing the dograh.com footer. */ + /* NOTE: background-attachment: fixed positions in VIEWPORT space but only + paints inside .app-surface, which starts right of the ~270px sidebar — + the +135px x-shift recentres the wordmark on the VISIBLE canvas. */ .app-surface { - background-color: var(--background); + background-color: oklch(0.984 0.001 80); + background-image: var(--brand-imprint); + background-size: min(68vw, 980px) auto; + background-position: calc(50% + 135px) calc(100% - 24px); + background-repeat: no-repeat; + background-attachment: fixed; + } + /* Sidebar is offcanvas on small screens — true centre there. */ + @media (max-width: 767px) { + .app-surface { + background-position: center calc(100% - 24px); + } } .dark .app-surface { - background-image: - radial-gradient(55rem 32rem at 100% 100%, color-mix(in oklch, var(--cta) 13%, transparent), transparent 55%), - radial-gradient(48rem 30rem at 0% 100%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 52%), - linear-gradient(0deg, color-mix(in oklch, var(--foreground) 4%, transparent), transparent 38%); + background-color: oklch(0.165 0.002 80); + } + + /* Giant faded "dograh" imprint for the auth pages (applied to the AuthShell + form column, shared by Stack + OSS login/signup). Same --brand-imprint as + .app-surface; element-relative here (no fixed attachment), so it centers + and scales to whatever element carries the class. */ + .auth-imprint { + background-image: var(--brand-imprint); + background-size: min(86%, 920px) auto; + background-position: center calc(100% - 32px); background-repeat: no-repeat; } - /* Faint warm wash at the bottom of the sidebar for subtle depth (dark only). */ - .dark .app-sidebar-surface { - background-image: linear-gradient( - 0deg, - color-mix(in oklch, var(--cta) 8%, transparent), - transparent 32% - ); - } +} + +/* --------------------------------------------------------------------------- + UN-LAYERED overrides. These intentionally live OUTSIDE @layer blocks: + they restyle elements that carry Tailwind utility classes (bg-sidebar, + rounded-lg, shadow-sm, border-*) and utilities sit in a later cascade + layer than @layer components — un-layered author CSS beats both. +--------------------------------------------------------------------------- */ + +/* Floating-dock sidebar: detached rounded panel. Targets the shadcn sidebar's + inner panel; applied via .app-sidebar-dock on . */ +.app-sidebar-dock [data-slot="sidebar-inner"] { + border-radius: 1.25rem; + overflow: hidden; +} +/* Flat carbon-charcoal panel with a soft light glow along the LEFT edge: + a 1px highlight line plus an inner bloom fading rightwards. */ +.dark .app-sidebar-dock [data-slot="sidebar-inner"] { + border-color: rgb(255 255 255 / 0.1); + background-color: oklch(0.18 0.002 80); + box-shadow: + inset 1px 0 0 rgb(255 255 255 / 0.1), + inset 3px 0 6px -4px rgb(255 255 255 / 0.08), + 0 24px 50px -14px rgb(0 0 0 / 0.85); +} + +/* Card surface ("Crosshatch + Top-Lit Edge", user-approved 2026-06-11 after a + 3-round design board): a 45° hairline twill weave at 1% laid over the panel + colour, plus — dark mode only — a brighter SOLID top border, like light + catching the machined top edge of the panel. Applied app-wide by the Card + primitive (components/ui/card.tsx). Un-layered so border-top-color beats + the border-border/60 utility. No gradients (user constraint). */ +.card-weave { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23000000' stroke-opacity='.015' fill='none'/%3E%3C/svg%3E"); + background-repeat: repeat; +} +.dark .card-weave { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23ffffff' stroke-opacity='.01' fill='none'/%3E%3C/svg%3E"); + border-top-color: rgb(255 255 255 / 0.2); +} + +/* Lead-form shell ("Ledger" treatment, user-approved 2026-06-11): neutral + charcoal slab where ONLY the header band is darker (body and footer share + the slab colour), muted compact labels, and underline-only fields with an + amber underline on focus. Applied by LeadModalShell; CaptchaChallenge + reuses slab + underline. */ +.dark .lead-form-slab { + background-color: oklch(0.215 0 0); + border-color: rgb(255 255 255 / 0.1); +} +/* Muted, compact labels — the big white default labels read amateurish. */ +.lead-form-underline label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--muted-foreground); +} +/* Ghost placeholders: present on every field, but barely-there. */ +.lead-form-underline :is(input, textarea)::placeholder { + color: var(--muted-foreground); + opacity: 0.14; +} +.lead-form-underline [data-slot="select-trigger"][data-placeholder] { + color: color-mix(in oklab, var(--muted-foreground) 17%, transparent); +} +/* Underline-only fields: transparent box, hairline bottom border, amber + underline on the focused control. Compact heights keep rows tight. */ +.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]) { + background-color: transparent; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + box-shadow: none; + padding-left: 2px; + padding-right: 2px; +} +.lead-form-underline :is(input, [data-slot="select-trigger"]) { + height: 2.125rem; +} +.lead-form-underline textarea { + min-height: 3.25rem; +} +/* The phone country selector ships its own box — flatten it to match. */ +.lead-form-underline .react-international-phone-country-selector-button { + border: 0 !important; + border-bottom: 1px solid var(--border) !important; + border-radius: 0 !important; + background: transparent !important; +} +.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]):focus-visible, +.lead-form-underline [data-slot="select-trigger"][data-state="open"] { + outline: none; + box-shadow: none; + border-bottom-color: var(--cta); } diff --git a/ui/src/components/auth/AuthEnterpriseCTA.tsx b/ui/src/components/auth/AuthEnterpriseCTA.tsx index 3f507cf2..961232fc 100644 --- a/ui/src/components/auth/AuthEnterpriseCTA.tsx +++ b/ui/src/components/auth/AuthEnterpriseCTA.tsx @@ -1,26 +1,38 @@ "use client"; -// Bland-style enterprise call-to-action rendered inside the auth brand panel. -// Links out to the main marketing site's enterprise intake form rather than the -// in-app modal, since the visitor is not yet authenticated here. Shared by the -// Stack Auth handler and the local/OSS auth pages. +// 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 ( - + <> - + + ); } diff --git a/ui/src/components/auth/AuthShell.tsx b/ui/src/components/auth/AuthShell.tsx index 5c472691..32ae6f03 100644 --- a/ui/src/components/auth/AuthShell.tsx +++ b/ui/src/components/auth/AuthShell.tsx @@ -26,8 +26,9 @@ export function AuthShell({ }) { return (
- {/* Form column (LEFT) — scrolls and stays centered so tall forms never clip. */} -
+ {/* Form column (LEFT) — scrolls and stays centered so tall forms never + clip. Carries the giant faded "dograh" imprint along its bottom. */} +
{/* Mobile-only wordmark (brand panel is hidden) */} diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index b1f172e2..bb67cc4e 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -227,8 +227,9 @@ export function AppSidebar() { asChild tooltip={tooltip} className={cn( - "transition-colors hover:bg-accent hover:text-accent-foreground", - isItemActive && "bg-cta/10 font-medium text-foreground hover:bg-cta/15" + "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" )} > {isItemActive && !isCollapsed && ( - + )} - + s[0]?.toUpperCase()) + .join("") || "U"; + + const userChipTrigger = ( + + ); + + // "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 ? ( @@ -285,15 +319,19 @@ export function AppSidebar() { ) : ( - ); return ( - - + +
{provider !== "stack" && ( -
+
- + {userChipTrigger} @@ -443,20 +476,15 @@ export function AppSidebar() { )} {provider === "stack" && ( -
+
- + {userChipTrigger} @@ -488,29 +516,20 @@ export function AppSidebar() {
)} -
- {isCollapsed ? ( - - -
- -
-
- -

Toggle theme

-
-
- ) : ( -
- -
- )} +
+ + +
+ +
+
+ +

Toggle theme

+
+
diff --git a/ui/src/components/lead-forms/CaptchaChallenge.tsx b/ui/src/components/lead-forms/CaptchaChallenge.tsx index ebc373cb..0f8c2dd7 100644 --- a/ui/src/components/lead-forms/CaptchaChallenge.tsx +++ b/ui/src/components/lead-forms/CaptchaChallenge.tsx @@ -5,6 +5,7 @@ // 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"; @@ -44,38 +45,45 @@ export function CaptchaChallenge({ }; return ( -
-
-

Quick check

-

Confirm you're human before we send this.

-
-
- - setAnswer(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") confirm(); - }} - placeholder="Answer" - /> -
-
- - +
+
+
+ + + +
+

Quick check

+

Confirm you're human before we send this.

+
+
+
+ + setAnswer(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") confirm(); + }} + placeholder="Answer" + /> +
+
+ + +
); diff --git a/ui/src/components/lead-forms/EnterpriseLeadFields.tsx b/ui/src/components/lead-forms/EnterpriseLeadFields.tsx index 067daa8b..3217ffb2 100644 --- a/ui/src/components/lead-forms/EnterpriseLeadFields.tsx +++ b/ui/src/components/lead-forms/EnterpriseLeadFields.tsx @@ -50,8 +50,6 @@ interface EnterpriseLeadFieldsProps { idPrefix: string; value: EnterpriseFieldsValue; onChange: (patch: Partial) => void; - // Work email is mandatory only when the visitor is logged out. - workEmailRequired: boolean; // The deployment question is surfaced only for certain entry points; elsewhere // it is hidden and the caller defaults the payload to "yes". showDeployment: boolean; @@ -62,7 +60,6 @@ export function EnterpriseLeadFields({ idPrefix: p, value, onChange, - workEmailRequired, showDeployment, emailError, }: EnterpriseLeadFieldsProps) { @@ -71,25 +68,21 @@ export function EnterpriseLeadFields({
- onChange({ name: e.target.value })} /> + onChange({ name: e.target.value })} />
- onChange({ company: e.target.value })} /> + onChange({ company: e.target.value })} />
- - onChange({ jobTitle: e.target.value })} /> + + onChange({ jobTitle: e.target.value })} />
- + (EMPTY_ENTERPRISE_FIELDS); const [emailError, setEmailError] = useState(null); const [captchaActive, setCaptchaActive] = useState(false); @@ -38,8 +38,6 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr // 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. - const workEmailRequired = !isAuthenticated; const reset = () => { setValue(EMPTY_ENTERPRISE_FIELDS); @@ -66,19 +64,18 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr 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) && - (!workEmailRequired || Boolean(value.workEmail.trim())); + Boolean(value.volume); const canSubmit = baseValid && !submitting; // Validate, then pop the anti-spam check on top of the modal. const handleSubmit = () => { - if (workEmailRequired || value.workEmail.trim()) { - const err = validateWorkEmail(value.workEmail); - if (err) { setEmailError(err); return; } - } - if (!value.name.trim() || !value.company.trim() || !value.phone.trim() || !value.volume) { + 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; } @@ -134,7 +131,6 @@ export function EnterpriseModal({ open, onOpenChange, source, prefill }: Enterpr idPrefix="ent" value={value} onChange={onFieldsChange} - workEmailRequired={workEmailRequired} showDeployment={showDeployment} emailError={emailError} /> diff --git a/ui/src/components/lead-forms/HireExpertModal.tsx b/ui/src/components/lead-forms/HireExpertModal.tsx index 3ca7bdf2..f6e4fe61 100644 --- a/ui/src/components/lead-forms/HireExpertModal.tsx +++ b/ui/src/components/lead-forms/HireExpertModal.tsx @@ -51,6 +51,7 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise } const baseValid = Boolean(name.trim()) && Boolean(company.trim()) && + Boolean(jobTitle.trim()) && Boolean(agentGoal.trim()) && Boolean(phone.trim()) && Boolean(volume); @@ -114,19 +115,17 @@ export function HireExpertModal({ open, onOpenChange, source, onOpenEnterprise }
- setName(e.target.value)} /> + setName(e.target.value)} />
- setCompany(e.target.value)} /> + setCompany(e.target.value)} />
- - setJobTitle(e.target.value)} /> + + setJobTitle(e.target.value)} />
diff --git a/ui/src/components/lead-forms/LeadModalShell.tsx b/ui/src/components/lead-forms/LeadModalShell.tsx index a5b1e09f..39be5f98 100644 --- a/ui/src/components/lead-forms/LeadModalShell.tsx +++ b/ui/src/components/lead-forms/LeadModalShell.tsx @@ -2,11 +2,14 @@ // 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 (icon + eyebrow + -// title), a scrollable body, a sticky footer (primary CTA + optional ghost -// secondary + optional helper slot), and a bottom trust-line slot. The visual -// language is refined dark minimalism: zinc surface, hairline border, one warm -// accent reserved for the primary action. +// 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"; @@ -22,7 +25,8 @@ import { import { cn } from "@/lib/utils"; interface LeadModalShellProps { - icon: LucideIcon; + // Accepted for caller compatibility; the Ledger design renders no icon. + icon?: LucideIcon; title: string; eyebrow?: string; description?: string; @@ -44,7 +48,6 @@ interface LeadModalShellProps { } export function LeadModalShell({ - icon: Icon, title, eyebrow, description, @@ -64,41 +67,39 @@ export function LeadModalShell({ - {/* Header */} - -
- - - -
- {eyebrow && ( - - {eyebrow} - - )} - - {title} - - {description && ( - - {description} - - )} -
+ {/* Header: a slightly darker band, separated by a hairline. */} + +
+ {eyebrow && ( + + {eyebrow} + + )} + + {title} + + {description && ( + + {description} + + )}
- {/* Scrollable body */} -
{children}
+ {/* Scrollable body: flat, compact underline fields. */} +
+
{children}
+
- {/* Sticky footer — actions first, then the optional helper line BELOW - the buttons, then the trust line at the very bottom. */} -
+ {/* 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. */} +
{secondary && ( @@ -125,7 +126,7 @@ export function LeadModalShell({ {/* Optional popup floated on top of the entire modal (captcha, etc.). */} {overlay && ( -
+
{overlay}
)} diff --git a/ui/src/components/lead-forms/OnboardingModal.tsx b/ui/src/components/lead-forms/OnboardingModal.tsx index 041978f4..c8556640 100644 --- a/ui/src/components/lead-forms/OnboardingModal.tsx +++ b/ui/src/components/lead-forms/OnboardingModal.tsx @@ -127,7 +127,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { if (wantsOnPrem) { const err = validateWorkEmail(ef.workEmail); if (err) { setEfEmailError(err); return; } - if (!ef.name.trim() || !ef.company.trim() || !ef.phone.trim() || !ef.volume) { + 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; } @@ -171,7 +171,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { - setCompanyName(e.target.value)} /> + setCompanyName(e.target.value)} />
@@ -259,7 +259,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { idPrefix="ob-op" value={ef} onChange={onEfChange} - workEmailRequired showDeployment={false} emailError={efEmailError} /> diff --git a/ui/src/components/lead-forms/leadFieldOptions.ts b/ui/src/components/lead-forms/leadFieldOptions.ts index 78ddd893..b45c4ef4 100644 --- a/ui/src/components/lead-forms/leadFieldOptions.ts +++ b/ui/src/components/lead-forms/leadFieldOptions.ts @@ -8,7 +8,8 @@ export type LeadSource = | "hire_expert" | "onboarding" | "pricing_custom_volume" - | "landing_contact"; + | "landing_contact" + | "auth_page"; export type LeadKind = "hire_expert" | "enterprise"; @@ -34,6 +35,7 @@ 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). diff --git a/ui/src/components/lead-forms/onboardingServiceClient.ts b/ui/src/components/lead-forms/onboardingServiceClient.ts index d2afb8ae..70c6a656 100644 --- a/ui/src/components/lead-forms/onboardingServiceClient.ts +++ b/ui/src/components/lead-forms/onboardingServiceClient.ts @@ -11,16 +11,15 @@ const BASE_URL = process.env.NEXT_PUBLIC_ONBOARDING_API_URL; // via console.error (captured as Sentry breadcrumbs) but never thrown. const TIMEOUT_MS = 6000; -// POST a JSON body to the onboarding service with the Dograh auth token attached. -async function post(path: string, token: string, body: unknown): Promise { +// POST a JSON body to the onboarding service. The Dograh auth token is attached +// when supplied; public endpoints (contact-sales) are called without one. +async function post(path: string, token: string | undefined, body: unknown): Promise { if (!BASE_URL) { - // Misconfig would otherwise be invisible: a token-bearing submit dropped on - // the floor while PostHog still records the event as "submitted". - if (token) { - console.error( - `[onboarding] NEXT_PUBLIC_ONBOARDING_API_URL is unset — "${path}" not persisted to the onboarding service`, - ); - } + // Misconfig would otherwise be invisible: a submit dropped on the floor + // while PostHog still records the event as "submitted". + console.error( + `[onboarding] NEXT_PUBLIC_ONBOARDING_API_URL is unset — "${path}" not persisted to the onboarding service`, + ); return; } @@ -31,7 +30,7 @@ async function post(path: string, token: string, body: unknown): Promise { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), signal: controller.signal, @@ -64,6 +63,15 @@ export async function postLeadToService( await post(LEAD_PATH[kind], token, body); } +// Persist a logged-out enterprise lead via the PUBLIC contact-sales endpoint +// (no auth; the service applies a honeypot + per-IP rate limit). It runs the +// same unified enterprise flow as the authenticated /leads/enterprise path. +export async function postContactSalesToService( + body: Record, +): Promise { + await post("/api/v1/contact-sales", undefined, body); +} + // Persist an onboarding submission (or skip — body carries `skipped`). export async function postOnboardingToService( token: string, diff --git a/ui/src/components/lead-forms/submitLead.ts b/ui/src/components/lead-forms/submitLead.ts index 01ee4a26..2036d27f 100644 --- a/ui/src/components/lead-forms/submitLead.ts +++ b/ui/src/components/lead-forms/submitLead.ts @@ -8,7 +8,7 @@ import posthog from "posthog-js"; import { PostHogEvent } from "@/constants/posthog-events"; import type { LeadKind, LeadSource } from "./leadFieldOptions"; -import { postLeadToService } from "./onboardingServiceClient"; +import { postContactSalesToService, postLeadToService } from "./onboardingServiceClient"; const SUBMIT_EVENT: Record = { hire_expert: PostHogEvent.HIRE_EXPERT_SUBMITTED, @@ -31,5 +31,11 @@ export async function submitLead({ kind, source, payload, token }: SubmitLeadArg // Persist to the separate user_onboarding service (best-effort). if (token) { await postLeadToService(kind, token, { source, ...payload }); + } else if (kind === "enterprise") { + // Logged-out visitor (e.g. the auth-page Enterprise Enquiry CTA): the + // public contact-sales endpoint persists the lead and runs the same + // unified enterprise flow server-side, keyed off `workEmail` (which the + // form requires when unauthenticated). + await postContactSalesToService({ source, ...payload }); } } diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx index d34e5e49..12ac700e 100644 --- a/ui/src/components/ui/card.tsx +++ b/ui/src/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef<