Compare commits

..

No commits in common. "main" and "fix/stripe-billing-security" have entirely different histories.

25 changed files with 126 additions and 579 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 B

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<g fill="none" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="18">
<path d="M 18 106 L 60 64" stroke="#0E6E6A"/>
<path d="M 60 64 L 106 106" stroke="#0E6E6A"/>
<path d="M 38 42 L 60 64 L 104 16" stroke="#C8811F"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -13,7 +13,7 @@
"type": "image/png" "type": "image/png"
} }
], ],
"theme_color": "#0E6E6A", "theme_color": "#3779b9",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"
} }

View File

@ -135,7 +135,7 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
className="flex items-center gap-2 text-base font-semibold text-base-content" className="flex items-center gap-2 text-base font-semibold text-base-content"
> >
<img <img
src="/crawlerx-logo.svg" src="/crawlerx-mark.png"
alt="" alt=""
className="size-6 rounded bg-white ring-1 ring-black/5" className="size-6 rounded bg-white ring-1 ring-black/5"
/> />

View File

@ -136,13 +136,9 @@ export function AuthInputField({
</div> </div>
<input <input
type={ type={
showPasswordToggle showPasswordToggle ? (isPasswordVisible ? "text" : "password") : type
? isPasswordVisible
? "text"
: "password"
: type
} }
className="w-full h-11 sm:h-12 bg-slate-50/90 hover:bg-slate-50 focus:bg-white border border-slate-200 rounded-xl pl-10 pr-10 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary focus:ring-4 focus:ring-primary/10 transition-all outline-none disabled:opacity-60" className="w-full h-11 sm:h-12 bg-slate-50/90 hover:bg-slate-50 focus:bg-white border border-slate-200 rounded-xl pl-10 pr-10 text-sm text-slate-900 placeholder:text-slate-400 focus:border-blue-600 focus:ring-4 focus:ring-blue-500/10 transition-all outline-none disabled:opacity-60"
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={onChange} onChange={onChange}
@ -188,7 +184,7 @@ export function AuthSubmitButton({
<button <button
type="submit" type="submit"
disabled={disabled || isSubmitting} disabled={disabled || isSubmitting}
className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-primary to-teal-700 hover:brightness-110 active:scale-[0.99] text-primary-content font-medium shadow-lg shadow-primary/25 transition-all flex items-center justify-center gap-2 text-sm disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer" className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 active:scale-[0.99] text-white font-medium shadow-lg shadow-blue-600/25 transition-all flex items-center justify-center gap-2 text-sm disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer"
> >
{isSubmitting ? ( {isSubmitting ? (
<span className="loading loading-spinner loading-sm" /> <span className="loading loading-spinner loading-sm" />
@ -227,11 +223,11 @@ export function AuthPageCard({
footer?: React.ReactNode; footer?: React.ReactNode;
}) { }) {
return ( return (
<div className="w-full max-w-[440px] bg-white/95 backdrop-blur-xl rounded-[28px] p-7 sm:p-9 shadow-[0_20px_60px_-15px_rgba(14,110,106,0.18),0_2px_20px_rgba(0,0,0,0.06)] border border-white/80 transition-all"> <div className="w-full max-w-[440px] bg-white/95 backdrop-blur-xl rounded-[28px] p-7 sm:p-9 shadow-[0_20px_60px_-15px_rgba(8,112,184,0.15),0_2px_20px_rgba(0,0,0,0.06)] border border-white/80 transition-all">
<div className="text-center space-y-1.5 mb-6"> <div className="text-center space-y-1.5 mb-6">
<img <img
src="/crawlerx-logo.svg" src="/transparent-logo.png"
alt="CrawlerX" alt="Metatroncube"
className="mx-auto size-11 object-contain drop-shadow-sm" className="mx-auto size-11 object-contain drop-shadow-sm"
/> />
<h2 className="text-2xl font-bold tracking-tight text-slate-900"> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
@ -246,9 +242,7 @@ export function AuthPageCard({
<div className="space-y-3.5">{children}</div> <div className="space-y-3.5">{children}</div>
{footer ? ( {footer ? <div className="mt-5 space-y-3 text-center">{footer}</div> : null}
<div className="mt-5 space-y-3 text-center">{footer}</div>
) : null}
<div className="flex items-center justify-center gap-1.5 text-xs text-slate-400 pt-4 mt-2"> <div className="flex items-center justify-center gap-1.5 text-xs text-slate-400 pt-4 mt-2">
<Lock className="size-3.5 text-slate-400 shrink-0" /> <Lock className="size-3.5 text-slate-400 shrink-0" />
@ -267,7 +261,7 @@ export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-primary to-teal-700 hover:brightness-110 text-primary-content font-medium shadow-md shadow-primary/25 transition-all flex items-center justify-center gap-2 text-sm" className="w-full h-11 sm:h-12 rounded-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-medium shadow-md shadow-blue-600/25 transition-all flex items-center justify-center gap-2 text-sm"
> >
<span>Back to sign in</span> <span>Back to sign in</span>
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
@ -279,18 +273,18 @@ export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) {
const VALUE_PROPS = [ const VALUE_PROPS = [
{ {
icon: Zap, icon: Zap,
title: "Live Site Audits", title: "Get Started Quickly",
desc: "120+ technical checks, prioritized by what actually moves rankings.", desc: "Set up your account in just a few simple steps.",
}, },
{ {
icon: ShieldCheck, icon: ShieldCheck,
title: "Review Before It Ships", title: "Your Data is Safe",
desc: "AI-drafted fixes wait for your approval — nothing publishes itself.", desc: "Enterprise-grade security to keep your information protected.",
}, },
{ {
icon: TrendingUp, icon: TrendingUp,
title: "Client-Ready Reporting", title: "Build for What's Next",
desc: "Rank movement and traffic, in language your clients understand.", desc: "Powerful tools and flexible features to help you grow.",
}, },
]; ];
@ -307,10 +301,10 @@ export function AuthPageShell({
}; };
}) { }) {
return ( return (
<div className="min-h-screen w-full relative flex flex-col lg:flex-row bg-[#0F161C] text-slate-900 selection:bg-primary selection:text-white overflow-x-hidden"> <div className="min-h-screen w-full relative flex flex-col lg:flex-row bg-[#020617] text-slate-900 selection:bg-blue-500 selection:text-white overflow-x-hidden">
<div <div
className="absolute inset-0 pointer-events-none z-0 opacity-95 transition-opacity" className="absolute inset-0 bg-cover bg-center pointer-events-none z-0 opacity-90 transition-opacity"
style={{ backgroundImage: "var(--brand-gradient)" }} style={{ backgroundImage: `url('/auth-bg.jpg')` }}
/> />
<div className="absolute inset-y-0 left-0 w-full lg:w-[50%] bg-gradient-to-r from-slate-950/95 via-slate-950/80 to-transparent pointer-events-none z-0" /> <div className="absolute inset-y-0 left-0 w-full lg:w-[50%] bg-gradient-to-r from-slate-950/95 via-slate-950/80 to-transparent pointer-events-none z-0" />
@ -318,44 +312,47 @@ export function AuthPageShell({
<div className="lg:w-[46%] xl:w-[44%] p-6 sm:p-10 lg:p-14 xl:p-16 flex flex-col justify-between z-10 text-white min-h-[auto] lg:min-h-screen"> <div className="lg:w-[46%] xl:w-[44%] p-6 sm:p-10 lg:p-14 xl:p-16 flex flex-col justify-between z-10 text-white min-h-[auto] lg:min-h-screen">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <img
src="/crawlerx-logo.svg" src="/transparent-logo.png"
alt="CrawlerX" alt="Metatroncube"
className="size-10 object-contain drop-shadow-md" className="size-10 object-contain drop-shadow-md"
/> />
<div className="font-bold text-lg tracking-tight text-white leading-none"> <div>
Crawler<span className="text-accent">X</span> <div className="font-bold text-lg tracking-tight text-white leading-none">
Metatroncube
</div>
<div className="text-[11px] font-medium text-blue-200/70 tracking-wider uppercase mt-1">
Solutions
</div>
</div> </div>
</div> </div>
<div className="my-auto py-8 lg:py-0"> <div className="my-auto py-8 lg:py-0">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/15 border border-accent/30 text-accent text-[11px] font-semibold tracking-wider uppercase mb-5 backdrop-blur-sm shadow-sm"> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/15 border border-blue-400/30 text-blue-300 text-[11px] font-semibold tracking-wider uppercase mb-5 backdrop-blur-sm shadow-sm">
<span className="size-1.5 rounded-full bg-accent animate-pulse" /> <span className="size-1.5 rounded-full bg-cyan-400 animate-pulse" />
WEBSITE INTELLIGENCE THAT ACTS JOIN OUR PLATFORM
</div> </div>
<h1 className="text-3xl sm:text-4xl xl:text-5xl font-extrabold tracking-tight text-white leading-[1.15]"> <h1 className="text-3xl sm:text-4xl xl:text-5xl font-extrabold tracking-tight text-white leading-[1.15]">
Find it. Fix it. Build something
<br /> <br />
<span className="bg-gradient-to-r from-accent via-teal-300 to-primary bg-clip-text text-transparent"> <span className="bg-gradient-to-r from-cyan-400 via-sky-300 to-blue-400 bg-clip-text text-transparent">
Prove it. exceptional.
</span> </span>
</h1> </h1>
<p className="mt-4 text-slate-300/85 text-sm xl:text-base leading-relaxed max-w-md font-normal"> <p className="mt-4 text-slate-300/85 text-sm xl:text-base leading-relaxed max-w-md font-normal">
Sign in to audit your sites, review AI-drafted fixes and report Create your account and get started in minutes. Join a growing
progress your clients actually understand. community of creators, builders and innovators.
</p> </p>
<div className="mt-8 space-y-4 max-w-md"> <div className="mt-8 space-y-4 max-w-md">
{VALUE_PROPS.map(({ icon: Icon, title, desc }) => ( {VALUE_PROPS.map(({ icon: Icon, title, desc }) => (
<div key={title} className="flex items-start gap-3.5"> <div key={title} className="flex items-start gap-3.5">
<div className="size-10 rounded-xl bg-primary/15 border border-accent/25 flex items-center justify-center shrink-0 shadow-inner"> <div className="size-10 rounded-xl bg-blue-500/15 border border-blue-400/25 flex items-center justify-center shrink-0 shadow-inner">
<Icon className="size-5 text-accent" /> <Icon className="size-5 text-blue-400" />
</div> </div>
<div> <div>
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">{title}</div>
{title}
</div>
<div className="text-xs text-slate-400 mt-0.5 leading-relaxed"> <div className="text-xs text-slate-400 mt-0.5 leading-relaxed">
{desc} {desc}
</div> </div>
@ -364,10 +361,10 @@ export function AuthPageShell({
))} ))}
</div> </div>
<div className="mt-10 font-['Caveat',cursive] text-2xl text-accent/90 leading-snug tracking-wide italic select-none"> <div className="mt-10 font-['Caveat',cursive] text-2xl text-blue-200/90 leading-snug tracking-wide italic select-none">
"Built for agencies who bill "More than software,
<br /> <br />
by results, not by hours." it's a better tomorrow."
</div> </div>
</div> </div>
@ -383,7 +380,7 @@ export function AuthPageShell({
<Link <Link
to={topLink.to} to={topLink.to}
search={topLink.search} search={topLink.search}
className="text-primary hover:brightness-90 font-semibold hover:underline flex items-center gap-1 transition-colors" className="text-blue-600 hover:text-blue-700 font-semibold hover:underline flex items-center gap-1 transition-colors"
> >
<span>{topLink.linkText}</span> <span>{topLink.linkText}</span>
<ArrowRight className="size-3.5" /> <ArrowRight className="size-3.5" />
@ -392,9 +389,7 @@ export function AuthPageShell({
) : null} ) : null}
</div> </div>
<div className="w-full my-auto flex justify-center py-4"> <div className="w-full my-auto flex justify-center py-4">{children}</div>
{children}
</div>
<div className="w-full flex justify-end pb-2 sm:pb-4 pt-4"> <div className="w-full flex justify-end pb-2 sm:pb-4 pt-4">
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] font-semibold tracking-widest text-slate-400/80 uppercase select-none"> <div className="flex items-center gap-2 text-[10px] sm:text-[11px] font-semibold tracking-widest text-slate-400/80 uppercase select-none">

View File

@ -15,59 +15,28 @@ import {
export function CrawlerXLogo() { export function CrawlerXLogo() {
return ( return (
<div className="flex items-center gap-3 select-none"> <div className="flex items-center gap-3 select-none">
<svg <svg width="40" height="40" viewBox="0 0 40 40" fill="none" className="shrink-0 drop-shadow-sm">
width="40" {/* Top facet - Cyan / Ice Blue (#4faede) */}
height="40"
viewBox="0 0 40 40"
fill="none"
className="shrink-0 drop-shadow-sm"
>
{/* Top facet - CrawlerX accent teal (#3fb6ae) */}
<path d="M20 4L34 12L20 20L6 12L20 4Z" fill="url(#cube-top-theme)" /> <path d="M20 4L34 12L20 20L6 12L20 4Z" fill="url(#cube-top-theme)" />
{/* Left facet - CrawlerX primary teal (#0e6e6a) */} {/* Left facet - MetatronCube Blue (#3779b9) */}
<path d="M6 12L20 20V36L6 28V12Z" fill="url(#cube-left-theme)" /> <path d="M6 12L20 20V36L6 28V12Z" fill="url(#cube-left-theme)" />
{/* Right facet - CrawlerX ink navy (#0f161c) */} {/* Right facet - Deep Navy (#0f1d34) */}
<path d="M20 20L34 12V28L20 36V20Z" fill="url(#cube-right-theme)" /> <path d="M20 20L34 12V28L20 36V20Z" fill="url(#cube-right-theme)" />
{/* Subtle highlights */} {/* Subtle highlights */}
<path d="M20 20V36" stroke="rgba(255,255,255,0.25)" strokeWidth="1" /> <path d="M20 20V36" stroke="rgba(255,255,255,0.25)" strokeWidth="1" />
<path <path d="M6 12L20 20L34 12" stroke="rgba(255,255,255,0.35)" strokeWidth="0.8" />
d="M6 12L20 20L34 12"
stroke="rgba(255,255,255,0.35)"
strokeWidth="0.8"
/>
<defs> <defs>
<linearGradient <linearGradient id="cube-top-theme" x1="6" y1="4" x2="34" y2="20" gradientUnits="userSpaceOnUse">
id="cube-top-theme" <stop stopColor="#4faede" />
x1="6" <stop offset="1" stopColor="#3779b9" />
y1="4"
x2="34"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3fb6ae" />
<stop offset="1" stopColor="#0e6e6a" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient id="cube-left-theme" x1="6" y1="12" x2="20" y2="36" gradientUnits="userSpaceOnUse">
id="cube-left-theme" <stop stopColor="#3779b9" />
x1="6" <stop offset="1" stopColor="#2563eb" />
y1="12"
x2="20"
y2="36"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0e6e6a" />
<stop offset="1" stopColor="#127b76" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient id="cube-right-theme" x1="20" y1="20" x2="34" y2="36" gradientUnits="userSpaceOnUse">
id="cube-right-theme" <stop stopColor="#0f1d34" />
x1="20" <stop offset="1" stopColor="#091424" />
y1="20"
x2="34"
y2="36"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0f161c" />
<stop offset="1" stopColor="#0a1210" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
@ -103,7 +72,7 @@ export function DotGridMatrix({ className }: { className?: string }) {
height="16" height="16"
patternUnits="userSpaceOnUse" patternUnits="userSpaceOnUse"
> >
<circle cx="2" cy="2" r="1.5" fill="#0e6e6a" opacity="0.25" /> <circle cx="2" cy="2" r="1.5" fill="#3779b9" opacity="0.25" />
</pattern> </pattern>
</defs> </defs>
<rect width="140" height="90" fill="url(#dot-grid-theme)" /> <rect width="140" height="90" fill="url(#dot-grid-theme)" />
@ -122,7 +91,7 @@ export function Floating3DChartCard({ className }: { className?: string }) {
<div className="relative rounded-3xl bg-white/95 p-5 shadow-[0_25px_60px_rgba(55,121,185,0.18),0_10px_25px_rgba(15,29,52,0.05)] border border-slate-100/90 backdrop-blur-xl -rotate-6 w-44 h-44 flex flex-col justify-between"> <div className="relative rounded-3xl bg-white/95 p-5 shadow-[0_25px_60px_rgba(55,121,185,0.18),0_10px_25px_rgba(15,29,52,0.05)] border border-slate-100/90 backdrop-blur-xl -rotate-6 w-44 h-44 flex flex-col justify-between">
{/* Top Row: "Grow Faster" Pill & Growth Arrow */} {/* Top Row: "Grow Faster" Pill & Growth Arrow */}
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<span className="px-2.5 py-0.5 rounded-full bg-[#0e6e6a] text-white text-[10px] font-bold shadow-xs tracking-wide"> <span className="px-2.5 py-0.5 rounded-full bg-[#3779b9] text-white text-[10px] font-bold shadow-xs tracking-wide">
Grow Faster Grow Faster
</span> </span>
@ -142,16 +111,9 @@ export function Floating3DChartCard({ className }: { className?: string }) {
strokeLinejoin="round" strokeLinejoin="round"
/> />
<defs> <defs>
<linearGradient <linearGradient id="theme-growth-arrow" x1="6" y1="24" x2="27" y2="9" gradientUnits="userSpaceOnUse">
id="theme-growth-arrow" <stop stopColor="#4faede" />
x1="6" <stop offset="1" stopColor="#3779b9" />
y1="24"
x2="27"
y2="9"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3fb6ae" />
<stop offset="1" stopColor="#0e6e6a" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
@ -159,36 +121,20 @@ export function Floating3DChartCard({ className }: { className?: string }) {
{/* 4 Rising Theme Blue Bars */} {/* 4 Rising Theme Blue Bars */}
<div className="flex items-end gap-2.5 w-full justify-center pb-1"> <div className="flex items-end gap-2.5 w-full justify-center pb-1">
<div className="w-4 h-9 rounded-md bg-gradient-to-t from-[#0f161c] to-[#163b39] shadow-xs" /> <div className="w-4 h-9 rounded-md bg-gradient-to-t from-[#0f1d34] to-[#1e3a66] shadow-xs" />
<div className="w-4 h-14 rounded-md bg-gradient-to-t from-[#163b39] to-[#127b76] shadow-xs" /> <div className="w-4 h-14 rounded-md bg-gradient-to-t from-[#1e3a66] to-[#2563eb] shadow-xs" />
<div className="w-4 h-19 rounded-md bg-gradient-to-t from-[#127b76] to-[#0e6e6a] shadow-xs" /> <div className="w-4 h-19 rounded-md bg-gradient-to-t from-[#2563eb] to-[#3779b9] shadow-xs" />
<div className="w-4 h-24 rounded-md bg-gradient-to-t from-[#0e6e6a] to-[#3fb6ae] shadow-md shadow-[#3fb6ae]/30" /> <div className="w-4 h-24 rounded-md bg-gradient-to-t from-[#3779b9] to-[#4faede] shadow-md shadow-[#4faede]/30" />
</div> </div>
</div> </div>
{/* Small Plant in White Pot */} {/* Small Plant in White Pot */}
<div className="absolute -bottom-3 -left-3 size-11 z-20"> <div className="absolute -bottom-3 -left-3 size-11 z-20">
<svg viewBox="0 0 44 44" fill="none"> <svg viewBox="0 0 44 44" fill="none">
<path <path d="M12 28L15 40H29L32 28H12Z" fill="#ffffff" stroke="#e2e8f0" strokeWidth="1.5" />
d="M12 28L15 40H29L32 28H12Z" <path d="M22 28C22 18 10 14 6 20C10 26 18 26 22 28Z" fill="#10b981" />
fill="#ffffff" <path d="M22 28C22 16 34 12 38 18C34 24 26 24 22 28Z" fill="#059669" />
stroke="#e2e8f0" <path d="M22 28V12" stroke="#047857" strokeWidth="2" strokeLinecap="round" />
strokeWidth="1.5"
/>
<path
d="M22 28C22 18 10 14 6 20C10 26 18 26 22 28Z"
fill="#10b981"
/>
<path
d="M22 28C22 16 34 12 38 18C34 24 26 24 22 28Z"
fill="#059669"
/>
<path
d="M22 28V12"
stroke="#047857"
strokeWidth="2"
strokeLinecap="round"
/>
</svg> </svg>
</div> </div>
</div> </div>
@ -207,41 +153,35 @@ export function Floating3DSearchCard({ className }: { className?: string }) {
<div className="relative rounded-3xl bg-white/95 p-4 shadow-[0_25px_60px_rgba(55,121,185,0.18),0_10px_25px_rgba(15,29,52,0.05)] border border-slate-100/90 backdrop-blur-xl rotate-6 w-48 space-y-2.5"> <div className="relative rounded-3xl bg-white/95 p-4 shadow-[0_25px_60px_rgba(55,121,185,0.18),0_10px_25px_rgba(15,29,52,0.05)] border border-slate-100/90 backdrop-blur-xl rotate-6 w-48 space-y-2.5">
{/* Search Input Simulation */} {/* Search Input Simulation */}
<div className="h-7 w-full rounded-xl bg-slate-50 border border-slate-200/80 px-2.5 flex items-center justify-between"> <div className="h-7 w-full rounded-xl bg-slate-50 border border-slate-200/80 px-2.5 flex items-center justify-between">
<Search className="size-3 text-[#0e6e6a]" /> <Search className="size-3 text-[#3779b9]" />
<div className="size-2 rounded-full bg-[#0e6e6a]" /> <div className="size-2 rounded-full bg-[#3779b9]" />
</div> </div>
{/* 3 Theme Pills */} {/* 3 Theme Pills */}
<div className="space-y-2 pt-0.5"> <div className="space-y-2 pt-0.5">
<div className="p-2 rounded-xl bg-emerald-50/80 border border-emerald-100 flex items-center gap-2"> <div className="p-2 rounded-xl bg-emerald-50/80 border border-emerald-100 flex items-center gap-2">
<span className="size-2 rounded-full bg-emerald-500 shrink-0" /> <span className="size-2 rounded-full bg-emerald-500 shrink-0" />
<span className="text-[11px] font-bold text-emerald-800"> <span className="text-[11px] font-bold text-emerald-800">Better Rankings</span>
Better Rankings
</span>
</div> </div>
<div className="p-2 rounded-xl bg-[#f0f8f7] border border-[#d9e8e7] flex items-center gap-2"> <div className="p-2 rounded-xl bg-[#f0f6fc] border border-[#d6e4f3] flex items-center gap-2">
<span className="size-2 rounded-full bg-[#0e6e6a] shrink-0" /> <span className="size-2 rounded-full bg-[#3779b9] shrink-0" />
<span className="text-[11px] font-bold text-[#0f161c]"> <span className="text-[11px] font-bold text-[#0f1d34]">More Traffic</span>
More Traffic
</span>
</div> </div>
<div className="p-2 rounded-xl bg-[#e3f3f2] border border-sky-100 flex items-center gap-2"> <div className="p-2 rounded-xl bg-[#e0f2fe] border border-sky-100 flex items-center gap-2">
<span className="size-2 rounded-full bg-[#3fb6ae] shrink-0" /> <span className="size-2 rounded-full bg-[#4faede] shrink-0" />
<span className="text-[11px] font-bold text-[#1a8a84]"> <span className="text-[11px] font-bold text-[#0284c7]">Real Results</span>
Real Results
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Hand-drawn Loop Arrow */} {/* Hand-drawn Loop Arrow */}
<div className="absolute -bottom-8 -left-8 size-16 text-[#3fb6ae]"> <div className="absolute -bottom-8 -left-8 size-16 text-[#4faede]">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none"> <svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<path <path
d="M38 8C34 22 14 18 12 32C11 38 18 42 24 38L22 42" d="M38 8C34 22 14 18 12 32C11 38 18 42 24 38L22 42"
stroke="#3fb6ae" stroke="#4faede"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@ -267,17 +207,10 @@ export function CornerWaveDecoration() {
fill="url(#theme-top-right-wave)" fill="url(#theme-top-right-wave)"
/> />
<defs> <defs>
<linearGradient <linearGradient id="theme-top-right-wave" x1="740" y1="0" x2="420" y2="520" gradientUnits="userSpaceOnUse">
id="theme-top-right-wave" <stop stopColor="#4faede" stopOpacity="0.22" />
x1="740" <stop offset="0.6" stopColor="#3779b9" stopOpacity="0.12" />
y1="0" <stop offset="1" stopColor="#0f1d34" stopOpacity="0" />
x2="420"
y2="520"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3fb6ae" stopOpacity="0.22" />
<stop offset="0.6" stopColor="#0e6e6a" stopOpacity="0.12" />
<stop offset="1" stopColor="#0f161c" stopOpacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
@ -290,7 +223,7 @@ export function CornerWaveDecoration() {
> >
<path <path
d="M-60 560C120 440 180 300 120 180C60 60 140 -20 220 -40" d="M-60 560C120 440 180 300 120 180C60 60 140 -20 220 -40"
stroke="#3fb6ae" stroke="#4faede"
strokeWidth="32" strokeWidth="32"
strokeOpacity="0.12" strokeOpacity="0.12"
/> />
@ -311,13 +244,13 @@ export function renderPlanIcon(type: keyof typeof PLAN_ICONS) {
} }
const TRUST_ICONS: Record<string, React.ReactNode> = { const TRUST_ICONS: Record<string, React.ReactNode> = {
guarantee: <ShieldCheck className="size-4.5 text-[#0e6e6a]" />, guarantee: <ShieldCheck className="size-4.5 text-[#3779b9]" />,
upi: <Zap className="size-4.5 text-[#0e6e6a]" />, upi: <Zap className="size-4.5 text-[#3779b9]" />,
gst: <FileText className="size-4.5 text-[#0e6e6a]" />, gst: <FileText className="size-4.5 text-[#3779b9]" />,
cards: <CreditCard className="size-4.5 text-[#0e6e6a]" />, cards: <CreditCard className="size-4.5 text-[#3779b9]" />,
support: <span className="text-sm">🇮🇳</span>, support: <span className="text-sm">🇮🇳</span>,
}; };
export function renderTrustIcon(id: string) { export function renderTrustIcon(id: string) {
return TRUST_ICONS[id] ?? <ShieldCheck className="size-4.5 text-[#0e6e6a]" />; return TRUST_ICONS[id] ?? <ShieldCheck className="size-4.5 text-[#3779b9]" />;
} }

View File

@ -85,7 +85,7 @@ function SiteForm({ projectId }: { projectId: string }) {
> >
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<img <img
src="/crawlerx-logo.svg" src="/transparent-logo.png"
alt="CrawlerX" alt="CrawlerX"
className="mx-auto size-10 rounded-lg" className="mx-auto size-10 rounded-lg"
/> />

View File

@ -60,7 +60,7 @@ export function PostSignupOnboarding({
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<img <img
src="/crawlerx-logo.svg" src="/transparent-logo.png"
alt="CrawlerX" alt="CrawlerX"
className="mx-auto size-10 rounded-lg" className="mx-auto size-10 rounded-lg"
/> />

View File

@ -187,7 +187,7 @@ function MobileTopBar({
className="ml-1 flex items-center gap-2 font-semibold text-base-content" className="ml-1 flex items-center gap-2 font-semibold text-base-content"
> >
<img <img
src="/crawlerx-logo.svg" src="/crawlerx-mark.png"
alt="" alt=""
className="size-6 rounded bg-white ring-1 ring-black/5" className="size-6 rounded bg-white ring-1 ring-black/5"
/> />

View File

@ -8,14 +8,14 @@
"Helvetica Neue", Arial, sans-serif; "Helvetica Neue", Arial, sans-serif;
} }
/* CrawlerX brand gradient (teal → ink navy), used on the sign-in surface and /* CrawlerX brand gradient (MetatronCube blue → deep black), used on the
the sidebar header. Matches the brand guide's product-UI palette. */ sign-in surface and the sidebar header. */
:root { :root {
--brand-gradient: linear-gradient( --brand-gradient: linear-gradient(
135deg, 135deg,
#0e6e6a 0%, #3779b9 0%,
#0f161c 70%, #0f1d34 70%,
#060a0d 100% #00060c 100%
); );
} }
@ -38,7 +38,7 @@
exclude: properties; exclude: properties;
} }
/* Light theme — CrawlerX brand (primary #0E6E6A, from the brand guide) */ /* Light theme — CrawlerX / MetatronCube brand (primary #3779b9) */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "crawlerx"; name: "crawlerx";
default: true; default: true;
@ -48,15 +48,15 @@
--color-base-200: #f6f8fa; --color-base-200: #f6f8fa;
--color-base-300: #e6eaef; --color-base-300: #e6eaef;
--color-base-content: #151515; --color-base-content: #151515;
--color-primary: #0e6e6a; --color-primary: #3779b9;
--color-primary-content: #ffffff; --color-primary-content: #ffffff;
--color-secondary: #0f161c; --color-secondary: #0f1d34;
--color-secondary-content: #ffffff; --color-secondary-content: #ffffff;
--color-accent: #3fb6ae; --color-accent: #4faede;
--color-accent-content: #06263a; --color-accent-content: #06263a;
--color-neutral: #151515; --color-neutral: #151515;
--color-neutral-content: #ffffff; --color-neutral-content: #ffffff;
--color-info: #0e6e6a; --color-info: #3779b9;
--color-info-content: #ffffff; --color-info-content: #ffffff;
--color-success: oklch(65% 0.18 145); --color-success: oklch(65% 0.18 145);
--color-success-content: oklch(100% 0 0); --color-success-content: oklch(100% 0 0);
@ -83,16 +83,16 @@
--color-base-200: #0e121b; --color-base-200: #0e121b;
--color-base-300: #232a37; --color-base-300: #232a37;
--color-base-content: #e8ebf0; --color-base-content: #e8ebf0;
--color-primary: #3fb6ae; --color-primary: #3f8ad4;
--color-primary-content: #06263a; --color-primary-content: #ffffff;
--color-secondary: #9fb3c8; --color-secondary: #9fb3c8;
--color-secondary-content: #0e121b; --color-secondary-content: #0e121b;
--color-accent: #3fb6ae; --color-accent: #4faede;
--color-accent-content: #06263a; --color-accent-content: #06263a;
--color-neutral: #e8ebf0; --color-neutral: #e8ebf0;
--color-neutral-content: #141821; --color-neutral-content: #141821;
--color-info: #3fb6ae; --color-info: #3f8ad4;
--color-info-content: #06263a; --color-info-content: #ffffff;
--color-success: oklch(65% 0.18 145); --color-success: oklch(65% 0.18 145);
--color-success-content: oklch(100% 0 0); --color-success-content: oklch(100% 0 0);
--color-warning: oklch(80% 0.15 80); --color-warning: oklch(80% 0.15 80);

View File

@ -38,7 +38,6 @@ import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index' import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index' import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
import { Route as ApiStripeWebhookRouteImport } from './routes/api/stripe/webhook' import { Route as ApiStripeWebhookRouteImport } from './routes/api/stripe/webhook'
import { Route as ApiPublicQuickAuditRouteImport } from './routes/api/public/quick-audit'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat' import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
@ -215,11 +214,6 @@ const ApiStripeWebhookRoute = ApiStripeWebhookRouteImport.update({
path: '/api/stripe/webhook', path: '/api/stripe/webhook',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiPublicQuickAuditRoute = ApiPublicQuickAuditRouteImport.update({
id: '/api/public/quick-audit',
path: '/api/public/quick-audit',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$', id: '/api/autumn/$',
path: '/api/autumn/$', path: '/api/autumn/$',
@ -423,7 +417,6 @@ export interface FileRoutesByFullPath {
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/public/quick-audit': typeof ApiPublicQuickAuditRoute
'/api/stripe/webhook': typeof ApiStripeWebhookRoute '/api/stripe/webhook': typeof ApiStripeWebhookRoute
'/settings/': typeof AppSettingsIndexRoute '/settings/': typeof AppSettingsIndexRoute
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute '/onboarding/': typeof AuthenticatedOnboardingIndexRoute
@ -480,7 +473,6 @@ export interface FileRoutesByTo {
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/public/quick-audit': typeof ApiPublicQuickAuditRoute
'/api/stripe/webhook': typeof ApiStripeWebhookRoute '/api/stripe/webhook': typeof ApiStripeWebhookRoute
'/settings': typeof AppSettingsIndexRoute '/settings': typeof AppSettingsIndexRoute
'/onboarding': typeof AuthenticatedOnboardingIndexRoute '/onboarding': typeof AuthenticatedOnboardingIndexRoute
@ -541,7 +533,6 @@ export interface FileRoutesById {
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/public/quick-audit': typeof ApiPublicQuickAuditRoute
'/api/stripe/webhook': typeof ApiStripeWebhookRoute '/api/stripe/webhook': typeof ApiStripeWebhookRoute
'/_app/settings/': typeof AppSettingsIndexRoute '/_app/settings/': typeof AppSettingsIndexRoute
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute '/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
@ -602,7 +593,6 @@ export interface FileRouteTypes {
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/public/quick-audit'
| '/api/stripe/webhook' | '/api/stripe/webhook'
| '/settings/' | '/settings/'
| '/onboarding/' | '/onboarding/'
@ -659,7 +649,6 @@ export interface FileRouteTypes {
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/public/quick-audit'
| '/api/stripe/webhook' | '/api/stripe/webhook'
| '/settings' | '/settings'
| '/onboarding' | '/onboarding'
@ -719,7 +708,6 @@ export interface FileRouteTypes {
| '/_authenticated/onboarding/chat' | '/_authenticated/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/api/public/quick-audit'
| '/api/stripe/webhook' | '/api/stripe/webhook'
| '/_app/settings/' | '/_app/settings/'
| '/_authenticated/onboarding/' | '/_authenticated/onboarding/'
@ -764,7 +752,6 @@ export interface RootRouteChildren {
ApiTeamSetupRoute: typeof ApiTeamSetupRoute ApiTeamSetupRoute: typeof ApiTeamSetupRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
ApiPublicQuickAuditRoute: typeof ApiPublicQuickAuditRoute
ApiStripeWebhookRoute: typeof ApiStripeWebhookRoute ApiStripeWebhookRoute: typeof ApiStripeWebhookRoute
ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
@ -975,13 +962,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiStripeWebhookRouteImport preLoaderRoute: typeof ApiStripeWebhookRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/public/quick-audit': {
id: '/api/public/quick-audit'
path: '/api/public/quick-audit'
fullPath: '/api/public/quick-audit'
preLoaderRoute: typeof ApiPublicQuickAuditRouteImport
parentRoute: typeof rootRouteImport
}
'/api/autumn/$': { '/api/autumn/$': {
id: '/api/autumn/$' id: '/api/autumn/$'
path: '/api/autumn/$' path: '/api/autumn/$'
@ -1405,7 +1385,6 @@ const rootRouteChildren: RootRouteChildren = {
ApiTeamSetupRoute: ApiTeamSetupRoute, ApiTeamSetupRoute: ApiTeamSetupRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute,
ApiPublicQuickAuditRoute: ApiPublicQuickAuditRoute,
ApiStripeWebhookRoute: ApiStripeWebhookRoute, ApiStripeWebhookRoute: ApiStripeWebhookRoute,
ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute, ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute, ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,

View File

@ -73,7 +73,7 @@ function OAuthConsentPage() {
<div className="w-full max-w-md rounded-2xl border border-base-300 bg-base-100 p-8 shadow-sm"> <div className="w-full max-w-md rounded-2xl border border-base-300 bg-base-100 p-8 shadow-sm">
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<img <img
src="/crawlerx-logo.svg" src="/transparent-logo.png"
alt="CrawlerX" alt="CrawlerX"
className="size-10 rounded-lg" className="size-10 rounded-lg"
/> />

View File

@ -125,7 +125,8 @@ function SubscribePage() {
}); });
const siteCount = Math.max(1, projectsQuery.data?.length ?? 1); const siteCount = Math.max(1, projectsQuery.data?.length ?? 1);
const activePlanId: "free" | "starter" | "pro" | "agency" = const activePlanId: "free" | "starter" | "pro" | "agency" =
billingQuery.data?.planId ?? (planStatus === "paid" ? "pro" : "free"); billingQuery.data?.planId ??
(planStatus === "paid" ? "pro" : "free");
const subscribeRouteState = getSubscribeRouteState({ const subscribeRouteState = getSubscribeRouteState({
hasSession, hasSession,
isCustomerLoading: customerQuery.isLoading, isCustomerLoading: customerQuery.isLoading,
@ -183,14 +184,14 @@ function SubscribePage() {
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm space-y-4 text-center p-8 rounded-3xl bg-white border border-slate-200 shadow-xl"> <div className="w-full max-w-sm space-y-4 text-center p-8 rounded-3xl bg-white border border-slate-200 shadow-xl">
<img <img
src="/crawlerx-logo.svg" src="/transparent-logo.png"
alt="CrawlerX" alt="CrawlerX"
className="mx-auto size-12 object-contain" className="mx-auto size-12 object-contain"
/> />
<h1 className="text-xl font-bold text-slate-900"> <h1 className="text-xl font-bold text-slate-900">
Finalizing your subscription&hellip; Finalizing your subscription&hellip;
</h1> </h1>
<span className="loading loading-spinner loading-md text-primary" /> <span className="loading loading-spinner loading-md text-blue-600" />
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Setting up your workspace limits and credits. Setting up your workspace limits and credits.
</p> </p>
@ -238,9 +239,7 @@ function SubscribePage() {
throw new Error("Stripe checkout session did not return a valid URL."); throw new Error("Stripe checkout session did not return a valid URL.");
} catch (err: unknown) { } catch (err: unknown) {
const errObj = err as const errObj = err as { message?: string; data?: { message?: string } } | undefined;
| { message?: string; data?: { message?: string } }
| undefined;
const rawMsg = errObj?.data?.message || errObj?.message; const rawMsg = errObj?.data?.message || errObj?.message;
const msg = rawMsg const msg = rawMsg
? getStandardErrorMessage(new Error(rawMsg), rawMsg) ? getStandardErrorMessage(new Error(rawMsg), rawMsg)
@ -279,13 +278,8 @@ function SubscribePage() {
} }
throw new Error("Unable to retrieve Stripe billing portal URL."); throw new Error("Unable to retrieve Stripe billing portal URL.");
} catch (err: unknown) { } catch (err: unknown) {
const errObj = err as const errObj = err as { message?: string; data?: { message?: string } } | undefined;
| { message?: string; data?: { message?: string } } const msg = errObj?.data?.message || errObj?.message || "Failed to open billing portal.";
| undefined;
const msg =
errObj?.data?.message ||
errObj?.message ||
"Failed to open billing portal.";
toast.error(msg); toast.error(msg);
} finally { } finally {
setIsOpeningPortal(false); setIsOpeningPortal(false);
@ -385,8 +379,7 @@ function SubscribePage() {
{/* Footer */} {/* Footer */}
<footer className="pt-8 pb-12 text-center text-xs text-slate-400 border-t border-slate-100"> <footer className="pt-8 pb-12 text-center text-xs text-slate-400 border-t border-slate-100">
CrawlerX by Metatroncube Solutions • Enterprise & Custom Agency Plans:{" "} CrawlerX by Metatroncube Solutions • Enterprise & Custom Agency Plans: {SUPPORT_EMAIL}
{SUPPORT_EMAIL}
</footer> </footer>
</div> </div>
</div> </div>

View File

@ -53,20 +53,10 @@ function loadHandler() {
} }
async function handleAutumnRequest(request: Request) { async function handleAutumnRequest(request: Request) {
// 404 in non-hosted modes, and also in hosted mode when Autumn isn't if (!isHostedAuthMode(env.AUTH_MODE)) {
// configured — a direct-Stripe deployment runs the local-ledger billing return new Response("Not found", {
// fallback instead (see subscription.ts) and has no Autumn customer to status: 404,
// read, so this route has nothing to serve. Checking here, before });
// loadHandler(), avoids autumn-js throwing "secret key not found" deep
// inside the handler once a client request reaches it.
if (!isHostedAuthMode(env.AUTH_MODE) || !env.AUTUMN_SECRET_KEY?.trim()) {
// JSON, not a plain-text body: the client-side autumn-js hooks call
// response.json() on every response regardless of status, and a
// non-JSON body throws a SyntaxError there instead of handling the 404.
return Response.json(
{ message: "Not found", code: "not_found" },
{ status: 404 },
);
} }
let context; let context;

View File

@ -1,125 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { selectQuickAuditFinding } from "@/server/features/publicAudit/quickAuditFindings";
import { normalizeUrl } from "@/server/lib/audit/url-utils";
import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
// Public, unauthenticated endpoint backing the "Run Live Audit" widget on the
// crawlerx.co marketing site. It crawls exactly one real page — the same
// crawler + HTML analyzer the authenticated product uses for every audit —
// and returns a finding derived from that crawl. It must never fall back to
// a canned/fabricated result: an unreachable or blocked site is reported as
// an honest error instead.
//
// Anonymous and unmetered, so it is rate-limited per IP and globally via KV
// (same pattern as invitation-send-limit.ts) to bound the outbound-crawl
// cost of public abuse.
const ALLOWED_ORIGINS = new Set([
"https://crawlerx.co",
"https://www.crawlerx.co",
"http://localhost:5173",
]);
const PER_IP_DAILY_LIMIT = 8;
const GLOBAL_DAILY_LIMIT = 500;
const WINDOW_TTL_SECONDS = 60 * 60 * 24;
function corsHeaders(origin: string | null): Record<string, string> {
const allowOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : "https://crawlerx.co";
return {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
Vary: "Origin",
};
}
async function consumeDailyBudget(key: string, limit: number): Promise<boolean> {
const count = Number((await env.KV.get(key)) ?? "0");
if (count >= limit) return false;
await env.KV.put(key, String(count + 1), {
expirationTtl: WINDOW_TTL_SECONDS,
});
return true;
}
function parseRequestedUrl(body: unknown): string | null {
if (typeof body !== "object" || body === null || !("url" in body)) return null;
const rawUrl = (body as { url: unknown }).url;
if (typeof rawUrl !== "string" || rawUrl.trim().length === 0) return null;
const withScheme = /^https?:\/\//i.test(rawUrl.trim())
? rawUrl.trim()
: `https://${rawUrl.trim()}`;
return normalizeUrl(withScheme);
}
async function handleQuickAudit(request: Request): Promise<Response> {
const headers = corsHeaders(request.headers.get("origin"));
if (request.method === "OPTIONS") {
return new Response(null, { headers });
}
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json(
{ error: "Invalid request body" },
{ status: 400, headers },
);
}
const normalizedUrl = parseRequestedUrl(body);
if (!normalizedUrl) {
return Response.json(
{ error: "That doesn't look like a valid URL." },
{ status: 400, headers },
);
}
const day = new Date().toISOString().slice(0, 10);
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
if (!(await consumeDailyBudget(`quick-audit:global:${day}`, GLOBAL_DAILY_LIMIT))) {
return Response.json(
{ error: "Quick audit is at capacity right now — please try again in a bit." },
{ status: 429, headers },
);
}
if (!(await consumeDailyBudget(`quick-audit:ip:${ip}:${day}`, PER_IP_DAILY_LIMIT))) {
return Response.json(
{
error:
"You've reached today's quick-audit limit. Sign up for unlimited audits.",
},
{ status: 429, headers },
);
}
const domain = new URL(normalizedUrl).hostname.replace(/^www\./, "");
const page = await crawlPage(normalizedUrl, null, false);
if (page.fetchClass !== "ok" || page.statusCode >= 400) {
const reason =
page.fetchClass === "blocked"
? "That site's security settings blocked our crawler from reading it."
: page.statusCode >= 400
? `That site returned an HTTP ${page.statusCode} error.`
: "We couldn't reach that site — check the URL and try again.";
return Response.json({ error: reason }, { status: 422, headers });
}
const finding = selectQuickAuditFinding(domain, page);
return Response.json(finding, { headers });
}
export const Route = createFileRoute("/api/public/quick-audit")({
server: {
handlers: {
POST: ({ request }: { request: Request }) => handleQuickAudit(request),
OPTIONS: ({ request }: { request: Request }) => handleQuickAudit(request),
},
},
});

View File

@ -122,7 +122,7 @@ export async function sendHostedVerificationEmail({
email, email,
transactionalId: config.verificationTemplateId, transactionalId: config.verificationTemplateId,
dataVariables: { dataVariables: {
appName: "CrawlerX", appName: "OpenSEO",
confirmationUrl, confirmationUrl,
}, },
}); });
@ -150,7 +150,7 @@ export async function sendHostedInvitationEmail({
email, email,
transactionalId: templateId, transactionalId: templateId,
dataVariables: { dataVariables: {
appName: "CrawlerX", appName: "OpenSEO",
inviteUrl, inviteUrl,
organizationName, organizationName,
inviterName, inviterName,
@ -172,7 +172,7 @@ export async function sendHostedPasswordResetEmail({
email, email,
transactionalId: config.passwordResetTemplateId, transactionalId: config.passwordResetTemplateId,
dataVariables: { dataVariables: {
appName: "CrawlerX", appName: "OpenSEO",
resetUrl, resetUrl,
}, },
}); });

View File

@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import type { CrawledPageResult } from "@/server/lib/audit/types";
import { selectQuickAuditFinding } from "@/server/features/publicAudit/quickAuditFindings";
function basePage(overrides: Partial<CrawledPageResult> = {}): CrawledPageResult {
return {
id: "page-1",
url: "https://example.com/",
statusCode: 200,
fetchClass: "ok",
redirectUrl: null,
title: "Example | A Great Business",
metaDescription: "A description of the business, well under the limit.",
canonicalUrl: "https://example.com/",
robotsMeta: null,
xRobotsTag: null,
headerCanonicalUrl: null,
ogTitle: null,
ogDescription: null,
ogImage: null,
h1Count: 1,
h2Count: 0,
h3Count: 0,
h4Count: 0,
h5Count: 0,
h6Count: 0,
headingOrder: [1],
wordCount: 400,
contentHash: null,
isHtml: true,
htmlBytes: 1000,
imagesTotal: 2,
imagesMissingAlt: 0,
images: [],
links: [],
hasStructuredData: true,
hreflangTags: [],
isIndexable: true,
responseTimeMs: 120,
crawlDepth: null,
inSitemap: false,
...overrides,
};
}
describe("selectQuickAuditFinding", () => {
it("reports a missing title as the headline issue, never a fabricated one", () => {
const finding = selectQuickAuditFinding("example.com", basePage({ title: "" }));
expect(finding.priority).toBe("Critical Impact");
expect(finding.action).toBe("Add a title tag");
expect(finding.current).toContain("no <title>");
expect(finding.suggested).toContain("example.com");
});
it("reports a healthy page as healthy instead of inventing an issue", () => {
const finding = selectQuickAuditFinding("example.com", basePage());
expect(finding.priority).toBe("Good Health");
expect(finding.score).toBeGreaterThan(90);
});
});

View File

@ -1,141 +0,0 @@
import type { CrawledPageResult } from "@/server/lib/audit/types";
export type QuickAuditPriority = "Critical Impact" | "High Priority" | "Good Health";
export interface QuickAuditFinding {
domain: string;
score: number;
action: string;
issue: string;
priority: QuickAuditPriority;
current: string;
suggested: string;
}
const TITLE_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 160;
type Issue = Omit<QuickAuditFinding, "domain" | "score"> & { weight: number };
/**
* Picks the single most-impactful real issue on a crawled page, plus a
* composite score from every issue found. Used by the public marketing-site
* "quick audit" widget, which crawls exactly one real page (via
* {@link import("@/server/workflows/site-audit-workflow-helpers").crawlPage})
* — this never fabricates a finding for a page it hasn't actually fetched.
*/
export function selectQuickAuditFinding(
domain: string,
page: CrawledPageResult,
): QuickAuditFinding {
const title = page.title.trim();
const description = page.metaDescription.trim();
const issues: Issue[] = [];
if (!title) {
issues.push({
weight: 30,
action: "Add a title tag",
issue:
"This page has no <title> tag — search engines fall back to a generic or URL-based title in results.",
priority: "Critical Impact",
current: "<!-- no <title> tag found -->",
suggested: `<title>${domain} | Describe this page in 50-60 characters</title>`,
});
} else if (title.length > TITLE_MAX_LENGTH) {
issues.push({
weight: 15,
action: "Shorten the title tag",
issue: `Title tag is ${title.length} characters — Google truncates titles past ~${TITLE_MAX_LENGTH} characters in search results.`,
priority: "High Priority",
current: `<title>${title}</title>`,
suggested: `<title>${title.slice(0, TITLE_MAX_LENGTH - 1).trim()}…</title>`,
});
}
if (!description) {
issues.push({
weight: 20,
action: "Add a meta description",
issue:
"No meta description found — Google will auto-generate a snippet from page content instead.",
priority: "High Priority",
current: '<!-- no <meta name="description"> tag found -->',
suggested: `<meta name="description" content="Describe what ${domain} offers in one or two sentences, under ${DESCRIPTION_MAX_LENGTH} characters." />`,
});
} else if (description.length > DESCRIPTION_MAX_LENGTH) {
issues.push({
weight: 8,
action: "Shorten the meta description",
issue: `Meta description is ${description.length} characters — Google truncates snippets past ~${DESCRIPTION_MAX_LENGTH} characters.`,
priority: "High Priority",
current: `<meta name="description" content="${description}" />`,
suggested: `<meta name="description" content="${description.slice(0, DESCRIPTION_MAX_LENGTH - 1).trim()}…" />`,
});
}
if (page.h1Count === 0) {
issues.push({
weight: 18,
action: "Add an H1 heading",
issue:
"No <h1> tag found on this page — a single clear H1 helps both users and search engines understand the page topic.",
priority: "High Priority",
current: "<!-- no <h1> tag found -->",
suggested: "<h1>State the page's main topic here</h1>",
});
} else if (page.h1Count > 1) {
issues.push({
weight: 10,
action: "Use a single H1 per page",
issue: `${page.h1Count} <h1> tags found — multiple H1s dilute the page's topical signal.`,
priority: "High Priority",
current: `${page.h1Count}× <h1>…</h1>`,
suggested: "Keep exactly one <h1>; demote the rest to <h2>.",
});
}
if (!page.canonicalUrl) {
issues.push({
weight: 10,
action: "Add a canonical tag",
issue:
"No canonical URL found — without one, duplicate or parameterized versions of this page can compete with each other in search results.",
priority: "High Priority",
current: '<!-- no <link rel="canonical"> tag found -->',
suggested: `<link rel="canonical" href="${page.url}" />`,
});
}
if (page.imagesMissingAlt > 0) {
issues.push({
weight: 6,
action: "Add alt text to images",
issue: `${page.imagesMissingAlt} of ${page.imagesTotal} images on this page are missing alt text.`,
priority: "Good Health",
current: '<img src="...">',
suggested: '<img src="..." alt="Describe what the image shows">',
});
}
if (issues.length === 0) {
return {
domain,
score: 96,
action: "No critical issues found on this quick scan",
issue:
"Title, meta description, H1, and canonical tag all look healthy on this page.",
priority: "Good Health",
current: `<title>${title}</title>`,
suggested:
"Run a full crawl in CrawlerX to check Core Web Vitals, internal linking, and every other page on the site.",
};
}
issues.sort((a, b) => b.weight - a.weight);
const totalPenalty = issues.reduce((sum, issue) => sum + issue.weight, 0);
const score = Math.max(35, 100 - totalPenalty);
const { weight: _weight, ...top } = issues[0];
return { domain, score, ...top };
}

View File

@ -6,7 +6,6 @@ import {
} from "@/shared/billing"; } from "@/shared/billing";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { import {
getOptionalEnvValue,
getRequiredEnvValue, getRequiredEnvValue,
isHostedServerAuthMode, isHostedServerAuthMode,
} from "@/server/lib/runtime-env"; } from "@/server/lib/runtime-env";
@ -62,13 +61,6 @@ export const getBillingUsageEvents = createServerFn({ method: "POST" })
return []; return [];
} }
// No Autumn behind this deployment (local-ledger billing fallback, see
// subscription.ts) — there's no per-event usage history to page through,
// just the running total the ledger keeps.
if (!(await getOptionalEnvValue("AUTUMN_SECRET_KEY"))) {
return [];
}
const events: BillingUsageEvent[] = []; const events: BillingUsageEvent[] = [];
let offset = 0; let offset = 0;