mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only waveform motif + proof points + Bland-style enterprise CTA block on the left, zinc-900 form card on the right; single-column on mobile). - AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise. - stack-theme: dark StackTheme token overrides synced to globals.css. - page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme; local-auth fallback preserved inside the shell. BackButton slimmed for the card. - Dark locked as default: <html className="dark">, next-themes ThemeProvider (defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d694a81f0a
commit
0662a1770f
7 changed files with 212 additions and 51 deletions
25
ui/src/app/handler/[...stack]/AuthEnterpriseCTA.tsx
Normal file
25
ui/src/app/handler/[...stack]/AuthEnterpriseCTA.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"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.
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function AuthEnterpriseCTA() {
|
||||
return (
|
||||
<a
|
||||
href="https://dograh.com/contact?intent=enterprise"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-white/20 bg-white/5 text-zinc-100 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Talk to our team
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
82
ui/src/app/handler/[...stack]/AuthShell.tsx
Normal file
82
ui/src/app/handler/[...stack]/AuthShell.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Dark two-column auth shell. LEFT (lg+ only): a brand/value panel with a
|
||||
// CSS-only audio-waveform motif, proof points, and a Bland-style enterprise CTA
|
||||
// block at the bottom (passed in as `enterpriseSlot`). RIGHT: a centered
|
||||
// zinc-900 card that wraps the Stack Auth form (`children`). Mobile collapses to
|
||||
// the single card column. Palette is the app's blacks/greys with one warm CTA
|
||||
// accent on the waveform + focus.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const PROOF_POINTS = [
|
||||
"Open source",
|
||||
"7+ telephony providers",
|
||||
"Open architecture",
|
||||
];
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
enterpriseSlot,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
enterpriseSlot?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-screen w-full bg-background lg:grid-cols-[45%_55%]">
|
||||
{/* Brand / value panel — hidden on mobile */}
|
||||
<aside className="relative hidden flex-col justify-between overflow-hidden border-r border-border/60 bg-zinc-950 p-10 lg:flex xl:p-14">
|
||||
{/* Ambient depth: soft radial glow behind the content */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -left-24 top-1/3 size-[28rem] rounded-full opacity-20 blur-3xl"
|
||||
style={{ background: "radial-gradient(circle, var(--cta), transparent 70%)" }}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="auth-waveform" aria-hidden>
|
||||
<span /><span /><span /><span /><span /><span /><span /><span />
|
||||
</div>
|
||||
<span className="text-xl font-semibold tracking-tight text-zinc-50">Dograh</span>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md space-y-6">
|
||||
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-50 xl:text-4xl">
|
||||
Voice AI for outbound calling, built in the open.
|
||||
</h1>
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-2 text-sm text-zinc-400">
|
||||
{PROOF_POINTS.map((point, i) => (
|
||||
<li key={point} className="flex items-center gap-3">
|
||||
{i > 0 && <span aria-hidden className="text-zinc-700">·</span>}
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Enterprise CTA block (Bland-style) */}
|
||||
<div className="relative max-w-md space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-sm font-semibold text-zinc-100">
|
||||
Need on-prem, data residency & a data perimeter?
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400">
|
||||
We deploy Dograh inside your environment for regulated and high-scale teams.
|
||||
</p>
|
||||
{enterpriseSlot}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Form column */}
|
||||
<main className="flex 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) */}
|
||||
<div className="flex items-center gap-3 lg:hidden">
|
||||
<div className="auth-waveform" aria-hidden>
|
||||
<span /><span /><span /><span /><span /><span /><span /><span />
|
||||
</div>
|
||||
<span className="text-lg font-semibold tracking-tight">Dograh</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,16 +9,14 @@ export function BackButton() {
|
|||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<header className="flex items-center border-b px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</header>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
import { StackHandler } from "@stackframe/stack";
|
||||
import { StackHandler, StackTheme } from "@stackframe/stack";
|
||||
|
||||
import { getAuthProvider } from "@/lib/auth/config";
|
||||
|
||||
import { AuthEnterpriseCTA } from "./AuthEnterpriseCTA";
|
||||
import { AuthShell } from "./AuthShell";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { stackAuthDarkTheme } from "./stack-theme";
|
||||
|
||||
export default async function Handler(props: unknown) {
|
||||
const authProvider = await getAuthProvider();
|
||||
|
||||
if (authProvider === "local") {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>Local Auth Mode</h1>
|
||||
<p>Stack Auth handler is disabled when using local authentication.</p>
|
||||
</div>
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<div className="space-y-2 text-center text-zinc-200">
|
||||
<h1 className="text-xl font-semibold">Local Auth Mode</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stack Auth handler is disabled when using local authentication.
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -21,15 +28,11 @@ export default async function Handler(props: unknown) {
|
|||
const app = await getStackServerApp();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<AuthShell enterpriseSlot={<AuthEnterpriseCTA />}>
|
||||
<BackButton />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<StackHandler
|
||||
fullPage
|
||||
app={app!}
|
||||
routeProps={props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StackTheme theme={stackAuthDarkTheme}>
|
||||
<StackHandler fullPage={false} app={app!} routeProps={props} />
|
||||
</StackTheme>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
35
ui/src/app/handler/[...stack]/stack-theme.ts
Normal file
35
ui/src/app/handler/[...stack]/stack-theme.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Dark token overrides for the embedded Stack Auth form so it blends into the
|
||||
// auth card surface (zinc-900 background, zinc-100 foreground, the warm CTA
|
||||
// accent on the primary button, zinc-800 borders/inputs). Kept in sync with the
|
||||
// .dark tokens in globals.css. Values are CSS color strings; Stack applies them
|
||||
// to its own CSS variables.
|
||||
|
||||
import type { StackTheme } from "@stackframe/stack";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ThemeConfig = NonNullable<ComponentProps<typeof StackTheme>["theme"]>;
|
||||
|
||||
export const stackAuthDarkTheme: ThemeConfig = {
|
||||
dark: {
|
||||
background: "oklch(0.205 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
cardForeground: "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
popoverForeground: "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.78 0.16 67)",
|
||||
primaryForeground: "oklch(0.16 0.02 60)",
|
||||
secondary: "oklch(0.269 0 0)",
|
||||
secondaryForeground: "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
mutedForeground: "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.269 0 0)",
|
||||
accentForeground: "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
destructiveForeground: "oklch(0.985 0 0)",
|
||||
border: "oklch(0.269 0 0)",
|
||||
input: "oklch(0.269 0 0)",
|
||||
ring: "oklch(0.78 0.16 67)",
|
||||
},
|
||||
radius: "0.625rem",
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ import AppLayout from "@/components/layout/AppLayout";
|
|||
import PostHogIdentify from "@/components/PostHogIdentify";
|
||||
import { SentryErrorBoundary } from "@/components/SentryErrorBoundary";
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { OnboardingProvider } from "@/context/OnboardingContext";
|
||||
|
|
@ -39,21 +40,24 @@ export default function RootLayout({
|
|||
}) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Inline script to prevent flash of light theme - runs before React hydrates */}
|
||||
{/* Inline script to prevent flash of light theme - runs before React hydrates.
|
||||
Dark is the locked default: only an explicit stored 'light' opts out. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
|
|
@ -61,26 +65,28 @@ export default function RootLayout({
|
|||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<SentryErrorBoundary>
|
||||
<AuthProvider>
|
||||
<AppConfigProvider>
|
||||
<Suspense fallback={<SpinLoader />}>
|
||||
<UserConfigProvider>
|
||||
<TelephonyConfigWarningsProvider>
|
||||
<OnboardingProvider>
|
||||
<PostHogIdentify />
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
<Toaster />
|
||||
<ChatwootWidget />
|
||||
</OnboardingProvider>
|
||||
</TelephonyConfigWarningsProvider>
|
||||
</UserConfigProvider>
|
||||
</Suspense>
|
||||
</AppConfigProvider>
|
||||
</AuthProvider>
|
||||
</SentryErrorBoundary>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
||||
<SentryErrorBoundary>
|
||||
<AuthProvider>
|
||||
<AppConfigProvider>
|
||||
<Suspense fallback={<SpinLoader />}>
|
||||
<UserConfigProvider>
|
||||
<TelephonyConfigWarningsProvider>
|
||||
<OnboardingProvider>
|
||||
<PostHogIdentify />
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
<Toaster />
|
||||
<ChatwootWidget />
|
||||
</OnboardingProvider>
|
||||
</TelephonyConfigWarningsProvider>
|
||||
</UserConfigProvider>
|
||||
</Suspense>
|
||||
</AppConfigProvider>
|
||||
</AuthProvider>
|
||||
</SentryErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
12
ui/src/components/ThemeProvider.tsx
Normal file
12
ui/src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// Thin wrapper around next-themes so the root (server) layout can mount a theme
|
||||
// provider without pulling client-only code into the server module graph. Dark
|
||||
// is the locked default; the system preference is intentionally not consulted.
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue