mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
ui changes
This commit is contained in:
parent
0eddce6c83
commit
9fe73c2ae2
16 changed files with 363 additions and 202 deletions
1
ui/public/brand-imprint-dark.svg
Normal file
1
ui/public/brand-imprint-dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
1
ui/public/brand-imprint-light.svg
Normal file
1
ui/public/brand-imprint-light.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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