Compare commits

...

8 Commits

Author SHA1 Message Date
metatroncubeswdev
510c46e5ed Add a real, rate-limited public quick-audit endpoint for crawlerx.co
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
The marketing site's "Run Live Audit" widget was entirely fake: it looked
up a canned preset or a generic templated result for any domain typed in,
after a scripted delay. This adds /api/public/quick-audit, which crawls
the submitted URL with the same crawler + HTML analyzer the authenticated
product uses, and reports one real finding (or an honest error if the
site is unreachable/blocked) instead of ever fabricating one. Per-IP and
global daily KV caps bound the cost of anonymous abuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 19:19:12 -04:00
metatroncubeswdev
8e3a9f2882 Replace the actual logo asset — crawlerx-mark.png was MetatronCube's cube
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
The previous commit pointed every <img> at crawlerx-mark.png instead of
transparent-logo.png, but both files are byte-for-byte the same
MetatronCube glass-cube artwork — the Oct-era rebrand renamed the file,
it never replaced the image. So the logo never actually changed anywhere
in the real app, despite every component referencing the "right" filename.

Added the real CrawlerX mark (the teal/amber check-and-arrow symbol
already used on the marketing site) as public/crawlerx-logo.svg and
repointed every logo reference at it.

Also: the favicons and apple-touch-icon were a literal pine tree (unrelated
leftover asset, not MetatronCube or CrawlerX), and the webmanifest's
theme_color was still MetatronCube blue. Regenerated all icon sizes from
the real mark and fixed the manifest color.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-18 21:05:08 -04:00
metatroncubeswdev
0e8bfa1fa1 Finish the CrawlerX rebrand: theme tokens, sign-in page, stray logo refs
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
The Oct-era rebrand commit swapped the sidebar/app-shell logo and most
daisyUI component colors, but missed several spots:

- app.css's "crawlerx" / "crawlerx-dark" theme tokens were still the old
  MetatronCube blue (#3779b9) instead of the brand guide's teal (#0E6E6A).
  Every daisyUI-token-based component (most of the app) now gets the
  correct color with this one change.
- AuthPage.tsx (sign-in/sign-up) was untouched by the token system: a
  mountain-climber stock photo background, "Metatroncube / Solutions"
  wordmark, blue/cyan gradients, generic value props, and a stray
  alt="Metatroncube" on the logo. Replaced with the brand gradient, the
  CrawlerX mark, on-brand copy and value props, and the real color tokens
  instead of hardcoded Tailwind blue/cyan classes.
- Four more components (onboarding chat, post-signup onboarding, the
  OAuth consent screen, the subscribe page) still pointed their logo
  <img> at /transparent-logo.png instead of /crawlerx-mark.png.
- PricingGraphics.tsx (the decorative cube/chart illustration on the
  subscribe page) had ~17 hardcoded MetatronCube-blue hex values; remapped
  the whole palette to the CrawlerX teal family.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-18 17:56:40 -04:00
metatroncubeswdev
336db032a4 Return JSON, not plain text, from the Autumn route's 404
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
autumn-js's client hooks call response.json() on every response regardless
of status. The plain-text "Not found" body from the previous commit's guard
made that throw a SyntaxError client-side ("Unexpected token 'N'...") in
hosted mode once Autumn-dependent UI actually mounted and called it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-18 01:36:42 -04:00
metatroncubeswdev
9298f67c7f Stop Autumn-only code paths throwing in local-ledger billing mode
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
Two call sites still hard-required AUTUMN_SECRET_KEY regardless of billing
mode: the /api/autumn/$ route (autumn-js's client-side hooks hit it after
sign-up/on billing screens) and getBillingUsageEvents (Autumn's per-event
usage history). Both now degrade gracefully — 404 and an empty list,
respectively — when Autumn isn't configured, the same way subscription.ts's
credit checks already do, instead of autumn-js throwing "secret key not
found" once a request actually reaches it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 21:31:30 -04:00
metatroncubeswdev
78d0aaad0c Fix hardcoded "OpenSEO" app name in transactional emails
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
Verification, invitation, and password-reset emails always said
"OpenSEO" regardless of which product/brand is actually deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 21:28:49 -04:00
metatroncubeswdev
324c9e064b Revert "vite.config.ts: actually read PORT/ALLOWED_HOST from env"
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
This reverts commit 1146dc2f6a9c8551007c3761f3539fb73c619800.
2026-10-17 19:55:01 -04:00
metatroncubeswdev
1146dc2f6a vite.config.ts: actually read PORT/ALLOWED_HOST from env
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
Both were documented self-host knobs (SELF_HOSTING_DOCKER.md, compose.yaml)
but hardcoded to port 3001 and seo.thedomainnest.com instead, so any second
instance on a different port/domain got either a port clash or Vite's
preview host-check ("Blocked request... not allowed"). Defaults preserve
exact current behavior for deployments that don't set these.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 19:49:21 -04:00
25 changed files with 579 additions and 126 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 707 B

7
public/crawlerx-logo.svg Normal file
View File

@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -13,7 +13,7 @@
"type": "image/png"
}
],
"theme_color": "#3779b9",
"theme_color": "#0E6E6A",
"background_color": "#ffffff",
"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"
>
<img
src="/crawlerx-mark.png"
src="/crawlerx-logo.svg"
alt=""
className="size-6 rounded bg-white ring-1 ring-black/5"
/>

