Files
gw-svelte/src/routes/+layout.svelte
T

245 lines
7.5 KiB
Svelte
Raw Normal View History

2026-05-02 08:26:18 +12:00
<script lang="ts">
2026-05-02 08:59:49 +12:00
import { onMount } from 'svelte';
import { navigating, page } from '$app/stores';
2026-05-02 08:26:18 +12:00
import { afterNavigate, disableScrollHandling } from '$app/navigation';
2026-05-02 08:59:49 +12:00
import { initClickTracking, trackPageView } from '$lib/analytics';
2026-05-06 08:27:24 +12:00
import MobileBookBar from '$lib/components/MobileBookBar.svelte';
import RouteSkeleton from '$lib/components/RouteSkeleton.svelte';
2026-05-07 21:47:42 +12:00
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
2026-05-15 01:28:10 +12:00
import readex400Woff2 from '@fontsource/readex-pro/files/readex-pro-latin-400-normal.woff2?url';
import readex700Woff2 from '@fontsource/readex-pro/files/readex-pro-latin-700-normal.woff2?url';
2026-05-12 00:45:02 +12:00
import '@fontsource/readex-pro/latin-400.css';
import '@fontsource/readex-pro/latin-500.css';
import '@fontsource/readex-pro/latin-600.css';
import '@fontsource/readex-pro/latin-700.css';
2026-05-15 01:28:10 +12:00
import unbounded700Woff2 from '@fontsource/unbounded/files/unbounded-latin-700-normal.woff2?url';
import unbounded800Woff2 from '@fontsource/unbounded/files/unbounded-latin-800-normal.woff2?url';
2026-05-12 00:45:02 +12:00
import '@fontsource/unbounded/latin-400.css';
import '@fontsource/unbounded/latin-600.css';
import '@fontsource/unbounded/latin-700.css';
import '@fontsource/unbounded/latin-800.css';
import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
import '@fortawesome/fontawesome-free/css/solid.min.css';
import '@fortawesome/fontawesome-free/css/brands.min.css';
2026-05-02 08:26:18 +12:00
import '$lib/styles/variables.css';
import '$lib/styles/base.css';
import '$lib/styles/layout.css';
import '$lib/styles/typography.css';
import '$lib/styles/buttons.css';
import '$lib/styles/forms.css';
import '$lib/styles/sections.css';
2026-05-26 08:30:08 +12:00
import '$lib/styles/motion.css';
2026-05-02 08:26:18 +12:00
import '$lib/styles/responsive.css';
2026-05-07 21:47:42 +12:00
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
2026-05-12 00:45:02 +12:00
let revealObserver: IntersectionObserver | null = null;
2026-05-15 01:28:10 +12:00
const scrollStoragePrefix = 'goodwalk:scroll:';
function getScrollStorageKey(urlLike: string) {
const url = new URL(urlLike, 'http://goodwalk.local');
return `${scrollStoragePrefix}${url.pathname}${url.search}`;
}
function saveScrollPosition(urlLike = window.location.href) {
if (typeof window === 'undefined') return;
sessionStorage.setItem(getScrollStorageKey(urlLike), String(window.scrollY));
}
function restoreScrollPosition(urlLike = window.location.href) {
if (typeof window === 'undefined' || window.location.hash) return;
const saved = sessionStorage.getItem(getScrollStorageKey(urlLike));
if (!saved) return;
const top = Number(saved);
if (!Number.isFinite(top)) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo({ top, left: 0, behavior: 'auto' });
});
});
}
2026-05-12 00:45:02 +12:00
2026-05-13 09:39:52 +12:00
function shouldAnimateAnchorBounce() {
return typeof window !== 'undefined' && window.innerWidth > 768;
}
2026-05-12 00:45:02 +12:00
function initReveal() {
revealObserver?.disconnect();
const targets = document.querySelectorAll<HTMLElement>(
'.section-heading, .service-card, .testimonial-card, .value-card'
);
targets.forEach(el => {
el.setAttribute('data-reveal', '');
});
revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
revealObserver?.unobserve(entry.target);
}
});
},
{ rootMargin: '0px 0px -40px 0px', threshold: 0.08 }
);
targets.forEach(el => revealObserver!.observe(el));
}
2026-05-13 00:34:34 +12:00
function bounceSection(hash: string) {
2026-05-13 09:39:52 +12:00
if (!shouldAnimateAnchorBounce()) return;
2026-05-13 00:34:34 +12:00
const id = hash.startsWith('#') ? hash.slice(1) : hash;
if (!id) return;
// Wait for smooth scroll to finish before playing the bounce
setTimeout(() => {
const el = document.getElementById(id);
if (!el) return;
el.classList.remove('anchor-bounce');
void el.offsetWidth; // force reflow so re-adding the class re-triggers
el.classList.add('anchor-bounce');
el.addEventListener('animationend', () => el.classList.remove('anchor-bounce'), { once: true });
}, 520);
}
2026-05-12 00:45:02 +12:00
onMount(() => {
initClickTracking();
requestAnimationFrame(initReveal);
2026-05-13 00:34:34 +12:00
2026-05-15 01:28:10 +12:00
restoreScrollPosition();
2026-05-13 00:34:34 +12:00
// Same-page hash clicks aren't caught by afterNavigate
function onHashChange() {
bounceSection(window.location.hash);
}
2026-05-15 01:28:10 +12:00
function onPageHide() {
saveScrollPosition();
}
function onVisibilityChange() {
if (document.visibilityState === 'hidden') {
saveScrollPosition();
}
}
function onPageShow(event: PageTransitionEvent) {
if (event.persisted) {
restoreScrollPosition();
}
}
2026-05-13 00:34:34 +12:00
window.addEventListener('hashchange', onHashChange);
2026-05-15 01:28:10 +12:00
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
window.removeEventListener('hashchange', onHashChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
2026-05-12 00:45:02 +12:00
});
2026-05-02 08:59:49 +12:00
function shouldShowSkeleton() {
const navigation = $navigating;
if (!navigation?.to?.url) {
return false;
}
const fromPath = navigation.from?.url.pathname ?? $page.url.pathname;
const toPath = navigation.to.url.pathname;
if (navigation.to.url.hash && toPath === fromPath) {
return false;
}
return toPath !== fromPath;
}
$: loadingPath = $navigating?.to?.url.pathname ?? $page.url.pathname;
$: showRouteSkeleton = shouldShowSkeleton();
2026-05-02 08:26:18 +12:00
afterNavigate(({ from, to }) => {
2026-05-13 00:34:34 +12:00
if (!from || !to) return;
if (to.url.hash && from.url.pathname !== to.url.pathname) {
bounceSection(to.url.hash);
return;
}
if (to.url.hash) {
2026-05-02 08:26:18 +12:00
return;
}
if (from.url.pathname !== to.url.pathname) {
disableScrollHandling();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
2026-05-12 00:45:02 +12:00
initReveal();
2026-05-02 08:26:18 +12:00
});
});
2026-05-02 08:59:49 +12:00
trackPageView(to.url.pathname + to.url.search, document.title);
2026-05-02 08:26:18 +12:00
}
});
</script>
2026-05-15 01:28:10 +12:00
<svelte:head>
<link rel="preload" href={readex400Woff2} as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href={readex700Woff2} as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href={unbounded700Woff2} as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href={unbounded800Woff2} as="font" type="font/woff2" crossorigin="anonymous" />
</svelte:head>
2026-05-07 21:47:42 +12:00
<svelte:body class:mobile-cta-enabled={mobileCtaButtonEnabled} />
<div class="layout-shell">
<div class:layout-content-loading={showRouteSkeleton} class="layout-content" aria-hidden={showRouteSkeleton}>
<slot />
</div>
{#if showRouteSkeleton}
<div class="layout-skeleton-layer" role="status" aria-label="Loading page" aria-live="polite">
<RouteSkeleton pathname={loadingPath} />
</div>
{/if}
</div>
2026-05-06 08:27:24 +12:00
<MobileBookBar />
<style>
.layout-shell {
position: relative;
}
.layout-content-loading {
visibility: hidden;
}
.layout-skeleton-layer {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: rgba(251, 251, 251, 0.98);
}
</style>