banner and intro fix

This commit is contained in:
Kismet Hasanaj
2026-05-03 01:06:11 +02:00
parent af798cc58c
commit d66c54088a
4 changed files with 106 additions and 32 deletions
+8
View File
@@ -109,6 +109,10 @@
3. Base styles — applied to plain HTML elements before any classes hit. 3. Base styles — applied to plain HTML elements before any classes hit.
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
@layer base { @layer base {
html {
background: var(--bg);
}
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
@@ -135,6 +139,10 @@
} }
} }
html[data-ui-ready="0"] body {
opacity: 0;
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
4. Site shell + ambient effects 4. Site shell + ambient effects
The .site-shell class is on the <main> element in page.tsx. The two The .site-shell class is on the <main> element in page.tsx. The two
+26 -1
View File
@@ -62,7 +62,32 @@ export default function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en-GB" suppressHydrationWarning> <html lang="en-GB" suppressHydrationWarning data-ui-ready="0">
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
var root = document.documentElement;
root.dataset.uiReady = "0";
try {
var consent = window.localStorage.getItem("novarix-cookie-consent");
var introSeen = window.sessionStorage.getItem("novarix-intro-seen");
var shouldShowBanner = consent !== "ack" && consent !== "rst";
var shouldPlayIntro = !shouldShowBanner && !introSeen;
root.dataset.showCookieBanner = shouldShowBanner ? "1" : "0";
root.dataset.showIntro = shouldPlayIntro ? "1" : "0";
} catch (error) {
root.dataset.showCookieBanner = "1";
root.dataset.showIntro = "0";
}
root.dataset.uiReady = "1";
})();
`,
}}
/>
</head>
<body> <body>
{children} {children}
<CookieBanner /> <CookieBanner />
+51 -20
View File
@@ -31,6 +31,8 @@ import { openCookieBanner } from "@/components/CookieBanner";
// Type for the mouse-pointer position used to move the background glow. // Type for the mouse-pointer position used to move the background glow.
type PointerState = { x: number; y: number }; type PointerState = { x: number; y: number };
const INTRO_SEEN_KEY = "novarix-intro-seen";
const INTRO_EVENT = "novarix:start-intro";
export default function HomePage() { export default function HomePage() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,43 +42,72 @@ export default function HomePage() {
// pointer — the mouse position (in % of page width/height). Used by the // pointer — the mouse position (in % of page width/height). Used by the
// soft glow that follows the cursor in the background. // soft glow that follows the cursor in the background.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const [showIntro, setShowIntro] = useState(false); const [showIntro, setShowIntro] = useState(() => {
if (typeof document === "undefined") return false;
return document.documentElement.dataset.showIntro === "1";
});
const [pointer, setPointer] = useState<PointerState>({ x: 50, y: 22 }); const [pointer, setPointer] = useState<PointerState>({ x: 50, y: 22 });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// INTRO OVERLAY EFFECT // INTRO OVERLAY EFFECT
// Plays the animated logo + wordmark the first time someone visits the // Plays the animated logo + wordmark the first time someone visits the
// site in this browser tab. We remember they've seen it using // site in this browser tab. We decide before hydration whether to play it,
// sessionStorage so it doesn't replay on every navigation. The intro lasts // which avoids the homepage flashing briefly before the intro appears.
// a little under 4 seconds, with a softer fade into the main page. // After the user accepts cookies on their first visit, we trigger the intro
// immediately from the banner close action.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
try { function playIntro() {
const introSeen = window.sessionStorage.getItem("novarix-intro-seen"); document.documentElement.dataset.showIntro = "1";
// Only set the "seen" flag if the user has accepted cookies. Without
// consent we still play the intro — we just don't remember it played.
const consent = window.localStorage.getItem("novarix-cookie-consent");
if (!introSeen) {
// Sync once with sessionStorage on first client mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowIntro(true); setShowIntro(true);
if (consent === "ack") { try {
window.sessionStorage.setItem("novarix-intro-seen", "true"); window.sessionStorage.setItem(INTRO_SEEN_KEY, "true");
} catch {
/* storage unavailable — silently ignore */
} }
// The SVG's built-in stroke + fill animation lasts ~3s.
// We hold a little longer so the page underneath can ease in while
// the overlay fades away.
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
document.documentElement.dataset.showIntro = "0";
setShowIntro(false); setShowIntro(false);
}, 3950); }, 3950);
return () => window.clearTimeout(timer); 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 =
document.documentElement.dataset.showIntro === "1" &&
consent === "ack" &&
!introSeen;
if (shouldPlayNow) {
timer = playIntro();
} else {
document.documentElement.dataset.showIntro = "0";
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowIntro(false);
} }
} catch { } catch {
/* storage unavailable — silently skip the intro */ document.documentElement.dataset.showIntro = "0";
// eslint-disable-next-line react-hooks/set-state-in-effect
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);
};
}, []); }, []);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+17 -7
View File
@@ -28,28 +28,35 @@ import { site } from "@/content";
const CONSENT_KEY = "novarix-cookie-consent"; const CONSENT_KEY = "novarix-cookie-consent";
const INTRO_SEEN_KEY = "novarix-intro-seen"; const INTRO_SEEN_KEY = "novarix-intro-seen";
const REOPEN_EVENT = "novarix:open-cookie-banner"; const REOPEN_EVENT = "novarix:open-cookie-banner";
const INTRO_EVENT = "novarix:start-intro";
type Consent = "unknown" | "ack" | "rst"; type Consent = "unknown" | "ack" | "rst";
export default function CookieBanner() { export default function CookieBanner() {
// Start hidden on the server and on first client paint to avoid a flash. // Start from the pre-hydration decision made in layout.tsx so the banner
const [visible, setVisible] = useState(false); // can appear immediately on first visit without waiting for a delayed effect.
const [visible, setVisible] = useState(() => {
if (typeof document === "undefined") return false;
return document.documentElement.dataset.showCookieBanner === "1";
});
// Read the stored consent on mount and decide whether to show the banner. // Read the stored consent on mount and decide whether to show the banner.
useEffect(() => { useEffect(() => {
try { try {
const stored = window.localStorage.getItem(CONSENT_KEY) as Consent | null; const stored = window.localStorage.getItem(CONSENT_KEY) as Consent | null;
if (stored !== "ack" && stored !== "rst") { if (stored !== "ack" && stored !== "rst") {
// No decision yet — show the banner. We delay slightly so the page document.documentElement.dataset.showCookieBanner = "1";
// settles in first; feels less aggressive than appearing instantly. // eslint-disable-next-line react-hooks/set-state-in-effect
const t = window.setTimeout(() => setVisible(true), 600); setVisible(true);
return () => window.clearTimeout(t); } else {
document.documentElement.dataset.showCookieBanner = "0";
} }
} catch { } catch {
// localStorage unavailable (private mode, blocked, etc.) — show the // localStorage unavailable (private mode, blocked, etc.) — show the
// banner so the user is still told. Sync-once-on-mount is a legitimate // banner so the user is still told. Sync-once-on-mount is a legitimate
// use of setState in an effect. // use of setState in an effect.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
document.documentElement.dataset.showCookieBanner = "1";
setVisible(true); setVisible(true);
} }
}, []); }, []);
@@ -67,10 +74,13 @@ export default function CookieBanner() {
if (choice === "rst") { if (choice === "rst") {
// Honour the rejection by clearing any non-consent storage. // Honour the rejection by clearing any non-consent storage.
window.sessionStorage.removeItem(INTRO_SEEN_KEY); window.sessionStorage.removeItem(INTRO_SEEN_KEY);
} else {
window.dispatchEvent(new Event(INTRO_EVENT));
} }
} catch { } catch {
/* storage unavailable — nothing to do */ /* storage unavailable — nothing to do */
} }
document.documentElement.dataset.showCookieBanner = "0";
setVisible(false); setVisible(false);
} }
@@ -81,7 +91,7 @@ export default function CookieBanner() {
role="dialog" role="dialog"
aria-live="polite" aria-live="polite"
aria-label="Cookie consent" aria-label="Cookie consent"
className="fixed inset-x-0 bottom-0 z-[1500] flex justify-center p-3 sm:p-5" className="fixed inset-x-0 bottom-0 z-[2500] flex justify-center p-3 sm:p-5"
> >
<div className="pointer-events-auto w-full max-w-3xl rounded-2xl border border-[var(--border-strong)] bg-[var(--surface-strong)] p-4 shadow-[0_24px_60px_-20px_oklch(0_0_0/0.45)] backdrop-blur-xl sm:p-5"> <div className="pointer-events-auto w-full max-w-3xl rounded-2xl border border-[var(--border-strong)] bg-[var(--surface-strong)] p-4 shadow-[0_24px_60px_-20px_oklch(0_0_0/0.45)] backdrop-blur-xl sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">