@sarimarcus/content-sites-ui 0.13.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.
- package/CHANGELOG.md +8 -0
- package/package.json +2 -2
- package/src/affiliate/AffiliateCard.astro +278 -0
- package/src/affiliate/AffiliateCardSection.astro +86 -0
- package/src/affiliate/AffiliateCarousel.astro +95 -0
- package/src/affiliate/AffiliateComparisonTable.astro +212 -0
- package/src/affiliate/AffiliateDisclosure.astro +31 -0
- package/src/affiliate/AffiliateLink.astro +46 -0
- package/src/affiliate/AmazonProductCard.astro +101 -0
- package/src/affiliate/AmazonProductSection.astro +72 -0
- package/src/affiliate/HotelAffiliateCTA.astro +175 -0
- package/src/affiliate/InlineAffiliateCTA.astro +67 -0
- package/src/affiliate/StickyCTA.astro +168 -0
- package/src/affiliate/TravelpayoutsWidget.astro +140 -0
- package/src/affiliate/index.ts +17 -2
- package/src/affiliate/resources.ts +30 -0
- package/src/affiliate/select.ts +40 -0
- package/src/affiliate/templateText.ts +10 -0
- package/src/affiliate/types.ts +71 -0
- package/src/seo/ArticleMetadata.astro +144 -0
- package/src/seo/AuthoritativeSources.astro +190 -0
- package/src/seo/Breadcrumb.astro +80 -0
- package/src/seo/JsonLd.astro +20 -0
- package/src/seo/OgImageMeta.astro +29 -0
- package/src/seo/index.ts +8 -2
- package/src/seo/jsonLd.ts +11 -0
- package/src/seo/types.ts +20 -0
- package/src/styles/tailwind.css +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.15.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- New `./affiliate`: `AffiliateDisclosure` (provider key required, texts from the site), `AffiliateLink`, `InlineAffiliateCTA`, `StickyCTA` (inert when hidden, tracked, several bars per page, safe with blocked storage), `AmazonProductCard`/`Section`, `TravelpayoutsWidget` (hotel, activity, train, car), `AffiliateCard`, `AffiliateCardSection`, `AffiliateCarousel`, `AffiliateComparisonTable`, `HotelAffiliateCTA`. Links and widget sources arrive built by the site. `resources.ts` declares the hosts each widget kind loads (SLA-2736).
|
|
8
|
+
|
|
9
|
+
## 0.14.0 (2026-10-01)
|
|
10
|
+
|
|
11
|
+
- New `./seo`: `JsonLd` (safe JSON-LD renderer: escapes `</script`, `<!--` and U+2028/9), `OgImageMeta`, `Breadcrumb` (chevron or star separator, tap targets, copy as props), `ArticleMetadata` (resolved author in, no byline without one) and `AuthoritativeSources` (domain labels and outbound classification as props; competitor sources unlinked, affiliates `rel="noopener sponsored"`; optional disambiguation of domain-derived labels shared by different URLs). `scrollbar-hide` utility in `tailwind.css` (SLA-2737).
|
|
12
|
+
|
|
5
13
|
## 0.13.0 (2026-10-01)
|
|
6
14
|
|
|
7
15
|
- New `./editorial`: `EditorialGuidePage` (breadcrumb band, hero and body slots; owns the Pagefind markers, `pagefindType`, `breadcrumbIgnore`) and `EventPage` (back link, rendered body as `TrustedHtml`, highlights, practical information, price, official website and tags as props; `breadcrumb`, `hero`, `after-body`, `after-details` and `tail` slots), plus `BeforeYouGo` and `NumberedStep`. `./layout`: `BreadcrumbBand` (SLA-2819).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
"@lucide/astro": "^1.23.0",
|
|
43
43
|
"astro": "^7.0.6",
|
|
44
44
|
"tailwindcss": "^4.1.18",
|
|
45
|
-
"@sarimarcus/content-sites-core": "0.
|
|
45
|
+
"@sarimarcus/content-sites-core": "0.15.0"
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* AffiliateCard — a bookable activity as one sponsored link: image with the title over it, description, duration,
|
|
4
|
+
* price, rating and the CTA. `href` arrives finished (tagged by the site) and `productId` is the site's.
|
|
5
|
+
*
|
|
6
|
+
* `image` is an imported asset (rendered as a responsive Picture, sized by `imageSizes`) or a URL string (a plain
|
|
7
|
+
* img). Social proof only appears with reviews: Best Seller from `bestSellerAt` reviews, Top Rated at 4.8+ with 100+.
|
|
8
|
+
* With `skipTheLineBadge`, a title or description that says so explicitly gets the Skip the line badge.
|
|
9
|
+
*
|
|
10
|
+
* Copy: neutral labels have English defaults. Lines that name a provider or make a claim (`labels.verified`,
|
|
11
|
+
* `labels.booksUp`, `cheapPriceFrame`, `skipQueueFrame`) render only when the site passes them.
|
|
12
|
+
*
|
|
13
|
+
* Kept for byte-identical adoption, each meant to be dropped later: `legacyFractionDigits` (fractional prices then
|
|
14
|
+
* render with the currency's default maximum, e.g. "€8.5"), `skipQueueFrame` (shown over `cheapPriceFrame` when the
|
|
15
|
+
* title contains "skip"), and `halfStarIds="index"` (the half-star clip id repeats when two card groups share a page;
|
|
16
|
+
* the default derives a page-unique id).
|
|
17
|
+
*/
|
|
18
|
+
import type { ImageMetadata } from 'astro';
|
|
19
|
+
import { Picture } from 'astro:assets';
|
|
20
|
+
import { deterministicId } from '@sarimarcus/content-sites-core/dom';
|
|
21
|
+
import Badge from '../primitives/Badge.astro';
|
|
22
|
+
import Icon from '../primitives/Icon.astro';
|
|
23
|
+
import type { AffiliateCardLabels } from './types';
|
|
24
|
+
import { templateLine } from './templateText';
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
href: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
productId?: string;
|
|
30
|
+
placement?: string;
|
|
31
|
+
image: ImageMetadata | string;
|
|
32
|
+
imageSizes?: string;
|
|
33
|
+
imageFetchpriority?: 'high' | 'low' | 'auto';
|
|
34
|
+
imageWidth?: number;
|
|
35
|
+
imageHeight?: number;
|
|
36
|
+
title: string;
|
|
37
|
+
description: string;
|
|
38
|
+
price: number;
|
|
39
|
+
currency: string;
|
|
40
|
+
locale?: string;
|
|
41
|
+
rating?: number;
|
|
42
|
+
reviewCount?: number;
|
|
43
|
+
duration: string;
|
|
44
|
+
index?: number;
|
|
45
|
+
pricePrefix?: string;
|
|
46
|
+
legacyFractionDigits?: boolean;
|
|
47
|
+
bestSellerAt?: number;
|
|
48
|
+
skipTheLineBadge?: boolean;
|
|
49
|
+
cheapPriceBelow?: number;
|
|
50
|
+
cheapPriceFrame?: string;
|
|
51
|
+
skipQueueFrame?: string;
|
|
52
|
+
labels?: AffiliateCardLabels;
|
|
53
|
+
titleColorClass?: string;
|
|
54
|
+
booksUpClass?: string;
|
|
55
|
+
halfStarIds?: 'deterministic' | 'index';
|
|
56
|
+
class?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const {
|
|
60
|
+
href,
|
|
61
|
+
provider,
|
|
62
|
+
productId = '',
|
|
63
|
+
placement = 'card',
|
|
64
|
+
image,
|
|
65
|
+
imageSizes = '(min-width: 1024px) 300px, (min-width: 640px) 45vw, 100vw',
|
|
66
|
+
imageFetchpriority,
|
|
67
|
+
imageWidth = 400,
|
|
68
|
+
imageHeight = 208,
|
|
69
|
+
title,
|
|
70
|
+
description,
|
|
71
|
+
price,
|
|
72
|
+
currency,
|
|
73
|
+
locale = 'en-GB',
|
|
74
|
+
rating = 0,
|
|
75
|
+
reviewCount = 0,
|
|
76
|
+
duration,
|
|
77
|
+
index = 0,
|
|
78
|
+
pricePrefix = 'From ',
|
|
79
|
+
legacyFractionDigits = false,
|
|
80
|
+
bestSellerAt = 1000,
|
|
81
|
+
skipTheLineBadge = true,
|
|
82
|
+
cheapPriceBelow = 22,
|
|
83
|
+
cheapPriceFrame,
|
|
84
|
+
skipQueueFrame,
|
|
85
|
+
labels = {},
|
|
86
|
+
titleColorClass = 'text-overlay-foreground',
|
|
87
|
+
booksUpClass = 'text-primary',
|
|
88
|
+
halfStarIds = 'deterministic',
|
|
89
|
+
class: className,
|
|
90
|
+
} = Astro.props;
|
|
91
|
+
|
|
92
|
+
const {
|
|
93
|
+
bestSeller = 'Best Seller',
|
|
94
|
+
topRated = 'Top Rated',
|
|
95
|
+
skipTheLine = 'Skip the line',
|
|
96
|
+
cta = 'Reserve Your Spot',
|
|
97
|
+
reviewCount: reviewCountLabel = (n: string) => `(${n} reviews)`,
|
|
98
|
+
verified,
|
|
99
|
+
booksUp,
|
|
100
|
+
} = labels;
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
const priceDigits = Number.isInteger(price) ? 0 : 2;
|
|
104
|
+
const formattedPrice = `${pricePrefix}${new Intl.NumberFormat(locale, legacyFractionDigits ? {
|
|
105
|
+
style: "currency",
|
|
106
|
+
currency,
|
|
107
|
+
minimumFractionDigits: 0,
|
|
108
|
+
} : {
|
|
109
|
+
style: "currency",
|
|
110
|
+
currency,
|
|
111
|
+
minimumFractionDigits: priceDigits,
|
|
112
|
+
maximumFractionDigits: priceDigits,
|
|
113
|
+
}).format(price)}`;
|
|
114
|
+
|
|
115
|
+
const formattedReviewCount = new Intl.NumberFormat(locale).format(reviewCount);
|
|
116
|
+
const formattedRating = rating.toFixed(1);
|
|
117
|
+
|
|
118
|
+
const fullStars = Math.floor(rating);
|
|
119
|
+
const hasHalfStar = (rating - fullStars) >= 0.5;
|
|
120
|
+
const emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
|
|
121
|
+
const halfStarClipId = !hasHalfStar ? '' : halfStarIds === 'index' ? `half-star-${index}` : deterministicId('half-star', title, href);
|
|
122
|
+
|
|
123
|
+
const hasReviews = reviewCount > 0;
|
|
124
|
+
const isBestSeller = reviewCount >= bestSellerAt;
|
|
125
|
+
const isTopRated = !isBestSeller && rating >= 4.8 && reviewCount >= 100;
|
|
126
|
+
|
|
127
|
+
const isSkipTheLine = skipTheLineBadge && /skip[\s-]?the[\s-]?line|skip the queue|beat the (queue|crowd)/i.test(
|
|
128
|
+
`${title} ${description}`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const priceFrame = skipQueueFrame && title.toLowerCase().includes('skip')
|
|
132
|
+
? skipQueueFrame
|
|
133
|
+
: cheapPriceFrame && price < cheapPriceBelow
|
|
134
|
+
? cheapPriceFrame
|
|
135
|
+
: null;
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
<a
|
|
139
|
+
href={href}
|
|
140
|
+
target="_blank"
|
|
141
|
+
rel="sponsored noopener"
|
|
142
|
+
data-track-event="affiliate_click"
|
|
143
|
+
data-track-affiliate-provider={provider}
|
|
144
|
+
data-track-affiliate-product-id={productId}
|
|
145
|
+
data-track-affiliate-placement={placement}
|
|
146
|
+
class:list={[
|
|
147
|
+
"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",
|
|
148
|
+
className,
|
|
149
|
+
]}
|
|
150
|
+
>
|
|
151
|
+
{isBestSeller && (
|
|
152
|
+
<Badge tone="custom" toneClass="bg-primary text-primary-foreground" size="sm" lines={0} class="absolute top-3 left-3 z-10 pointer-events-none !px-2.5 !py-1 !font-semibold"><Fragment set:html={templateLine(bestSeller)} /></Badge>
|
|
153
|
+
)}
|
|
154
|
+
{isTopRated && (
|
|
155
|
+
<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"><Fragment set:html={templateLine(topRated)} /></Badge>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
<div class="relative h-48 sm:h-52 overflow-hidden shrink-0">
|
|
159
|
+
{typeof image === 'string' ? (
|
|
160
|
+
<img
|
|
161
|
+
src={image}
|
|
162
|
+
alt={title}
|
|
163
|
+
width={String(imageWidth)}
|
|
164
|
+
height={String(imageHeight)}
|
|
165
|
+
loading="lazy"
|
|
166
|
+
decoding="async"
|
|
167
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
168
|
+
/>
|
|
169
|
+
) : (
|
|
170
|
+
<Picture
|
|
171
|
+
src={image}
|
|
172
|
+
alt={title}
|
|
173
|
+
widths={[320, 480, 640, 960]}
|
|
174
|
+
sizes={imageSizes}
|
|
175
|
+
formats={['avif', 'webp']}
|
|
176
|
+
fallbackFormat="webp"
|
|
177
|
+
loading="lazy"
|
|
178
|
+
decoding="async"
|
|
179
|
+
fetchpriority={imageFetchpriority}
|
|
180
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
<div class="absolute inset-0 bg-gradient-to-t from-overlay/95 via-overlay/40 to-transparent"></div>
|
|
184
|
+
<h3
|
|
185
|
+
class:list={["absolute bottom-3 left-4 right-4 text-xl sm:text-2xl font-semibold", titleColorClass, "leading-tight font-display [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"]}
|
|
186
|
+
>
|
|
187
|
+
{title}
|
|
188
|
+
</h3>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="p-4 sm:p-5 flex flex-col flex-1">
|
|
192
|
+
<p class="text-base text-muted-foreground line-clamp-2 mb-3">
|
|
193
|
+
{description}
|
|
194
|
+
</p>{skipTheLineBadge && ' '}{isSkipTheLine && (
|
|
195
|
+
<Badge tone="custom" toneClass="bg-primary/10 text-primary" size="sm" lines={0} class="w-fit mb-3 !font-semibold !gap-1">
|
|
196
|
+
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
197
|
+
<polyline points="13 17 18 12 13 7"></polyline>
|
|
198
|
+
<polyline points="6 17 11 12 6 7"></polyline>
|
|
199
|
+
</svg><Fragment set:html={templateLine(skipTheLine)} /></Badge>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<div class="flex items-start justify-between mb-3">
|
|
203
|
+
<span class="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
|
204
|
+
<svg
|
|
205
|
+
class="w-3.5 h-3.5 shrink-0"
|
|
206
|
+
viewBox="0 0 24 24"
|
|
207
|
+
fill="none"
|
|
208
|
+
stroke="currentColor"
|
|
209
|
+
stroke-width="2"
|
|
210
|
+
stroke-linecap="round"
|
|
211
|
+
stroke-linejoin="round"
|
|
212
|
+
aria-hidden="true"
|
|
213
|
+
>
|
|
214
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
215
|
+
<polyline points="12 6 12 12 16 14"></polyline>
|
|
216
|
+
</svg>
|
|
217
|
+
{duration}
|
|
218
|
+
</span>
|
|
219
|
+
<div class="text-right">
|
|
220
|
+
<p class="text-xl font-semibold text-foreground leading-none">{formattedPrice}</p>
|
|
221
|
+
{priceFrame && (
|
|
222
|
+
<p class="text-xs text-muted-foreground mt-0.5">{priceFrame}</p>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{hasReviews && (
|
|
228
|
+
<div class="flex items-center gap-1.5 mb-4">
|
|
229
|
+
<span class="text-sm font-semibold text-foreground mr-1">{formattedRating}</span>
|
|
230
|
+
{Array.from({ length: fullStars }).map(() => (
|
|
231
|
+
<svg class="w-4 h-4 text-accent fill-accent" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" aria-hidden="true">
|
|
232
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
233
|
+
</svg>
|
|
234
|
+
))}
|
|
235
|
+
{hasHalfStar && (
|
|
236
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" aria-hidden="true">
|
|
237
|
+
<defs>
|
|
238
|
+
<clipPath id={halfStarClipId}>
|
|
239
|
+
<rect x="0" y="0" width="12" height="24" />
|
|
240
|
+
</clipPath>
|
|
241
|
+
</defs>
|
|
242
|
+
<polygon
|
|
243
|
+
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
|
244
|
+
class="text-accent fill-accent"
|
|
245
|
+
clip-path={`url(#${halfStarClipId})`}
|
|
246
|
+
/>
|
|
247
|
+
<polygon
|
|
248
|
+
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
|
249
|
+
fill="none"
|
|
250
|
+
stroke="currentColor"
|
|
251
|
+
stroke-width="1"
|
|
252
|
+
class="text-border/50"
|
|
253
|
+
/>
|
|
254
|
+
</svg>
|
|
255
|
+
)}
|
|
256
|
+
{Array.from({ length: emptyStars }).map(() => (
|
|
257
|
+
<svg class="w-4 h-4 text-border/50 fill-border/50" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" aria-hidden="true">
|
|
258
|
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
259
|
+
</svg>
|
|
260
|
+
))}
|
|
261
|
+
<span class="text-xs text-muted-foreground ml-1">{reviewCountLabel(formattedReviewCount)}</span>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
<div class="mt-auto">
|
|
266
|
+
<span
|
|
267
|
+
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"
|
|
268
|
+
>
|
|
269
|
+
<Icon name="ticket" class="w-4 h-4" aria-hidden="true" /><Fragment set:html={templateLine(cta)} /></span>
|
|
270
|
+
{hasReviews && verified && (
|
|
271
|
+
<p class="text-xs text-muted-foreground text-center mt-2"><Fragment set:html={templateLine(verified(formattedReviewCount))} /></p>
|
|
272
|
+
)}
|
|
273
|
+
{isBestSeller && booksUp && (
|
|
274
|
+
<p class:list={["text-xs", booksUpClass, "text-center mt-2"]}><Fragment set:html={templateLine(booksUp)} /></p>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</a>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* AffiliateCardSection — a titled band of AffiliateCards. `items` arrive resolved and in their final order; every
|
|
4
|
+
* item renders (no cap). Up to 4 columns: a fifth item wraps onto a new row rather than narrowing the cards, and
|
|
5
|
+
* `sizes` follows the column count actually rendered. `cardOptions` reach every card (labels, thresholds, classes);
|
|
6
|
+
* the disclosure arrives through the `disclosure` slot.
|
|
7
|
+
*/
|
|
8
|
+
import AffiliateCard from './AffiliateCard.astro';
|
|
9
|
+
import type { AffiliateCardItem } from './types';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
items: AffiliateCardItem[];
|
|
13
|
+
title?: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
placement?: string;
|
|
16
|
+
imageFetchpriority?: 'high' | 'low' | 'auto';
|
|
17
|
+
cardOptions?: Record<string, unknown>;
|
|
18
|
+
sectionClass?: string;
|
|
19
|
+
headingClass?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
items,
|
|
26
|
+
title = "Recommended Tours & Activities",
|
|
27
|
+
subtitle,
|
|
28
|
+
placement,
|
|
29
|
+
imageFetchpriority,
|
|
30
|
+
cardOptions = {},
|
|
31
|
+
sectionClass = 'py-12 bg-muted/30 border-t border-border',
|
|
32
|
+
headingClass = 'text-3xl sm:text-4xl font-normal font-display',
|
|
33
|
+
id,
|
|
34
|
+
class: className,
|
|
35
|
+
} = Astro.props;
|
|
36
|
+
|
|
37
|
+
const visible = (items ?? []).map(({ id: _id, ...item }) => item);
|
|
38
|
+
|
|
39
|
+
const gridClasses: Record<number, string> = {
|
|
40
|
+
1: "grid grid-cols-1 max-w-md mx-auto",
|
|
41
|
+
2: "grid gap-6 grid-cols-1 sm:grid-cols-2",
|
|
42
|
+
3: "grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
|
43
|
+
4: "grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Desktop figures are measured off max-w-shell (80rem) minus lg:px-8 padding and the gap-6 gutters.
|
|
47
|
+
const gridSizes: Record<number, string> = {
|
|
48
|
+
1: '(min-width: 640px) 448px, 100vw',
|
|
49
|
+
2: '(min-width: 1024px) 596px, (min-width: 640px) 45vw, 100vw',
|
|
50
|
+
3: '(min-width: 1024px) 389px, (min-width: 640px) 45vw, 100vw',
|
|
51
|
+
4: '(min-width: 1024px) 286px, (min-width: 640px) 45vw, 100vw',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const gridClass = gridClasses[visible.length] || gridClasses[4];
|
|
55
|
+
const imageSizes = gridSizes[visible.length] || gridSizes[4];
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
visible.length > 0 && (
|
|
60
|
+
<section id={id} class:list={[sectionClass, className]}>
|
|
61
|
+
<div class="max-w-shell mx-auto px-4 sm:px-6 lg:px-8">
|
|
62
|
+
<div class:list={["flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1", subtitle ? "mb-2" : "mb-8"]}>
|
|
63
|
+
<h2 class={headingClass}>
|
|
64
|
+
{title}
|
|
65
|
+
</h2>
|
|
66
|
+
<slot name="disclosure" />
|
|
67
|
+
</div>
|
|
68
|
+
{subtitle && (
|
|
69
|
+
<p class="text-sm text-muted-foreground mb-8">{subtitle}</p>
|
|
70
|
+
)}
|
|
71
|
+
<div class={gridClass}>
|
|
72
|
+
{visible.map((item, index) => (
|
|
73
|
+
<AffiliateCard
|
|
74
|
+
{...cardOptions}
|
|
75
|
+
{...item}
|
|
76
|
+
index={index}
|
|
77
|
+
placement={placement}
|
|
78
|
+
imageSizes={imageSizes}
|
|
79
|
+
imageFetchpriority={imageFetchpriority}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* AffiliateCarousel — a CardCarousel strip of AffiliateCards. With `ids`, the strip renders exactly those
|
|
4
|
+
* activities in that running order (an editor's list; `idOf` reads an activity's numeric id). Without, the most
|
|
5
|
+
* reviewed come first, titles deduplicated so near-duplicate listings don't eat the strip. Either way at most
|
|
6
|
+
* `maxCards`. `resolve` turns a visible activity into the card's props (called only for the cards rendered);
|
|
7
|
+
* without it each activity already is one. The disclosure arrives through the `disclosure` slot.
|
|
8
|
+
*/
|
|
9
|
+
import CardCarousel from '../composed/CardCarousel.astro';
|
|
10
|
+
import AffiliateCard from './AffiliateCard.astro';
|
|
11
|
+
import type { AffiliateCardItem } from './types';
|
|
12
|
+
|
|
13
|
+
type Activity = { id?: string | number; title?: string; reviewCount?: number; [key: string]: unknown };
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
activities: Activity[];
|
|
17
|
+
ids?: number[];
|
|
18
|
+
idOf?: (activity: Activity) => number;
|
|
19
|
+
resolve?: (activity: any) => AffiliateCardItem;
|
|
20
|
+
heading?: string;
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
headingTag?: 'h2' | 'h3';
|
|
23
|
+
headingClass?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
maxCards?: number;
|
|
26
|
+
placement?: string;
|
|
27
|
+
imageSizes?: string;
|
|
28
|
+
cardOptions?: Record<string, unknown>;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
activities,
|
|
34
|
+
ids,
|
|
35
|
+
idOf = (activity) => Number(String(activity.id ?? '').replace(/^\D+-/, '')),
|
|
36
|
+
resolve = (activity) => activity as AffiliateCardItem,
|
|
37
|
+
heading = 'Recommended tours',
|
|
38
|
+
subtitle,
|
|
39
|
+
headingTag = 'h3',
|
|
40
|
+
headingClass = 'font-display text-lg font-semibold text-foreground',
|
|
41
|
+
id,
|
|
42
|
+
maxCards = 4,
|
|
43
|
+
placement = 'carousel',
|
|
44
|
+
imageSizes,
|
|
45
|
+
cardOptions = {},
|
|
46
|
+
class: className,
|
|
47
|
+
} = Astro.props;
|
|
48
|
+
|
|
49
|
+
const all = activities ?? [];
|
|
50
|
+
const sorted = [...all].sort((a, b) => (b.reviewCount ?? 0) - (a.reviewCount ?? 0));
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
const deduped = sorted.filter((a) => {
|
|
53
|
+
const key = String(a.title ?? '').trim().toLowerCase();
|
|
54
|
+
if (seen.has(key)) return false;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const filtered = ids
|
|
60
|
+
? ids.map((wanted) => all.find((a) => idOf(a) === wanted)).filter((a): a is Activity => Boolean(a))
|
|
61
|
+
: deduped;
|
|
62
|
+
|
|
63
|
+
const visible = filtered.slice(0, maxCards).map((a) => {
|
|
64
|
+
const { id: _id, ...item } = resolve(a);
|
|
65
|
+
return item;
|
|
66
|
+
});
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
visible.length > 0 && (
|
|
71
|
+
<div class:list={["my-6", className]}>
|
|
72
|
+
<CardCarousel
|
|
73
|
+
heading={heading}
|
|
74
|
+
headingTag={headingTag}
|
|
75
|
+
headingClass={headingClass}
|
|
76
|
+
description={subtitle}
|
|
77
|
+
id={id}
|
|
78
|
+
ariaLabel={heading}
|
|
79
|
+
>
|
|
80
|
+
<slot name="disclosure" slot="header-note" />
|
|
81
|
+
{visible.map((item, index) => (
|
|
82
|
+
<div class="snap-start shrink-0 w-[78vw] sm:w-72">
|
|
83
|
+
<AffiliateCard
|
|
84
|
+
{...cardOptions}
|
|
85
|
+
{...item}
|
|
86
|
+
placement={placement}
|
|
87
|
+
imageSizes={imageSizes}
|
|
88
|
+
index={index}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</CardCarousel>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|