2026-05-02 08:26:18 +12:00
|
|
|
<script lang="ts">
|
2026-05-02 08:59:49 +12:00
|
|
|
import { onMount } from 'svelte';
|
2026-05-04 16:30:05 +12:00
|
|
|
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';
|
2026-05-04 16:30:05 +12:00
|
|
|
import RouteSkeleton from '$lib/components/RouteSkeleton.svelte';
|
2026-05-07 21:47:42 +12:00
|
|
|
import { isMobileCtaButtonEnabled } from '$lib/feature-flags';
|
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-13 00:34:34 +12:00
|
|
|
import '@fontsource/roboto/latin-400.css';
|
|
|
|
|
import '@fontsource/roboto/latin-500.css';
|
|
|
|
|
import '@fontsource/roboto/latin-700.css';
|
|
|
|
|
import '@fontsource/noto-sans/latin-400.css';
|
|
|
|
|
import '@fontsource/noto-sans/latin-500.css';
|
|
|
|
|
import '@fontsource/noto-sans/latin-700.css';
|
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 '@fontsource/fredoka/latin-600.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';
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// Same-page hash clicks aren't caught by afterNavigate
|
|
|
|
|
function onHashChange() {
|
|
|
|
|
bounceSection(window.location.hash);
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('hashchange', onHashChange);
|
|
|
|
|
return () => window.removeEventListener('hashchange', onHashChange);
|
2026-05-12 00:45:02 +12:00
|
|
|
});
|
2026-05-02 08:59:49 +12:00
|
|
|
|
2026-05-04 16:30:05 +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-07 21:47:42 +12:00
|
|
|
<svelte:body class:mobile-cta-enabled={mobileCtaButtonEnabled} />
|
|
|
|
|
|
2026-05-04 16:30:05 +12:00
|
|
|
<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 />
|
|
|
|
|
|
2026-05-04 16:30:05 +12:00
|
|
|
<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>
|