ui changes

This commit is contained in:
Pritesh 2026-06-12 17:11:37 +05:30
parent 0eddce6c83
commit 9fe73c2ae2
16 changed files with 363 additions and 202 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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 <Sidebar variant="floating">. */
.app-sidebar-dock [data-slot="sidebar-inner"] {
border-radius: 1.25rem;
overflow: hidden;
}
/* Flat carbon-charcoal panel with a soft light glow along the LEFT edge:
a 1px highlight line plus an inner bloom fading rightwards. */
.dark .app-sidebar-dock [data-slot="sidebar-inner"] {
border-color: rgb(255 255 255 / 0.1);
background-color: oklch(0.18 0.002 80);
box-shadow:
inset 1px 0 0 rgb(255 255 255 / 0.1),
inset 3px 0 6px -4px rgb(255 255 255 / 0.08),
0 24px 50px -14px rgb(0 0 0 / 0.85);
}
/* Card surface ("Crosshatch + Top-Lit Edge", user-approved 2026-06-11 after a
3-round design board): a 45° hairline twill weave at 1% laid over the panel
colour, plus dark mode only a brighter SOLID top border, like light
catching the machined top edge of the panel. Applied app-wide by the Card
primitive (components/ui/card.tsx). Un-layered so border-top-color beats
the border-border/60 utility. No gradients (user constraint). */
.card-weave {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23000000' stroke-opacity='.015' fill='none'/%3E%3C/svg%3E");
background-repeat: repeat;
}
.dark .card-weave {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23ffffff' stroke-opacity='.01' fill='none'/%3E%3C/svg%3E");
border-top-color: rgb(255 255 255 / 0.2);
}
/* Lead-form shell ("Ledger" treatment, user-approved 2026-06-11): neutral
charcoal slab where ONLY the header band is darker (body and footer share
the slab colour), muted compact labels, and underline-only fields with an
amber underline on focus. Applied by LeadModalShell; CaptchaChallenge
reuses slab + underline. */
.dark .lead-form-slab {
background-color: oklch(0.215 0 0);
border-color: rgb(255 255 255 / 0.1);
}
/* Muted, compact labels — the big white default labels read amateurish. */
.lead-form-underline label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--muted-foreground);
}
/* Ghost placeholders: present on every field, but barely-there. */
.lead-form-underline :is(input, textarea)::placeholder {
color: var(--muted-foreground);
opacity: 0.14;
}
.lead-form-underline [data-slot="select-trigger"][data-placeholder] {
color: color-mix(in oklab, var(--muted-foreground) 17%, transparent);
}
/* Underline-only fields: transparent box, hairline bottom border, amber
underline on the focused control. Compact heights keep rows tight. */
.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]) {
background-color: transparent;
border-top: 0;
border-left: 0;
border-right: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
box-shadow: none;
padding-left: 2px;
padding-right: 2px;
}
.lead-form-underline :is(input, [data-slot="select-trigger"]) {
height: 2.125rem;
}
.lead-form-underline textarea {
min-height: 3.25rem;
}
/* The phone country selector ships its own box — flatten it to match. */
.lead-form-underline .react-international-phone-country-selector-button {
border: 0 !important;
border-bottom: 1px solid var(--border) !important;
border-radius: 0 !important;
background: transparent !important;
}
.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]):focus-visible,
.lead-form-underline [data-slot="select-trigger"][data-state="open"] {
outline: none;
box-shadow: none;
border-bottom-color: var(--cta);
}

View file

@ -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 (
<a
href="https://dograh.com/contact?intent=enterprise"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<>
<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>
</a>
<EnterpriseModal open={open} onOpenChange={setOpen} source="auth_page" />
</>
);
}

View file

@ -26,8 +26,9 @@ export function AuthShell({
}) {
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. */}
<main className="flex min-h-screen flex-col overflow-y-auto">
{/* 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) */}

View file

