519 lines
23 KiB
TypeScript
519 lines
23 KiB
TypeScript
"use client";
|
|
|
|
// =============================================================================
|
|
// Novarix Networks — Homepage
|
|
// =============================================================================
|
|
//
|
|
// This file controls the LAYOUT and STYLING of the homepage.
|
|
// All editable TEXT lives in `/content.ts` at the project root.
|
|
//
|
|
// How this file is organised, top to bottom:
|
|
// 1. Imports + setup
|
|
// 2. Intro overlay (the animated logo shown on first visit)
|
|
// 3. Site shell (background gradient + ambient orbs + grid)
|
|
// 4. Header (logo + navigation)
|
|
// 5. Hero (big headline + buttons)
|
|
// 6. Services (three cards under "What we do")
|
|
// 7. How we engage (three cards under "Working with us")
|
|
// 8. Contact (the dark contact card)
|
|
// 9. Footer (company details + copyright)
|
|
//
|
|
// Each section is marked with a clear comment header you can search for.
|
|
// The "use client" line at the top tells Next.js this page runs in the
|
|
// browser (needed for the animated intro and the mouse-tracking glow).
|
|
// =============================================================================
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
|
import { site } from "@/content";
|
|
import { openCookieBanner } from "@/components/CookieBanner";
|
|
|
|
// Type for the mouse-pointer position used to move the background glow.
|
|
type PointerState = { x: number; y: number };
|
|
const INTRO_SEEN_KEY = "novarix-intro-seen";
|
|
const INTRO_EVENT = "novarix:start-intro";
|
|
|
|
export default function HomePage() {
|
|
// ---------------------------------------------------------------------------
|
|
// STATE
|
|
// ---------------------------------------------------------------------------
|
|
// showIntro — true while the animated logo intro is playing.
|
|
// pointer — the mouse position (in % of page width/height). Used by the
|
|
// soft glow that follows the cursor in the background.
|
|
// ---------------------------------------------------------------------------
|
|
const [showIntro, setShowIntro] = useState(false);
|
|
const [pointer, setPointer] = useState<PointerState>({ x: 50, y: 22 });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INTRO OVERLAY EFFECT
|
|
// Plays the animated logo + wordmark the first time someone visits the
|
|
// site in this browser tab. The initial check runs in useLayoutEffect so
|
|
// the intro can mount before the first browser paint, avoiding a visible
|
|
// flash of the homepage underneath.
|
|
// ---------------------------------------------------------------------------
|
|
useLayoutEffect(() => {
|
|
function playIntro() {
|
|
setShowIntro(true);
|
|
try {
|
|
window.sessionStorage.setItem(INTRO_SEEN_KEY, "true");
|
|
} catch {
|
|
/* storage unavailable — silently ignore */
|
|
}
|
|
|
|
const timer = window.setTimeout(() => {
|
|
setShowIntro(false);
|
|
}, 3950);
|
|
|
|
return timer;
|
|
}
|
|
|
|
let timer: number | null = null;
|
|
|
|
try {
|
|
const introSeen = window.sessionStorage.getItem(INTRO_SEEN_KEY);
|
|
const consent = window.localStorage.getItem("novarix-cookie-consent");
|
|
const shouldPlayNow = consent === "ack" && !introSeen;
|
|
|
|
if (shouldPlayNow) {
|
|
timer = playIntro();
|
|
} else {
|
|
setShowIntro(false);
|
|
}
|
|
} catch {
|
|
setShowIntro(false);
|
|
}
|
|
|
|
function handleStartIntro() {
|
|
if (timer !== null) window.clearTimeout(timer);
|
|
timer = playIntro();
|
|
}
|
|
|
|
window.addEventListener(INTRO_EVENT, handleStartIntro);
|
|
|
|
return () => {
|
|
if (timer !== null) window.clearTimeout(timer);
|
|
window.removeEventListener(INTRO_EVENT, handleStartIntro);
|
|
};
|
|
}, []);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BACKGROUND POSITION
|
|
// Translates the current mouse pointer into two CSS variables (--mx, --my)
|
|
// that the .site-shell background gradient reads. Memoised so React doesn't
|
|
// rebuild the style object on every render.
|
|
// ---------------------------------------------------------------------------
|
|
const backgroundStyle = useMemo(
|
|
() =>
|
|
({
|
|
["--mx"]: `${pointer.x}%`,
|
|
["--my"]: `${pointer.y}%`,
|
|
}) as React.CSSProperties,
|
|
[pointer.x, pointer.y]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* =====================================================================
|
|
INTRO OVERLAY
|
|
A single self-contained animated SVG of the Novarix wordmark,
|
|
drawn dead-centre in the viewport. The SVG itself contains all the
|
|
animation (strokes draw over 2s, fill in over the next 1s — see
|
|
/public/branding/animated_logo_intro.svg). Hidden after ~4s
|
|
(see the useEffect above + the CSS fade-out timing).
|
|
===================================================================== */}
|
|
{showIntro && (
|
|
<div className="intro-overlay" aria-hidden="true">
|
|
<div className="intro-backdrop" />
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src="/branding/animated_logo_intro.svg"
|
|
alt=""
|
|
className="intro-svg"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* =====================================================================
|
|
SITE SHELL
|
|
The <main> wraps the whole page. It tracks the mouse so the
|
|
background glow can follow the cursor, and applies the .site-shell
|
|
class (defined in globals.css) for the background gradient.
|
|
===================================================================== */}
|
|
<main
|
|
id="top"
|
|
className={`site-shell ${showIntro ? "intro-running" : ""}`}
|
|
style={backgroundStyle}
|
|
onMouseMove={(event) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
|
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
|
setPointer({ x, y });
|
|
}}
|
|
>
|
|
{/* -------------------------------------------------------------------
|
|
AMBIENT LAYER
|
|
Two soft floating "orbs" and a faint grid behind everything.
|
|
Purely decorative. All styles live in globals.css under
|
|
.ambient-orb / .ambient-grid. Sits at z-index -10 so it's
|
|
under the page content.
|
|
------------------------------------------------------------------- */}
|
|
<div
|
|
className="pointer-events-none fixed inset-0 -z-10 overflow-hidden"
|
|
aria-hidden="true"
|
|
>
|
|
<div className="ambient-orb ambient-orb-a" />
|
|
<div className="ambient-orb ambient-orb-b" />
|
|
<div className="ambient-grid" />
|
|
</div>
|
|
|
|
{/* ===================================================================
|
|
HEADER
|
|
Sticky at the top of the page. Contains the wordmark logo (links
|
|
back to top) and the primary navigation pulled from
|
|
content.ts -> site.nav. Two logo images swap automatically for
|
|
light/dark mode.
|
|
=================================================================== */}
|
|
<header className="site-header sticky top-0 z-50 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--bg)_82%,transparent)] backdrop-blur-xl">
|
|
<div className="mx-auto flex h-20 w-full max-w-6xl items-center justify-between gap-6 px-6 sm:h-24 sm:px-8">
|
|
{/* Logo / wordmark */}
|
|
<a
|
|
href="#top"
|
|
aria-label="Novarix Networks — back to top"
|
|
className="inline-flex shrink-0 items-center"
|
|
>
|
|
<Image
|
|
src="/branding/novarix-wordmark-colour.png"
|
|
alt="Novarix Networks"
|
|
width={2048}
|
|
height={430}
|
|
priority
|
|
className="brand-light h-9 w-auto max-w-[42vw] object-contain sm:h-12"
|
|
/>
|
|
<Image
|
|
src="/branding/novarix-wordmark-white.png"
|
|
alt="Novarix Networks"
|
|
width={2048}
|
|
height={430}
|
|
priority
|
|
className="brand-dark h-9 w-auto max-w-[42vw] object-contain sm:h-12"
|
|
/>
|
|
</a>
|
|
|
|
{/* Desktop navigation — hidden on small screens (sm:flex) */}
|
|
<nav
|
|
aria-label="Primary"
|
|
className="hidden items-center gap-7 text-sm text-[var(--text-soft)] sm:flex"
|
|
>
|
|
{site.nav.map((item) => (
|
|
<a
|
|
key={item.href}
|
|
href={item.href}
|
|
className="transition-colors hover:text-[var(--text)]"
|
|
>
|
|
{item.label}
|
|
</a>
|
|
))}
|
|
{/* Pill-style Contact button on the right */}
|
|
<a
|
|
href="#contact"
|
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--border-strong)] px-4 py-1.5 text-[var(--text)] transition-all hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
|
>
|
|
Contact
|
|
<span aria-hidden="true">→</span>
|
|
</a>
|
|
</nav>
|
|
|
|
{/* Mobile-only Contact pill — replaces the full nav on small screens */}
|
|
<a
|
|
href="#contact"
|
|
className="inline-flex items-center rounded-full border border-[var(--border-strong)] px-3 py-1.5 text-xs text-[var(--text)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)] sm:hidden"
|
|
>
|
|
Contact
|
|
</a>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ===================================================================
|
|
HERO
|
|
The first thing visitors see: status pill, eyebrow, big headline,
|
|
descriptive paragraph, and two call-to-action buttons.
|
|
All text comes from content.ts -> site.hero.
|
|
=================================================================== */}
|
|
<section className="relative pt-24 pb-20 sm:pt-32 sm:pb-28">
|
|
<div className="mx-auto w-full max-w-6xl px-6 sm:px-8">
|
|
<div className="max-w-4xl">
|
|
{/* Optional pulsing-dot pill. Hidden if site.hero.badge is "" */}
|
|
{site.hero.badge && (
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface)] px-3 py-1 text-xs font-medium text-[var(--text-soft)] backdrop-blur">
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent)] opacity-75" />
|
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent)]" />
|
|
</span>
|
|
{site.hero.badge}
|
|
</span>
|
|
)}
|
|
|
|
{/* Small uppercase eyebrow */}
|
|
<p className="mt-7 text-xs font-semibold tracking-[0.18em] text-[var(--accent)] uppercase">
|
|
{site.hero.eyebrow}
|
|
</p>
|
|
|
|
{/* Main headline. The middle word ("accent") is shown in a
|
|
brand-coloured gradient. Font size scales with viewport. */}
|
|
<h1 className="mt-5 text-[clamp(2.5rem,6vw,4.8rem)] leading-[1.04] font-semibold tracking-[-0.04em] text-balance">
|
|
{site.hero.headlineBefore}{" "}
|
|
<span className="bg-gradient-to-r from-brand-500 to-brand-700 bg-clip-text text-transparent dark:from-brand-300 dark:to-brand-500">
|
|
{site.hero.headlineAccent}
|
|
</span>{" "}
|
|
{site.hero.headlineAfter}
|
|
</h1>
|
|
|
|
{/* Supporting paragraph */}
|
|
<p className="mt-7 max-w-2xl text-lg leading-relaxed text-[var(--text-soft)] sm:text-xl">
|
|
{site.hero.description}
|
|
</p>
|
|
|
|
{/* Call-to-action buttons */}
|
|
<div className="mt-10 flex flex-wrap items-center gap-3">
|
|
{/* Primary button — solid background */}
|
|
<a
|
|
href={site.hero.primaryCta.href}
|
|
className="group inline-flex h-12 items-center gap-2 rounded-xl bg-[var(--button-bg)] px-5 text-sm font-semibold text-[var(--button-fg)] shadow-sm transition-all hover:-translate-y-0.5 hover:bg-[var(--button-hover)] hover:shadow-md"
|
|
>
|
|
{site.hero.primaryCta.label}
|
|
<span
|
|
aria-hidden="true"
|
|
className="transition-transform group-hover:translate-x-0.5"
|
|
>
|
|
→
|
|
</span>
|
|
</a>
|
|
{/* Secondary button — outlined */}
|
|
<a
|
|
href={site.hero.secondaryCta.href}
|
|
className="inline-flex h-12 items-center rounded-xl border border-[var(--border-strong)] bg-[var(--surface)] px-5 text-sm font-semibold text-[var(--text)] backdrop-blur transition-all hover:-translate-y-0.5 hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
|
>
|
|
{site.hero.secondaryCta.label}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ===================================================================
|
|
SERVICES
|
|
"What we do" — section heading then a 3-column grid of cards
|
|
(1 column on mobile). The number of cards comes from
|
|
content.ts -> site.services.items, so adding a fourth card there
|
|
automatically appears here. Below the grid sits an optional
|
|
dashed-border "Capabilities" line.
|
|
=================================================================== */}
|
|
<section
|
|
id="services"
|
|
className="relative border-t border-[var(--border)] py-24 sm:py-28"
|
|
>
|
|
<div className="mx-auto w-full max-w-6xl px-6 sm:px-8">
|
|
{/* Section heading */}
|
|
<div className="max-w-3xl">
|
|
<p className="text-xs font-semibold tracking-[0.18em] text-[var(--accent)] uppercase">
|
|
{site.services.eyebrow}
|
|
</p>
|
|
<h2 className="mt-3 text-[clamp(1.9rem,3.2vw,2.75rem)] leading-tight font-semibold tracking-[-0.03em]">
|
|
{site.services.title}
|
|
</h2>
|
|
<p className="mt-5 max-w-2xl text-base leading-relaxed text-[var(--text-soft)] sm:text-lg">
|
|
{site.services.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Service cards grid — 1 column on mobile, 3 columns on md+ */}
|
|
<div className="mt-12 grid gap-5 sm:gap-6 md:grid-cols-3">
|
|
{site.services.items.map((service) => (
|
|
<article
|
|
key={service.title}
|
|
className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-7 backdrop-blur transition-all hover:-translate-y-1 hover:border-[var(--border-strong)] hover:shadow-[0_20px_40px_-20px_oklch(0_0_0/0.25)]"
|
|
>
|
|
{/* Thin gradient hairline at the top of each card on hover */}
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[var(--border-strong)] to-transparent opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
{/* Card top row: index number + arrow that appears on hover */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-mono text-xs tracking-widest text-[var(--text-soft)]">
|
|
{service.index}
|
|
</span>
|
|
<span
|
|
aria-hidden="true"
|
|
className="text-[var(--text-soft)] opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100"
|
|
>
|
|
→
|
|
</span>
|
|
</div>
|
|
{/* Card title + body */}
|
|
<h3 className="mt-5 text-lg font-semibold tracking-tight">
|
|
{service.title}
|
|
</h3>
|
|
<p className="mt-3 text-sm leading-relaxed text-[var(--text-soft)]">
|
|
{service.description}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
|
|
{/* Optional capabilities strip. Hidden if capabilities is "". */}
|
|
{site.services.capabilities && (
|
|
<div className="mt-10 rounded-2xl border border-dashed border-[var(--border)] p-5 text-sm leading-relaxed text-[var(--text-soft)] sm:p-6">
|
|
<span className="font-semibold tracking-wide text-[var(--text)] uppercase">
|
|
Capabilities ·
|
|
</span>{" "}
|
|
{site.services.capabilities}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ===================================================================
|
|
HOW WE ENGAGE
|
|
Three engagement-model cards. Same shape as Services but the
|
|
cards are simpler (no number, no hover arrow). Cards come from
|
|
content.ts -> site.engage.items.
|
|
=================================================================== */}
|
|
<section
|
|
id="engage"
|
|
className="relative border-t border-[var(--border)] py-24 sm:py-28"
|
|
>
|
|
<div className="mx-auto w-full max-w-6xl px-6 sm:px-8">
|
|
{/* Section heading */}
|
|
<div className="max-w-3xl">
|
|
<p className="text-xs font-semibold tracking-[0.18em] text-[var(--accent)] uppercase">
|
|
{site.engage.eyebrow}
|
|
</p>
|
|
<h2 className="mt-3 text-[clamp(1.9rem,3.2vw,2.75rem)] leading-tight font-semibold tracking-[-0.03em]">
|
|
{site.engage.title}
|
|
</h2>
|
|
<p className="mt-5 max-w-2xl text-base leading-relaxed text-[var(--text-soft)] sm:text-lg">
|
|
{site.engage.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Engagement cards grid */}
|
|
<div className="mt-12 grid gap-5 sm:gap-6 md:grid-cols-3">
|
|
{site.engage.items.map((engagement) => (
|
|
<div
|
|
key={engagement.title}
|
|
className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-7 backdrop-blur"
|
|
>
|
|
<h3 className="text-lg font-semibold tracking-tight">
|
|
{engagement.title}
|
|
</h3>
|
|
<p className="mt-3 text-sm leading-relaxed text-[var(--text-soft)]">
|
|
{engagement.description}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ===================================================================
|
|
CONTACT
|
|
A single dark, rounded "callout" card with the contact email and
|
|
response-time note. The button uses a `mailto:` link so clicking
|
|
opens the visitor's mail app pre-addressed to us.
|
|
=================================================================== */}
|
|
<section
|
|
id="contact"
|
|
className="relative border-t border-[var(--border)] py-24 sm:py-28"
|
|
>
|
|
<div className="mx-auto w-full max-w-4xl px-6 sm:px-8">
|
|
<div className="overflow-hidden rounded-3xl border border-[var(--border)] bg-[var(--surface)] p-8 backdrop-blur sm:p-12">
|
|
{/* Section heading inside the card */}
|
|
<p className="text-xs font-semibold tracking-[0.18em] text-[var(--accent)] uppercase">
|
|
{site.contact.eyebrow}
|
|
</p>
|
|
<h2 className="mt-3 text-[clamp(1.9rem,3.2vw,2.75rem)] leading-tight font-semibold tracking-[-0.03em]">
|
|
{site.contact.title}
|
|
</h2>
|
|
<p className="mt-5 max-w-2xl text-base leading-relaxed text-[var(--text-soft)] sm:text-lg">
|
|
{site.contact.description}
|
|
</p>
|
|
|
|
{/* Email button + small response-time note */}
|
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
|
<a
|
|
href={`mailto:${site.contact.email}`}
|
|
className="group inline-flex h-12 items-center gap-2 rounded-xl bg-[var(--button-bg)] px-5 text-sm font-semibold text-[var(--button-fg)] shadow-sm transition-all hover:-translate-y-0.5 hover:bg-[var(--button-hover)] hover:shadow-md"
|
|
>
|
|
{site.contact.email}
|
|
<span
|
|
aria-hidden="true"
|
|
className="transition-transform group-hover:translate-x-0.5"
|
|
>
|
|
→
|
|
</span>
|
|
</a>
|
|
<span className="text-sm text-[var(--text-soft)]">
|
|
{site.contact.note}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ===================================================================
|
|
FOOTER
|
|
Two columns on desktop: company details on the left, tagline +
|
|
copyright on the right. Stacks to one column on mobile.
|
|
All text comes from content.ts -> site.footer.
|
|
=================================================================== */}
|
|
<footer className="border-t border-[var(--border)] py-12">
|
|
<div className="mx-auto grid w-full max-w-6xl gap-8 px-6 sm:grid-cols-[1fr_auto] sm:px-8">
|
|
{/* Left column: company name, registration, contact email */}
|
|
<div className="text-sm leading-relaxed text-[var(--text-soft)]">
|
|
<p className="font-semibold text-[var(--text)]">
|
|
{site.footer.company}
|
|
</p>
|
|
<p className="mt-1">{site.footer.registered}</p>
|
|
<p className="mt-1">
|
|
<a
|
|
href={`mailto:${site.footer.email}`}
|
|
className="transition-colors hover:text-[var(--accent)]"
|
|
>
|
|
{site.footer.email}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
{/* Right column: tagline, cookie links, copyright */}
|
|
<div className="flex flex-col gap-2 text-sm text-[var(--text-soft)] sm:items-end sm:text-right">
|
|
<span className="text-xs tracking-[0.14em] uppercase">
|
|
{site.footer.tagline}
|
|
</span>
|
|
{/* Cookie & privacy links — keep them small and unobtrusive */}
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
|
<Link
|
|
href={site.footer.cookiePolicyHref}
|
|
className="transition-colors hover:text-[var(--accent)]"
|
|
>
|
|
{site.footer.cookiePolicyLabel}
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={openCookieBanner}
|
|
className="cursor-pointer text-left transition-colors hover:text-[var(--accent)]"
|
|
>
|
|
{site.footer.cookiePrefsLabel}
|
|
</button>
|
|
</div>
|
|
<span>
|
|
© {new Date().getFullYear()} {site.footer.company}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|