@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,212 @@
1
+ ---
2
+ /**
3
+ * AffiliateComparisonTable — side-by-side comparison of a few monetised options, each row ending in a CTA. Rows come
4
+ * from the caller; an external row's link is sponsored unless `sponsored: false`. A real table from md up, stacked
5
+ * cards below (exactly one is in the DOM per viewport); with no row carrying a link the CTA column is dropped. The
6
+ * disclosure arrives through the `disclosure` slot, below the table.
7
+ */
8
+ import Icon from '../primitives/Icon.astro';
9
+ import Badge from '../primitives/Badge.astro';
10
+ import type { ComparisonRow, ComparisonTableLabels } from './types';
11
+
12
+ interface Props {
13
+ rows: ComparisonRow[];
14
+ title?: string;
15
+ titleTag?: 'p' | 'h2' | 'h3';
16
+ nameLabel?: string;
17
+ priceLabel?: string;
18
+ featureLabel?: string;
19
+ placement?: string;
20
+ labels?: ComparisonTableLabels;
21
+ class?: string;
22
+ }
23
+
24
+ const {
25
+ rows = [],
26
+ title,
27
+ titleTag = 'p',
28
+ nameLabel = 'Option',
29
+ priceLabel = 'Price',
30
+ featureLabel = 'Highlight',
31
+ placement = 'comparison-table',
32
+ labels = {},
33
+ class: className = '',
34
+ } = Astro.props;
35
+
36
+ const {
37
+ caption = 'Comparison of options',
38
+ ctaColumn = 'Book or view',
39
+ externalCta = 'Check availability',
40
+ internalCta = 'View details',
41
+ externalAria = (cta: string, name: string) => `${cta}: ${name} (opens in a new tab)`,
42
+ internalAria = (cta: string, name: string) => `${cta}: ${name}`,
43
+ rated = (rating: number, count?: number) => `Rated ${rating} out of 5${count ? ` from ${count} reviews` : ''}`,
44
+ } = labels;
45
+
46
+ const captionText = title ?? caption;
47
+ const TitleTag = titleTag;
48
+ const hasAnyCta = rows.some((row) => row.href);
49
+
50
+ const ctaBase =
51
+ 'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2';
52
+ const ctaAffiliate = `${ctaBase} bg-accent-strong text-primary-foreground hover:bg-accent`;
53
+ const ctaInternal = `${ctaBase} border border-border bg-muted/40 text-foreground hover:bg-muted/70`;
54
+
55
+ const defaultCta = (external: boolean) => (external ? externalCta : internalCta);
56
+ ---
57
+
58
+ {rows.length > 0 && (
59
+ <div class:list={['not-prose my-8', className]}>
60
+ {title && (
61
+ <TitleTag class="mb-4 font-display text-lg font-semibold text-foreground">{title}</TitleTag>
62
+ )}
63
+
64
+ {/* md and up: a real table */}
65
+ <div class="hidden overflow-x-auto md:block">
66
+ <table class="w-full border-collapse overflow-hidden rounded-lg border border-border bg-card text-sm">
67
+ <caption class="sr-only">{captionText}</caption>
68
+ <thead>
69
+ <tr class="bg-muted/60">
70
+ <th scope="col" class="border-b border-border px-4 py-3 text-left font-semibold text-foreground">{nameLabel}</th>
71
+ <th scope="col" class="border-b border-border px-4 py-3 text-left font-semibold text-foreground">{priceLabel}</th>
72
+ <th scope="col" class="border-b border-border px-4 py-3 text-left font-semibold text-foreground">{featureLabel}</th>
73
+ {hasAnyCta && (
74
+ <th scope="col" class="border-b border-border px-4 py-3 text-right font-semibold text-foreground">
75
+ <span class="sr-only">{ctaColumn}</span>
76
+ </th>
77
+ )}
78
+ </tr>
79
+ </thead>
80
+ <tbody>
81
+ {rows.map((row, i) => {
82
+ const external = row.external !== false;
83
+ const rel = row.sponsored === false ? 'noopener noreferrer' : 'sponsored noopener noreferrer';
84
+ const cta = row.cta ?? defaultCta(external);
85
+ return (
86
+ <tr class={i % 2 === 1 ? 'bg-muted/20' : ''}>
87
+ <th scope="row" class="border-b border-border px-4 py-3 text-left align-top font-normal last:border-b-0">
88
+ <span class="block font-display font-semibold text-foreground">{row.name}</span>
89
+ {row.subtitle && (
90
+ <span class="mt-0.5 block text-xs text-muted-foreground">{row.subtitle}</span>
91
+ )}
92
+ {row.badge && (
93
+ <Badge tone="accent" size="sm" lines={2} class="mt-1.5 max-w-[26ch]">{row.badge}</Badge>
94
+ )}
95
+ {typeof row.rating === 'number' && (
96
+ <span class="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground" role="img" aria-label={rated(row.rating, row.ratingCount)}>
97
+ <Icon name="star" filled class="h-3.5 w-3.5 text-accent" />
98
+ <span class="font-medium text-foreground">{row.rating.toFixed(1)}</span>
99
+ {row.ratingCount && <span>({row.ratingCount})</span>}
100
+ </span>
101
+ )}
102
+ </th>
103
+ <td class="border-b border-border px-4 py-3 align-top text-muted-foreground last:border-b-0">
104
+ {/* a price can carry a long sourcing caveat */}
105
+ <span class="line-clamp-3">{row.price}</span>
106
+ </td>
107
+ <td class="border-b border-border px-4 py-3 align-top leading-relaxed text-muted-foreground last:border-b-0">
108
+ <span class="line-clamp-2">{row.keyFeature}</span>
109
+ </td>
110
+ {hasAnyCta && (
111
+ <td class="border-b border-border px-4 py-3 text-right align-top last:border-b-0">
112
+ {row.href ? (
113
+ external ? (
114
+ <a
115
+ href={row.href}
116
+ target="_blank"
117
+ rel={rel}
118
+ data-track-event="affiliate_click"
119
+ data-track-cta-id={row.trackId}
120
+ data-track-cta-placement={placement}
121
+ class={ctaAffiliate}
122
+ aria-label={externalAria(cta, row.name)}
123
+ >
124
+ {cta}
125
+ <Icon name="arrow-up-right" class="h-4 w-4" />
126
+ </a>
127
+ ) : (
128
+ <a href={row.href} class={ctaInternal} aria-label={internalAria(cta, row.name)}>
129
+ {cta}
130
+ <Icon name="arrow-right" class="h-4 w-4" />
131
+ </a>
132
+ )
133
+ ) : (
134
+ <span class="text-muted-foreground" aria-hidden="true">—</span>
135
+ )}
136
+ </td>
137
+ )}
138
+ </tr>
139
+ );
140
+ })}
141
+ </tbody>
142
+ </table>
143
+ </div>
144
+
145
+ {/* below md: stacked cards */}
146
+ <ul class="space-y-4 md:hidden">
147
+ {rows.map((row) => {
148
+ const external = row.external !== false;
149
+ const rel = row.sponsored === false ? 'noopener noreferrer' : 'sponsored noopener noreferrer';
150
+ const cta = row.cta ?? defaultCta(external);
151
+ return (
152
+ <li class="rounded-xl border border-border bg-card p-4">
153
+ {/* the badge gets its own line so a long label never squeezes the name */}
154
+ <div class="min-w-0">
155
+ <p class="font-display font-semibold text-foreground">{row.name}</p>
156
+ {row.subtitle && <p class="mt-0.5 text-sm text-muted-foreground">{row.subtitle}</p>}
157
+ </div>
158
+ {row.badge && (
159
+ <Badge tone="accent" size="sm" lines={2} class="mt-1.5">{row.badge}</Badge>
160
+ )}
161
+
162
+ {typeof row.rating === 'number' && (
163
+ <p class="mt-2 flex items-center gap-1 text-xs text-muted-foreground" role="img" aria-label={rated(row.rating, row.ratingCount)}>
164
+ <Icon name="star" filled class="h-3.5 w-3.5 text-accent" />
165
+ <span class="font-medium text-foreground">{row.rating.toFixed(1)}</span>
166
+ {row.ratingCount && <span>({row.ratingCount})</span>}
167
+ </p>
168
+ )}
169
+
170
+ <dl class="mt-3 space-y-2 text-sm">
171
+ <div class="flex justify-between gap-4">
172
+ <dt class="shrink-0 text-muted-foreground">{priceLabel}</dt>
173
+ <dd class="min-w-0 text-right font-medium text-foreground line-clamp-3">{row.price}</dd>
174
+ </div>
175
+ <div class="flex justify-between gap-4">
176
+ <dt class="shrink-0 text-muted-foreground">{featureLabel}</dt>
177
+ <dd class="min-w-0 text-right text-foreground line-clamp-2">{row.keyFeature}</dd>
178
+ </div>
179
+ </dl>
180
+
181
+ {row.href && (
182
+ <div class="mt-4">
183
+ {external ? (
184
+ <a
185
+ href={row.href}
186
+ target="_blank"
187
+ rel={rel}
188
+ data-track-event="affiliate_click"
189
+ data-track-cta-id={row.trackId}
190
+ data-track-cta-placement={placement}
191
+ class={`${ctaAffiliate} w-full`}
192
+ aria-label={externalAria(cta, row.name)}
193
+ >
194
+ {cta}
195
+ <Icon name="arrow-up-right" class="h-4 w-4" />
196
+ </a>
197
+ ) : (
198
+ <a href={row.href} class={`${ctaInternal} w-full`} aria-label={internalAria(cta, row.name)}>
199
+ {cta}
200
+ <Icon name="arrow-right" class="h-4 w-4" />
201
+ </a>
202
+ )}
203
+ </div>
204
+ )}
205
+ </li>
206
+ );
207
+ })}
208
+ </ul>
209
+
210
+ <slot name="disclosure" />
211
+ </div>
212
+ )}
@@ -0,0 +1,31 @@
1
+ ---
2
+ /**
3
+ * AffiliateDisclosure — the one-line affiliate disclosure. The text is the site's: `texts` maps each provider key
4
+ * to its sentence and `provider` is required, so a surface can never fall back to another provider's wording. An
5
+ * unknown or empty key throws at build time.
6
+ */
7
+ interface Props {
8
+ provider: string;
9
+ texts: Record<string, string>;
10
+ baseClass?: string;
11
+ class?: string;
12
+ }
13
+
14
+ const {
15
+ provider,
16
+ texts,
17
+ baseClass = 'text-xs leading-relaxed text-muted-foreground mt-4',
18
+ class: className,
19
+ } = Astro.props;
20
+
21
+ const text = provider ? texts?.[provider] : undefined;
22
+ if (!text) {
23
+ throw new Error(
24
+ `AffiliateDisclosure: no disclosure text for provider "${provider}" (known: ${Object.keys(texts ?? {}).join(', ') || 'none'}).`,
25
+ );
26
+ }
27
+ ---
28
+
29
+ <p class:list={[baseClass, className]}>
30
+ {text}
31
+ </p>
@@ -0,0 +1,46 @@
1
+ ---
2
+ /**
3
+ * AffiliateLink — an inline affiliate text link with the external-link glyph. `href` arrives finished (tagged by the
4
+ * site); `rel` defaults to the sponsored relation every affiliate link must carry, so a caller passes another value
5
+ * only for a classified non-affiliate link. `productId` adds the product dimension when the site has one.
6
+ */
7
+ interface Props {
8
+ href: string;
9
+ provider: string;
10
+ placement: string;
11
+ productId?: string;
12
+ rel?: string;
13
+ class?: string;
14
+ }
15
+
16
+ const { href, provider, placement, productId, rel = 'sponsored noopener', class: className } = Astro.props;
17
+ ---
18
+
19
+ <a
20
+ href={href}
21
+ target="_blank"
22
+ rel={rel}
23
+ data-track-event="affiliate_click"
24
+ data-track-affiliate-provider={provider}
25
+ data-track-affiliate-product-id={productId}
26
+ data-track-affiliate-placement={placement}
27
+ class:list={[
28
+ "inline-flex items-center gap-0.5 underline decoration-dotted underline-offset-2 hover:decoration-solid transition-all",
29
+ className,
30
+ ]}
31
+ >
32
+ <slot />
33
+ <svg
34
+ class="w-3 h-3 inline-block"
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="2"
39
+ aria-hidden="true"
40
+ >
41
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
42
+ ></path>
43
+ <polyline points="15 3 21 3 21 9"></polyline>
44
+ <line x1="10" y1="14" x2="21" y2="3"></line>
45
+ </svg>
46
+ </a>
@@ -0,0 +1,101 @@
1
+ ---
2
+ /**
3
+ * AmazonProductCard — the whole card is one sponsored link; the CTA is a styled span (anchors must not nest).
4
+ * `href` arrives finished (the site builds the store URL and its tag). Catalogue rules: no price and no review
5
+ * count, and no hotlinked product image — without a local `image` the card shows a category icon.
6
+ */
7
+ import Badge from '../primitives/Badge.astro';
8
+ import Icon from '../primitives/Icon.astro';
9
+ import { templateLine } from './templateText';
10
+
11
+ interface Props {
12
+ href: string;
13
+ asin: string;
14
+ title: string;
15
+ subtitle?: string;
16
+ reason: string;
17
+ image?: string;
18
+ imageAlt?: string;
19
+ category?: 'book' | 'gear' | 'photography' | 'gourmet';
20
+ badge?: string;
21
+ placement?: string;
22
+ ctaLabel?: string;
23
+ class?: string;
24
+ }
25
+
26
+ const {
27
+ href,
28
+ asin,
29
+ title,
30
+ subtitle,
31
+ reason,
32
+ image,
33
+ imageAlt,
34
+ category = 'book',
35
+ badge,
36
+ placement = 'card',
37
+ ctaLabel = 'View on Amazon',
38
+ class: className,
39
+ } = Astro.props;
40
+
41
+ const categoryIcon: Record<string, string> = {
42
+ book: 'book-open',
43
+ gear: 'luggage',
44
+ photography: 'camera',
45
+ gourmet: 'utensils',
46
+ };
47
+ const placeholderIcon = categoryIcon[category] ?? 'book-open';
48
+ ---
49
+
50
+ <a
51
+ href={href}
52
+ target="_blank"
53
+ rel="sponsored noopener"
54
+ data-track-event="affiliate_click"
55
+ data-track-affiliate-provider="amazon"
56
+ data-track-affiliate-product-id={asin}
57
+ data-track-affiliate-placement={placement}
58
+ class:list={[
59
+ "group relative bg-card rounded-xl overflow-hidden card-lift border border-border flex flex-col h-full no-underline text-inherit focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
60
+ className,
61
+ ]}
62
+ >
63
+ {badge && (
64
+ <Badge tone="custom" toneClass="bg-accent text-accent-foreground" size="sm" lines={0} class="absolute top-3 left-3 z-10 pointer-events-none !px-2.5 !py-1 !font-semibold">
65
+ {badge}
66
+ </Badge>
67
+ )}
68
+
69
+ <div class="relative h-44 sm:h-48 overflow-hidden shrink-0 bg-muted flex items-center justify-center">
70
+ {image ? (
71
+ <img
72
+ src={image}
73
+ alt={imageAlt ?? title}
74
+ width="320"
75
+ height="192"
76
+ loading="lazy"
77
+ decoding="async"
78
+ class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105"
79
+ />
80
+ ) : (
81
+ <Icon name={placeholderIcon} class="w-12 h-12 text-muted-foreground/40" />
82
+ )}
83
+ </div>
84
+
85
+ <div class="p-4 sm:p-5 flex flex-col flex-1">
86
+ <h3 class="text-lg font-semibold text-card-foreground leading-tight font-display">
87
+ {title}
88
+ </h3>
89
+ {subtitle && <p class="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
90
+ <p class="text-base text-muted-foreground line-clamp-2 mt-2 mb-4">
91
+ {reason}
92
+ </p>
93
+
94
+ <div class="mt-auto">
95
+ <span
96
+ class="flex items-center justify-center gap-2 w-full py-2.5 px-4 bg-primary text-primary-foreground font-medium rounded-lg group-hover:bg-primary/90 group-active:bg-primary/80 transition-colors"
97
+ ><Fragment set:html={templateLine(ctaLabel)} /><Icon name="arrow-up-right" class="w-4 h-4" />
98
+ </span>
99
+ </div>
100
+ </div>
101
+ </a>
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * AmazonProductSection — a grid of AmazonProductCards, capped at 4. Renders nothing unless `enabled` and there are
4
+ * products. Each product carries its finished `href`; the disclosure arrives through the `disclosure` slot.
5
+ */
6
+ import AmazonProductCard from './AmazonProductCard.astro';
7
+
8
+ interface Product {
9
+ href: string;
10
+ asin: string;
11
+ title: string;
12
+ subtitle?: string;
13
+ reason: string;
14
+ image?: string;
15
+ imageAlt?: string;
16
+ category?: 'book' | 'gear' | 'photography' | 'gourmet';
17
+ badge?: string;
18
+ }
19
+
20
+ interface Props {
21
+ title?: string;
22
+ subtitle?: string;
23
+ products: Product[];
24
+ maxCards?: number;
25
+ enabled?: boolean;
26
+ ctaLabel?: string;
27
+ id?: string;
28
+ class?: string;
29
+ }
30
+
31
+ const {
32
+ title = "Recommended reading",
33
+ subtitle,
34
+ products,
35
+ maxCards,
36
+ enabled = true,
37
+ ctaLabel,
38
+ id,
39
+ class: className,
40
+ } = Astro.props;
41
+
42
+ const cappedMax = Math.min(maxCards ?? 4, 4);
43
+ const visibleProducts = enabled ? [...(products ?? [])].slice(0, cappedMax) : [];
44
+
45
+ const gridClasses: Record<number, string> = {
46
+ 1: "grid grid-cols-1 max-w-md mx-auto",
47
+ 2: "grid gap-6 grid-cols-1 sm:grid-cols-2",
48
+ 3: "grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
49
+ 4: "grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
50
+ };
51
+
52
+ const gridClass = gridClasses[visibleProducts.length] || gridClasses[4];
53
+ ---
54
+
55
+ {
56
+ visibleProducts.length > 0 && (
57
+ <section id={id} class:list={["py-12 bg-muted/30 border-t border-border", className]}>
58
+ <div class="max-w-shell mx-auto px-4 sm:px-6 lg:px-8">
59
+ <div class:list={["flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1", subtitle ? "mb-2" : "mb-8"]}>
60
+ <h2 class="text-3xl sm:text-4xl font-normal font-display">{title}</h2>
61
+ <slot name="disclosure" />
62
+ </div>
63
+ {subtitle && <p class="text-sm text-muted-foreground mb-8">{subtitle}</p>}
64
+ <div class={gridClass}>
65
+ {visibleProducts.map((product) => (
66
+ <AmazonProductCard {...product} ctaLabel={ctaLabel} />
67
+ ))}
68
+ </div>
69
+ </div>
70
+ </section>
71
+ )
72
+ }
@@ -0,0 +1,175 @@
1
+ ---
2
+ /**
3
+ * HotelAffiliateCTA — the per-property booking block (`section`) or a lone booking button (`button`). The site
4
+ * resolves `href`; `isSearchFallback` says it is a search for this property rather than its booking page, which
5
+ * softens the wording and adds the free-to-search line. The un-monetised official site is a quiet text link.
6
+ *
7
+ * `priceCompactMax`: a price longer than this renders as a plain line instead of the "from X / night" numerals.
8
+ * `priceNoteSlot`: the block carries a place for `priceNote` (a sourcing caveat under the subhead).
9
+ * `officialSiteTrackId` adds outbound-click tracking to the official-site link. `placement` overrides both default
10
+ * analytics placements, for a page that renders the block more than once. Copy defaults are English, built from
11
+ * `entityNoun`; the disclosure arrives through the `disclosure` slot.
12
+ */
13
+ import { isBadgeLength } from '../primitives/badgeText';
14
+ import type { HotelCtaLabels } from './types';
15
+ import { templateLine } from './templateText';
16
+
17
+
18
+ interface Props {
19
+ name: string;
20
+ href: string;
21
+ isSearchFallback: boolean;
22
+ variant?: 'section' | 'button';
23
+ entityNoun?: string;
24
+ trackId?: string;
25
+ placement?: string;
26
+ priceText?: string | null;
27
+ priceCompactMax?: number;
28
+ priceNoteSlot?: boolean;
29
+ priceNote?: string | null;
30
+ rating?: number;
31
+ ratingCount?: number;
32
+ ratingSource?: string;
33
+ website?: string;
34
+ officialSiteTrackId?: string;
35
+ labels?: HotelCtaLabels;
36
+ panelClass?: string;
37
+ class?: string;
38
+ }
39
+
40
+ const {
41
+ name,
42
+ href,
43
+ isSearchFallback,
44
+ variant = 'section',
45
+ entityNoun = 'hotel',
46
+ trackId = 'hotel-affiliate-cta',
47
+ placement,
48
+ priceText = null,
49
+ priceCompactMax,
50
+ priceNoteSlot = false,
51
+ priceNote = null,
52
+ rating,
53
+ ratingCount,
54
+ ratingSource,
55
+ website,
56
+ officialSiteTrackId,
57
+ labels = {},
58
+ panelClass = 'border-secondary/20 bg-secondary/5',
59
+ class: className,
60
+ } = Astro.props;
61
+
62
+ const {
63
+ headline = (n: string) => `Find the best rate for ${n}`,
64
+ bookCta = `Book this ${entityNoun}`,
65
+ searchCta = 'Check prices & availability',
66
+ bookSubhead = 'Check current availability and rates.',
67
+ searchSubhead = `Search current prices and availability for this ${entityNoun}.`,
68
+ from = 'from ',
69
+ perNight = ' / night',
70
+ reviews = (n: string) => `(${n} reviews)`,
71
+ ratingOn = (s: string) => `on ${s}`,
72
+ freeToSearch = 'Free to search · No sign-up needed',
73
+ officialSite = `Visit the ${entityNoun}'s official site`,
74
+ } = labels;
75
+
76
+ const ctaLabel = isSearchFallback ? searchCta : bookCta;
77
+ const subhead = isSearchFallback ? searchSubhead : bookSubhead;
78
+ const priceIsCompact = priceCompactMax === undefined || isBadgeLength(priceText, priceCompactMax);
79
+ const note = priceNoteSlot ? priceNote : null;
80
+
81
+ const ctaClasses =
82
+ 'group flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary';
83
+ ---
84
+
85
+ {variant === 'button' ? (
86
+ <a
87
+ href={href}
88
+ target="_blank"
89
+ rel="sponsored noopener noreferrer"
90
+ data-track-event="affiliate_click"
91
+ data-track-cta-id={trackId}
92
+ data-track-cta-placement={placement ?? 'above-fold'}
93
+ class:list={[ctaClasses, 'sm:w-auto', className]}
94
+ >
95
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
96
+ <path d="M2 4v16M2 8h18a2 2 0 0 1 2 2v10M2 17h20M6 8V6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
97
+ </svg>
98
+ <span>{ctaLabel}</span>
99
+ <svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
100
+ <path d="M5 12h14M13 6l6 6-6 6" />
101
+ </svg>
102
+ </a>
103
+ ) : (
104
+ <div class:list={["rounded-xl border", panelClass, "p-6", className]}>
105
+ <p class="font-display text-lg font-semibold text-foreground">{headline(name)}</p>
106
+ <p class="mt-1 text-sm text-muted-foreground">{subhead}</p>{priceNoteSlot && ' '}{note && (
107
+ <p class="mt-3 text-sm leading-snug text-muted-foreground">{note}</p>
108
+ )}
109
+
110
+ {(priceText || rating) && (
111
+ <div class="mt-4 flex flex-wrap items-baseline gap-x-5 gap-y-2">
112
+ {priceText && (
113
+ priceIsCompact ? (
114
+ <p class="text-foreground">
115
+ <span class="text-sm text-muted-foreground">{from}</span>
116
+ <span class="text-2xl font-semibold">{priceText}</span>
117
+ <span class="text-sm text-muted-foreground">{perNight}</span>
118
+ </p>
119
+ ) : (
120
+ <p class="text-base font-medium text-foreground leading-snug">{priceText}</p>
121
+ )
122
+ )}
123
+ {rating && (
124
+ <span class="inline-flex items-center gap-1.5 text-sm">
125
+ <svg class="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
126
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
127
+ </svg>
128
+ <span class="font-semibold text-foreground">{rating.toFixed(1)}</span>
129
+ {ratingCount ? (
130
+ <span class="text-muted-foreground">{reviews(ratingCount.toLocaleString())}</span>
131
+ ) : ratingSource ? (
132
+ <span class="text-muted-foreground">{ratingOn(ratingSource)}</span>
133
+ ) : null}
134
+ </span>
135
+ )}
136
+ </div>
137
+ )}
138
+
139
+ <a
140
+ href={href}
141
+ target="_blank"
142
+ rel="sponsored noopener noreferrer"
143
+ data-track-event="affiliate_click"
144
+ data-track-cta-id={trackId}
145
+ data-track-cta-placement={placement ?? 'book-section'}
146
+ class:list={[ctaClasses, 'mt-4']}
147
+ >
148
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
149
+ <path d="M2 4v16M2 8h18a2 2 0 0 1 2 2v10M2 17h20M6 8V6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
150
+ </svg>
151
+ <span>{ctaLabel}</span>
152
+ <svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
153
+ <path d="M5 12h14M13 6l6 6-6 6" />
154
+ </svg>
155
+ </a>
156
+
157
+ {isSearchFallback && (
158
+ <p class="mt-2 text-center text-xs text-muted-foreground"><Fragment set:html={templateLine(freeToSearch)} /></p>
159
+ )}
160
+
161
+ <div class="mt-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
162
+ {website && (
163
+ <a
164
+ href={website}
165
+ target="_blank"
166
+ rel="noopener noreferrer"
167
+ data-track-event={officialSiteTrackId ? 'outbound_click' : undefined}
168
+ data-track-link-id={officialSiteTrackId}
169
+ class="text-sm text-muted-foreground underline-offset-2 hover:text-primary hover:underline"
170
+ ><Fragment set:html={templateLine(officialSite)} /></a>
171
+ )}
172
+ <slot name="disclosure" />
173
+ </div>
174
+ </div>
175
+ )}