@sarimarcus/content-sites-ui 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +55 -2
- package/package.json +7 -3
- package/src/affiliate/EmeraldLoader.astro +40 -0
- package/src/affiliate/index.ts +4 -1
- package/src/affiliate/resources.ts +7 -0
- package/src/affiliate/travelpayoutsStyler.ts +557 -0
- package/src/composed/ArrowLinkGrid.astro +36 -0
- package/src/composed/index.ts +1 -0
- package/src/editorial/ArticleFeatureCard.astro +77 -0
- package/src/editorial/ArticlesListingPage.astro +40 -0
- package/src/editorial/NotFoundPage.astro +98 -0
- package/src/editorial/StaticProsePage.astro +48 -0
- package/src/editorial/index.ts +4 -0
- package/src/layout/ConsentAnalytics.astro +121 -0
- package/src/layout/DocumentShell.astro +171 -0
- package/src/layout/ListingShell.astro +32 -0
- package/src/layout/analytics.ts +151 -0
- package/src/layout/index.ts +7 -0
- package/src/layout/resources.ts +19 -0
- package/src/search/SearchPage.astro +222 -0
- package/src/search/index.ts +2 -0
- package/src/search/searchPage.ts +591 -0
- package/src/styles/not-found.css +151 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ArticlesListingPage — the shell of an article listing (index, topic hub, their paginated pages): breadcrumb band,
|
|
4
|
+
* a header (eyebrow, heading, accent rule, lede) and <main>. The route keeps Layout, schema, paging and image maps,
|
|
5
|
+
* passes the breadcrumb in `breadcrumb`, an optional line under the lede in `header-extra`, and the main content
|
|
6
|
+
* (its own Containers) as default-slot children.
|
|
7
|
+
*/
|
|
8
|
+
import BreadcrumbBand from '../layout/BreadcrumbBand.astro';
|
|
9
|
+
import Container from '../layout/Container.astro';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
eyebrow: string;
|
|
13
|
+
heading: string;
|
|
14
|
+
lede: string;
|
|
15
|
+
/** Mark <main> as the Pagefind body. */
|
|
16
|
+
pagefindBody?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { eyebrow, heading, lede, pagefindBody = false } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<BreadcrumbBand>
|
|
23
|
+
<slot name="breadcrumb" />
|
|
24
|
+
</BreadcrumbBand>
|
|
25
|
+
|
|
26
|
+
<header class="bg-muted/30 border-b border-border pt-12 pb-10">
|
|
27
|
+
<Container size="measure">
|
|
28
|
+
<p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>
|
|
29
|
+
<h1 class="text-3xl sm:text-4xl font-normal font-display text-foreground leading-tight text-balance">
|
|
30
|
+
{heading}
|
|
31
|
+
</h1>
|
|
32
|
+
<div class="mt-4 flex"><span class="block h-0.5 w-16 rounded-full bg-accent"></span></div>
|
|
33
|
+
<p class="mt-5 text-lg text-muted-foreground leading-relaxed">{lede}</p>
|
|
34
|
+
<slot name="header-extra" />
|
|
35
|
+
</Container>
|
|
36
|
+
</header>
|
|
37
|
+
|
|
38
|
+
<main class="py-14" data-pagefind-body={pagefindBody ? '' : undefined}>
|
|
39
|
+
<slot />
|
|
40
|
+
</main>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* NotFoundPage — the 404 body: a decorative arch-and-zellige backdrop, the large "404", a message, a home button, a
|
|
4
|
+
* search link and a short list of section links. The route keeps Layout (with `noindex`), passes every string and
|
|
5
|
+
* imports `@sarimarcus/content-sites-ui/not-found.css`.
|
|
6
|
+
*/
|
|
7
|
+
import Button from '../primitives/Button.astro';
|
|
8
|
+
import LinkArrow from '../primitives/LinkArrow.astro';
|
|
9
|
+
import Icon from '../primitives/Icon.astro';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
tagline: string;
|
|
13
|
+
lede: string;
|
|
14
|
+
homeHref: string;
|
|
15
|
+
homeLabel: string;
|
|
16
|
+
searchHref: string;
|
|
17
|
+
searchLabel: string;
|
|
18
|
+
searchAriaLabel: string;
|
|
19
|
+
exploreLabel: string;
|
|
20
|
+
/** Accessible name of the section links. */
|
|
21
|
+
exploreAriaLabel?: string;
|
|
22
|
+
links: { href: string; label: string }[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { tagline, lede, homeHref, homeLabel, searchHref, searchLabel, searchAriaLabel, exploreLabel, exploreAriaLabel = 'Explore sections', links } = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<main class="relative min-h-[80vh] flex items-center justify-center overflow-hidden bg-muted">
|
|
29
|
+
{/* Geometric zellige-inspired background pattern — pure CSS */}
|
|
30
|
+
<div class="absolute inset-0 opacity-[0.04] pointer-events-none" aria-hidden="true">
|
|
31
|
+
<div class="not-found-zellige-grid"></div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{/* Decorative horseshoe arches — layered behind content */}
|
|
35
|
+
<div class="absolute inset-0 flex items-end justify-center pointer-events-none" aria-hidden="true">
|
|
36
|
+
<div class="not-found-arch-row">
|
|
37
|
+
<div class="not-found-arch not-found-arch--left"></div>
|
|
38
|
+
<div class="not-found-arch not-found-arch--center"></div>
|
|
39
|
+
<div class="not-found-arch not-found-arch--right"></div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="relative z-10 max-w-deck mx-auto px-6 py-20 text-center">
|
|
44
|
+
{/* Decorative star motif */}
|
|
45
|
+
<div class="flex justify-center mb-6" aria-hidden="true">
|
|
46
|
+
<div class="not-found-islamic-star"></div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* The 404 number — large and decorative */}
|
|
50
|
+
<h1 class="text-accent text-[8rem] sm:text-[10rem] md:text-[12rem] leading-none font-display tracking-tight select-none not-found-fade-in-up">
|
|
51
|
+
404
|
|
52
|
+
</h1>
|
|
53
|
+
|
|
54
|
+
{/* Thin accent separator */}
|
|
55
|
+
<div class="mx-auto w-24 h-px bg-gradient-to-r from-transparent via-accent to-transparent my-6 not-found-fade-in-up not-found-delay-1" aria-hidden="true"></div>
|
|
56
|
+
|
|
57
|
+
{/* Message */}
|
|
58
|
+
<h2 class="font-display text-2xl sm:text-3xl text-foreground mb-4 not-found-fade-in-up not-found-delay-1">
|
|
59
|
+
{tagline}
|
|
60
|
+
</h2>
|
|
61
|
+
<p class="text-muted-foreground text-lg sm:text-xl leading-relaxed max-w-lg mx-auto mb-10 not-found-fade-in-up not-found-delay-2">
|
|
62
|
+
{lede}
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+
{/* Primary CTA */}
|
|
66
|
+
<div class="mb-6 not-found-fade-in-up not-found-delay-2">
|
|
67
|
+
<Button href={homeHref} variant="primary">
|
|
68
|
+
{homeLabel}
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Search prompt */}
|
|
73
|
+
<div class="mb-12 not-found-fade-in-up not-found-delay-3">
|
|
74
|
+
<a
|
|
75
|
+
href={searchHref}
|
|
76
|
+
data-search-trigger
|
|
77
|
+
class="group flex items-center gap-3 w-full max-w-sm mx-auto px-4 py-3 rounded-xl border border-foreground/20 bg-white/60 backdrop-blur-sm text-left hover:border-primary/40 hover:bg-white/80 transition-all no-underline"
|
|
78
|
+
aria-label={searchAriaLabel}
|
|
79
|
+
>
|
|
80
|
+
<Icon name="search" class="w-4 h-4 text-muted-foreground shrink-0" />
|
|
81
|
+
<span class="text-muted-foreground text-sm flex-1">{searchLabel}</span>
|
|
82
|
+
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border border-border text-muted-foreground/60 bg-muted/50">⌘K</kbd>
|
|
83
|
+
</a>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Explore sections */}
|
|
87
|
+
<div class="not-found-fade-in-up not-found-delay-4">
|
|
88
|
+
<p class="text-sm uppercase tracking-widest text-muted-foreground/60 mb-5 font-medium">
|
|
89
|
+
{exploreLabel}
|
|
90
|
+
</p>
|
|
91
|
+
<nav aria-label={exploreAriaLabel} class="flex flex-wrap justify-center gap-x-6 gap-y-3">
|
|
92
|
+
{links.map((link, i) => (
|
|
93
|
+
<LinkArrow href={link.href} color={i % 2 === 0 ? 'secondary' : 'primary'} size="base">{link.label}</LinkArrow>
|
|
94
|
+
))}
|
|
95
|
+
</nav>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</main>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* StaticProsePage — the shell of a legal or site-information page (privacy, terms, contact, editorial standards,
|
|
4
|
+
* agents). The route keeps Layout, schema and the prose itself (default slot), so copy and links stay in site source.
|
|
5
|
+
* Not Pagefind-indexed: it carries no body marker.
|
|
6
|
+
*/
|
|
7
|
+
import Container from '../layout/Container.astro';
|
|
8
|
+
import Button from '../primitives/Button.astro';
|
|
9
|
+
|
|
10
|
+
interface CtaLink {
|
|
11
|
+
href: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
heading: string;
|
|
17
|
+
/** Small uppercase line above the heading ("Site policy"). */
|
|
18
|
+
eyebrow?: string;
|
|
19
|
+
/** Closing box under the prose: a short line and secondary buttons. */
|
|
20
|
+
cta?: { text: string; links: CtaLink[] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { heading, eyebrow, cta } = Astro.props;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<main class="py-16 lg:py-24">
|
|
27
|
+
<Container size="measure">
|
|
28
|
+
{eyebrow && <p class="text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">{eyebrow}</p>}
|
|
29
|
+
<h1 class="text-3xl sm:text-4xl font-medium font-display mb-8">{heading}</h1>
|
|
30
|
+
|
|
31
|
+
<div class="prose prose-stone max-w-none">
|
|
32
|
+
<slot />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<slot name="after" />
|
|
36
|
+
|
|
37
|
+
{cta && (
|
|
38
|
+
<div class="not-prose mt-12 rounded-2xl border border-border bg-muted/40 p-6">
|
|
39
|
+
<p class="text-sm text-muted-foreground mb-4">
|
|
40
|
+
{cta.text}
|
|
41
|
+
</p>
|
|
42
|
+
<div class="flex flex-wrap gap-3">
|
|
43
|
+
{cta.links.map((link) => <Button href={link.href} variant="secondary">{link.label}</Button>)}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</Container>
|
|
48
|
+
</main>
|
package/src/editorial/index.ts
CHANGED
|
@@ -3,4 +3,8 @@ export { default as EditorialGuidePage } from './EditorialGuidePage.astro';
|
|
|
3
3
|
export { default as EventPage } from './EventPage.astro';
|
|
4
4
|
export { default as BeforeYouGo } from './BeforeYouGo.astro';
|
|
5
5
|
export { default as NumberedStep } from './NumberedStep.astro';
|
|
6
|
+
export { default as StaticProsePage } from './StaticProsePage.astro';
|
|
7
|
+
export { default as NotFoundPage } from './NotFoundPage.astro';
|
|
8
|
+
export { default as ArticlesListingPage } from './ArticlesListingPage.astro';
|
|
9
|
+
export { default as ArticleFeatureCard } from './ArticleFeatureCard.astro';
|
|
6
10
|
export type { EventInfoRow, EventInfoIcon } from './event';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Google Tag Manager behind Consent Mode v2, with the Silktide consent manager. Production only. Deferred to the first
|
|
4
|
+
* interaction, or to idle after load (8 s timeout keeps GTM outside the Lighthouse TBT window). Inline on purpose:
|
|
5
|
+
* the consent default must be pushed before the gtm.js event in one synchronous step, which a deferred module cannot
|
|
6
|
+
* guarantee. Defines `window.openCookiePreferences`, which the footer's cookie-preferences button calls.
|
|
7
|
+
* The `<noscript>` iframe goes at the start of `<body>`: render with `part="noscript"` there and `part="script"` at
|
|
8
|
+
* the end of `<body>`.
|
|
9
|
+
*/
|
|
10
|
+
import { GTM_ORIGIN, SILKTIDE_BASE } from './resources.ts';
|
|
11
|
+
|
|
12
|
+
export interface ConsentType {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
description: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
defaultValue?: boolean;
|
|
18
|
+
/** The Consent Mode v2 storage key(s) this category grants. */
|
|
19
|
+
gtag?: string | string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
gtmId: string;
|
|
24
|
+
part: 'noscript' | 'script';
|
|
25
|
+
/** The banner's prompt text. */
|
|
26
|
+
prompt?: string;
|
|
27
|
+
consentTypes?: ConsentType[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { gtmId, part, prompt = '', consentTypes = [] } = Astro.props;
|
|
31
|
+
const isProd = import.meta.env.PROD;
|
|
32
|
+
const gtmOrigin = GTM_ORIGIN;
|
|
33
|
+
const silktideScript = `${SILKTIDE_BASE}/silktide-consent-manager.js`;
|
|
34
|
+
const silktideConfig = { text: { prompt: { description: prompt } }, consentTypes };
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
{isProd && part === 'noscript' && (
|
|
38
|
+
<noscript set:html={`<iframe src="${gtmOrigin}/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
|
|
39
|
+
)}
|
|
40
|
+
{isProd && part === 'script' && (
|
|
41
|
+
<script is:inline define:vars={{ gtmId, gtmOrigin, silktideScript, silktideConfig }}>
|
|
42
|
+
(function () {
|
|
43
|
+
let thirdPartyLoaded = false;
|
|
44
|
+
|
|
45
|
+
function loadThirdPartyScripts() {
|
|
46
|
+
if (thirdPartyLoaded) return;
|
|
47
|
+
thirdPartyLoaded = true;
|
|
48
|
+
|
|
49
|
+
window.dataLayer = window.dataLayer || [];
|
|
50
|
+
|
|
51
|
+
// GTM treats a dataLayer entry as a consent command only when it is an `arguments` object; a plain array is
|
|
52
|
+
// ignored, which leaves analytics_storage unset and blocks tags with an additional consent check.
|
|
53
|
+
function gtag() { window.dataLayer.push(arguments); }
|
|
54
|
+
gtag('consent', 'default', {
|
|
55
|
+
ad_storage: 'denied',
|
|
56
|
+
ad_user_data: 'denied',
|
|
57
|
+
ad_personalization: 'denied',
|
|
58
|
+
analytics_storage: 'granted',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
window.dataLayer.push({
|
|
62
|
+
'gtm.start': new Date().getTime(),
|
|
63
|
+
event: 'gtm.js',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// On the main thread: Partytown race-dropped the initial page_view (SLA-2661).
|
|
67
|
+
var gtmScript = document.createElement('script');
|
|
68
|
+
gtmScript.async = true;
|
|
69
|
+
gtmScript.src = gtmOrigin + '/gtm.js?id=' + gtmId;
|
|
70
|
+
document.head.appendChild(gtmScript);
|
|
71
|
+
|
|
72
|
+
var consentScript = document.createElement('script');
|
|
73
|
+
consentScript.src = silktideScript;
|
|
74
|
+
consentScript.async = true;
|
|
75
|
+
consentScript.onload = function () {
|
|
76
|
+
if (!window.silktideConsentManager) return;
|
|
77
|
+
window.silktideConsentManager.init(silktideConfig);
|
|
78
|
+
};
|
|
79
|
+
document.head.appendChild(consentScript);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
var evtOpts = { once: true, passive: true, capture: true };
|
|
83
|
+
['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (evt) {
|
|
84
|
+
window.addEventListener(evt, loadThirdPartyScripts, evtOpts);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function scheduleThirdPartyScripts() {
|
|
88
|
+
if ('requestIdleCallback' in window) {
|
|
89
|
+
window.requestIdleCallback(loadThirdPartyScripts, { timeout: 8000 });
|
|
90
|
+
} else {
|
|
91
|
+
setTimeout(loadThirdPartyScripts, 6000);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (document.readyState === 'complete') {
|
|
96
|
+
scheduleThirdPartyScripts();
|
|
97
|
+
} else {
|
|
98
|
+
window.addEventListener('load', scheduleThirdPartyScripts, { once: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Silktide is lazy-loaded: if this click is the first interaction, load it and poll briefly (about 4 s).
|
|
102
|
+
window.openCookiePreferences = function () {
|
|
103
|
+
if (window.silktideConsentManager) {
|
|
104
|
+
window.silktideConsentManager.getInstance().toggleModal(true);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
loadThirdPartyScripts();
|
|
108
|
+
var attempts = 0;
|
|
109
|
+
var poll = setInterval(function () {
|
|
110
|
+
attempts++;
|
|
111
|
+
if (window.silktideConsentManager) {
|
|
112
|
+
clearInterval(poll);
|
|
113
|
+
window.silktideConsentManager.getInstance().toggleModal(true);
|
|
114
|
+
} else if (attempts > 40) {
|
|
115
|
+
clearInterval(poll);
|
|
116
|
+
}
|
|
117
|
+
}, 100);
|
|
118
|
+
};
|
|
119
|
+
})();
|
|
120
|
+
</script>
|
|
121
|
+
)}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The HTML document every site page renders into: head metadata (description, robots, canonical, Open Graph, article,
|
|
4
|
+
* Twitter, title), RSS and favicons, non-critical stylesheets, consent-gated analytics, the Travelpayouts Emerald
|
|
5
|
+
* loader, the skip link and the landmarks (navigation and footer wrapped out of the Pagefind index, `#main-content`).
|
|
6
|
+
* The site's thin Layout supplies identity, theme, fonts, chrome and policy; it keeps its own stylesheet import and
|
|
7
|
+
* its single `<script>` so bundle names stay the site's.
|
|
8
|
+
*
|
|
9
|
+
* Slots: `head-start` (after robots: preconnects), `fonts` (font-face block and preloads), `head` (page additions,
|
|
10
|
+
* JSON-LD), `body-start` (decorative layers), `nav`, `footer`, default (page content).
|
|
11
|
+
*/
|
|
12
|
+
import { normalizePagePath, normalizeSiteUrl, toAbsoluteSiteUrl } from '@sarimarcus/content-sites-core/url';
|
|
13
|
+
import { OgImageMeta } from '../seo/index.ts';
|
|
14
|
+
import EmeraldLoader from '../affiliate/EmeraldLoader.astro';
|
|
15
|
+
import AsyncStylesheet from './AsyncStylesheet.astro';
|
|
16
|
+
import ConsentAnalytics, { type ConsentType } from './ConsentAnalytics.astro';
|
|
17
|
+
import { SILKTIDE_STYLESHEET } from './resources.ts';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
siteName: string;
|
|
23
|
+
/** Absolute origin, e.g. `https://www.example.com`. */
|
|
24
|
+
siteUrl: string;
|
|
25
|
+
canonicalUrl?: string;
|
|
26
|
+
ogImage?: string;
|
|
27
|
+
ogImageAlt?: string;
|
|
28
|
+
ogType?: string;
|
|
29
|
+
ogLocale?: string;
|
|
30
|
+
articleAuthor?: string;
|
|
31
|
+
/** Absolute URL of the author's profile page, resolved by the site. */
|
|
32
|
+
articleAuthorUrl?: string;
|
|
33
|
+
articlePublishedTime?: string;
|
|
34
|
+
articleModifiedTime?: string;
|
|
35
|
+
articleSection?: string;
|
|
36
|
+
noindex?: boolean;
|
|
37
|
+
preloadImage?: string;
|
|
38
|
+
twitterHandle?: string;
|
|
39
|
+
themeColor: string;
|
|
40
|
+
lang?: string;
|
|
41
|
+
rssTitle?: string;
|
|
42
|
+
/** Non-critical stylesheet URLs (`?url` imports), loaded after the title in this order. */
|
|
43
|
+
stylesheets?: string[];
|
|
44
|
+
gtmId: string;
|
|
45
|
+
consentPrompt: string;
|
|
46
|
+
consentTypes: ConsentType[];
|
|
47
|
+
/** Travelpayouts id for the Emerald loader; omit to leave it out. */
|
|
48
|
+
travelpayoutsTrs?: string;
|
|
49
|
+
skipLinkLabel: string;
|
|
50
|
+
mainClass?: string;
|
|
51
|
+
footerClass?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
siteName,
|
|
58
|
+
siteUrl,
|
|
59
|
+
canonicalUrl: providedCanonicalUrl,
|
|
60
|
+
ogImage = '/og-default.jpg',
|
|
61
|
+
ogImageAlt,
|
|
62
|
+
ogType,
|
|
63
|
+
ogLocale = 'en',
|
|
64
|
+
articleAuthor,
|
|
65
|
+
articleAuthorUrl,
|
|
66
|
+
articlePublishedTime,
|
|
67
|
+
articleModifiedTime,
|
|
68
|
+
articleSection,
|
|
69
|
+
noindex = false,
|
|
70
|
+
preloadImage,
|
|
71
|
+
twitterHandle,
|
|
72
|
+
themeColor,
|
|
73
|
+
lang = 'en',
|
|
74
|
+
rssTitle = siteName,
|
|
75
|
+
stylesheets = [],
|
|
76
|
+
gtmId,
|
|
77
|
+
consentPrompt,
|
|
78
|
+
consentTypes,
|
|
79
|
+
travelpayoutsTrs,
|
|
80
|
+
skipLinkLabel,
|
|
81
|
+
mainClass,
|
|
82
|
+
footerClass,
|
|
83
|
+
} = Astro.props;
|
|
84
|
+
|
|
85
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
86
|
+
const canonicalUrl = providedCanonicalUrl
|
|
87
|
+
? normalizeSiteUrl(providedCanonicalUrl, siteUrl, baseUrl)
|
|
88
|
+
: toAbsoluteSiteUrl(normalizePagePath(Astro.url.pathname, baseUrl), siteUrl, baseUrl);
|
|
89
|
+
const imageAlt = ogImageAlt ?? `${title} - ${siteName}`;
|
|
90
|
+
|
|
91
|
+
function normalizeArticleTime(dateStr: string | undefined): string | undefined {
|
|
92
|
+
if (!dateStr) return undefined;
|
|
93
|
+
if (dateStr.includes('T')) return dateStr;
|
|
94
|
+
const date = new Date(`${dateStr}T00:00:00Z`);
|
|
95
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const publishedTime = normalizeArticleTime(articlePublishedTime);
|
|
99
|
+
const modifiedTime = normalizeArticleTime(articleModifiedTime);
|
|
100
|
+
const isArticleMeta = Boolean(articleAuthorUrl || publishedTime || modifiedTime || articleSection);
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
<!doctype html>
|
|
104
|
+
<html lang={lang}>
|
|
105
|
+
<head>
|
|
106
|
+
<meta charset="UTF-8" />
|
|
107
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
108
|
+
<meta name="theme-color" content={themeColor} />
|
|
109
|
+
<meta name="description" content={description} />
|
|
110
|
+
<meta name="robots" content={noindex ? 'noindex, follow' : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'} />
|
|
111
|
+
<slot name="head-start" />
|
|
112
|
+
|
|
113
|
+
<slot name="fonts" />
|
|
114
|
+
|
|
115
|
+
<link rel="canonical" href={canonicalUrl} />
|
|
116
|
+
<link rel="alternate" type="application/rss+xml" title={rssTitle} href={`${baseUrl}/rss.xml`} />
|
|
117
|
+
<link rel="icon" type="image/svg+xml" href={`${baseUrl}/favicon.svg`} />
|
|
118
|
+
<link rel="icon" type="image/x-icon" href={`${baseUrl}/favicon.ico`} />
|
|
119
|
+
|
|
120
|
+
<AsyncStylesheet href={SILKTIDE_STYLESHEET} />
|
|
121
|
+
|
|
122
|
+
{preloadImage && <link rel="preload" as="image" href={preloadImage} fetchpriority="high" />}
|
|
123
|
+
|
|
124
|
+
<meta property="og:type" content={ogType ?? (isArticleMeta ? 'article' : 'website')} />
|
|
125
|
+
<meta property="og:url" content={canonicalUrl} />
|
|
126
|
+
<meta property="og:title" content={title} />
|
|
127
|
+
<meta property="og:description" content={description} />
|
|
128
|
+
<OgImageMeta image={ogImage} alt={imageAlt} siteUrl={siteUrl} baseUrl={baseUrl} />
|
|
129
|
+
<meta property="og:site_name" content={siteName} />
|
|
130
|
+
<meta property="og:locale" content={ogLocale} />
|
|
131
|
+
{articleAuthor && <meta name="author" content={articleAuthor} />}
|
|
132
|
+
{articleAuthorUrl && <meta property="article:author" content={articleAuthorUrl} />}
|
|
133
|
+
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
|
|
134
|
+
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
|
|
135
|
+
{articleSection && <meta property="article:section" content={articleSection} />}
|
|
136
|
+
|
|
137
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
138
|
+
{twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
|
|
139
|
+
{twitterHandle && <meta name="twitter:creator" content={twitterHandle} />}
|
|
140
|
+
<meta name="twitter:title" content={title} />
|
|
141
|
+
<meta name="twitter:description" content={description} />
|
|
142
|
+
|
|
143
|
+
<title>{title}</title>
|
|
144
|
+
|
|
145
|
+
{stylesheets.map((href) => <AsyncStylesheet href={href} />)}
|
|
146
|
+
|
|
147
|
+
{travelpayoutsTrs && <EmeraldLoader trs={travelpayoutsTrs} />}
|
|
148
|
+
|
|
149
|
+
<slot name="head" />
|
|
150
|
+
</head>
|
|
151
|
+
<body class="min-h-screen bg-background flex flex-col">
|
|
152
|
+
<ConsentAnalytics gtmId={gtmId} part="noscript" />
|
|
153
|
+
<slot name="body-start" />
|
|
154
|
+
<a
|
|
155
|
+
href="#main-content"
|
|
156
|
+
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
|
|
157
|
+
>
|
|
158
|
+
{skipLinkLabel}
|
|
159
|
+
</a>
|
|
160
|
+
|
|
161
|
+
{Astro.slots.has('nav') && <div data-pagefind-ignore><slot name="nav" /></div>}
|
|
162
|
+
|
|
163
|
+
<div id="main-content" class:list={[mainClass, 'flex-1']}>
|
|
164
|
+
<slot />
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{Astro.slots.has('footer') && <div data-pagefind-ignore class={footerClass}><slot name="footer" /></div>}
|
|
168
|
+
|
|
169
|
+
<ConsentAnalytics gtmId={gtmId} part="script" prompt={consentPrompt} consentTypes={consentTypes} />
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ListingShell — the frame of a listing hub: breadcrumb band, hero, optional full-width bands, <main> with a
|
|
4
|
+
* Container, and trailing bands, all inside one Pagefind-ignore wrapper (listings are not indexed). The route keeps
|
|
5
|
+
* Layout and passes StructuredData to Layout directly, so its JSON-LD lands in <head>; everything else comes in by slot.
|
|
6
|
+
*/
|
|
7
|
+
import BreadcrumbBand from './BreadcrumbBand.astro';
|
|
8
|
+
import Container from './Container.astro';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
mainClass?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { mainClass = 'py-12 lg:py-20' } = Astro.props;
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<div data-pagefind-ignore>
|
|
18
|
+
<BreadcrumbBand ignore={false}>
|
|
19
|
+
<slot name="breadcrumb" />
|
|
20
|
+
</BreadcrumbBand>
|
|
21
|
+
|
|
22
|
+
<slot name="hero" />
|
|
23
|
+
<slot name="before-main" />
|
|
24
|
+
|
|
25
|
+
<main class={mainClass}>
|
|
26
|
+
<Container>
|
|
27
|
+
<slot />
|
|
28
|
+
</Container>
|
|
29
|
+
</main>
|
|
30
|
+
|
|
31
|
+
<slot name="after-main" />
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Pushes custom events to the GTM dataLayer.
|
|
2
|
+
// GTM consent mode (Silktide analytics_storage) governs whether GA4 receives each event.
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
dataLayer: Record<string, unknown>[];
|
|
7
|
+
silktideConsentManager?: { hasConsent(id: string): boolean };
|
|
8
|
+
track?: (eventName: string, params?: Record<string, unknown>) => void;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasAnalyticsConsent(): boolean {
|
|
13
|
+
const mgr = window.silktideConsentManager;
|
|
14
|
+
if (!mgr) return true; // not yet loaded — GTM consent mode enforces at send time
|
|
15
|
+
return typeof mgr.hasConsent === 'function' ? mgr.hasConsent('analytics') : true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function track(eventName: string, params: Record<string, unknown> = {}): void {
|
|
19
|
+
if (!hasAnalyticsConsent()) return;
|
|
20
|
+
window.dataLayer = window.dataLayer || [];
|
|
21
|
+
window.dataLayer.push({ event: eventName, page_path: window.location.pathname, ...params });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Page type: the first path segment, 'home' at the root.
|
|
26
|
+
*
|
|
27
|
+
* The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
|
|
28
|
+
* segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
|
|
29
|
+
* normalisation is what makes the two agree.
|
|
30
|
+
*/
|
|
31
|
+
export function pageTypeOf(pathname: string): string {
|
|
32
|
+
const first = pathname.split('/').filter(Boolean)[0];
|
|
33
|
+
if (!first) return 'home';
|
|
34
|
+
const bare = first.replace(/\.html$/, '');
|
|
35
|
+
return bare === 'index' || bare === '' ? 'home' : bare;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Param key derived from the data-track-* attribute name where the
|
|
39
|
+
// event-specific prefix (e.g. 'cta_', 'affiliate_', 'link_') has been stripped.
|
|
40
|
+
const HREF_DESTINATION_KEYS: Record<string, string> = {
|
|
41
|
+
outbound_click: 'link_destination',
|
|
42
|
+
cta_click: 'cta_destination',
|
|
43
|
+
internal_link_click: 'link_destination',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Reads data-track-* attributes from the clicked element and fires track().
|
|
47
|
+
// Handles affiliate_click, cta_click, outbound_click, internal_link_click.
|
|
48
|
+
export function setupDelegatedTracking(): void {
|
|
49
|
+
// MPA — pathname is stable for the page's lifetime; compute once.
|
|
50
|
+
const pageType = pageTypeOf(window.location.pathname);
|
|
51
|
+
|
|
52
|
+
document.addEventListener('click', (e: MouseEvent) => {
|
|
53
|
+
const target = e.target;
|
|
54
|
+
if (!(target instanceof Element)) return;
|
|
55
|
+
const el = target.closest<HTMLElement>('[data-track-event]');
|
|
56
|
+
if (!el) return;
|
|
57
|
+
const eventName = el.dataset.trackEvent;
|
|
58
|
+
if (!eventName) return;
|
|
59
|
+
|
|
60
|
+
const params: Record<string, unknown> = { page_type: pageType };
|
|
61
|
+
|
|
62
|
+
for (const key of Object.keys(el.dataset)) {
|
|
63
|
+
if (key === 'trackEvent' || !key.startsWith('track')) continue;
|
|
64
|
+
const param = key
|
|
65
|
+
.slice('track'.length)
|
|
66
|
+
.replace(/([A-Z])/g, '_$1')
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/^_/, '');
|
|
69
|
+
params[param] = el.dataset[key];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fall back to href for the destination param when the markup didn't set it
|
|
73
|
+
// — saves repeating the URL on every CTA/link.
|
|
74
|
+
const destKey = HREF_DESTINATION_KEYS[eventName];
|
|
75
|
+
if (destKey && !params[destKey] && el instanceof HTMLAnchorElement) {
|
|
76
|
+
params[destKey] = el.href;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
track(eventName, params);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fires faq_expand on <details data-faq-track> open. Uses capture because toggle doesn't bubble.
|
|
84
|
+
export function setupFaqTracking(): void {
|
|
85
|
+
document.addEventListener(
|
|
86
|
+
'toggle',
|
|
87
|
+
(e: Event) => {
|
|
88
|
+
const details = e.target as HTMLDetailsElement;
|
|
89
|
+
if (!details.hasAttribute('data-faq-track') || !details.open) return;
|
|
90
|
+
const question = details.querySelector('summary h3')?.textContent?.trim() ?? '';
|
|
91
|
+
track('faq_expand', { question });
|
|
92
|
+
},
|
|
93
|
+
true,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fires scroll_depth at 25/50/75/100% — deduped per page view, removed after 100%.
|
|
98
|
+
// Reads layout-triggering geometry (scrollHeight) inside a rAF-throttled tick
|
|
99
|
+
// so rapid scroll events coalesce to one measurement per frame.
|
|
100
|
+
export function setupScrollDepthTracking(): void {
|
|
101
|
+
const thresholds = [25, 50, 75, 100];
|
|
102
|
+
const fired = new Set<number>();
|
|
103
|
+
let frameQueued = false;
|
|
104
|
+
|
|
105
|
+
function measure() {
|
|
106
|
+
frameQueued = false;
|
|
107
|
+
const total = document.documentElement.scrollHeight;
|
|
108
|
+
if (total <= window.innerHeight) return;
|
|
109
|
+
const pct = Math.round(((window.scrollY + window.innerHeight) / total) * 100);
|
|
110
|
+
for (const d of thresholds) {
|
|
111
|
+
if (pct >= d && !fired.has(d)) {
|
|
112
|
+
fired.add(d);
|
|
113
|
+
track('scroll_depth', { percent: d });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (fired.size === thresholds.length) {
|
|
117
|
+
window.removeEventListener('scroll', onScroll);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function onScroll() {
|
|
122
|
+
if (frameQueued) return;
|
|
123
|
+
frameQueued = true;
|
|
124
|
+
requestAnimationFrame(measure);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AnalyticsOptions {
|
|
131
|
+
/** Pages that get scroll-depth tracking, tested against `location.pathname`. Omitted or false: none; true: every page. */
|
|
132
|
+
scrollDepth?: boolean | RegExp | ((pathname: string) => boolean);
|
|
133
|
+
/** Track `<details data-faq-track>` opens. */
|
|
134
|
+
faq?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let installed = false;
|
|
138
|
+
|
|
139
|
+
/** Sets `window.track` and binds delegated click, FAQ and scroll-depth tracking. A second call is a no-op. */
|
|
140
|
+
export function setupAnalytics(options: AnalyticsOptions = {}): void {
|
|
141
|
+
if (installed) return;
|
|
142
|
+
installed = true;
|
|
143
|
+
window.track = track;
|
|
144
|
+
setupDelegatedTracking();
|
|
145
|
+
if (options.faq) setupFaqTracking();
|
|
146
|
+
const { scrollDepth } = options;
|
|
147
|
+
const path = window.location.pathname;
|
|
148
|
+
const wantsScroll =
|
|
149
|
+
scrollDepth instanceof RegExp ? scrollDepth.test(path) : typeof scrollDepth === 'function' ? scrollDepth(path) : scrollDepth === true;
|
|
150
|
+
if (wantsScroll) setupScrollDepthTracking();
|
|
151
|
+
}
|