/** * page.tsx — Novarix Networks home page * * This is the only route in the site ("/"). It renders a single-page layout * with the following sections: * * 1. Intro overlay — first-visit only splash (Lottie + wordmark) * 2. Header — sticky nav with brand wordmark * 3. Hero — headline, sub-text, CTA buttons * 4. Services — three service cards * 5. Direction — three-phase platform roadmap * 6. Contact — mailto link * 7. Footer — copyright line * * ─── State overview ────────────────────────────────────────────────────── * * showIntro Controls whether the splash overlay is rendered. Starts * false (SSR safe), is set to true on first mount if the * "novarix-intro-seen" sessionStorage key is absent, then * reverts to false after the intro duration elapses. * * mounted Prevents the intro overlay from rendering during SSR / the * hydration pass, which would cause a server/client mismatch. * * pointer Tracks normalised cursor position (0–100 %) for the ambient * radial-gradient background effect. * * ─── Adding / editing content ──────────────────────────────────────────── * * • Update the `services` array to change service cards. * • Update the direction items inline in the JSX. * • The intro animation JSON lives at /public/branding/animated_logo_intro.json * • Wordmark images live at /public/branding/novarix-wordmark-{colour,white}.png * * ─── Dependencies ──────────────────────────────────────────────────────── * * next/image — optimised image component (lazy-loading, AVIF/WebP) * lottie-react — renders the Bodymovin / Lottie JSON animation * * Install lottie-react if not already present: * npm install lottie-react */ "use client"; import Image from "next/image"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useRef, useState } from "react"; /** * Lottie is dynamically imported so neither the library (~150 KB) nor the * animation JSON (261 KB) are included in the initial page bundle. They are * fetched only on first visit when the intro overlay is needed. */ const Lottie = dynamic(() => import("lottie-react"), { ssr: false }); /* ── Service card data ─────────────────────────────────────────────────── */ /** * Each entry renders one card in the Services section. * Add, remove, or reorder entries here without touching the JSX. * * Fields: * title — card heading * description — body copy (2–3 sentences recommended) * icon — Unicode emoji or inline SVG string used as the card icon */ const services = [ { eyebrow: "ISP", title: "Business Connectivity", description: "Routed internet access for organisations that need clear ownership, static addressing, and a provider willing to talk through the real topology.", points: ["Business internet access", "Static IP addressing", "Routed handoff options"], }, { eyebrow: "MSP", title: "Managed Network Services", description: "Managed edge, routing, firewall, and switching support for teams that want stronger control without carrying every operational detail alone.", points: ["Managed edge infrastructure", "Change and migration support", "Operational visibility"], }, { eyebrow: "Transit", title: "Transit and Interconnect", description: "Transit and interconnection planning for operators, platforms, and technical environments where routing posture matters.", points: ["IP transit readiness", "Peering strategy", "Carrier engagement"], }, ] as const; const networkSignals = [ { label: "Operating focus", value: "ISP / MSP / Transit" }, { label: "Designed for", value: "Business-critical networks" }, { label: "Built around", value: "Practical engineering" }, ] as const; const platformPhases = [ { phase: "Phase 1", title: "Connectivity and managed support", description: "Lead with services that can be delivered credibly from day one: internet access, managed network support, and engineering advisory work.", }, { phase: "Phase 2", title: "Transit, peering, and interconnect", description: "Grow toward exchange participation, partner interconnection, and clearer transit propositions as the network footprint matures.", }, { phase: "Phase 3", title: "Selective edge infrastructure", description: "Introduce regional edge or platform capability only where real demand, operational control, and commercial return justify it.", }, ] as const; /* ── Types ─────────────────────────────────────────────────────────────── */ type PointerState = { x: number; // cursor X as percentage of viewport width (0–100) y: number; // cursor Y as percentage of viewport height (0–100) }; /* ── Constants ─────────────────────────────────────────────────────────── */ /** * Total duration the intro overlay is visible (milliseconds). * Must be long enough to cover: * • Lottie animation length * • Wordmark slide-in (350 ms delay + ~1 000 ms animation = ~1 350 ms) * • CSS fade-out at 2 450 ms (620 ms duration) * * The JS timer uses 3 200 ms so it matches the full CSS sequence before * React removes the overlay from the DOM. */ const INTRO_DURATION_MS = 3200; /** sessionStorage key used to gate the intro to one play per browser tab */ const INTRO_STORAGE_KEY = "novarix-intro-seen"; /* ── Component ─────────────────────────────────────────────────────────── */ export default function HomePage() { // Prevents intro from rendering during SSR / hydration mismatch const [mounted, setMounted] = useState(false); // Whether to show the intro overlay const [showIntro, setShowIntro] = useState(false); // Lazily-loaded Lottie animation data (null until fetched) const [introAnimation, setIntroAnimation] = useState | null>(null); // Normalised cursor position for the ambient gradient const [pointer, setPointer] = useState({ x: 50, y: 22 }); // Ref to the Lottie instance — can be used to imperatively control playback const lottieRef = useRef(null); /* ── Side effects ── */ useEffect(() => { setMounted(true); let timer: number | undefined; try { const introSeen = window.sessionStorage.getItem(INTRO_STORAGE_KEY); if (!introSeen) { window.sessionStorage.setItem(INTRO_STORAGE_KEY, "true"); /* * Dynamically import the 261 KB animation JSON only on first visit. * This keeps it out of the initial page bundle entirely. */ import("@/public/branding/animated_logo_intro.json").then((mod) => { setIntroAnimation(mod.default as Record); setShowIntro(true); timer = window.setTimeout(() => setShowIntro(false), INTRO_DURATION_MS); }); } } catch { /* * sessionStorage may be unavailable (private browsing restrictions, * storage quota exceeded, or cross-origin iframes). Silently skip * the intro rather than crashing. */ } return () => { if (timer) window.clearTimeout(timer); }; }, []); /* ── Derived values ── */ /** * Memoised inline style object for the ambient background. * Only recalculated when pointer.x or pointer.y change, preventing * object identity churn on every render. */ const backgroundStyle = useMemo( () => ({ "--mx": `${pointer.x}%`, "--my": `${pointer.y}%`, } as React.CSSProperties), [pointer.x, pointer.y] ); /* ── Handlers ── */ /** * Updates the pointer state on mouse movement. * Coordinates are normalised to the bounding rect of the main element * (rather than the window) to avoid edge cases near fixed headers. */ function handleMouseMove(event: React.MouseEvent) { const rect = event.currentTarget.getBoundingClientRect(); setPointer({ x: ((event.clientX - rect.left) / rect.width) * 100, y: ((event.clientY - rect.top) / rect.height) * 100, }); } /* ── Render ── */ return ( <> {/* ── Intro overlay ─────────────────────────────────────────────── Rendered only: (a) after hydration (`mounted`) (b) on the first visit within the session (`showIntro`) aria-hidden="true" hides it from screen readers; the main content is focusable without waiting for the intro to finish. ─────────────────────────────────────────────────────────────────── */} {mounted && showIntro && (