Files
novarix.uk/app/page.tsx
T
Kismet Hasanaj c22d54abb0 .
2026-05-03 01:09:44 +02:00

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>
</>
);
}