From a3f0900be409224d2017c4d1807d62971fe81a3f Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sat, 13 Jun 2026 17:53:37 +0530 Subject: [PATCH 1/2] superhero landing --- web/app/globals.css | 57 ++ web/src/components/LandingShell.tsx | 25 +- .../components/landing/LandingCodeBlock.tsx | 52 ++ .../landing/LandingFeatureSpotlight.tsx | 67 +++ .../components/landing/LandingFinalCta.tsx | 35 ++ web/src/components/landing/LandingFooter.tsx | 85 +++ .../components/landing/LandingLimitations.tsx | 55 ++ .../components/landing/LandingPathStrip.tsx | 44 +- .../components/landing/LandingProductMock.tsx | 514 ++++++++++++++++++ .../components/landing/LandingStatsStrip.tsx | 65 +++ .../components/landing/LandingUseCases.tsx | 53 ++ web/src/strings.json | 94 +++- web/src/views/Landing.tsx | 285 ++++------ 13 files changed, 1220 insertions(+), 211 deletions(-) create mode 100644 web/src/components/landing/LandingCodeBlock.tsx create mode 100644 web/src/components/landing/LandingFeatureSpotlight.tsx create mode 100644 web/src/components/landing/LandingFinalCta.tsx create mode 100644 web/src/components/landing/LandingFooter.tsx create mode 100644 web/src/components/landing/LandingLimitations.tsx create mode 100644 web/src/components/landing/LandingProductMock.tsx create mode 100644 web/src/components/landing/LandingStatsStrip.tsx create mode 100644 web/src/components/landing/LandingUseCases.tsx diff --git a/web/app/globals.css b/web/app/globals.css index 0d43cdd..7cebdcd 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -518,3 +518,60 @@ select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Landing page polish */ +.landing-grid-bg { + background-image: + radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--app-text-subtle) 14%, transparent) 1px, transparent 0); + background-size: 24px 24px; +} + +.landing-gradient-text { + background: linear-gradient(135deg, var(--app-link) 0%, #8b5cf6 55%, var(--app-link-soft) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.landing-mock-glow { + position: relative; +} + +.landing-mock-glow::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--app-link) 45%, transparent), + color-mix(in srgb, #8b5cf6 35%, transparent), + color-mix(in srgb, var(--app-link) 20%, transparent) + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .landing-float { + animation: landingFloat 6s ease-in-out infinite; + } + + @keyframes landingFloat { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } + } +} + +.landing-section-alt { + background: color-mix(in srgb, var(--app-bg-muted) 35%, transparent); +} diff --git a/web/src/components/LandingShell.tsx b/web/src/components/LandingShell.tsx index d46642e..55119f2 100644 --- a/web/src/components/LandingShell.tsx +++ b/web/src/components/LandingShell.tsx @@ -12,19 +12,18 @@ export interface LandingShellProps { } const NAV_ITEMS = [ - { href: '#how-it-works', labelKey: 'navHowItWorks' as const }, + { href: '#features', labelKey: 'navFeatures' as const }, { href: '#quick-start', labelKey: 'navQuickStart' as const }, { href: '#google-setup', labelKey: 'navGoogleSetup' as const }, - { href: '#features', labelKey: 'navFeatures' as const }, -]; +] as const; export default function LandingShell({ children, footer }: LandingShellProps) { const vl = strings.views.landing; const app = strings.app; return ( -
-
+
+
@@ -43,6 +42,14 @@ export default function LandingShell({ children, footer }: LandingShellProps) { {vl[labelKey]} ))} + + {vl.navGithub} +
@@ -73,6 +80,14 @@ export default function LandingShell({ children, footer }: LandingShellProps) { {vl[labelKey]} ))} + + {vl.navGithub} +
diff --git a/web/src/components/landing/LandingCodeBlock.tsx b/web/src/components/landing/LandingCodeBlock.tsx new file mode 100644 index 0000000..8daa0d3 --- /dev/null +++ b/web/src/components/landing/LandingCodeBlock.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { Check, Copy } from 'lucide-react'; +import { strings } from '@/lib/strings'; + +const vl = strings.views.landing; + +interface LandingCodeBlockProps { + label?: string; + command: string; +} + +export default function LandingCodeBlock({ label, command }: LandingCodeBlockProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard unavailable */ + } + }, [command]); + + return ( +
+
+ {label ? ( +

{label}

+ ) : ( + + )} + +
+
+        
+          $ 
+          {command}
+        
+      
+
+ ); +} diff --git a/web/src/components/landing/LandingFeatureSpotlight.tsx b/web/src/components/landing/LandingFeatureSpotlight.tsx new file mode 100644 index 0000000..8a6bae3 --- /dev/null +++ b/web/src/components/landing/LandingFeatureSpotlight.tsx @@ -0,0 +1,67 @@ +'use client'; + +import Link from 'next/link'; +import { CheckCircle2, ChevronRight } from 'lucide-react'; +import LandingProductMock, { type LandingProductMockVariant } from '@/components/landing/LandingProductMock'; + +interface LandingFeatureSpotlightProps { + eyebrow: string; + title: string; + description: string; + bullets: readonly string[]; + mockVariant: LandingProductMockVariant; + ctaHref: string; + ctaLabel: string; + reversed?: boolean; +} + +export default function LandingFeatureSpotlight({ + eyebrow, + title, + description, + bullets, + mockVariant, + ctaHref, + ctaLabel, + reversed = false, +}: LandingFeatureSpotlightProps) { + const textCol = ( +
+

{eyebrow}

+

{title}

+

{description}

+
    + {bullets.map((bullet) => ( +
  • + + {bullet} +
  • + ))} +
+ + {ctaLabel} + + +
+ ); + + const mockCol = ( +
+ +
+ ); + + return ( +
*:first-child]:order-2' : '' + }`} + > + {textCol} + {mockCol} +
+ ); +} diff --git a/web/src/components/landing/LandingFinalCta.tsx b/web/src/components/landing/LandingFinalCta.tsx new file mode 100644 index 0000000..0a25c11 --- /dev/null +++ b/web/src/components/landing/LandingFinalCta.tsx @@ -0,0 +1,35 @@ +'use client'; + +import Link from 'next/link'; +import Button from '@/components/Button'; +import { strings } from '@/lib/strings'; + +const vl = strings.views.landing; + +export default function LandingFinalCta() { + return ( +
+
+

{vl.finalCtaTitle}

+

+ {vl.finalCtaSubtitle} +

+
+ + + + + + +
+

+ {vl.heroProofNoSubscription} · {vl.heroProofLocalData} +

+
+
+ ); +} diff --git a/web/src/components/landing/LandingFooter.tsx b/web/src/components/landing/LandingFooter.tsx new file mode 100644 index 0000000..2c81b8f --- /dev/null +++ b/web/src/components/landing/LandingFooter.tsx @@ -0,0 +1,85 @@ +'use client'; + +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { ExternalLink } from 'lucide-react'; +import { strings } from '@/lib/strings'; + +const vl = strings.views.landing; +const app = strings.app; + +function FooterLink({ + href, + children, + external, +}: { + href: string; + children: ReactNode; + external?: boolean; +}) { + const className = 'text-sm text-muted-foreground transition-colors hover:text-link'; + if (external) { + return ( + + {children} + + + ); + } + return ( + + {children} + + ); +} + +export default function LandingFooter() { + return ( +
+
+
+

{app.productName}

+

{vl.footerCopyright}

+

© {new Date().getFullYear()}

+
+
+

+ {vl.footerProductTitle} +

+
    +
  • {vl.footerOpenApp}
  • +
  • {vl.footerRunAudit}
  • +
  • {vl.footerChat}
  • +
+
+
+

+ {vl.footerDocsTitle} +

+
    +
  • {vl.footerContributing}
  • +
  • {vl.footerMcp}
  • +
  • {vl.limitationsReadmeLink}
  • +
+
+
+

+ {vl.footerCommunityTitle} +

+
    +
  • {vl.trustGithub}
  • +
  • {vl.footerIssues}
  • +
+
+
+

+ {vl.footerLegalTitle} +

+
    +
  • {vl.footerLicense}
  • +
+
+
+
+ ); +} diff --git a/web/src/components/landing/LandingLimitations.tsx b/web/src/components/landing/LandingLimitations.tsx new file mode 100644 index 0000000..fda5fa2 --- /dev/null +++ b/web/src/components/landing/LandingLimitations.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { ExternalLink } from 'lucide-react'; +import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { strings } from '@/lib/strings'; + +const vl = strings.views.landing; + +export default function LandingLimitations() { + return ( +
+
+ +
+
+

{vl.limitationsIsTitle}

+
    + {vl.limitationsIsItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

{vl.limitationsIsntTitle}

+
    + {vl.limitationsIsntItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

+ + {vl.limitationsReadmeLink} + + +

+
+
+ ); +} diff --git a/web/src/components/landing/LandingPathStrip.tsx b/web/src/components/landing/LandingPathStrip.tsx index e0173f0..69fce38 100644 --- a/web/src/components/landing/LandingPathStrip.tsx +++ b/web/src/components/landing/LandingPathStrip.tsx @@ -6,38 +6,48 @@ import { strings } from '@/lib/strings'; const vl = strings.views.landing; const STEPS = [ - { id: 'quick-start', icon: Download, label: vl.pathStepInstall, hint: vl.pathStepInstallHint }, - { id: 'how-it-works', icon: Play, label: vl.pathStepCrawl, hint: vl.pathStepCrawlHint }, - { id: 'google-setup', icon: Settings2, label: vl.pathStepGoogle, hint: vl.pathStepGoogleHint }, - { id: 'features', icon: BarChart2, label: vl.pathStepReport, hint: vl.pathStepReportHint }, + { step: 1, id: 'quick-start', icon: Download, label: vl.pathStepInstall, hint: vl.pathStepInstallHint }, + { step: 2, id: 'spotlights', icon: Play, label: vl.pathStepCrawl, hint: vl.pathStepCrawlHint }, + { step: 3, id: 'google-setup', icon: Settings2, label: vl.pathStepGoogle, hint: vl.pathStepGoogleHint }, + { step: 4, id: 'features', icon: BarChart2, label: vl.pathStepReport, hint: vl.pathStepReportHint }, ] as const; export default function LandingPathStrip() { return ( -
-

+

+

{vl.pathTitle}

-
- {STEPS.map(({ id, icon: Icon, label, hint }, index) => ( -
+
+
+ {STEPS.map(({ step, id, icon: Icon, label, hint }, index) => ( +
- - + + + + + + {step} + - - {label} - - {hint} + {label} + {hint} {index < STEPS.length - 1 ? ( ) : null} diff --git a/web/src/components/landing/LandingProductMock.tsx b/web/src/components/landing/LandingProductMock.tsx new file mode 100644 index 0000000..dce9e9a --- /dev/null +++ b/web/src/components/landing/LandingProductMock.tsx @@ -0,0 +1,514 @@ +'use client'; + +import { useId, type ReactNode } from 'react'; + +export type LandingProductMockVariant = 'default' | 'crawl' | 'issues'; + +interface LandingProductMockProps { + variant?: LandingProductMockVariant; + className?: string; + elevated?: boolean; +} + +const NAV_ITEMS = [ + { label: 'Overview', activeFor: ['default'] as const }, + { label: 'Issues', activeFor: ['issues'] as const }, + { label: 'All URLs', activeFor: ['crawl'] as const }, + { label: 'Search', activeFor: [] as const }, + { label: 'Export', activeFor: [] as const }, +]; + +function MockWidget({ + title, + children, + className = '', +}: { + title: string; + children: ReactNode; + className?: string; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function MockKpi({ + label, + value, + accent, + delta, +}: { + label: string; + value: string; + accent?: boolean; + delta?: string; +}) { + return ( +
+

{label}

+
+

{value}

+ {delta ? {delta} : null} +
+
+ ); +} + +function MockSparkline({ points, className = '' }: { points: number[]; className?: string }) { + const fillId = useId(); + const max = Math.max(...points); + const min = Math.min(...points); + const range = max - min || 1; + const coords = points + .map((p, i) => { + const x = (i / (points.length - 1)) * 100; + const y = 100 - ((p - min) / range) * 100; + return `${x},${y}`; + }) + .join(' '); + + return ( + + + + + + + + + + + ); +} + +function MockLineChart({ label }: { label?: string }) { + const fillId = useId(); + const points = [12, 18, 15, 22, 19, 28, 24, 32, 29, 38, 34, 42]; + const max = Math.max(...points); + const coords = points + .map((p, i) => `${(i / (points.length - 1)) * 100},${100 - (p / max) * 85}`) + .join(' '); + + return ( + + + {[25, 50, 75].map((y) => ( + + ))} + + + + + + + + + +
+ Week 1 + Week 4 +
+
+ ); +} + +function MockDonut({ + segments, + centerLabel, + centerValue, +}: { + segments: { label: string; value: number; color: string }[]; + centerLabel?: string; + centerValue?: string; +}) { + const total = segments.reduce((sum, s) => sum + s.value, 0); + let cumulative = 0; + const gradient = segments + .map((s) => { + const start = (cumulative / total) * 100; + cumulative += s.value; + const end = (cumulative / total) * 100; + return `${s.color} ${start}% ${end}%`; + }) + .join(', '); + + return ( +
+
+
+
+ {centerValue ? ( + {centerValue} + ) : null} + {centerLabel ? ( + {centerLabel} + ) : null} +
+
+
    + {segments.map((s) => ( +
  • + + + {s.label} + + {s.value}% +
  • + ))} +
+
+ ); +} + +const SCORE_RING_STROKE: Record = { + 'text-link': 'stroke-blue-400', + 'text-amber-400': 'stroke-amber-400', + 'text-emerald-400': 'stroke-emerald-400', +}; + +function MockScoreRing({ + score, + label, + color = 'text-link', +}: { + score: number; + label: string; + color?: string; +}) { + const circumference = 2 * Math.PI * 18; + const offset = circumference - (score / 100) * circumference; + const strokeClass = SCORE_RING_STROKE[color] ?? SCORE_RING_STROKE['text-link']; + + return ( +
+
+ + + + + + {score} + +
+ {label} +
+ ); +} + +function MockHorizontalBars({ + items, +}: { + items: { label: string; value: number; color: string }[]; +}) { + const max = Math.max(...items.map((i) => i.value)); + return ( +
    + {items.map((item) => ( +
  • +
    + {item.label} + {item.value.toLocaleString()} +
    +
    +
    +
    +
  • + ))} +
+ ); +} + +function MockStackedBar({ + segments, +}: { + segments: { label: string; value: number; color: string }[]; +}) { + const total = segments.reduce((sum, s) => sum + s.value, 0); + return ( +
+
+ {segments.map((s) => ( +
+ ))} +
+
+ {segments.map((s) => ( + + + {s.label} ({s.value}) + + ))} +
+
+ ); +} + +function MockBarChart() { + const heights = [40, 65, 52, 78, 45, 88, 60, 72, 55, 80, 68, 92]; + return ( +
+ {heights.map((h, i) => ( +
+ ))} +
+ ); +} + +function MockIssueRow({ severity, title }: { severity: string; title: string }) { + const severityClass = + severity === 'Critical' + ? 'bg-red-500/20 text-red-400' + : severity === 'High' + ? 'bg-amber-500/20 text-amber-400' + : 'bg-blue-500/15 text-link'; + return ( +
+ + {severity} + + {title} +
+ ); +} + +function MockUrlRow({ path, status }: { path: string; status: number }) { + const statusClass = status >= 400 ? 'text-red-400' : status >= 300 ? 'text-amber-400' : 'text-emerald-400'; + return ( +
+ {status} + {path} +
+ ); +} + +function isNavActive(activeFor: readonly string[], variant: LandingProductMockVariant) { + return activeFor.includes(variant); +} + +function CrawlPanel() { + return ( + <> +
+ + + +
+
+ + + + + + +
+ +
+ + + +
+
+ + ); +} + +function IssuesPanel() { + return ( + <> +
+
+ +
+ + +
+
+ + + + + +

↓ 18% vs last crawl

+
+
+
+ + + +
+ +
+ + + +
+
+ + ); +} + +function OverviewPanel() { + return ( + <> +
+ + + +
+
+ + + + + + +
+
+ + +
+ + +
+
+
+ +
+ + +
+
+ + ); +} + +export default function LandingProductMock({ + variant = 'default', + className = '', + elevated = false, +}: LandingProductMockProps) { + return ( +
+
+ + + + + + + https://site-audit.local/{variant === 'crawl' ? 'links' : variant === 'issues' ? 'issues' : 'overview'} + +
+ +
+ + +
+ {variant === 'crawl' ? : variant === 'issues' ? : } +
+
+
+ ); +} diff --git a/web/src/components/landing/LandingStatsStrip.tsx b/web/src/components/landing/LandingStatsStrip.tsx new file mode 100644 index 0000000..ce874d6 --- /dev/null +++ b/web/src/components/landing/LandingStatsStrip.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { Bot, ExternalLink, GitBranch, Shield, Sparkles } from 'lucide-react'; +import { strings } from '@/lib/strings'; + +const vl = strings.views.landing; + +const STATS = [ + { icon: Shield, label: vl.stat1Label, value: vl.stat1Value }, + { icon: GitBranch, label: vl.stat2Label, value: vl.stat2Value }, + { icon: Sparkles, label: vl.stat3Label, value: vl.stat3Value }, + { icon: Bot, label: vl.stat4Label, value: vl.stat4Value }, +] as const; + +const STACK = [vl.trustStackDocker, vl.trustStackPostgres, vl.trustStackNext, vl.trustLicense] as const; + +export default function LandingStatsStrip() { + return ( +
+
+

+ {vl.statsTitle} +

+
+ {STATS.map(({ icon: Icon, label, value }) => ( +
+ + + +

+ {label} +

+

{value}

+
+ ))} +
+
+

{vl.trustTitle}

+
+ {STACK.map((item) => ( + + {item} + + ))} + + {vl.trustGithub} + + +
+
+
+
+ ); +} diff --git a/web/src/components/landing/LandingUseCases.tsx b/web/src/components/landing/LandingUseCases.tsx new file mode 100644 index 0000000..d40a3e9 --- /dev/null +++ b/web/src/components/landing/LandingUseCases.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Briefcase, Building2, Code2 } from 'lucide-react'; +import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { strings } from '@/lib/strings'; + +const vl = strings.views.landing; + +const USE_CASES = [ + { + icon: Briefcase, + title: vl.useCase1Title, + description: vl.useCase1Description, + }, + { + icon: Building2, + title: vl.useCase2Title, + description: vl.useCase2Description, + }, + { + icon: Code2, + title: vl.useCase3Title, + description: vl.useCase3Description, + }, +] as const; + +export default function LandingUseCases() { + return ( +
+
+ +
+ {USE_CASES.map(({ icon: Icon, title, description }) => ( +
+ + + +

{title}

+

{description}

+
+ ))} +
+
+
+ ); +} diff --git a/web/src/strings.json b/web/src/strings.json index 2974f04..73428b2 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -1372,18 +1372,102 @@ "metaTitle": "Site Audit — Self-hosted technical SEO", "metaDescription": "Crawl your sites, find technical SEO issues, connect Search Console and Analytics, and export client reports — free and self-hosted.", "navFeatures": "Features", - "navHowItWorks": "How it works", "navQuickStart": "Quick start", "navGoogleSetup": "Google setup", + "navGithub": "GitHub", "navOpenApp": "Dashboard", "navRunAudit": "Run audit", "heroBadge": "Open source · Self-hosted", - "heroTitle": "Technical SEO audits you control", - "heroSubtitle": "Crawl sites, surface real issues, connect Search Console and Analytics, and ship client reports — on infrastructure you own.", + "heroTitleLine1": "Self-hosted technical SEO", + "heroTitleAccent": "audits you control", + "heroSubtitle": "Crawl every URL, prioritize real issues, connect Search Console and Analytics, and export client reports — on infrastructure you own.", + "heroProofNoSubscription": "No subscription tiers", + "heroProofLocalData": "Your data stays local", + "heroProofExport": "Export HTML & PDF", "ctaDashboard": "Open dashboard", "ctaRunAudit": "Run your first audit", "ctaGoogleGuide": "Google setup guide", - "scrollHint": "New here? Follow the steps below.", + "scrollHint": "New here? See how to get started.", + "statsTitle": "Built for teams who own their data", + "stat1Label": "License", + "stat1Value": "MIT · Open source", + "stat2Label": "Integrations", + "stat2Value": "GSC · GA4 · Bing", + "stat3Label": "Audit depth", + "stat3Value": "Crawl · Lighthouse · Issues", + "stat4Label": "AI workflow", + "stat4Value": "Chat · MCP tools", + "trustTitle": "Runs on your stack", + "trustGithub": "View on GitHub", + "trustLicense": "MIT License", + "trustStackDocker": "Docker", + "trustStackPostgres": "PostgreSQL", + "trustStackNext": "Next.js", + "spotlight1Eyebrow": "Site crawl", + "spotlight1Title": "Map every URL like Screaming Frog", + "spotlight1Description": "Spider or sitemap discovery, status codes, redirect chains, and a full site structure — with static or JavaScript rendering.", + "spotlight1Bullets": [ + "Crawl maps and path trees for large sites", + "Redirect and canonical analysis per URL", + "Export sitemaps and compare audit runs" + ], + "spotlight2Eyebrow": "Issues & performance", + "spotlight2Title": "Prioritized fixes with Lighthouse scores", + "spotlight2Description": "Health scores, issue boards by severity, and Core Web Vitals — the workflows you expect from enterprise audit tools.", + "spotlight2Bullets": [ + "Critical and high-priority issue triage", + "On-page title, meta, and content checks", + "Search Console queries when Google is connected" + ], + "spotlightsSectionTitle": "See the product in action", + "spotlightsSectionSubtitle": "Familiar audit workflows — crawl maps, issue boards, and performance scores — in a UI built for daily SEO work.", + "spotlight1Cta": "Run your first crawl", + "spotlight2Cta": "Open the dashboard", + "useCasesEyebrow": "Who it's for", + "useCasesTitle": "One platform, different workflows", + "useCasesSubtitle": "Whether you audit client sites or your own properties, Site Audit fits how technical SEO teams actually work.", + "useCase1Title": "Agencies & consultants", + "useCase1Description": "Manage a portfolio of client properties, compare audit runs, and ship HTML or PDF exports without per-seat SaaS fees.", + "useCase2Title": "In-house SEO teams", + "useCase2Description": "Connect Search Console and GA4 per property, track issues over time, and keep crawl history on infrastructure you control.", + "useCase3Title": "Developers & platform teams", + "useCase3Description": "Run with Docker, store data in PostgreSQL, and extend workflows with MCP-compatible AI tools in Cursor or Claude Desktop.", + "copyCommand": "Copy", + "copyCommandDone": "Copied", + "limitationsTitle": "Honest scope", + "limitationsSubtitle": "Site Audit focuses on transparent, self-hosted technical SEO — not every paid SaaS data product.", + "limitationsIsTitle": "What it is", + "limitationsIsItems": [ + "Self-hosted crawl and technical audit platform", + "GSC, GA4, and Bing Webmaster integrations with your credentials", + "Portfolio management and HTML/PDF exports for clients", + "AI chat and MCP tools over your audit data" + ], + "limitationsIsntTitle": "What it isn't", + "limitationsIsntItems": [ + "No live backlink index (GSC Links CSV import only)", + "No proprietary rank tracker or keyword volume APIs", + "No live AI citation checks or managed cloud hosting", + "Not a substitute for verified Google property access" + ], + "limitationsReadmeLink": "Read full scope in README", + "finalCtaTitle": "Start your first audit today", + "finalCtaSubtitle": "Install with Docker or local dev, then crawl your first property in minutes.", + "footerProductTitle": "Product", + "footerDocsTitle": "Documentation", + "footerCommunityTitle": "Community", + "footerLegalTitle": "Legal", + "footerContributing": "Contributing", + "footerMcp": "MCP setup", + "footerIssues": "GitHub issues", + "footerLicense": "License", + "footerCopyright": "Open-source SEO crawl and audit platform.", + "githubRepoUrl": "https://github.com/codefrydev/WebsiteProfiling", + "githubContributingUrl": "https://github.com/codefrydev/WebsiteProfiling/blob/master/CONTRIBUTING.md", + "githubMcpUrl": "https://github.com/codefrydev/WebsiteProfiling/blob/master/docs/MCP.md", + "githubIssuesUrl": "https://github.com/codefrydev/WebsiteProfiling/issues", + "githubLicenseUrl": "https://github.com/codefrydev/WebsiteProfiling/blob/master/LICENSE", + "githubReadmeUrl": "https://github.com/codefrydev/WebsiteProfiling#scope-and-limitations", "pathTitle": "Get started", "pathStepInstall": "Install", "pathStepInstallHint": "Docker or local dev", @@ -1533,7 +1617,7 @@ ] } }, - "footerOpenApp": "Open app", + "footerOpenApp": "Dashboard", "footerRunAudit": "Run audit", "footerChat": "AI chat" }, diff --git a/web/src/views/Landing.tsx b/web/src/views/Landing.tsx index 2c74549..b3af71a 100644 --- a/web/src/views/Landing.tsx +++ b/web/src/views/Landing.tsx @@ -2,34 +2,36 @@ import Link from 'next/link'; import { - AlertOctagon, ArrowDown, - BarChart2, - ChevronRight, + Check, Cpu, - FolderTree, Gauge, Key, Link2, MessageSquare, - Play, - Settings2, TrendingUp, } from 'lucide-react'; import AppLogo from '@/components/AppLogo'; import Button from '@/components/Button'; +import LandingCodeBlock from '@/components/landing/LandingCodeBlock'; +import LandingFeatureSpotlight from '@/components/landing/LandingFeatureSpotlight'; +import LandingFinalCta from '@/components/landing/LandingFinalCta'; +import LandingFooter from '@/components/landing/LandingFooter'; import LandingGoogleSetup from '@/components/landing/LandingGoogleSetup'; +import LandingLimitations from '@/components/landing/LandingLimitations'; import LandingPathStrip from '@/components/landing/LandingPathStrip'; +import LandingProductMock from '@/components/landing/LandingProductMock'; import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; import LandingShell from '@/components/LandingShell'; +import LandingStatsStrip from '@/components/landing/LandingStatsStrip'; +import LandingUseCases from '@/components/landing/LandingUseCases'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; -const app = strings.app; + +const HERO_PROOF = [vl.heroProofNoSubscription, vl.heroProofLocalData, vl.heroProofExport] as const; const FEATURES = [ - { icon: FolderTree, title: vl.featureCrawlTitle, description: vl.featureCrawlDescription }, - { icon: AlertOctagon, title: vl.featureIssuesTitle, description: vl.featureIssuesDescription }, { icon: Gauge, title: vl.featureOnPageTitle, description: vl.featureOnPageDescription }, { icon: TrendingUp, title: vl.featureSearchTitle, description: vl.featureSearchDescription }, { icon: Key, title: vl.featureKeywordsTitle, description: vl.featureKeywordsDescription }, @@ -38,201 +40,111 @@ const FEATURES = [ { icon: Cpu, title: vl.featureSelfHostedTitle, description: vl.featureSelfHostedDescription }, ] as const; -const STEPS = [ - { - step: 1, - icon: Play, - title: vl.step1Title, - description: vl.step1Description, - href: '/pipeline', - linkLabel: vl.ctaRunAudit, - }, - { - step: 2, - icon: Settings2, - title: vl.step2Title, - description: vl.step2Description, - href: '#google-setup', - linkLabel: vl.step2Link, - }, - { - step: 3, - icon: BarChart2, - title: vl.step3Title, - description: vl.step3Description, - href: '/home', - linkLabel: vl.step3Link, - }, -] as const; - -function CodeBlock({ label, command }: { label?: string; command: string }) { - return ( -
- {label ? ( -

{label}

- ) : null} -
-        {command}
-      
-
- ); -} - export default function LandingPage() { - const footer = ( -
-
-

{app.productName}

-

{app.productSubtitle}

-
-
- - {vl.footerOpenApp} - - - {vl.footerRunAudit} - - - {vl.footerChat} - -
-
- ); - return ( - + }>
+
-
-
-
+
+
+
+
-
+
- - {vl.heroBadge} - -
- +
+ + {vl.heroBadge} + +
+
+ + {strings.app.productName}
-

- {vl.heroTitle} +

+ {vl.heroTitleLine1} + {vl.heroTitleAccent}

-

+

{vl.heroSubtitle}

-
+
- -
+
    + {HERO_PROOF.map((item) => ( +
  • + + {item} +
  • + ))} +
{vl.scrollHint}
-
-

- {vl.pathTitle} -

-
    - {STEPS.map(({ step, title, description, href, linkLabel }) => ( -
  1. - - - {step} - - - {title} - - {description} - - - {linkLabel} - - - - -
  2. - ))} -
+
+
+
-
+
+ + -
- {STEPS.map(({ step, icon: Icon, title, description, href, linkLabel }, index) => ( -
- {index < STEPS.length - 1 ? ( - - ) : null} -
- - {step} - - -
-

{title}

-

{description}

- {href.startsWith('/') ? ( - - {linkLabel} - - - ) : ( - - {linkLabel} - - - )} -
- ))} -
+ +
- +
- - + +

{vl.quickStartDocsHint}

@@ -254,28 +166,33 @@ export default function LandingPage() {
- -
- {FEATURES.map(({ icon: Icon, title, description }) => ( -
- - - -

{title}

-

{description}

-
- ))} +
+ +
+ {FEATURES.map(({ icon: Icon, title, description }) => ( +
+ + + +

{title}

+

{description}

+
+ ))} +
+ + + ); } From ac382c3bdc250f0af3fd771bae5eebefc615f525 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 14 Jun 2026 11:26:23 +0530 Subject: [PATCH 2/2] Minor gap --- .../versions/016_competitor_keyword_gap.py | 29 +++ .../keywords/competitor_gap_store.py | 93 +++++++ src/website_profiling/reporting/builder.py | 18 +- tests/test_competitor_gap_store.py | 229 ++++++++++++++++++ web/app/api/compare/export/route.ts | 63 +---- .../google/page-data/history/route.ts | 25 +- .../integrations/google/page-data/route.ts | 9 +- .../api/keywords/competitor-import/route.ts | 99 +++++--- web/src/components/links/tabs/IssuesTab.tsx | 2 +- .../links/tabs/SearchRetentionTab.tsx | 17 +- web/src/lib/loadReportDb.test.ts | 94 +++++++ web/src/lib/loadReportDb.ts | 52 +++- web/src/server/compareExportRoute.test.ts | 123 ++++++++++ .../keywordsCompetitorImportRoute.test.ts | 50 +++- web/src/server/pageDataHistoryRoute.test.ts | 44 ++++ web/src/server/pageGoogleData.ts | 39 +++ web/src/strings.json | 3 +- web/src/views/KeywordsExplorer.tsx | 7 +- 18 files changed, 870 insertions(+), 126 deletions(-) create mode 100644 alembic/versions/016_competitor_keyword_gap.py create mode 100644 src/website_profiling/integrations/keywords/competitor_gap_store.py create mode 100644 tests/test_competitor_gap_store.py create mode 100644 web/src/lib/loadReportDb.test.ts create mode 100644 web/src/server/compareExportRoute.test.ts create mode 100644 web/src/server/pageDataHistoryRoute.test.ts diff --git a/alembic/versions/016_competitor_keyword_gap.py b/alembic/versions/016_competitor_keyword_gap.py new file mode 100644 index 0000000..3b64ff4 --- /dev/null +++ b/alembic/versions/016_competitor_keyword_gap.py @@ -0,0 +1,29 @@ +"""Property-scoped competitor keyword gap imports. + +Revision ID: 016_competitor_keyword_gap +Revises: 015_crawl_page_html +""" +from __future__ import annotations + +from alembic import op + +revision = "016_competitor_keyword_gap" +down_revision = "015_crawl_page_html" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS competitor_keyword_gap ( + property_id BIGINT PRIMARY KEY REFERENCES properties(id) ON DELETE CASCADE, + data JSONB NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS competitor_keyword_gap CASCADE;") diff --git a/src/website_profiling/integrations/keywords/competitor_gap_store.py b/src/website_profiling/integrations/keywords/competitor_gap_store.py new file mode 100644 index 0000000..ea585b2 --- /dev/null +++ b/src/website_profiling/integrations/keywords/competitor_gap_store.py @@ -0,0 +1,93 @@ +"""Read/write per-property competitor keyword gap rows.""" +from __future__ import annotations + +import json +from typing import Any + +from psycopg import Connection +from psycopg.types.json import Json + +from ...db.storage import _parse_row_json, _sanitize_for_json + + +def _normalize_competitor(value: str) -> str: + return str(value or "").strip().lower() + + +def read_competitor_keyword_gap(conn: Connection, property_id: int | None) -> list[dict[str, Any]]: + """Return stored competitor keyword gap rows for property_id.""" + if property_id is None: + return [] + try: + cur = conn.execute( + "SELECT data FROM competitor_keyword_gap WHERE property_id = %s", + (property_id,), + ) + row = cur.fetchone() + if row is None: + return _migrate_legacy_config_if_empty(conn, property_id) + data = _parse_row_json(row) + if isinstance(data, list): + return [r for r in data if isinstance(r, dict)] + return [] + except Exception: + return [] + + +def _migrate_legacy_config_if_empty(conn: Connection, property_id: int) -> list[dict[str, Any]]: + """One-time read from global pipeline_config when property has no rows yet.""" + try: + from ...config import get_str + from ...db.config_store import read_pipeline_config + + known, _ = read_pipeline_config(conn) + raw = (get_str(known or {}, "competitor_keyword_gap_json", "") or "").strip() + if not raw: + return [] + parsed = json.loads(raw) + if not isinstance(parsed, list): + return [] + rows = [r for r in parsed if isinstance(r, dict)] + if rows: + write_competitor_keyword_gap(conn, property_id, rows) + return rows + except Exception: + return [] + + +def write_competitor_keyword_gap( + conn: Connection, + property_id: int, + rows: list[dict[str, Any]], +) -> None: + """Replace all competitor keyword gap rows for property_id.""" + conn.execute( + """ + INSERT INTO competitor_keyword_gap (property_id, data, updated_at) + VALUES (%s, %s, now()) + ON CONFLICT (property_id) DO UPDATE SET + data = EXCLUDED.data, + updated_at = now() + """, + (property_id, Json(_sanitize_for_json(rows))), + ) + conn.commit() + + +def merge_competitor_keyword_import( + conn: Connection, + property_id: int, + competitor: str, + new_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Replace rows for competitor (case-insensitive), keep other competitors, upsert.""" + competitor_norm = _normalize_competitor(competitor) + existing = read_competitor_keyword_gap(conn, property_id) + kept = [ + r + for r in existing + if _normalize_competitor(str(r.get("competitor") or "")) != competitor_norm + ] + merged = kept + [r for r in new_rows if isinstance(r, dict)] + write_competitor_keyword_gap(conn, property_id, merged) + return merged diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py index f0febd2..24660ee 100644 --- a/src/website_profiling/reporting/builder.py +++ b/src/website_profiling/reporting/builder.py @@ -911,14 +911,16 @@ def _bool_col(col): except Exception as e: report_data.setdefault("ml_errors", []).append(f"rich_results: {e}") try: - from ..config import get_str - import json as _json - - comp_kw = (get_str(config or {}, "competitor_keyword_gap_json", "") or "").strip() - if comp_kw: - parsed = _json.loads(comp_kw) - if isinstance(parsed, list): - report_data["competitor_keyword_gap"] = parsed + from ..db import db_session as _ck_db + from ..commands.config_resolve import resolve_property_id_from_cfg + from ..integrations.keywords.competitor_gap_store import read_competitor_keyword_gap + + with _ck_db() as conn: + property_id_ck = resolve_property_id_from_cfg(config, conn) + if property_id_ck is not None: + gap_rows = read_competitor_keyword_gap(conn, property_id_ck) + if gap_rows: + report_data["competitor_keyword_gap"] = gap_rows except Exception as e: report_data.setdefault("ml_errors", []).append(f"competitor_keywords: {e}") if run_id is not None: diff --git a/tests/test_competitor_gap_store.py b/tests/test_competitor_gap_store.py new file mode 100644 index 0000000..6e6af39 --- /dev/null +++ b/tests/test_competitor_gap_store.py @@ -0,0 +1,229 @@ +"""Tests for competitor keyword gap store.""" +from __future__ import annotations + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from website_profiling.integrations.keywords.competitor_gap_store import ( + _migrate_legacy_config_if_empty, + merge_competitor_keyword_import, + read_competitor_keyword_gap, + write_competitor_keyword_gap, +) + + +def _mock_conn_with_row(data: object | None) -> MagicMock: + conn = MagicMock() + if data is None: + conn.execute.return_value.fetchone.return_value = None + else: + conn.execute.return_value.fetchone.return_value = {"data": data} + return conn + + +def test_merge_replaces_same_competitor_only() -> None: + conn = MagicMock() + existing = [ + {"keyword": "old", "competitor": "rival.com"}, + {"keyword": "other", "competitor": "other.com"}, + ] + new_rows = [{"keyword": "new-kw", "competitor": "rival.com"}] + + with patch( + "website_profiling.integrations.keywords.competitor_gap_store.read_competitor_keyword_gap", + return_value=existing, + ): + merged = merge_competitor_keyword_import(conn, 1, "rival.com", new_rows) + + assert len(merged) == 2 + assert merged[0]["keyword"] == "other" + assert merged[1]["keyword"] == "new-kw" + conn.execute.assert_called() + conn.commit.assert_called() + + +def test_read_returns_empty_when_no_property() -> None: + conn = MagicMock() + assert read_competitor_keyword_gap(conn, None) == [] + + +def test_write_calls_upsert() -> None: + conn = MagicMock() + write_competitor_keyword_gap(conn, 5, [{"keyword": "x"}]) + conn.execute.assert_called_once() + conn.commit.assert_called_once() + + +def test_read_returns_stored_dict_rows() -> None: + conn = _mock_conn_with_row([{"keyword": "a", "competitor": "x.com"}, "skip"]) + rows = read_competitor_keyword_gap(conn, 3) + assert rows == [{"keyword": "a", "competitor": "x.com"}] + + +def test_read_returns_empty_when_data_not_list() -> None: + conn = _mock_conn_with_row({"not": "a list"}) + assert read_competitor_keyword_gap(conn, 2) == [] + + +def test_read_migrates_when_row_missing() -> None: + conn = _mock_conn_with_row(None) + legacy = [{"keyword": "legacy", "competitor": "old.com"}] + with patch( + "website_profiling.integrations.keywords.competitor_gap_store._migrate_legacy_config_if_empty", + return_value=legacy, + ) as migrate: + out = read_competitor_keyword_gap(conn, 9) + migrate.assert_called_once_with(conn, 9) + assert out == legacy + + +def test_read_returns_empty_on_db_error() -> None: + conn = MagicMock() + conn.execute.side_effect = RuntimeError("db down") + assert read_competitor_keyword_gap(conn, 1) == [] + + +def test_migrate_legacy_empty_config() -> None: + conn = MagicMock() + with patch( + "website_profiling.db.config_store.read_pipeline_config", + return_value=({}, []), + ): + assert _migrate_legacy_config_if_empty(conn, 1) == [] + + +def test_migrate_legacy_parses_and_writes() -> None: + conn = MagicMock() + rows = [{"keyword": "kw", "competitor": "c.com"}] + raw = json.dumps(rows) + with patch( + "website_profiling.db.config_store.read_pipeline_config", + return_value=({"competitor_keyword_gap_json": raw}, []), + ): + out = _migrate_legacy_config_if_empty(conn, 4) + assert out == rows + conn.execute.assert_called() + conn.commit.assert_called() + + +def test_migrate_legacy_ignores_non_list_json() -> None: + conn = MagicMock() + with patch( + "website_profiling.db.config_store.read_pipeline_config", + return_value=({"competitor_keyword_gap_json": json.dumps({"bad": True})}, []), + ): + assert _migrate_legacy_config_if_empty(conn, 1) == [] + + +def test_migrate_legacy_returns_empty_on_error() -> None: + conn = MagicMock() + with patch( + "website_profiling.db.config_store.read_pipeline_config", + side_effect=RuntimeError("fail"), + ): + assert _migrate_legacy_config_if_empty(conn, 1) == [] + + +def _require_database_url() -> None: + if not (os.environ.get("DATABASE_URL") or "").strip(): + pytest.skip("DATABASE_URL not set") + + +def _integration_property_id(domain: str) -> int: + from website_profiling.db import db_session + from website_profiling.db.property_store import upsert_property_by_domain + + with db_session() as conn: + return upsert_property_by_domain(conn, "Competitor Gap Test", domain) + + +def _reset_competitor_gap(conn, property_id: int) -> None: + """Clear stored rows so legacy pipeline_config cannot leak into merge tests.""" + conn.execute( + "DELETE FROM competitor_keyword_gap WHERE property_id = %s", + (property_id,), + ) + write_competitor_keyword_gap(conn, property_id, []) + + +@pytest.fixture +def roundtrip_property_id() -> int: + _require_database_url() + return _integration_property_id("competitor-gap-roundtrip.example") + + +@pytest.fixture +def migrate_property_id() -> int: + _require_database_url() + return _integration_property_id("competitor-gap-migrate.example") + + +@pytest.mark.integration +def test_competitor_gap_db_roundtrip(roundtrip_property_id: int) -> None: + from website_profiling.db import db_session + + with db_session() as conn: + _reset_competitor_gap(conn, roundtrip_property_id) + merged = merge_competitor_keyword_import( + conn, + roundtrip_property_id, + "rival.com", + [{"keyword": "kw1", "competitor": "rival.com"}], + ) + assert len(merged) == 1 + + merged2 = merge_competitor_keyword_import( + conn, + roundtrip_property_id, + "other.com", + [{"keyword": "kw2", "competitor": "other.com"}], + ) + assert len(merged2) == 2 + + merged3 = merge_competitor_keyword_import( + conn, + roundtrip_property_id, + "rival.com", + [{"keyword": "new-kw", "competitor": "rival.com"}], + ) + assert len(merged3) == 2 + assert {r["keyword"] for r in merged3} == {"kw2", "new-kw"} + assert read_competitor_keyword_gap(conn, roundtrip_property_id) == merged3 + + +@pytest.mark.integration +def test_migrate_legacy_config_from_pipeline(migrate_property_id: int) -> None: + from website_profiling.db import db_session + + legacy_rows = [{"keyword": "from-config", "competitor": "legacy.com"}] + with db_session() as conn: + conn.execute( + """ + INSERT INTO pipeline_config (key, value, is_unknown, updated_at) + VALUES (%s, %s, false, now()) + ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + is_unknown = false, + updated_at = now() + """, + ("competitor_keyword_gap_json", json.dumps(legacy_rows)), + ) + conn.execute( + "DELETE FROM competitor_keyword_gap WHERE property_id = %s", + (migrate_property_id,), + ) + conn.commit() + rows = read_competitor_keyword_gap(conn, migrate_property_id) + assert rows == legacy_rows + conn.execute( + "DELETE FROM pipeline_config WHERE key = %s", + ("competitor_keyword_gap_json",), + ) + conn.execute( + "DELETE FROM competitor_keyword_gap WHERE property_id = %s", + (migrate_property_id,), + ) + conn.commit() diff --git a/web/app/api/compare/export/route.ts b/web/app/api/compare/export/route.ts index 72d6af2..5d609b5 100644 --- a/web/app/api/compare/export/route.ts +++ b/web/app/api/compare/export/route.ts @@ -1,26 +1,12 @@ import { NextResponse, type NextRequest } from 'next/server'; import { withDb } from '@/server/db'; +import { buildIssueDeltas } from '@/lib/reportCompareExtras'; import type { ApiRouteHandler } from '@/types/api'; -import type { ReportCategory, ReportIssue } from '@/types'; +import type { ReportCategory, ReportPayload } from '@/types/report'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -function issueKey(cat: string, iss: ReportIssue): string { - return `${cat}|${iss.url || ''}|${iss.message || ''}`; -} - -function collectIssues(categories: ReportCategory[] = []): Map { - const map = new Map(); - for (const cat of categories) { - const name = cat.name || cat.id || ''; - for (const issue of cat.issues || []) { - map.set(issueKey(name, issue), { cat: name, issue }); - } - } - return map; -} - function csvEscape(value: string): string { if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`; return value; @@ -47,51 +33,26 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { const rows = await Promise.all( [reportIdA, reportIdB].map(async (id) => { - const cur = await client.query<{ data: { categories?: ReportCategory[] } }>( + const cur = await client.query<{ data: ReportPayload }>( 'SELECT data FROM report_payload WHERE id = $1', [id], ); - return cur.rows[0]?.data || { categories: [] }; + return cur.rows[0]?.data ?? { categories: [] as ReportCategory[] }; }), ); return rows; }); - const issuesA = collectIssues(payloadA.categories); - const issuesB = collectIssues(payloadB.categories); + const deltas = buildIssueDeltas(payloadA, payloadB); const lines = ['change,category,priority,url,message,recommendation']; - for (const [key, { cat, issue }] of issuesA) { - if (!issuesB.has(key)) { - lines.push( - [ - 'removed', - cat, - issue.priority || '', - issue.url || '', - issue.message || '', - issue.recommendation || '', - ] - .map((v) => csvEscape(String(v))) - .join(','), - ); - } - } - for (const [key, { cat, issue }] of issuesB) { - if (!issuesA.has(key)) { - lines.push( - [ - 'added', - cat, - issue.priority || '', - issue.url || '', - issue.message || '', - issue.recommendation || '', - ] - .map((v) => csvEscape(String(v))) - .join(','), - ); - } + for (const row of deltas) { + const change = row.kind === 'new' ? 'added' : 'removed'; + lines.push( + [change, row.category, row.priority, row.url, row.message, ''] + .map((v) => csvEscape(String(v))) + .join(','), + ); } const csv = `${lines.join('\n')}\n`; diff --git a/web/app/api/integrations/google/page-data/history/route.ts b/web/app/api/integrations/google/page-data/history/route.ts index b513a69..9a1c022 100644 --- a/web/app/api/integrations/google/page-data/history/route.ts +++ b/web/app/api/integrations/google/page-data/history/route.ts @@ -1,15 +1,20 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { withDb } from '@/server/db'; -import { historySummary, parseJsonField, sliceFromGoogleRow } from '@/server/pageGoogleData'; +import { + historySummary, + parseJsonField, + resolvePropertyIdForPageGoogle, + sliceFromGoogleRow, +} from '@/server/pageGoogleData'; import type { ApiRouteHandler } from '@/types/api'; import type { PoolClient } from 'pg'; export const runtime = 'nodejs'; /** - * GET /api/integrations/google/page-data/history?url=... - * Lists site-wide google_data rows that have metrics for this page. + * GET /api/integrations/google/page-data/history?url=...&propertyId=... + * Lists property-scoped google_data rows that have metrics for this page. */ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); @@ -22,8 +27,20 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { + const propertyId = await resolvePropertyIdForPageGoogle( + client, + url, + request.nextUrl.searchParams.get('propertyId'), + request.nextUrl.searchParams.get('domain'), + ); + + if (propertyId == null) { + return NextResponse.json({ url, history: [] }); + } + const { rows } = await client.query( - 'SELECT id, fetched_at, data FROM google_data ORDER BY id DESC LIMIT 10', + 'SELECT id, fetched_at, data FROM google_data WHERE property_id = $1 ORDER BY id DESC LIMIT 10', + [propertyId], ); const history: Array<{ id: number; diff --git a/web/app/api/integrations/google/page-data/route.ts b/web/app/api/integrations/google/page-data/route.ts index 1e389ff..3588941 100644 --- a/web/app/api/integrations/google/page-data/route.ts +++ b/web/app/api/integrations/google/page-data/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; import { withDb } from '@/server/db'; -import { loadGoogleDataRow, sliceFromGoogleRow } from '@/server/pageGoogleData'; +import { loadGoogleDataRow, resolvePropertyIdForPageGoogle, sliceFromGoogleRow } from '@/server/pageGoogleData'; import type { ApiRouteHandler } from '@/types/api'; import type { PoolClient } from 'pg'; @@ -24,9 +24,16 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise { + const propertyId = await resolvePropertyIdForPageGoogle( + client, + url, + request.nextUrl.searchParams.get('propertyId'), + request.nextUrl.searchParams.get('domain'), + ); const row = await loadGoogleDataRow( client, googleSnapshotId != null && Number.isFinite(googleSnapshotId) ? googleSnapshotId : null, + propertyId, ); if (!row) { return NextResponse.json({ diff --git a/web/app/api/keywords/competitor-import/route.ts b/web/app/api/keywords/competitor-import/route.ts index fd14ea1..2936942 100644 --- a/web/app/api/keywords/competitor-import/route.ts +++ b/web/app/api/keywords/competitor-import/route.ts @@ -1,20 +1,43 @@ import { NextResponse, type NextRequest } from 'next/server'; import { spawn } from 'child_process'; -import path from 'path'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { resolvePythonExecutable } from '@/server/resolvePython'; -import { withDb } from '@/server/db'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const REPO_ROOT = process.env.WEBSITE_PROFILING_ROOT || path.resolve(process.cwd(), '..'); +const MERGE_SCRIPT = ` +import json, sys +from website_profiling.integrations.keywords.competitor_csv import parse_competitor_keyword_csv +from website_profiling.integrations.keywords.competitor_gap_store import merge_competitor_keyword_import +from website_profiling.db.storage import db_session + +payload = json.load(sys.stdin) +property_id = int(payload["propertyId"]) +competitor = payload.get("competitor") or "" +rows = parse_competitor_keyword_csv(payload.get("csvText") or "", competitor=competitor) +with db_session() as conn: + merged = merge_competitor_keyword_import(conn, property_id, competitor, rows) +print(json.dumps({"count": len(rows), "rows": rows[:500], "mergedCount": len(merged), "mergedRows": merged[:500]})) +`; -export async function POST(request: NextRequest) { +/** + * POST /api/keywords/competitor-import + * Body: { propertyId, competitor, csvText } + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const body = await request.json().catch(() => ({})); + let body: { propertyId?: number; competitor?: string; csvText?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const propertyId = Number(body.propertyId || 0); const competitor = String(body.competitor || '').trim(); const csvText = String(body.csvText || ''); @@ -22,44 +45,40 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'propertyId, competitor, and csvText required' }, { status: 400 }); } - const python = resolvePythonExecutable(process.env.PYTHON, REPO_ROOT); - const script = ` -import json, sys -from website_profiling.integrations.keywords.competitor_csv import parse_competitor_keyword_csv -rows = parse_competitor_keyword_csv(sys.stdin.read(), competitor=sys.argv[1]) -print(json.dumps({"count": len(rows), "rows": rows[:500]})) -`; + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + return new Promise((resolve) => { - const proc = spawn(python, ['-c', script, competitor], { - cwd: REPO_ROOT, - env: { ...process.env, PYTHONPATH: path.join(REPO_ROOT, 'src') }, + const proc = spawn(pythonExe, ['-c', MERGE_SCRIPT], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, }); - let out = ''; - let err = ''; - proc.stdout.on('data', (c) => { out += c.toString(); }); - proc.stderr.on('data', (c) => { err += c.toString(); }); - proc.stdin.write(csvText); - proc.stdin.end(); + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stderr?.on('data', (c: Buffer | string) => { stderr += c.toString(); }); + proc.stdin?.write(JSON.stringify({ propertyId, competitor, csvText })); + proc.stdin?.end(); proc.on('close', (code) => { - if (code !== 0) { - resolve(NextResponse.json({ error: err.trim() || 'import failed' }, { status: 500 })); + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve( + NextResponse.json({ + count: parsed.count ?? 0, + rows: parsed.rows ?? [], + mergedCount: parsed.mergedCount ?? parsed.count ?? 0, + mergedRows: parsed.mergedRows ?? parsed.rows ?? [], + }), + ); return; } - try { - const parsed = JSON.parse(out) as { count?: number; rows?: unknown[] }; - const rows = Array.isArray(parsed.rows) ? parsed.rows : []; - void withDb(async (client) => { - await client.query( - `INSERT INTO pipeline_config (key, value, is_unknown, updated_at) - VALUES ('competitor_keyword_gap_json', $1, false, now()) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, - [JSON.stringify(rows)], - ); - }).catch(() => {}); - resolve(NextResponse.json(parsed)); - } catch { - resolve(NextResponse.json({ error: 'invalid response' }, { status: 500 })); - } + resolve( + NextResponse.json( + { error: (stderr || stdout).trim() || 'Import failed' }, + { status: 500 }, + ), + ); }); }); -} +}; diff --git a/web/src/components/links/tabs/IssuesTab.tsx b/web/src/components/links/tabs/IssuesTab.tsx index be24b6c..ecbca7c 100644 --- a/web/src/components/links/tabs/IssuesTab.tsx +++ b/web/src/components/links/tabs/IssuesTab.tsx @@ -97,7 +97,7 @@ export default function IssuesTab({ lhData, inspectorDetails, pageUrl }: IssuesT }, [allIssues, issueFilter]); const typeChart = useMemo(() => { - const order = ['broken', 'redirect', 'seo', 'content', 'category', 'security'] as const; + const order = ['broken', 'redirect', 'seo', 'content', 'category', 'security', 'browser'] as const; const labels = [...it.typeLabels]; const values = order.map((t) => allIssues.filter((i) => i.type === t).length); const filteredLabels: string[] = []; diff --git a/web/src/components/links/tabs/SearchRetentionTab.tsx b/web/src/components/links/tabs/SearchRetentionTab.tsx index 4db03b5..6542cec 100644 --- a/web/src/components/links/tabs/SearchRetentionTab.tsx +++ b/web/src/components/links/tabs/SearchRetentionTab.tsx @@ -7,6 +7,7 @@ import type { CompareMetricRow } from '@/lib/reportCompare'; import type { PageGa4Slice, PageGscSlice } from '@/server/pageGoogleData'; import { buildPageTrafficHints } from '@/lib/pageTrafficHints'; import { strings, format } from '../../../lib/strings'; +import { useOptionalPipeline } from '@/context/PipelineContext'; import { CompareMetricCard } from '../../compare/CompareDeltaBadge'; import CopyBtn from '../CopyBtn'; @@ -76,6 +77,14 @@ function compareLabel(row: HistoryRow): string { export default function SearchRetentionTab({ link }: SearchRetentionTabProps) { const pageUrl = link.url || ''; + const pipeline = useOptionalPipeline(); + const propertyId = Number(pipeline?.configState.active_property_id || 0); + + const pageGoogleQuery = useMemo(() => { + const q = new URLSearchParams({ url: pageUrl }); + if (propertyId > 0) q.set('propertyId', String(propertyId)); + return q; + }, [pageUrl, propertyId]); const [loading, setLoading] = useState(true); const [pageData, setPageData] = useState(null); @@ -97,16 +106,16 @@ export default function SearchRetentionTab({ link }: SearchRetentionTabProps) { const [coachCached, setCoachCached] = useState(false); const loadSnapshot = useCallback(async (googleSnapshotId?: number | null) => { - const q = new URLSearchParams({ url: pageUrl }); + const q = new URLSearchParams(pageGoogleQuery); if (googleSnapshotId != null) q.set('googleSnapshotId', String(googleSnapshotId)); const res = await fetch(`/api/integrations/google/page-data?${q}`); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText); return (await res.json()) as PageDataResponse; - }, [pageUrl]); + }, [pageGoogleQuery]); const loadHistories = useCallback(async () => { const [siteRes, liveRes] = await Promise.all([ - fetch(`/api/integrations/google/page-data/history?url=${encodeURIComponent(pageUrl)}`), + fetch(`/api/integrations/google/page-data/history?${pageGoogleQuery}`), fetch(`/api/integrations/google/page-live/history?url=${encodeURIComponent(pageUrl)}`), ]); const site = siteRes.ok ? ((await siteRes.json()) as { history?: HistoryRow[] }).history || [] : []; @@ -114,7 +123,7 @@ export default function SearchRetentionTab({ link }: SearchRetentionTabProps) { setSiteHistory(site); setLiveHistory(live); return { site, live }; - }, [pageUrl]); + }, [pageGoogleQuery, pageUrl]); const refreshAll = useCallback(async () => { setLoading(true); diff --git a/web/src/lib/loadReportDb.test.ts b/web/src/lib/loadReportDb.test.ts new file mode 100644 index 0000000..db388a9 --- /dev/null +++ b/web/src/lib/loadReportDb.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { PoolClient } from 'pg'; +import { readReportPayloadFromDatabase } from './loadReportDb'; + +function mockClient(queries: Record): PoolClient { + return { + query: vi.fn(async (sql: string, params?: unknown[]) => { + if (String(sql).includes('canonical_domain')) { + return queries.reportList ?? { rows: [] }; + } + if (String(sql).includes('FROM report_payload WHERE id')) { + return queries.reportById ?? { rows: [] }; + } + if (String(sql).includes('FROM report_payload ORDER BY id')) { + return queries.latestReport ?? { rows: [] }; + } + if (String(sql).includes('FROM properties WHERE canonical_domain')) { + const domain = String(params?.[0] ?? ''); + const id = (queries.propertyByDomain as Record | undefined)?.[domain]; + return id != null ? { rows: [{ id: String(id) }] } : { rows: [] }; + } + if (String(sql).includes('competitor_keyword_gap')) { + return { rows: [] }; + } + if (String(sql).includes('google_data')) { + return { rows: [] }; + } + if (String(sql).includes('keyword_data')) { + return { rows: [] }; + } + if (String(sql).includes('gsc_links_data')) { + return { rows: [] }; + } + return { rows: [] }; + }), + } as unknown as PoolClient; +} + +describe('readReportPayloadFromDatabase domain scoping', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('loads latest report for domain when reportId is absent', async () => { + const client = mockClient({ + reportList: { + rows: [ + { + id: 99, + generated_at: '2026-01-01', + site_name: 'B Site', + canonical_domain: 'b.com', + }, + { + id: 100, + generated_at: '2026-02-01', + site_name: 'A Site', + canonical_domain: 'a.com', + }, + ], + }, + reportById: { + rows: [{ data: { site_name: 'B Site', canonical_domain: 'b.com', categories: [] } }], + }, + propertyByDomain: { 'b.com': 5 }, + }); + + const payload = await readReportPayloadFromDatabase(client, null, 'b.com'); + expect(payload.site_name).toBe('B Site'); + expect(client.query).toHaveBeenCalledWith( + 'SELECT data FROM report_payload WHERE id = $1', + [99], + ); + }); + + it('throws when no report matches domain', async () => { + const client = mockClient({ + reportList: { + rows: [ + { + id: 1, + generated_at: '2026-01-01', + site_name: 'A', + canonical_domain: 'a.com', + }, + ], + }, + }); + + await expect(readReportPayloadFromDatabase(client, null, 'missing.com')).rejects.toThrow( + 'No report for domain', + ); + }); +}); diff --git a/web/src/lib/loadReportDb.ts b/web/src/lib/loadReportDb.ts index 0a67ec9..a2fb84f 100644 --- a/web/src/lib/loadReportDb.ts +++ b/web/src/lib/loadReportDb.ts @@ -3,11 +3,12 @@ import type { CrawlPageHtmlRunRow, CrawlRunRow, CrawlRunSummary, + CompetitorKeywordGapRow, ReportListRow, ReportLink, ReportPayload, } from '@/types/report'; -import { normalizeDomainQueryParam } from '@/lib/domainSlug'; +import { normalizeDomainQueryParam, domainQueryMatchesRow } from '@/lib/domainSlug'; import { googlePayloadMatchesDomain, stripGoogleIfDomainMismatch } from '@/lib/filterGoogleForDomain'; async function crawlRunStartUrlsMap(client: PoolClient): Promise> { @@ -279,7 +280,29 @@ export async function readLatestGscLinksPayload( } } -async function lookupPropertyIdByDomain( +/** Per-property competitor keyword gap rows from import UI. */ +export async function readCompetitorKeywordGap( + client: PoolClient, + propertyId: number | null, +): Promise { + if (propertyId == null) return []; + try { + const { rows } = await client.query<{ data: unknown }>( + 'SELECT data FROM competitor_keyword_gap WHERE property_id = $1', + [propertyId], + ); + if (!rows.length) return []; + const raw = parseJsonField(rows[0].data); + if (!Array.isArray(raw)) return []; + return raw.filter((r): r is CompetitorKeywordGapRow => + r != null && typeof r === 'object' && !Array.isArray(r), + ); + } catch { + return []; + } +} + +export async function lookupPropertyIdByDomain( client: PoolClient, domainRaw: string, ): Promise { @@ -346,6 +369,11 @@ export async function mergeSidecarPayloadData( const gscLinks = await readLatestGscLinksPayload(client, propertyId); if (gscLinks) merged.gsc_links = gscLinks as ReportPayload['gsc_links']; + const competitorGap = await readCompetitorKeywordGap(client, propertyId); + if (competitorGap.length > 0) { + merged.competitor_keyword_gap = competitorGap; + } + if (scopedDomain) { merged = stripGoogleIfDomainMismatch(merged, scopedDomain); } @@ -358,8 +386,20 @@ export async function readReportPayloadFromDatabase( domainSlug?: string | null, ): Promise { let row; - if (reportId != null) { - const res = await client.query('SELECT data FROM report_payload WHERE id = $1', [reportId]); + let resolvedReportId = reportId; + + if (resolvedReportId == null && domainSlug) { + const reports = await listReportsFromDatabase(client); + const normalized = normalizeDomainQueryParam(domainSlug); + const match = reports.find((r) => domainQueryMatchesRow(r, normalized)); + if (!match) { + throw new Error('No report for domain'); + } + resolvedReportId = match.id; + } + + if (resolvedReportId != null) { + const res = await client.query('SELECT data FROM report_payload WHERE id = $1', [resolvedReportId]); row = res.rows[0]; } else { const res = await client.query( @@ -368,7 +408,9 @@ export async function readReportPayloadFromDatabase( row = res.rows[0]; } if (!row) { - throw new Error(reportId != null ? 'Report not found' : 'No report_payload in DB'); + throw new Error( + resolvedReportId != null ? 'Report not found' : 'No report_payload in DB', + ); } const payload = parseJsonField(row.data) as ReportPayload; return mergeSidecarPayloadData(client, payload, domainSlug); diff --git a/web/src/server/compareExportRoute.test.ts b/web/src/server/compareExportRoute.test.ts new file mode 100644 index 0000000..a359849 --- /dev/null +++ b/web/src/server/compareExportRoute.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { localRequest } from '@/server/testHelpers/routeTestUtils'; + +const queryMock = vi.fn(); + +vi.mock('@/server/db', () => ({ + withDb: async (fn: (client: { query: typeof queryMock }) => Promise) => + fn({ query: queryMock }), +})); + +describe('compare/export route', () => { + beforeEach(() => { + queryMock.mockReset(); + vi.resetModules(); + }); + + it('returns 400 when report ids missing', async () => { + const { POST } = await import('../../app/api/compare/export/route'); + const res = await POST( + localRequest('/api/compare/export', { + method: 'POST', + body: JSON.stringify({ reportIdA: 0, reportIdB: 2 }), + }), + ); + expect(res.status).toBe(400); + }); + + it('uses buildIssueDeltas keying (trailing slash normalized)', async () => { + const category = { id: 'tech', name: 'Technical', issues: [] as Array<{ + url?: string; + message?: string; + priority?: string; + }> }; + + const payloadA = { + categories: [ + { + ...category, + issues: [ + { + url: 'https://example.com/page', + message: 'Missing title', + priority: 'High', + }, + ], + }, + ], + }; + const payloadB = { + categories: [ + { + ...category, + issues: [ + { + url: 'https://example.com/page/', + message: 'Missing title', + priority: 'High', + }, + ], + }, + ], + }; + + queryMock + .mockResolvedValueOnce({ rows: [{ data: payloadA }] }) + .mockResolvedValueOnce({ rows: [{ data: payloadB }] }); + + const { POST } = await import('../../app/api/compare/export/route'); + const res = await POST( + localRequest('/api/compare/export', { + method: 'POST', + body: JSON.stringify({ reportIdA: 1, reportIdB: 2 }), + }), + ); + expect(res.status).toBe(200); + const csv = await res.text(); + const dataLines = csv.trim().split('\n').slice(1); + expect(dataLines.length).toBe(0); + }); + + it('reports added and removed issues', async () => { + const cat = { id: 'seo', name: 'SEO', issues: [] as Array<{ + url?: string; + message?: string; + priority?: string; + }> }; + + queryMock + .mockResolvedValueOnce({ + rows: [{ + data: { + categories: [{ + ...cat, + issues: [{ url: 'https://a.com/', message: 'issue one', priority: 'High' }], + }], + }, + }], + }) + .mockResolvedValueOnce({ + rows: [{ + data: { + categories: [{ + ...cat, + issues: [{ url: 'https://b.com/', message: 'issue two', priority: 'Medium' }], + }], + }, + }], + }); + + const { POST } = await import('../../app/api/compare/export/route'); + const res = await POST( + localRequest('/api/compare/export', { + method: 'POST', + body: JSON.stringify({ reportIdA: 10, reportIdB: 20 }), + }), + ); + const csv = await res.text(); + expect(csv).toContain('removed'); + expect(csv).toContain('added'); + expect(csv).toContain('issue one'); + expect(csv).toContain('issue two'); + }); +}); diff --git a/web/src/server/keywordsCompetitorImportRoute.test.ts b/web/src/server/keywordsCompetitorImportRoute.test.ts index 747583f..5297240 100644 --- a/web/src/server/keywordsCompetitorImportRoute.test.ts +++ b/web/src/server/keywordsCompetitorImportRoute.test.ts @@ -2,20 +2,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { localRequest } from '@/server/testHelpers/routeTestUtils'; const spawnMock = vi.fn(); -const queryMock = vi.fn(); vi.mock('child_process', () => ({ spawn: (...args: unknown[]) => spawnMock(...args), })); -vi.mock('@/server/db', () => ({ - withDb: async (fn: (client: { query: typeof queryMock }) => Promise) => fn({ query: queryMock }), -})); - describe('keywords/competitor-import route', () => { beforeEach(() => { spawnMock.mockReset(); - queryMock.mockReset(); vi.resetModules(); }); @@ -30,14 +24,25 @@ describe('keywords/competitor-import route', () => { expect(res.status).toBe(400); }); - it('imports competitor keywords on success', async () => { + it('imports competitor keywords on success via property-scoped store', async () => { spawnMock.mockImplementation(() => ({ - stdout: { on: (_: string, cb: (c: Buffer) => void) => cb(Buffer.from('{"count":1,"rows":[{"keyword":"kw","competitor":"rival.com"}]}')) }, + stdout: { + on: (_: string, cb: (c: Buffer) => void) => + cb( + Buffer.from( + JSON.stringify({ + count: 1, + rows: [{ keyword: 'kw', competitor: 'rival.com' }], + mergedCount: 1, + mergedRows: [{ keyword: 'kw', competitor: 'rival.com' }], + }), + ), + ), + }, stderr: { on: () => undefined }, stdin: { write: () => undefined, end: () => undefined }, on: (_: string, cb: (code: number) => void) => cb(0), })); - queryMock.mockResolvedValue(undefined); const { POST } = await import('../../app/api/keywords/competitor-import/route'); const res = await POST( localRequest('/api/keywords/competitor-import', { @@ -52,5 +57,32 @@ describe('keywords/competitor-import route', () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.count).toBe(1); + expect(body.mergedCount).toBe(1); + expect(spawnMock).toHaveBeenCalled(); + const script = String(spawnMock.mock.calls[0]?.[1]?.[1] ?? ''); + expect(script).toContain('merge_competitor_keyword_import'); + }); + + it('returns 500 when python fails', async () => { + spawnMock.mockImplementation(() => ({ + stdout: { on: () => undefined }, + stderr: { on: (_: string, cb: (c: Buffer) => void) => cb(Buffer.from('db error')) }, + stdin: { write: () => undefined, end: () => undefined }, + on: (_: string, cb: (code: number) => void) => cb(1), + })); + const { POST } = await import('../../app/api/keywords/competitor-import/route'); + const res = await POST( + localRequest('/api/keywords/competitor-import', { + method: 'POST', + body: JSON.stringify({ + propertyId: 2, + competitor: 'rival.com', + csvText: 'Keyword,Volume\nkw,100\n', + }), + }), + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toContain('db error'); }); }); diff --git a/web/src/server/pageDataHistoryRoute.test.ts b/web/src/server/pageDataHistoryRoute.test.ts new file mode 100644 index 0000000..22738c5 --- /dev/null +++ b/web/src/server/pageDataHistoryRoute.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { localRequest } from '@/server/testHelpers/routeTestUtils'; + +const queryMock = vi.fn(); + +vi.mock('@/server/db', () => ({ + withDb: async (fn: (client: { query: typeof queryMock }) => Promise) => + fn({ query: queryMock }), +})); + +vi.mock('@/server/resolvePropertyId', () => ({ + resolvePropertyIdFromRequest: vi.fn(async (propertyIdRaw: string | null) => { + if (propertyIdRaw === '3') return { propertyId: 3 }; + return { propertyId: null, error: 'missing' }; + }), +})); + +describe('integrations/google/page-data/history route', () => { + beforeEach(() => { + queryMock.mockReset(); + vi.resetModules(); + }); + + it('returns 400 when url missing', async () => { + const { GET } = await import('../../app/api/integrations/google/page-data/history/route'); + const res = await GET(localRequest('/api/integrations/google/page-data/history')); + expect(res.status).toBe(400); + }); + + it('queries google_data scoped by property_id', async () => { + queryMock.mockResolvedValue({ rows: [] }); + const { GET } = await import('../../app/api/integrations/google/page-data/history/route'); + const res = await GET( + localRequest( + '/api/integrations/google/page-data/history?url=https://example.com/page&propertyId=3', + ), + ); + expect(res.status).toBe(200); + expect(queryMock).toHaveBeenCalledWith( + expect.stringContaining('WHERE property_id = $1'), + [3], + ); + }); +}); diff --git a/web/src/server/pageGoogleData.ts b/web/src/server/pageGoogleData.ts index d0fe636..6711b9d 100644 --- a/web/src/server/pageGoogleData.ts +++ b/web/src/server/pageGoogleData.ts @@ -215,6 +215,7 @@ export function sliceFromGoogleRow( export async function loadGoogleDataRow( client: PoolClient, googleSnapshotId: number | null, + propertyId?: number | null, ): Promise<{ id: number; fetchedAt: string | null; raw: Record } | null> { if (googleSnapshotId != null) { const { rows } = await client.query( @@ -230,6 +231,20 @@ export async function loadGoogleDataRow( raw, }; } + if (propertyId != null && propertyId > 0) { + const { rows } = await client.query( + 'SELECT id, fetched_at, data FROM google_data WHERE property_id = $1 ORDER BY id DESC LIMIT 1', + [propertyId], + ); + if (!rows.length) return null; + const raw = parseJsonField(rows[0].data); + if (!raw) return null; + return { + id: Number(rows[0].id), + fetchedAt: rows[0].fetched_at ? String(rows[0].fetched_at) : null, + raw, + }; + } const { rows } = await client.query( 'SELECT id, fetched_at, data FROM google_data ORDER BY id DESC LIMIT 1', ); @@ -243,6 +258,30 @@ export async function loadGoogleDataRow( }; } +export async function resolvePropertyIdForPageGoogle( + client: PoolClient, + pageUrl: string, + propertyIdParam: string | null, + domainParam: string | null, +): Promise { + const { resolvePropertyIdFromRequest } = await import('./resolvePropertyId'); + if (propertyIdParam) { + const { propertyId } = await resolvePropertyIdFromRequest(propertyIdParam, null); + return propertyId; + } + if (domainParam) { + const { propertyId } = await resolvePropertyIdFromRequest(null, domainParam); + return propertyId; + } + try { + const host = new URL(pageUrl).hostname; + const { lookupPropertyIdByDomain } = await import('@/lib/loadReportDb'); + return await lookupPropertyIdByDomain(client, host); + } catch { + return null; + } +} + export function historySummary(gsc: PageGscSlice | null, ga4: PageGa4Slice | null) { return { gsc: gsc diff --git a/web/src/strings.json b/web/src/strings.json index 73428b2..a37613b 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -3857,7 +3857,8 @@ "SEO", "Content", "Category", - "Security" + "Security", + "Browser" ], "issueTooltip": "{n} issue{s}", "redirectWithFinal": "Redirect {status} → {finalUrl}" diff --git a/web/src/views/KeywordsExplorer.tsx b/web/src/views/KeywordsExplorer.tsx index 40165af..a74f9e5 100644 --- a/web/src/views/KeywordsExplorer.tsx +++ b/web/src/views/KeywordsExplorer.tsx @@ -54,7 +54,7 @@ const EMPTY_HISTORY: KeywordHistoryMap = {}; export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) { const router = useRouter(); - const { data, startUrlByRunId, selectedReportId } = useReport(); + const { data, startUrlByRunId, selectedReportId, loadReport } = useReport(); const pipeline = useOptionalPipeline(); const propertyId = Number(pipeline?.configState.active_property_id || 0); const ke = strings.views.keywordsExplorer; @@ -444,7 +444,10 @@ export default function KeywordsExplorer({ onOpenIntegrations }: ViewProps) { /> ) : activeTab === 'competitor' ? ( <> - + void loadReport()} + />