mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
ui changes
This commit is contained in:
parent
0eddce6c83
commit
9fe73c2ae2
16 changed files with 363 additions and 202 deletions
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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 & 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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue