@sarimarcus/content-sites-ui 0.14.0 → 0.15.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.
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * InlineAffiliateCTA — one line of copy plus a tracked affiliate button: the slim alternative to a card grid.
4
+ * `href` arrives finished (tagged by the site). `trackPlacement` is the analytics placement value the site has
5
+ * already normalised. With `provider`, the button also carries the provider/product/placement triple the cards emit,
6
+ * so inline clicks land in the same analytics fields. Render an AffiliateDisclosure nearby.
7
+ */
8
+ import Icon from '../primitives/Icon.astro';
9
+
10
+ interface Props {
11
+ href: string;
12
+ label: string;
13
+ description?: string;
14
+ cta?: string;
15
+ trackId: string;
16
+ trackPlacement: string;
17
+ provider?: string;
18
+ productId?: string;
19
+ class?: string;
20
+ }
21
+
22
+ const {
23
+ href,
24
+ label,
25
+ description,
26
+ cta = 'See tours',
27
+ trackId,
28
+ trackPlacement,
29
+ provider,
30
+ productId,
31
+ class: className = '',
32
+ } = Astro.props;
33
+ ---
34
+
35
+ <div
36
+ class:list={[
37
+ 'flex flex-col gap-4 rounded-2xl border border-accent/30 bg-accent/10 p-5 sm:flex-row sm:items-center sm:justify-between sm:p-6',
38
+ className,
39
+ ]}
40
+ >
41
+ <div class="flex items-start gap-3">
42
+ <span class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent/25 text-accent-strong">
43
+ <Icon name="ticket" class="h-5 w-5" />
44
+ </span>
45
+ <div>
46
+ <p class="font-display text-base font-semibold text-foreground">{label}</p>
47
+ {description && (
48
+ <p class="mt-0.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
49
+ )}
50
+ </div>
51
+ </div>
52
+ <a
53
+ href={href}
54
+ target="_blank"
55
+ rel="sponsored noopener noreferrer"
56
+ data-track-event="affiliate_click"
57
+ data-track-cta-id={trackId}
58
+ data-track-cta-placement={trackPlacement}
59
+ data-track-affiliate-provider={provider}
60
+ data-track-affiliate-product-id={provider ? (productId ?? '') : undefined}
61
+ data-track-affiliate-placement={provider ? trackPlacement : undefined}
62
+ class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-accent-strong px-5 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
63
+ >
64
+ {cta}
65
+ <Icon name="arrow-up-right" class="h-4 w-4" />
66
+ </a>
67
+ </div>
@@ -0,0 +1,168 @@
1
+ ---
2
+ /**
3
+ * StickyCTA — the bottom bar that slides in once the reader has scrolled past the fold and points back at the
4
+ * affiliate block (`anchorId`). Mobile-only unless `showOnDesktop`.
5
+ *
6
+ * Behaviour (inline script, one copy per bar, each bar initialised once): shows after 800px of scroll, hides near
7
+ * the page end and while the `hideWhenVisible` element (looked up once the document has parsed) is on screen; the
8
+ * dismiss button hides it for the session under `dismissKey` (storage failures are ignored); while hidden the bar is
9
+ * `aria-hidden` and, when `inertWhenHidden`, `inert`, so its controls leave the tab order. Several bars on one page each keep their own state. The script only
10
+ * binds bars that carry `data-sticky-dismiss-key`, so a hand-built bar using the same `data-sticky-cta` marker is
11
+ * left alone. A `script` slot replaces the built-in script entirely.
12
+ *
13
+ * `track` adds the cta_click attributes to the button. `desktopHiddenFirst` puts `md:hidden` before the other
14
+ * bar classes rather than after them, and `ctaWhitespace="double"` emits two spaces before the button when there is
15
+ * no secondary action: both only reproduce a site's earlier markup byte for byte.
16
+ */
17
+ import Icon from '../primitives/Icon.astro';
18
+
19
+ interface Props {
20
+ label?: string;
21
+ anchorId?: string;
22
+ eyebrow?: string;
23
+ note?: string;
24
+ icon?: string;
25
+ secondary?: { anchorId: string; icon: string; label: string };
26
+ showOnDesktop?: boolean;
27
+ hideWhenVisible?: string;
28
+ dismissKey?: string;
29
+ dismissLabel?: string;
30
+ track?: boolean;
31
+ trackId?: string;
32
+ inertWhenHidden?: boolean;
33
+ truncateNote?: boolean;
34
+ shadowClass?: string;
35
+ desktopHiddenFirst?: boolean;
36
+ ctaWhitespace?: 'single' | 'double';
37
+ }
38
+
39
+ const {
40
+ label = 'Reserve Your Spot',
41
+ anchorId = 'affiliate-tours',
42
+ eyebrow,
43
+ note = 'Found the right tour? Reserve your spot before it fills.',
44
+ icon = 'ticket',
45
+ secondary,
46
+ showOnDesktop = false,
47
+ hideWhenVisible,
48
+ dismissKey = 'affiliate-bar-dismissed',
49
+ dismissLabel = 'Dismiss',
50
+ track = true,
51
+ trackId = 'affiliate-sticky-bar',
52
+ inertWhenHidden = true,
53
+ truncateNote = false,
54
+ shadowClass = 'shadow-[0_-2px_10px_rgba(0,0,0,0.08)]',
55
+ desktopHiddenFirst = false,
56
+ ctaWhitespace = 'single',
57
+ } = Astro.props;
58
+
59
+ const customScript = Astro.slots.has('script');
60
+ const hideOnDesktop = !showOnDesktop;
61
+ ---
62
+
63
+ <div
64
+ data-sticky-cta
65
+ class:list={[
66
+ hideOnDesktop && desktopHiddenFirst && 'md:hidden',
67
+ 'fixed bottom-0 left-0 right-0 z-40 translate-y-full opacity-0 transition-[transform,opacity] duration-300 ease-out motion-reduce:[transition:opacity_0.15s_ease] bg-background/95 backdrop-blur-sm border-t border-border',
68
+ shadowClass,
69
+ { 'md:hidden': hideOnDesktop && !desktopHiddenFirst },
70
+ ]}
71
+ style="padding-bottom: env(safe-area-inset-bottom, 0px);"
72
+ aria-hidden="true"
73
+ inert={inertWhenHidden}
74
+ data-sticky-dismiss-key={customScript ? undefined : dismissKey}
75
+ data-sticky-hide-when-visible={customScript ? undefined : hideWhenVisible}
76
+ >
77
+ <div class="mx-auto max-w-shell px-4 sm:px-6 lg:px-8 py-3">
78
+ <div class="flex items-center gap-3">
79
+ <div class="flex-1 min-w-0">
80
+ {eyebrow && (
81
+ <p class="text-xs font-medium text-foreground truncate">{eyebrow}</p>
82
+ )}
83
+ <p class:list={['text-xs text-muted-foreground', { truncate: truncateNote }]}>{note}</p>
84
+ </div>{(secondary || ctaWhitespace === 'double') && ' '}{secondary && (
85
+ <a
86
+ href={`#${secondary.anchorId}`}
87
+ class="inline-flex items-center justify-center min-w-[44px] min-h-[44px] border border-border bg-card text-foreground rounded-lg hover:bg-muted active:bg-muted/80 transition-colors shrink-0"
88
+ aria-label={secondary.label}
89
+ >
90
+ <Icon name={secondary.icon} class="w-4 h-4" aria-hidden="true" />
91
+ </a>
92
+ )}
93
+ <a
94
+ href={`#${anchorId}`}
95
+ data-track-event={track ? 'cta_click' : undefined}
96
+ data-track-cta-id={track ? trackId : undefined}
97
+ data-track-cta-label={track ? label : undefined}
98
+ data-track-cta-placement={track ? 'sticky' : undefined}
99
+ class="inline-flex items-center gap-1.5 px-4 min-h-[44px] bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 active:bg-primary/80 transition-colors shrink-0"
100
+ >
101
+ <Icon name={icon} class="w-3.5 h-3.5" aria-hidden="true" />
102
+ {label}
103
+ <Icon name="arrow-down" class="w-3.5 h-3.5" aria-hidden="true" />
104
+ </a>
105
+ <button
106
+ data-sticky-close
107
+ type="button"
108
+ aria-label={dismissLabel}
109
+ class="min-w-[44px] min-h-[44px] flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors shrink-0"
110
+ >
111
+ <Icon name="x" class="w-4 h-4" aria-hidden="true" />
112
+ </button>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ {customScript ? <slot name="script" /> : (
118
+ <script is:inline>
119
+ (() => {
120
+ const read = (key) => { try { return sessionStorage.getItem(key); } catch { return null; } };
121
+ const write = (key) => { try { sessionStorage.setItem(key, '1'); } catch {} };
122
+ document.querySelectorAll('[data-sticky-cta][data-sticky-dismiss-key]:not([data-sticky-ready])').forEach((bar) => {
123
+ bar.setAttribute('data-sticky-ready', '');
124
+ const key = bar.getAttribute('data-sticky-dismiss-key');
125
+ if (read(key)) return;
126
+ const useInert = bar.hasAttribute('inert');
127
+ let visible = false;
128
+ let dismissed = false;
129
+ let covered = false;
130
+ const set = (on) => {
131
+ if (visible === on) return;
132
+ visible = on;
133
+ bar.classList.toggle('translate-y-full', !on);
134
+ bar.classList.toggle('opacity-0', !on);
135
+ bar.classList.toggle('translate-y-0', on);
136
+ bar.classList.toggle('opacity-100', on);
137
+ bar.setAttribute('aria-hidden', on ? 'false' : 'true');
138
+ if (useInert) bar.toggleAttribute('inert', !on);
139
+ };
140
+ const update = () => {
141
+ if (dismissed || covered) return set(false);
142
+ const y = window.scrollY;
143
+ const nearBottom = y + window.innerHeight > document.documentElement.scrollHeight - 300;
144
+ set(y > 800 && !nearBottom);
145
+ };
146
+ bar.querySelector('[data-sticky-close]')?.addEventListener('click', () => {
147
+ dismissed = true;
148
+ set(false);
149
+ write(key);
150
+ });
151
+ const selector = bar.getAttribute('data-sticky-hide-when-visible');
152
+ const watch = () => {
153
+ let target = null;
154
+ try { target = document.querySelector(selector); } catch {}
155
+ if (!target || !('IntersectionObserver' in window)) return;
156
+ new IntersectionObserver((entries) => {
157
+ covered = entries[0].isIntersecting;
158
+ update();
159
+ }, { threshold: 0 }).observe(target);
160
+ };
161
+ if (selector && document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch, { once: true });
162
+ else if (selector) watch();
163
+ window.addEventListener('scroll', update, { passive: true });
164
+ update();
165
+ });
166
+ })();
167
+ </script>
168
+ )}
@@ -0,0 +1,140 @@
1
+ ---
2
+ /**
3
+ * TravelpayoutsWidget — a search widget whose loader script the site builds (`src`, with its own tracking params).
4
+ * `kind` picks the wrapper and the defaults; the hosts each kind loads are declared in `resources.ts`, which the
5
+ * site's CSP must cover. `car` runs its loader inside a sandboxed `srcdoc` iframe: its third-party script looks up
6
+ * a fixed element id, so two car widgets on one page would otherwise both render into the first.
7
+ *
8
+ * `variant="section"` is a full-width band with an h2; `compact` is a block with an h3. The disclosure arrives
9
+ * through the `disclosure` slot. `marker` adds `data-affiliate-widget="<kind>"` to the widget wrapper, the marker
10
+ * the CSP coverage gate reads first; it is off by default because it changes the markup.
11
+ * `trackId`/`trackPlacement` add the widget-engagement analytics attributes. `compactRegion` gives the compact
12
+ * block `role="region"`, so its `aria-label` names something (on a bare div assistive tech ignores it); off by
13
+ * default only because it changes the markup.
14
+ */
15
+ type Kind = 'hotel' | 'activity' | 'train' | 'car';
16
+
17
+ interface Props {
18
+ kind: Kind;
19
+ src: string;
20
+ title: string;
21
+ subtitle?: string;
22
+ id?: string;
23
+ class?: string;
24
+ variant?: 'section' | 'compact';
25
+ ariaLabel?: string;
26
+ iframeTitle?: string;
27
+ wrapperClass?: string;
28
+ trackId?: string;
29
+ trackPlacement?: string;
30
+ marker?: boolean;
31
+ compactRegion?: boolean;
32
+ }
33
+
34
+ const WRAPPER: Record<Kind, string> = {
35
+ hotel: 'hotel-search-widget min-h-[180px] sm:min-h-[160px]',
36
+ activity: 'activity-search-widget min-h-[280px]',
37
+ train: 'train-search-widget min-h-[280px]',
38
+ car: 'car-rental-widget',
39
+ };
40
+ const LABEL: Record<Kind, string> = {
41
+ hotel: 'Hotel search',
42
+ activity: 'Activity search',
43
+ train: 'Train and bus search',
44
+ car: 'Car rental search',
45
+ };
46
+
47
+ const {
48
+ kind,
49
+ src,
50
+ title,
51
+ subtitle,
52
+ id,
53
+ class: className,
54
+ variant = 'section',
55
+ ariaLabel = LABEL[kind],
56
+ iframeTitle = LABEL[kind],
57
+ wrapperClass = WRAPPER[kind],
58
+ trackId,
59
+ trackPlacement,
60
+ marker = false,
61
+ compactRegion = false,
62
+ } = Astro.props;
63
+
64
+ if (!WRAPPER[kind]) throw new Error(`TravelpayoutsWidget: unknown kind "${kind}" (hotel, activity, train, car).`);
65
+
66
+ const isolate = kind === 'car';
67
+ const widgetDoc = `<!doctype html><html><head><meta charset="utf-8" /></head><body style="margin:0"><script async src="${src}" charset="utf-8"></script></body></html>`;
68
+ const markerValue = marker ? kind : undefined;
69
+ ---
70
+
71
+ {
72
+ variant === "section" ? (
73
+ <section
74
+ id={id}
75
+ class:list={["mt-16 lg:mt-24 py-12 bg-muted/30", className]}
76
+ aria-label={ariaLabel}
77
+ data-track-event={trackId ? "affiliate_widget_engage" : undefined}
78
+ data-track-cta-id={trackId}
79
+ data-track-cta-placement={trackPlacement}
80
+ >
81
+ <div class="max-w-shell mx-auto px-4 sm:px-6 lg:px-8">
82
+ <div class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 mb-2">
83
+ <h2 class="text-2xl sm:text-3xl font-normal font-display">{title}</h2>
84
+ <slot name="disclosure" />
85
+ </div>
86
+ {subtitle && (
87
+ <p class="text-sm text-muted-foreground mb-8">{subtitle}</p>
88
+ )}
89
+ {isolate ? (
90
+ <div class={wrapperClass} data-affiliate-widget={markerValue}>
91
+ <iframe
92
+ srcdoc={widgetDoc}
93
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
94
+ loading="lazy"
95
+ title={iframeTitle}
96
+ class="w-full h-[478px] sm:h-[430px] border-0 block"
97
+ />
98
+ </div>
99
+ ) : (
100
+ <div class={wrapperClass} data-affiliate-widget={markerValue}>
101
+ <script async src={src} charset="utf-8" is:inline />
102
+ </div>
103
+ )}
104
+ </div>
105
+ </section>
106
+ ) : (
107
+ <div
108
+ id={id}
109
+ class:list={[className]}
110
+ role={compactRegion ? "region" : undefined}
111
+ aria-label={ariaLabel}
112
+ data-track-event={trackId ? "affiliate_widget_engage" : undefined}
113
+ data-track-cta-id={trackId}
114
+ data-track-cta-placement={trackPlacement}
115
+ >
116
+ <div class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 mb-2">
117
+ <h3 class="text-lg font-semibold font-display">{title}</h3>
118
+ <slot name="disclosure" />
119
+ </div>
120
+ {subtitle && (
121
+ <p class="text-sm text-muted-foreground mb-6">{subtitle}</p>
122
+ )}
123
+ {isolate ? (
124
+ <div class={wrapperClass} data-affiliate-widget={markerValue}>
125
+ <iframe
126
+ srcdoc={widgetDoc}
127
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
128
+ loading="lazy"
129
+ title={iframeTitle}
130
+ class="w-full h-[478px] sm:h-[430px] border-0 block"
131
+ />
132
+ </div>
133
+ ) : (
134
+ <div class={wrapperClass} data-affiliate-widget={markerValue}>
135
+ <script async src={src} charset="utf-8" is:inline />
136
+ </div>
137
+ )}
138
+ </div>
139
+ )
140
+ }
@@ -1,2 +1,17 @@
1
- // Public surface of @sarimarcus/content-sites-ui/affiliate. Components land here from SLA-2742 on.
2
- export {};
1
+ // Public surface of @sarimarcus/content-sites-ui/affiliate.
2
+ export { default as AffiliateDisclosure } from './AffiliateDisclosure.astro';
3
+ export { default as AffiliateLink } from './AffiliateLink.astro';
4
+ export { default as InlineAffiliateCTA } from './InlineAffiliateCTA.astro';
5
+ export { default as StickyCTA } from './StickyCTA.astro';
6
+ export { default as TravelpayoutsWidget } from './TravelpayoutsWidget.astro';
7
+ export { default as AmazonProductCard } from './AmazonProductCard.astro';
8
+ export { default as AmazonProductSection } from './AmazonProductSection.astro';
9
+ export { default as AffiliateCard } from './AffiliateCard.astro';
10
+ export { default as AffiliateCardSection } from './AffiliateCardSection.astro';
11
+ export { default as AffiliateCarousel } from './AffiliateCarousel.astro';
12
+ export { default as AffiliateComparisonTable } from './AffiliateComparisonTable.astro';
13
+ export { default as HotelAffiliateCTA } from './HotelAffiliateCTA.astro';
14
+ export { externalResources } from './resources';
15
+ export type { WidgetKind, CspDirective, WidgetResources } from './resources';
16
+ export type { AffiliateCardLabels, AffiliateCardItem, HotelCtaLabels, ComparisonRow, ComparisonTableLabels } from './types';
17
+ export { selectActivities, type SelectableActivity, type SelectOptions } from './select';
@@ -0,0 +1,30 @@
1
+ // The third-party hosts each TravelpayoutsWidget kind loads, per CSP directive. A site rendering a kind must allow
2
+ // these in its Content-Security-Policy (scripts/validate-csp-coverage.mjs checks the built site against this list).
3
+ // Read from each loader's own code: the tpwdg.com/c91 loader script, then what it injects (scripts, frames,
4
+ // stylesheets). The loaders also call analytics endpoints (connect-src: tpo.gg, brand.travelpayouts.com, and
5
+ // suggest.apistp.com for the hotel autocomplete); those are not declared here.
6
+ // Every other component in this folder loads nothing: its outbound links are caller data, not resources.
7
+ // Plain data and erasable types only: the gate imports this file under Node.
8
+
9
+ export type WidgetKind = 'hotel' | 'activity' | 'train' | 'car';
10
+ export type CspDirective = 'script-src' | 'frame-src' | 'style-src';
11
+ export type WidgetResources = Partial<Record<CspDirective, readonly string[]>>;
12
+
13
+ export const externalResources: Record<WidgetKind, WidgetResources> = {
14
+ hotel: {
15
+ 'script-src': ['https://tpwdg.com'],
16
+ },
17
+ activity: {
18
+ 'script-src': ['https://tpwdg.com', 'https://widget.getyourguide.com'],
19
+ 'frame-src': ['https://widget.getyourguide.com'],
20
+ },
21
+ train: {
22
+ 'script-src': ['https://c91.travelpayouts.com', 'https://www.omio.com'],
23
+ 'style-src': ['https://www.omio.com'],
24
+ },
25
+ car: {
26
+ 'script-src': ['https://tpwdg.com', 'https://www.discovercars.com'],
27
+ 'frame-src': ['https://widget.discovercars.com'],
28
+ },
29
+ };
30
+
@@ -0,0 +1,40 @@
1
+ /** An activity a card band can order: a provider id ("gyg-N" or N) and its review count. */
2
+ export interface SelectableActivity {
3
+ id: string | number;
4
+ reviewCount?: number;
5
+ }
6
+
7
+ export interface SelectOptions {
8
+ /** Ids shown first, in this order; "gyg-10" and 10 match the same activity. */
9
+ pinnedIds?: ReadonlyArray<string | number>;
10
+ /** Keep the rest in the given order instead of most-reviewed first. */
11
+ keepOrder?: boolean;
12
+ maxCards?: number;
13
+ /** Called for a pinned id with no matching activity. */
14
+ onMissingPin?: (id: string | number) => void;
15
+ }
16
+
17
+ const idKey = (value: string | number | undefined) => (value === undefined ? '' : String(value).replace(/^gyg-/, ''));
18
+
19
+ /** The activities a card band shows: pinned ids first, then the rest by review count (or as given), capped. */
20
+ export function selectActivities<T extends SelectableActivity>(activities: ReadonlyArray<T> | undefined, options: SelectOptions = {}): T[] {
21
+ const { pinnedIds = [], keepOrder = false, maxCards = 4, onMissingPin } = options;
22
+ const pool = [...(activities ?? [])];
23
+ const pinned: T[] = [];
24
+ const pinnedKeys = new Set<string>();
25
+ for (const wanted of pinnedIds) {
26
+ const key = idKey(wanted);
27
+ if (!key || pinnedKeys.has(key)) continue;
28
+ const match = pool.find((activity) => idKey(activity.id) === key);
29
+ if (match) {
30
+ pinned.push(match);
31
+ pinnedKeys.add(key);
32
+ } else {
33
+ onMissingPin?.(wanted);
34
+ }
35
+ }
36
+ const unpinned = pool.filter((activity) => !pinnedKeys.has(idKey(activity.id)));
37
+ // A missing count compares as NaN, exactly as the sites' sorts did; treating it as 0 would reorder live bands.
38
+ const rest = keepOrder ? unpinned : unpinned.sort((a, b) => Number(b.reviewCount) - Number(a.reviewCount));
39
+ return [...pinned, ...rest].slice(0, maxCards);
40
+ }
@@ -0,0 +1,10 @@
1
+ // Renders a copy prop exactly as the same words written as template text would: compressHTML keeps the line break
2
+ // on each side of a text node that sits on its own line, and template text is not entity-escaped (an apostrophe
3
+ // stays an apostrophe). Only markup-significant characters are escaped.
4
+ export function templateLine(text: string): string {
5
+ const safe = text
6
+ .replace(/&(?=[a-zA-Z#][a-zA-Z0-9]*;)/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;');
9
+ return `\n${safe}\n`;
10
+ }
@@ -0,0 +1,71 @@
1
+ import type { ImageMetadata } from 'astro';
2
+
3
+ /** AffiliateCard copy. Every key but `verified` and `booksUp` has an English default. */
4
+ export interface AffiliateCardLabels {
5
+ bestSeller?: string;
6
+ topRated?: string;
7
+ skipTheLine?: string;
8
+ cta?: string;
9
+ reviewCount?: (formattedCount: string) => string;
10
+ verified?: (formattedCount: string) => string;
11
+ booksUp?: string;
12
+ }
13
+
14
+ /** One card in an AffiliateCardSection or AffiliateCarousel: the card's own props, resolved by the site. */
15
+ export interface AffiliateCardItem {
16
+ id?: string | number;
17
+ href: string;
18
+ provider: string;
19
+ productId?: string;
20
+ image: ImageMetadata | string;
21
+ title: string;
22
+ description: string;
23
+ price: number;
24
+ currency: string;
25
+ rating?: number;
26
+ reviewCount?: number;
27
+ duration: string;
28
+ locale?: string;
29
+ }
30
+
31
+ /** HotelAffiliateCTA copy; every key has an English default built from `entityNoun`. */
32
+ export interface HotelCtaLabels {
33
+ headline?: (name: string) => string;
34
+ bookCta?: string;
35
+ searchCta?: string;
36
+ bookSubhead?: string;
37
+ searchSubhead?: string;
38
+ from?: string;
39
+ perNight?: string;
40
+ reviews?: (formattedCount: string) => string;
41
+ ratingOn?: (source: string) => string;
42
+ freeToSearch?: string;
43
+ officialSite?: string;
44
+ }
45
+
46
+ /** One AffiliateComparisonTable row. `external: false` = an internal link; `sponsored: false` = an external link with no paid relationship. */
47
+ export interface ComparisonRow {
48
+ name: string;
49
+ subtitle?: string;
50
+ price: string;
51
+ keyFeature: string;
52
+ rating?: number;
53
+ ratingCount?: number;
54
+ badge?: string;
55
+ href?: string;
56
+ external?: boolean;
57
+ sponsored?: boolean;
58
+ cta?: string;
59
+ trackId?: string;
60
+ }
61
+
62
+ /** AffiliateComparisonTable copy, all with English defaults. */
63
+ export interface ComparisonTableLabels {
64
+ caption?: string;
65
+ ctaColumn?: string;
66
+ externalCta?: string;
67
+ internalCta?: string;
68
+ externalAria?: (cta: string, name: string) => string;
69
+ internalAria?: (cta: string, name: string) => string;
70
+ rated?: (rating: number, count?: number) => string;
71
+ }