From 0917f4e5a83167b4de58a717c268bbeb14611812 Mon Sep 17 00:00:00 2001 From: Yash Sangwan Date: Sat, 29 Aug 2026 19:38:23 +0530 Subject: [PATCH 1/2] fix: smooth the landing page navbar handover and open the FAQ to crawlers The two navbars swapped at different scroll points, and one of them pushed everything below the hero down by 56px in the middle of a scroll. The FAQ answers only existed after a click, so no crawler ever saw them. Also fixes the Discord card clipping, the feature rotation on mobile, and a triple GitHub star fetch. --- .changeset/landing-page-seo-and-rotation.md | 40 +++ .changeset/navbar-handover-jerk.md | 52 +++ frontend/src/app/blog/[slug]/page.tsx | 2 +- frontend/src/app/blog/page.tsx | 2 +- frontend/src/app/layout.tsx | 15 +- frontend/src/app/page.tsx | 247 +++----------- .../src/app/privacy/legal-pages-ssr.test.tsx | 33 +- frontend/src/components/blog/blog-navbar.tsx | 29 +- .../landing-page/components/faq-section.tsx | 91 ++++-- .../components/feature-section.tsx | 54 +++- .../landing-page/components/hero-section.tsx | 11 +- .../landing-page/components/landing-page.tsx | 214 +++++++++++++ .../landing-page/components/navbar.tsx | 211 ++++++------ .../components/work-smarter-section.tsx | 50 ++- frontend/src/hooks/use-scrolled-past.ts | 33 ++ frontend/src/services/github-service.ts | 26 +- .../discord/discord-community-link.tsx | 302 +++++++++++++----- 17 files changed, 912 insertions(+), 500 deletions(-) create mode 100644 .changeset/landing-page-seo-and-rotation.md create mode 100644 .changeset/navbar-handover-jerk.md create mode 100644 frontend/src/features/landing-page/components/landing-page.tsx create mode 100644 frontend/src/hooks/use-scrolled-past.ts diff --git a/.changeset/landing-page-seo-and-rotation.md b/.changeset/landing-page-seo-and-rotation.md new file mode 100644 index 00000000..6e1b9e6a --- /dev/null +++ b/.changeset/landing-page-seo-and-rotation.md @@ -0,0 +1,40 @@ +--- +'frontend': patch +--- + +Put the FAQ answers in the page, and stop the feature list rotating at readers + +The eight FAQ answers were mounted only once their question was clicked, so the +most substantial writing on the landing page - which S3 permissions are needed, +where the credentials are actually stored - reached a crawler as nothing at all. +They are in the markup now, collapsed by a `0fr` grid row rather than dropped +from the tree, and the SSR test asserts every answer is really there. The +accordion also has `aria-expanded` and `aria-controls` it never had, and each +question is a heading wrapping its button rather than a heading buried inside +one, so the eight questions form an outline a reader can navigate. + +`/` is now a server shell around the client page, which is what lets it emit +FAQPage structured data built from the same `faqData` the section renders - +Google requires the markup to describe what a visitor can see, and generating +both from one array is what keeps that true when a question is edited. The route +was already server-rendered on demand, so the shell costs nothing new. + +`verification` no longer ships `content="your-google-verification-code"` on +every page: a claim to own the site backed by a token that verifies nothing. It +reads `GOOGLE_SITE_VERIFICATION` from the server environment and is left off +when unset. + +Both feature sections rotated every six seconds for as long as the tab was open. +Narrower than `lg` the image column beside the list is `hidden`, so the timer's +only effect was to swap the paragraph a reader was in the middle of - and the +pause is bound to hover, which a touch device never fires, so on a phone there +was no way to stop it. Rotation is now gated on the viewport being wide enough +to show the image and on the section being on screen at all. The 500ms crossfade +it promised never ran either: `key` remounts the image, and a brand new element +has no previous opacity to transition from, so it was a hard cut. It fades in +now. + +Three star counters mount on the landing page in the same tick, and the GitHub +cache is only written once a response lands - so all three missed it and all +three fetched, against a limit of sixty requests an hour for an unauthenticated +IP. Callers now share a request that is already running. diff --git a/.changeset/navbar-handover-jerk.md b/.changeset/navbar-handover-jerk.md new file mode 100644 index 00000000..7b008cf7 --- /dev/null +++ b/.changeset/navbar-handover-jerk.md @@ -0,0 +1,52 @@ +--- +'frontend': patch +--- + +Hand the landing page's two navbars over without the jerk + +Scrolling out of the hero made the page lurch. Three separate things fired on +that one scroll position, and one of them moved the page: the wrapper holding +everything below the hero carried +`style={{ paddingTop: showMobileNav ? '3.5rem' : '0' }}`, so 56px appeared under +the hero at the exact moment the bar faded in and everything below it dropped. +An inline style also beats the `lg:pt-0` beside it, so desktop - which has no +mobile bar to make room for - got the same shove. The padding is held open +permanently now. It costs nothing to look at, because the hero is `min-h-screen` +and so the padding is below the fold right up until the scroll position where +the bar arrives to cover it. + +The floating desktop navbar mounted on the way past, too, which built thirty-odd +nodes, a backdrop-filter layer to rasterise and a star count that lands a tick +later and re-centres the pill, all on the frame the browser was already busy +scrolling - and then ran two `setTimeout`s to fade in what it had just built. It +is now mounted with the rest of the page and revealed with a composited fade, +the same way the mobile bar always was. It fades out to `invisible` rather than +just transparent, so a bar nobody can see is not still tabbable. + +Both bars now take their cue from one `IntersectionObserver` on the hero's +sentinel, which the page owns and passes down. That replaces two `scroll` +listeners that each called `getBoundingClientRect()` on every event, forcing a +synchronous layout per frame, one of them re-registering itself on every toggle. +It also means the bars hand off at the same pixel: the floating nav used to +appear while the hero was still most of the screen, which is what made the page +look like it had two navbars at once. + +The blog navbar ran a third copy of that listener, tracking a sentinel into two +state values that nothing rendered. It is gone, along with the sentinel it +watched. + +The Discord card in the hero navbar opened off the top of the screen. That +navbar asks for a card above it, which is right where it starts - near the +bottom of the viewport - but it scrolls with the page, and a reader who has +pushed it near the top has no room above it left. `placement` is a preference +now rather than an outcome: the card is measured against the space on each side +of its trigger when it opens, and moves to the other side when the requested one +cannot hold it and the other is roomier. + +The connector squiggle beside that card is drawn above the chrome it comes out +of, so it wins every overlap - and out of a navbar, where the trigger is a 20px +icon in a packed row, what it won was the theme toggle sitting next to it. It is +measured against its neighbours now and left out where there is something within +its reach, which leaves the card to stand on its own in both navbars. Out of the +sidebar, where the row is full width and the arrow leaves into open page, it is +unchanged. diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx index 395a25eb..814b9188 100644 --- a/frontend/src/app/blog/[slug]/page.tsx +++ b/frontend/src/app/blog/[slug]/page.tsx @@ -213,7 +213,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug: {/* Static Header Spacer */} -
+
{/* Article Container */}
diff --git a/frontend/src/app/blog/page.tsx b/frontend/src/app/blog/page.tsx index fb469a58..81ddc75d 100644 --- a/frontend/src/app/blog/page.tsx +++ b/frontend/src/app/blog/page.tsx @@ -129,7 +129,7 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { {/* Static Header Spacer */} -
+
{/* Hero Section */}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b13a3c34..8b07ea07 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -76,9 +76,18 @@ export const metadata: Metadata = { 'max-snippet': -1, }, }, - verification: { - google: 'your-google-verification-code', // Add your Google Search Console verification code - }, + /** + * Only emitted once a real code is configured. + * + * The placeholder that stood here put + * `` + * on every page: a claim to own the site, backed by a token that verifies + * nothing. Read from the server environment, so it never reaches the client + * bundle, and left off entirely when unset. + */ + ...(process.env.GOOGLE_SITE_VERIFICATION + ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } + : {}), }; export default async function RootLayout({ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0a1d42eb..17954a2e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,214 +1,41 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { FaGithub } from 'react-icons/fa'; -import { Menu, X } from 'lucide-react'; -import HeroSection from '@/features/landing-page/components/hero-section'; -import Navbar from '@/features/landing-page/components/navbar'; -import { DiscordCommunityLink } from '@/shared/components/discord/discord-community-link'; -import FeaturesSection from '@/features/landing-page/components/feature-section'; -import WorkSmarterSection from '@/features/landing-page/components/work-smarter-section'; -import FAQSection from '@/features/landing-page/components/faq-section'; -import CTASection from '@/features/landing-page/components/cta-section'; -import ThemeToggleCustom from '@/shared/components/layout/ThemeToggleCustom'; -import { SiteFooter } from '@/shared/components/layout/site-footer'; -import { useOpndriveStars } from '@/hooks/use-github-stars'; -import { useAuth } from '@/hooks/use-auth'; -import { DOCS_URL } from '@/config/links'; - -const navItems = [ - { label: 'Home', href: '#hero' }, - { label: 'Features', href: '#features' }, - { label: 'Tools', href: '#tools' }, - { label: 'FAQ', href: '#faq' }, - { label: 'Docs', href: DOCS_URL }, - { label: 'Get Started', href: '#get-started' }, -]; - -export default function LandingPage() { - const router = useRouter(); - const { userCreds } = useAuth(); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [showMobileNav, setShowMobileNav] = useState(false); - - // Use custom hook for GitHub stars - const { stars } = useOpndriveStars(); - - // Simple scroll detection to show navbar after hero section - useEffect(() => { - const handleScroll = () => { - const heroSection = document.getElementById('hero'); - if (heroSection) { - const heroRect = heroSection.getBoundingClientRect(); - const shouldShowNav = heroRect.bottom <= 0; // Show when hero is completely scrolled past - setShowMobileNav(shouldShowNav); - } - }; - - window.addEventListener('scroll', handleScroll); - handleScroll(); // Check initial state - - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - /** - * Where the main call to action goes, and what it admits to doing. - * - * It has always sent a visitor with a connected bucket to their drive rather - * than back through the connect flow - it just did so while still reading - * "Get Started", so the one button whose label people rely on was the one - * that did not describe itself. The label now names the destination. - * - * The session comes from the auth context rather than a second, hand-copied - * read of the `s3_user_session` key, which is the sort of duplicate that - * survives a rename of the original. - * - * There is deliberately no loading state. This page is public now, so the - * server renders it before any session has been looked for - and a hero - * button reading "Loading..." is what a crawler would have indexed as the - * call to action. It says "Get Started" and goes to /connect until a session - * turns up, which is the right answer for almost everyone who lands here and - * a harmless one for the rest: /connect offers them the way back in. - */ - const hasSession = userCreds !== null; - const ctaLabel = hasSession ? 'Go to Dashboard' : 'Get Started'; - - const handleGetStarted = () => { - router.push(hasSession ? '/dashboard' : '/connect'); - }; - - const handleNavClick = (href: string) => { - setIsMobileMenuOpen(false); - if (href.startsWith('http')) { - window.location.href = href; - return; - } - if (!href.startsWith('#')) { - router.push(href); - return; - } - const element = document.querySelector(href); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } - }; +import { headers } from 'next/headers'; +import LandingPage from '@/features/landing-page/components/landing-page'; +import { faqData } from '@/features/landing-page/config/faq-section'; + +/** + * A server shell around the landing page, which is a client component and so + * cannot do either of the two things below. + * + * The FAQ structured data is built from the same `faqData` the section renders, + * because Google requires the markup to describe what a visitor can actually + * see - generating both from one array is what keeps that true when a question + * is edited. And the nonce, which every inline script here carries, can only be + * read on the server. + * + * The route was already server-rendered on demand before this - the root layout + * reads `headers()` - so the shell costs nothing it was not already paying. + */ +export default async function Page() { + const nonce = (await headers()).get('x-nonce') ?? undefined; return ( -
- {/* Simple Mobile Navbar - Hidden in hero, visible after */} -
-
-
- {/* Logo */} -
- Opndrive Logo - Opndrive -
- - {/* Hamburger Menu Button */} - -
-
- - {/* Mobile Menu Dropdown */} - {isMobileMenuOpen && showMobileNav && ( -
-
- {/* Navigation Links */} -
- {navItems.map((item, index) => ( - - ))} -
- - {/* Divider */} -
- - {/* Actions Section */} -
- {/* GitHub Link */} - - - GitHub - {stars !== null && ( - {stars.toLocaleString()} - )} - - - {/* Discord Community - taps open the Bento sheet */} - setIsMobileMenuOpen(false)} - /> - - {/* Theme Toggle */} -
- Theme - -
- - {/* Get Started Button */} - -
-
-
- )} -
- - {/* Overlay to close menu when clicking outside */} - {isMobileMenuOpen && showMobileNav && ( -
setIsMobileMenuOpen(false)} - /> - )} - - - {/* Add top padding after hero section for mobile navbar */} -
- - - - - - -
-
+ <> +