View File

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

View File

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

View File

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

View File

@ -60,7 +60,7 @@ export function PostSignupOnboarding({
<div className="text-center space-y-3">
<img
src="/transparent-logo.png"
src="/crawlerx-logo.svg"
alt="CrawlerX"
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"
>
<img
src="/crawlerx-mark.png"
src="/crawlerx-logo.svg"
alt=""
className="size-6 rounded bg-white ring-1 ring-black/5"
/>

View File

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

View File

@ -38,6 +38,7 @@ import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
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 ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
@ -214,6 +215,11 @@ const ApiStripeWebhookRoute = ApiStripeWebhookRouteImport.update({
path: '/api/stripe/webhook',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicQuickAuditRoute = ApiPublicQuickAuditRouteImport.update({
id: '/api/public/quick-audit',
path: '/api/public/quick-audit',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$',
path: '/api/autumn/$',
@ -417,6 +423,7 @@ export interface FileRoutesByFullPath {
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/public/quick-audit': typeof ApiPublicQuickAuditRoute
'/api/stripe/webhook': typeof ApiStripeWebhookRoute
'/settings/': typeof AppSettingsIndexRoute
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
@ -473,6 +480,7 @@ export interface FileRoutesByTo {
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/public/quick-audit': typeof ApiPublicQuickAuditRoute
'/api/stripe/webhook': typeof ApiStripeWebhookRoute
'/settings': typeof AppSettingsIndexRoute
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
@ -533,6 +541,7 @@ export interface FileRoutesById {
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/api/public/quick-audit': typeof ApiPublicQuickAuditRoute
'/api/stripe/webhook': typeof ApiStripeWebhookRoute
'/_app/settings/': typeof AppSettingsIndexRoute
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
@ -593,6 +602,7 @@ export interface FileRouteTypes {
| '/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
| '/api/public/quick-audit'
| '/api/stripe/webhook'
| '/settings/'
| '/onboarding/'
@ -649,6 +659,7 @@ export interface FileRouteTypes {
| '/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
| '/api/public/quick-audit'
| '/api/stripe/webhook'
| '/settings'
| '/onboarding'
@ -708,6 +719,7 @@ export interface FileRouteTypes {
| '/_authenticated/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
| '/api/public/quick-audit'
| '/api/stripe/webhook'
| '/_app/settings/'
| '/_authenticated/onboarding/'
@ -752,6 +764,7 @@ export interface RootRouteChildren {
ApiTeamSetupRoute: typeof ApiTeamSetupRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
ApiPublicQuickAuditRoute: typeof ApiPublicQuickAuditRoute
ApiStripeWebhookRoute: typeof ApiStripeWebhookRoute
ApiGa4OauthCallbackRoute: typeof ApiGa4OauthCallbackRoute
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
@ -962,6 +975,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiStripeWebhookRouteImport
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/$': {
id: '/api/autumn/$'
path: '/api/autumn/$'
@ -1385,6 +1405,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiTeamSetupRoute: ApiTeamSetupRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
ApiPublicQuickAuditRoute: ApiPublicQuickAuditRoute,
ApiStripeWebhookRoute: ApiStripeWebhookRoute,
ApiGa4OauthCallbackRoute: ApiGa4OauthCallbackRoute,
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="flex flex-col items-center text-center">
<img
src="/transparent-logo.png"
src="/crawlerx-logo.svg"
alt="CrawlerX"
className="size-10 rounded-lg"
/>

View File

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

View File

@ -53,10 +53,20 @@ function loadHandler() {
}
async function handleAutumnRequest(request: Request) {
if (!isHostedAuthMode(env.AUTH_MODE)) {
return new Response("Not found", {
status: 404,
});
// 404 in non-hosted modes, and also in hosted mode when Autumn isn't
// configured — a direct-Stripe deployment runs the local-ledger billing
// fallback instead (see subscription.ts) and has no Autumn customer to
// 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;

View File

@ -0,0 +1,125 @@
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,
transactionalId: config.verificationTemplateId,
dataVariables: {
appName: "OpenSEO",
appName: "CrawlerX",
confirmationUrl,
},
});
@ -150,7 +150,7 @@ export async function sendHostedInvitationEmail({
email,
transactionalId: templateId,
dataVariables: {
appName: "OpenSEO",
appName: "CrawlerX",
inviteUrl,
organizationName,
inviterName,
@ -172,7 +172,7 @@ export async function sendHostedPasswordResetEmail({
email,
transactionalId: config.passwordResetTemplateId,
dataVariables: {
appName: "OpenSEO",
appName: "CrawlerX",
resetUrl,
},
});

View File

@ -0,0 +1,62 @@
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

@ -0,0 +1,141 @@
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,6 +6,7 @@ import {
} from "@/shared/billing";
import { AppError } from "@/server/lib/errors";
import {
getOptionalEnvValue,
getRequiredEnvValue,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
@ -61,6 +62,13 @@ export const getBillingUsageEvents = createServerFn({ method: "POST" })
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[] = [];
let offset = 0;