@ -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"
)}
>
<Link
@ -238,9 +239,17 @@ export function AppSidebar() {
translate="no"
>
{isItemActive && !isCollapsed && (
<span className="absolute inset-y-1 left-0 w-0.5 rounded-full bg-cta" aria-hidden />
<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")} />
<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"
@ -266,18 +275,43 @@ export function AppSidebar() {
);
};
// "Hire an Expert" CTA shown in the footer next to the user avatar.
// Expanded: icon + label. Collapsed: icon-only with a tooltip.
// 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-8 w-8"
className="h-7 w-7 rounded-full"
onClick={() => openHireExpert("sidebar")}
aria-label="Hire an Expert"
>
<UserRound className="h-4 w-4" />
<UserRound className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
@ -285,15 +319,19 @@ export function AppSidebar() {
</TooltipContent>
</Tooltip>
) : (
<Button size="sm" className="gap-2" onClick={() => openHireExpert("sidebar")}>
<UserRound className="h-4 w-4" />
<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="app-sidebar-surface border-r border-border/60">
<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
@ -399,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 items-center", isCollapsed ? "flex-col gap-2" : "justify-between")}>
<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">
@ -443,20 +476,15 @@ export function AppSidebar() {
)}
{provider === "stack" && (
<div className={cn("flex items-center", isCollapsed ? "flex-col gap-2" : "justify-between")}>
<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">
@ -488,29 +516,20 @@ export function AppSidebar() {
</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>

View file

@ -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 (
<div className="w-full max-w-xs space-y-4 rounded-xl border border-border/60 bg-card p-5 shadow-xl">
<div className="space-y-1">
<p className="text-sm font-semibold">Quick check</p>
<p className="text-xs text-muted-foreground">Confirm you&apos;re human before we send this.</p>
</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-xs hover:bg-cta/90 focus-visible:ring-cta/50"
>
Confirm &amp; submit
</Button>
<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&apos;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 &amp; submit
</Button>
</div>
</div>
</div>
);

View file

@ -50,8 +50,6 @@ interface EnterpriseLeadFieldsProps {
idPrefix: string;
value: EnterpriseFieldsValue;
onChange: (patch: Partial<EnterpriseFieldsValue>) => 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({
<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`} value={value.name} onChange={(e) => onChange({ name: e.target.value })} />
<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`} value={value.company} onChange={(e) => onChange({ company: e.target.value })} />
<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 <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id={`${p}-title`} value={value.jobTitle} onChange={(e) => onChange({ jobTitle: e.target.value })} />
<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{!workEmailRequired && <span className="text-muted-foreground"> (optional)</span>}
</Label>
<Label htmlFor={`${p}-email`}>Work email</Label>
<Input
id={`${p}-email`}
type="email"

View file

@ -28,7 +28,7 @@ interface EnterpriseModalProps {
}
export function EnterpriseModal({ open, onOpenChange, source, prefill }: EnterpriseModalProps) {
const { getAccessToken, isAuthenticated } = useAuth(); // Dograh token for the onboarding service
const { getAccessToken } = useAuth(); // Dograh token for the onboarding service
const [value, setValue] = useState<EnterpriseFieldsValue>(EMPTY_ENTERPRISE_FIELDS);
const [emailError, setEmailError] = useState<string | null>(null);
const [captchaActive, setCaptchaActive] = useState(false);
@ -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}
/>

View file

@ -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 }
<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" value={name} onChange={(e) => setName(e.target.value)} />
<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" value={company} onChange={(e) => setCompany(e.target.value)} />
<Input id="hire-company" placeholder="Acme Inc." value={company} onChange={(e) => setCompany(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="hire-title">
Job title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="hire-title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<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">

View file

@ -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({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"max-h-[90vh] gap-0 overflow-hidden p-0 sm:max-w-[520px]",
"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 */}
<DialogHeader className="space-y-0 border-b border-border/60 px-6 py-5 text-left">
<div className="flex items-start gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/40 text-cta">
<Icon className="size-5" />
</span>
<div className="min-w-0 space-y-1">
{eyebrow && (
<span className="block text-[0.7rem] font-medium uppercase tracking-[0.14em] text-cta/90">
{eyebrow}
</span>
)}
<DialogTitle className="text-lg font-semibold leading-tight">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-sm leading-snug">
{description}
</DialogDescription>
)}
</div>
{/* 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 */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-5">{children}</div>
{/* 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>
{/* Sticky footer 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/60 bg-background/80 px-6 py-4 backdrop-blur-sm">
{/* 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
@ -114,7 +115,7 @@ export function LeadModalShell({
type="button"
onClick={primary.onClick}
disabled={primary.disabled || primary.loading}
className="bg-cta text-cta-foreground shadow-xs hover:bg-cta/90 focus-visible:ring-cta/50"
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>
@ -125,7 +126,7 @@ export function LeadModalShell({
{/* 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/85 p-6 backdrop-blur-sm">
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/70 p-6 backdrop-blur-md">
{overlay}
</div>
)}

View file

@ -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) {
<Label htmlFor="ob-company">
Company name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input id="ob-company" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
<Input id="ob-company" placeholder="Acme Inc." value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="space-y-1.5">
@ -259,7 +259,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
idPrefix="ob-op"
value={ef}
onChange={onEfChange}
workEmailRequired
showDeployment={false}
emailError={efEmailError}
/>

View file

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

View file

@ -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<void> {
// POST a JSON body to the onboarding service. The Dograh auth token is attached
// when supplied; public endpoints (contact-sales) are called without one.
async function post(path: string, token: string | undefined, body: unknown): Promise<void> {
if (!BASE_URL) {
// Misconfig would otherwise be invisible: a 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<void> {
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<string, unknown>,
): Promise<void> {
await post("/api/v1/contact-sales", undefined, body);
}
// Persist an onboarding submission (or skip — body carries `skipped`).
export async function postOnboardingToService(
token: string,

View file

@ -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<LeadKind, string> = {
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 });
}
}

View file

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm dark:shadow-md dark:shadow-black/25",
"card-weave rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm dark:shadow-md dark:shadow-black/25",
className
)}
{...props}