@sarimarcus/content-sites-ui 0.25.0 → 0.27.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 +13 -0
- package/README.md +5 -0
- package/package.json +2 -2
- package/src/affiliate/AffiliateBandShell.astro +35 -0
- package/src/affiliate/AffiliateCard.astro +8 -18
- package/src/affiliate/AffiliateCardSection.astro +17 -46
- package/src/affiliate/AffiliateCarousel.astro +2 -2
- package/src/affiliate/AmazonProductCard.astro +5 -4
- package/src/affiliate/AmazonProductSection.astro +4 -22
- package/src/affiliate/TravelpayoutsWidget.astro +4 -32
- package/src/affiliate/TravelpayoutsWidgetBody.astro +31 -0
- package/src/affiliate/band.ts +25 -0
- package/src/affiliate/cardClasses.ts +5 -0
- package/src/affiliate/index.ts +1 -1
- package/src/affiliate/select.ts +2 -2
- package/src/affiliate/types.ts +15 -0
- package/src/composed/index.ts +1 -2
- package/src/editorial/ArticleTopicPage.astro +109 -0
- package/src/editorial/index.ts +3 -1
- package/src/editorial/topic.ts +9 -0
- package/src/editorial/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.27.0 (2026-10-05)
|
|
6
|
+
|
|
7
|
+
- `./editorial`: `ArticleTopicPage`, the body of an article topic hub (`/articles/topic/<category>`) and its paginated pages, promoted from the tourism sites' identical route copies. Strings, the overview and the author cards are props; the blocks a site renders through its own adapters (start-here, grid, pagination, go-deeper, places, faq) are slots. A page above 1 renders only the topic nav, grid and pagination. Also `rankByCount` and type `TopicAuthorCard`. Proven on both tourism sites: their topic routes rewritten onto it render every page byte-identical (SLA-2975).
|
|
8
|
+
|
|
9
|
+
## 0.26.0 (2026-10-05)
|
|
10
|
+
|
|
11
|
+
- `./affiliate`: `AffiliateCardSection` and `AmazonProductSection` render through one band shell and grid table; `AffiliateCard` and `AmazonProductCard` share their link, body, CTA and badge classes; `TravelpayoutsWidget` renders one widget body for both variants and takes `WidgetKind`. Output is unchanged (SLA-2921).
|
|
12
|
+
### Breaking
|
|
13
|
+
|
|
14
|
+
- `selectActivities`: an activity with no `reviewCount` sorts last instead of comparing as NaN, which left its place to the engine. A band whose cache has such an activity reorders: on the live sites, one Dark Skies page (`tours/marrakech-to-merzouga-desert-tour`). No migration (SLA-2921).
|
|
15
|
+
|
|
16
|
+
- `cardOptions` on `AffiliateCardSection` and `AffiliateCarousel` is typed `AffiliateCardOptions` (new export) instead of `Record<string, unknown>`: an option the card does not take, or a wrong value type, now fails `astro check`. Every site's options already fit; no migration (SLA-2921).
|
|
17
|
+
|
|
5
18
|
## 0.25.0 (2026-10-05)
|
|
6
19
|
|
|
7
20
|
- `./editorial` `ArticleCategoryNav` builds its own links: it takes the articles and the site's category map (`categories`, in display order), links `/articles` and `/articles/topic/<slug>`, and defaults its "All" label and aria label. New type `ArticleCategoryMeta` (SLA-2961).
|
package/README.md
CHANGED
|
@@ -99,6 +99,11 @@ prose stay in the route so the site's link and outbound-domain scanners still se
|
|
|
99
99
|
`cta` box. Carries no Pagefind body.
|
|
100
100
|
- `NotFoundPage`: the 404 body; the route passes `noindex` to Layout and imports `@sarimarcus/content-sites-ui/not-found.css`.
|
|
101
101
|
- `ArticlesListingPage` / `ArticleFeatureCard`: article index, topic hubs and their paginated pages.
|
|
102
|
+
- `ArticleTopicPage`: the body of `/articles/topic/<category>` and its paginated pages, around the topic nav. Blocks
|
|
103
|
+
the site renders through its own adapters arrive as slots: `start-here`, `grid`, `pagination`, `go-deeper`,
|
|
104
|
+
`places`, `faq` (wrapped in a measure column only when filled), plus `breadcrumb`, `header-extra` and
|
|
105
|
+
`overview-byline`. The overview and the author cards are props. A page above 1 renders only the nav, grid and
|
|
106
|
+
pagination. `rankByCount` ranks a topic's authors or collections by article count.
|
|
102
107
|
- `ListingShell`: listing hubs (breadcrumb, `hero`, `before-main`, Container'd `<main>`, `after-main`), one outer
|
|
103
108
|
`data-pagefind-ignore`.
|
|
104
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"@lucide/astro": "^1.23.0",
|
|
50
50
|
"astro": "^7.0.6",
|
|
51
51
|
"tailwindcss": "^4.1.18",
|
|
52
|
-
"@sarimarcus/content-sites-core": "0.
|
|
52
|
+
"@sarimarcus/content-sites-core": "0.27.0"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* AffiliateBandShell — the titled band around an affiliate card grid: heading row with the disclosure slot, optional
|
|
4
|
+
* subtitle, then the grid. Internal to the affiliate sections; not exported.
|
|
5
|
+
*/
|
|
6
|
+
import { bandGridClass, BAND_SECTION_CLASS, BAND_HEADING_CLASS } from './band';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string;
|
|
10
|
+
subtitle?: string;
|
|
11
|
+
count: number;
|
|
12
|
+
sectionClass?: string;
|
|
13
|
+
headingClass?: string;
|
|
14
|
+
id?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { title, subtitle, count, sectionClass = BAND_SECTION_CLASS, headingClass = BAND_HEADING_CLASS, id, class: className } = Astro.props;
|
|
19
|
+
---
|
|
20
|
+
<section id={id} class:list={[sectionClass, className]}>
|
|
21
|
+
<div class="max-w-shell mx-auto px-4 sm:px-6 lg:px-8">
|
|
22
|
+
<div class:list={["flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1", subtitle ? "mb-2" : "mb-8"]}>
|
|
23
|
+
<h2 class={headingClass}>
|
|
24
|
+
{title}
|
|
25
|
+
</h2>
|
|
26
|
+
<slot name="disclosure" />
|
|
27
|
+
</div>
|
|
28
|
+
{subtitle && (
|
|
29
|
+
<p class="text-sm text-muted-foreground mb-8">{subtitle}</p>
|
|
30
|
+
)}
|
|
31
|
+
<div class={bandGridClass(count)}>
|
|
32
|
+
<slot />
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
import type { ImageMetadata } from 'astro';
|
|
19
19
|
import { deterministicId } from '@sarimarcus/content-sites-core/dom';
|
|
20
20
|
import Badge from '../primitives/Badge.astro';
|
|
21
|
+
import { CARD_LINK_CLASS, CARD_BODY_CLASS, CARD_CTA_CLASS, CARD_BADGE_CLASS } from './cardClasses';
|
|
21
22
|
import Icon from '../primitives/Icon.astro';
|
|
22
|
-
import type {
|
|
23
|
+
import type { AffiliateCardOptions } from './types';
|
|
23
24
|
import { templateLine } from './templateText';
|
|
24
25
|
import ResponsivePicture from '../primitives/ResponsivePicture.astro';
|
|
25
26
|
|
|
26
|
-
interface Props {
|
|
27
|
+
interface Props extends AffiliateCardOptions {
|
|
27
28
|
href: string;
|
|
28
29
|
provider: string;
|
|
29
30
|
productId?: string;
|
|
@@ -42,17 +43,6 @@ interface Props {
|
|
|
42
43
|
reviewCount?: number;
|
|
43
44
|
duration: string;
|
|
44
45
|
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
46
|
class?: string;
|
|
57
47
|
}
|
|
58
48
|
|
|
@@ -144,15 +134,15 @@ const priceFrame = skipQueueFrame && title.toLowerCase().includes('skip')
|
|
|
144
134
|
data-track-affiliate-product-id={productId}
|
|
145
135
|
data-track-affiliate-placement={placement}
|
|
146
136
|
class:list={[
|
|
147
|
-
|
|
137
|
+
CARD_LINK_CLASS,
|
|
148
138
|
className,
|
|
149
139
|
]}
|
|
150
140
|
>
|
|
151
141
|
{isBestSeller && (
|
|
152
|
-
<Badge tone="custom" toneClass="bg-primary text-primary-foreground" size="sm" lines={0} class=
|
|
142
|
+
<Badge tone="custom" toneClass="bg-primary text-primary-foreground" size="sm" lines={0} class={CARD_BADGE_CLASS}><Fragment set:html={templateLine(bestSeller)} /></Badge>
|
|
153
143
|
)}
|
|
154
144
|
{isTopRated && (
|
|
155
|
-
<Badge tone="custom" toneClass="bg-accent text-accent-foreground" size="sm" lines={0} class=
|
|
145
|
+
<Badge tone="custom" toneClass="bg-accent text-accent-foreground" size="sm" lines={0} class={CARD_BADGE_CLASS}><Fragment set:html={templateLine(topRated)} /></Badge>
|
|
156
146
|
)}
|
|
157
147
|
|
|
158
148
|
<div class="relative h-48 sm:h-52 overflow-hidden shrink-0">
|
|
@@ -185,7 +175,7 @@ const priceFrame = skipQueueFrame && title.toLowerCase().includes('skip')
|
|
|
185
175
|
</h3>
|
|
186
176
|
</div>
|
|
187
177
|
|
|
188
|
-
<div class=
|
|
178
|
+
<div class={CARD_BODY_CLASS}>
|
|
189
179
|
<p class="text-base text-muted-foreground line-clamp-2 mb-3">
|
|
190
180
|
{description}
|
|
191
181
|
</p>{skipTheLineBadge && ' '}{isSkipTheLine && (
|
|
@@ -261,7 +251,7 @@ const priceFrame = skipQueueFrame && title.toLowerCase().includes('skip')
|
|
|
261
251
|
|
|
262
252
|
<div class="mt-auto">
|
|
263
253
|
<span
|
|
264
|
-
class=
|
|
254
|
+
class={CARD_CTA_CLASS}
|
|
265
255
|
>
|
|
266
256
|
<Icon name="ticket" class="w-4 h-4" aria-hidden="true" /><Fragment set:html={templateLine(cta)} /></span>
|
|
267
257
|
{hasReviews && verified && (
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* the disclosure arrives through the `disclosure` slot.
|
|
7
7
|
*/
|
|
8
8
|
import AffiliateCard from './AffiliateCard.astro';
|
|
9
|
-
import
|
|
9
|
+
import AffiliateBandShell from './AffiliateBandShell.astro';
|
|
10
|
+
import { bandImageSizes } from './band';
|
|
11
|
+
import type { AffiliateCardItem, AffiliateCardOptions } from './types';
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
items: AffiliateCardItem[];
|
|
@@ -14,7 +16,7 @@ interface Props {
|
|
|
14
16
|
subtitle?: string;
|
|
15
17
|
placement?: string;
|
|
16
18
|
imageFetchpriority?: 'high' | 'low' | 'auto';
|
|
17
|
-
cardOptions?:
|
|
19
|
+
cardOptions?: AffiliateCardOptions;
|
|
18
20
|
sectionClass?: string;
|
|
19
21
|
headingClass?: string;
|
|
20
22
|
id?: string;
|
|
@@ -28,59 +30,28 @@ const {
|
|
|
28
30
|
placement,
|
|
29
31
|
imageFetchpriority,
|
|
30
32
|
cardOptions = {},
|
|
31
|
-
sectionClass
|
|
32
|
-
headingClass
|
|
33
|
+
sectionClass,
|
|
34
|
+
headingClass,
|
|
33
35
|
id,
|
|
34
36
|
class: className,
|
|
35
37
|
} = Astro.props;
|
|
36
38
|
|
|
37
39
|
const visible = (items ?? []).map(({ id: _id, ...item }) => item);
|
|
38
40
|
|
|
39
|
-
const
|
|
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];
|
|
41
|
+
const imageSizes = bandImageSizes(visible.length);
|
|
56
42
|
---
|
|
57
43
|
|
|
58
44
|
{
|
|
59
45
|
visible.length > 0 && (
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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>
|
|
46
|
+
<AffiliateBandShell title={title} subtitle={subtitle} count={visible.length} sectionClass={sectionClass} headingClass={headingClass} id={id} class={className}><slot name="disclosure" slot="disclosure" />{visible.map((item, index) => (
|
|
47
|
+
<AffiliateCard
|
|
48
|
+
{...cardOptions}
|
|
49
|
+
{...item}
|
|
50
|
+
index={index}
|
|
51
|
+
placement={placement}
|
|
52
|
+
imageSizes={imageSizes}
|
|
53
|
+
imageFetchpriority={imageFetchpriority}
|
|
54
|
+
/>
|
|
55
|
+
))}</AffiliateBandShell>
|
|
85
56
|
)
|
|
86
57
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import CardCarousel from '../composed/CardCarousel.astro';
|
|
10
10
|
import AffiliateCard from './AffiliateCard.astro';
|
|
11
|
-
import type { AffiliateCardItem } from './types';
|
|
11
|
+
import type { AffiliateCardItem, AffiliateCardOptions } from './types';
|
|
12
12
|
|
|
13
13
|
type Activity = { id?: string | number; title?: string; reviewCount?: number; [key: string]: unknown };
|
|
14
14
|
|
|
@@ -25,7 +25,7 @@ interface Props {
|
|
|
25
25
|
maxCards?: number;
|
|
26
26
|
placement?: string;
|
|
27
27
|
imageSizes?: string;
|
|
28
|
-
cardOptions?:
|
|
28
|
+
cardOptions?: AffiliateCardOptions;
|
|
29
29
|
class?: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* count, and no hotlinked product image — without a local `image` the card shows a category icon.
|
|
6
6
|
*/
|
|
7
7
|
import Badge from '../primitives/Badge.astro';
|
|
8
|
+
import { CARD_LINK_CLASS, CARD_BODY_CLASS, CARD_CTA_CLASS, CARD_BADGE_CLASS } from './cardClasses';
|
|
8
9
|
import Icon from '../primitives/Icon.astro';
|
|
9
10
|
import { templateLine } from './templateText';
|
|
10
11
|
|
|
@@ -56,12 +57,12 @@ const placeholderIcon = categoryIcon[category] ?? 'book-open';
|
|
|
56
57
|
data-track-affiliate-product-id={asin}
|
|
57
58
|
data-track-affiliate-placement={placement}
|
|
58
59
|
class:list={[
|
|
59
|
-
|
|
60
|
+
CARD_LINK_CLASS,
|
|
60
61
|
className,
|
|
61
62
|
]}
|
|
62
63
|
>
|
|
63
64
|
{badge && (
|
|
64
|
-
<Badge tone="custom" toneClass="bg-accent text-accent-foreground" size="sm" lines={0} class=
|
|
65
|
+
<Badge tone="custom" toneClass="bg-accent text-accent-foreground" size="sm" lines={0} class={CARD_BADGE_CLASS}>
|
|
65
66
|
{badge}
|
|
66
67
|
</Badge>
|
|
67
68
|
)}
|
|
@@ -82,7 +83,7 @@ const placeholderIcon = categoryIcon[category] ?? 'book-open';
|
|
|
82
83
|
)}
|
|
83
84
|
</div>
|
|
84
85
|
|
|
85
|
-
<div class=
|
|
86
|
+
<div class={CARD_BODY_CLASS}>
|
|
86
87
|
<h3 class="text-lg font-semibold text-card-foreground leading-tight font-display">
|
|
87
88
|
{title}
|
|
88
89
|
</h3>
|
|
@@ -93,7 +94,7 @@ const placeholderIcon = categoryIcon[category] ?? 'book-open';
|
|
|
93
94
|
|
|
94
95
|
<div class="mt-auto">
|
|
95
96
|
<span
|
|
96
|
-
class=
|
|
97
|
+
class={CARD_CTA_CLASS}
|
|
97
98
|
><Fragment set:html={templateLine(ctaLabel)} /><Icon name="arrow-up-right" class="w-4 h-4" />
|
|
98
99
|
</span>
|
|
99
100
|
</div>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* products. Each product carries its finished `href`; the disclosure arrives through the `disclosure` slot.
|
|
5
5
|
*/
|
|
6
6
|
import AmazonProductCard from './AmazonProductCard.astro';
|
|
7
|
+
import AffiliateBandShell from './AffiliateBandShell.astro';
|
|
7
8
|
|
|
8
9
|
interface Product {
|
|
9
10
|
href: string;
|
|
@@ -42,31 +43,12 @@ const {
|
|
|
42
43
|
const cappedMax = Math.min(maxCards ?? 4, 4);
|
|
43
44
|
const visibleProducts = enabled ? [...(products ?? [])].slice(0, cappedMax) : [];
|
|
44
45
|
|
|
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
46
|
---
|
|
54
47
|
|
|
55
48
|
{
|
|
56
49
|
visibleProducts.length > 0 && (
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
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>
|
|
50
|
+
<AffiliateBandShell title={title} subtitle={subtitle} count={visibleProducts.length} id={id} class={className}><slot name="disclosure" slot="disclosure" />{visibleProducts.map((product) => (
|
|
51
|
+
<AmazonProductCard {...product} ctaLabel={ctaLabel} />
|
|
52
|
+
))}</AffiliateBandShell>
|
|
71
53
|
)
|
|
72
54
|
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* block `role="region"`, so its `aria-label` names something (on a bare div assistive tech ignores it); off by
|
|
13
13
|
* default only because it changes the markup.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
import TravelpayoutsWidgetBody from './TravelpayoutsWidgetBody.astro';
|
|
16
|
+
import type { WidgetKind as Kind } from './resources';
|
|
16
17
|
|
|
17
18
|
interface Props {
|
|
18
19
|
kind: Kind;
|
|
@@ -64,7 +65,6 @@ const {
|
|
|
64
65
|
if (!WRAPPER[kind]) throw new Error(`TravelpayoutsWidget: unknown kind "${kind}" (hotel, activity, train, car).`);
|
|
65
66
|
|
|
66
67
|
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
68
|
const markerValue = marker ? kind : undefined;
|
|
69
69
|
---
|
|
70
70
|
|
|
@@ -86,21 +86,7 @@ const markerValue = marker ? kind : undefined;
|
|
|
86
86
|
{subtitle && (
|
|
87
87
|
<p class="text-sm text-muted-foreground mb-8">{subtitle}</p>
|
|
88
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
|
-
)}
|
|
89
|
+
<TravelpayoutsWidgetBody src={src} isolate={isolate} wrapperClass={wrapperClass} iframeTitle={iframeTitle} marker={markerValue} />
|
|
104
90
|
</div>
|
|
105
91
|
</section>
|
|
106
92
|
) : (
|
|
@@ -120,21 +106,7 @@ const markerValue = marker ? kind : undefined;
|
|
|
120
106
|
{subtitle && (
|
|
121
107
|
<p class="text-sm text-muted-foreground mb-6">{subtitle}</p>
|
|
122
108
|
)}
|
|
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
|
-
)}
|
|
109
|
+
<TravelpayoutsWidgetBody src={src} isolate={isolate} wrapperClass={wrapperClass} iframeTitle={iframeTitle} marker={markerValue} />
|
|
138
110
|
</div>
|
|
139
111
|
)
|
|
140
112
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TravelpayoutsWidgetBody — the widget wrapper both TravelpayoutsWidget variants render: the loader script, or for
|
|
4
|
+
* `isolate` the same script inside a sandboxed `srcdoc` iframe. Internal; not exported.
|
|
5
|
+
*/
|
|
6
|
+
interface Props {
|
|
7
|
+
src: string;
|
|
8
|
+
isolate: boolean;
|
|
9
|
+
wrapperClass: string;
|
|
10
|
+
iframeTitle: string;
|
|
11
|
+
marker?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { src, isolate, wrapperClass, iframeTitle, marker } = Astro.props;
|
|
15
|
+
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>`;
|
|
16
|
+
---
|
|
17
|
+
{isolate ? (
|
|
18
|
+
<div class={wrapperClass} data-affiliate-widget={marker}>
|
|
19
|
+
<iframe
|
|
20
|
+
srcdoc={widgetDoc}
|
|
21
|
+
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
|
22
|
+
loading="lazy"
|
|
23
|
+
title={iframeTitle}
|
|
24
|
+
class="w-full h-[478px] sm:h-[430px] border-0 block"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
) : (
|
|
28
|
+
<div class={wrapperClass} data-affiliate-widget={marker}>
|
|
29
|
+
<script async src={src} charset="utf-8" is:inline />
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The affiliate band grid, shared by AffiliateCardSection and AmazonProductSection: up to 4 columns, a fifth item wraps.
|
|
2
|
+
const GRID: Record<number, string> = {
|
|
3
|
+
1: 'grid grid-cols-1 max-w-md mx-auto',
|
|
4
|
+
2: 'grid gap-6 grid-cols-1 sm:grid-cols-2',
|
|
5
|
+
3: 'grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
6
|
+
4: 'grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// The card image `sizes` per column count. Desktop figures are measured off max-w-shell (80rem) minus lg:px-8 padding
|
|
10
|
+
// and the gap-6 gutters, so they change with GRID.
|
|
11
|
+
const SIZES: Record<number, string> = {
|
|
12
|
+
1: '(min-width: 640px) 448px, 100vw',
|
|
13
|
+
2: '(min-width: 1024px) 596px, (min-width: 640px) 45vw, 100vw',
|
|
14
|
+
3: '(min-width: 1024px) 389px, (min-width: 640px) 45vw, 100vw',
|
|
15
|
+
4: '(min-width: 1024px) 286px, (min-width: 640px) 45vw, 100vw',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The grid classes for a band holding `count` cards. */
|
|
19
|
+
export const bandGridClass = (count: number) => GRID[count] || GRID[4];
|
|
20
|
+
|
|
21
|
+
/** The card image `sizes` for a band holding `count` cards. */
|
|
22
|
+
export const bandImageSizes = (count: number) => SIZES[count] || SIZES[4];
|
|
23
|
+
|
|
24
|
+
export const BAND_SECTION_CLASS = 'py-12 bg-muted/30 border-t border-border';
|
|
25
|
+
export const BAND_HEADING_CLASS = 'text-3xl sm:text-4xl font-normal font-display';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Class strings AffiliateCard and AmazonProductCard share: the whole-card link, its body, the CTA bar and corner badge.
|
|
2
|
+
export const CARD_LINK_CLASS = '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';
|
|
3
|
+
export const CARD_BODY_CLASS = 'p-4 sm:p-5 flex flex-col flex-1';
|
|
4
|
+
export const CARD_CTA_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';
|
|
5
|
+
export const CARD_BADGE_CLASS = 'absolute top-3 left-3 z-10 pointer-events-none !px-2.5 !py-1 !font-semibold';
|
package/src/affiliate/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { externalResources, emeraldResources, EMERALD_ORIGIN, widgetLoaders } fr
|
|
|
15
15
|
export { widgetSrc } from './widgetSrc';
|
|
16
16
|
export { gygCardItem, gygCardLocale, type GygCardActivity } from './cardItem';
|
|
17
17
|
export type { WidgetKind, CspDirective, WidgetResources } from './resources';
|
|
18
|
-
export type { AffiliateCardLabels, AffiliateCardItem, HotelCtaLabels, ComparisonRow, ComparisonTableLabels } from './types';
|
|
18
|
+
export type { AffiliateCardLabels, AffiliateCardOptions, AffiliateCardItem, HotelCtaLabels, ComparisonRow, ComparisonTableLabels } from './types';
|
|
19
19
|
export { selectActivities, type SelectableActivity, type SelectOptions } from './select';
|
|
20
20
|
export { default as EmeraldLoader } from './EmeraldLoader.astro';
|
|
21
21
|
export { setupTravelpayoutsStyler } from './travelpayoutsStyler.ts';
|
package/src/affiliate/select.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function selectActivities<T extends SelectableActivity>(activities: Reado
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
const unpinned = pool.filter((activity) => !pinnedKeys.has(idKey(activity.id)));
|
|
37
|
-
// A missing count
|
|
38
|
-
const rest = keepOrder ? unpinned : unpinned.sort((a, b) =>
|
|
37
|
+
// A missing count sorts as 0, so it lands after every reviewed activity instead of breaking the comparator.
|
|
38
|
+
const rest = keepOrder ? unpinned : unpinned.sort((a, b) => (b.reviewCount ?? 0) - (a.reviewCount ?? 0));
|
|
39
39
|
return [...pinned, ...rest].slice(0, maxCards);
|
|
40
40
|
}
|
package/src/affiliate/types.ts
CHANGED
|
@@ -11,6 +11,21 @@ export interface AffiliateCardLabels {
|
|
|
11
11
|
booksUp?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** The card-wide presentation a site applies to every card in a section or carousel. */
|
|
15
|
+
export interface AffiliateCardOptions {
|
|
16
|
+
pricePrefix?: string;
|
|
17
|
+
legacyFractionDigits?: boolean;
|
|
18
|
+
bestSellerAt?: number;
|
|
19
|
+
skipTheLineBadge?: boolean;
|
|
20
|
+
cheapPriceBelow?: number;
|
|
21
|
+
cheapPriceFrame?: string;
|
|
22
|
+
skipQueueFrame?: string;
|
|
23
|
+
labels?: AffiliateCardLabels;
|
|
24
|
+
titleColorClass?: string;
|
|
25
|
+
booksUpClass?: string;
|
|
26
|
+
halfStarIds?: 'deterministic' | 'index';
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
/** One card in an AffiliateCardSection or AffiliateCarousel: the card's own props, resolved by the site. */
|
|
15
30
|
export interface AffiliateCardItem {
|
|
16
31
|
id?: string | number;
|
package/src/composed/index.ts
CHANGED
|
@@ -9,5 +9,4 @@ export { default as EntityCard } from './EntityCard.astro';
|
|
|
9
9
|
export { default as ArrowLinkGrid } from './ArrowLinkGrid.astro';
|
|
10
10
|
// RelatedContent sits here, not in ./editorial: it renders CardCarousel, whose <style> ships to every importer of the barrel.
|
|
11
11
|
export { default as RelatedContent } from './RelatedContent.astro';
|
|
12
|
-
export { default as RelatedContentCard } from './RelatedContentCard.astro';
|
|
13
|
-
export type { RelatedItem, RelatedTone } from './related';
|
|
12
|
+
export { default as RelatedContentCard } from './RelatedContentCard.astro';export type { RelatedItem, RelatedTone } from './related';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ArticleTopicPage — an article topic hub (/articles/topic/<category>) or one of its paginated pages, inside
|
|
4
|
+
* ArticlesListingPage. The route keeps paths, data, SEO, schema and the site Layout; every string arrives as a prop.
|
|
5
|
+
* Blocks the site renders through its own adapters (cards, related content, FAQ, pagination) arrive as slots, so its
|
|
6
|
+
* markdown, colour and URL rules still apply. A page above 1 renders only the topic nav, the grid and pagination.
|
|
7
|
+
* Slots:
|
|
8
|
+
* breadcrumb — the site Breadcrumb.
|
|
9
|
+
* header-extra — a line under the lede (e.g. the article count). Page 1 only.
|
|
10
|
+
* start-here — the site's related-content block above the overview. Page 1 only.
|
|
11
|
+
* overview-byline — the overview byline, under its heading.
|
|
12
|
+
* grid — the site's article card grid for this page.
|
|
13
|
+
* pagination — the site's pagination.
|
|
14
|
+
* go-deeper, places — related-content blocks after the list. Page 1 only.
|
|
15
|
+
* faq — the site's FAQ, in a measure column. Page 1 only.
|
|
16
|
+
*/
|
|
17
|
+
import Container from '../layout/Container.astro';
|
|
18
|
+
import Callout from '../primitives/Callout.astro';
|
|
19
|
+
import type { TrustedHtml } from '../types';
|
|
20
|
+
import ArticlesListingPage from './ArticlesListingPage.astro';
|
|
21
|
+
import ArticleCategoryNav from './ArticleCategoryNav.astro';
|
|
22
|
+
import type { ArticleCategoryMeta, TopicAuthorCard } from './types';
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
eyebrow: string;
|
|
26
|
+
heading: string;
|
|
27
|
+
lede: string;
|
|
28
|
+
/** Every article, for the topic nav's counts. */
|
|
29
|
+
articles: readonly { data: { category?: string | null } }[];
|
|
30
|
+
categories: Readonly<Record<string, ArticleCategoryMeta>>;
|
|
31
|
+
category: string;
|
|
32
|
+
/** Page 1 is the hub; a later page carries only the nav, grid and pagination. */
|
|
33
|
+
currentPage: number;
|
|
34
|
+
/** Heading over the page-1 list. */
|
|
35
|
+
listHeading?: string;
|
|
36
|
+
overview?: { heading: string; html: TrustedHtml; guide?: { title: string; lead: string; href: string; linkLabel: string } };
|
|
37
|
+
authors?: { heading: string; items: TopicAuthorCard[] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { eyebrow, heading, lede, articles, categories, category, currentPage, listHeading, overview, authors } = Astro.props;
|
|
41
|
+
const hub = currentPage === 1;
|
|
42
|
+
// Astro.slots.has() is true for a slotted expression that evaluated to false, so test what it rendered.
|
|
43
|
+
const faqHtml = hub && Astro.slots.has('faq') ? (await Astro.slots.render('faq')).trim() : '';
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
{hub ? (
|
|
47
|
+
<ArticlesListingPage eyebrow={eyebrow} heading={heading} lede={lede} pagefindBody>
|
|
48
|
+
<slot name="breadcrumb" slot="breadcrumb" />
|
|
49
|
+
<slot name="header-extra" slot="header-extra" />
|
|
50
|
+
<Container>
|
|
51
|
+
<ArticleCategoryNav articles={articles} categories={categories} current={category} class="mb-12" />
|
|
52
|
+
<slot name="start-here" /></Container>
|
|
53
|
+
|
|
54
|
+
{overview && (
|
|
55
|
+
<section id="overview" class="mb-14">
|
|
56
|
+
<Container size="measure">
|
|
57
|
+
<h2 class="text-2xl sm:text-3xl font-normal font-display text-foreground mb-4">{overview.heading}</h2>
|
|
58
|
+
<slot name="overview-byline" />
|
|
59
|
+
<div class="prose prose-lg max-w-none mt-6" set:html={overview.html} />
|
|
60
|
+
{overview.guide && (
|
|
61
|
+
<Callout type="tip" title={overview.guide.title} class="mt-8">
|
|
62
|
+
{overview.guide.lead}<a href={overview.guide.href} class="font-medium text-primary hover:underline">{overview.guide.linkLabel}</a>
|
|
63
|
+
</Callout>
|
|
64
|
+
)}
|
|
65
|
+
</Container>
|
|
66
|
+
</section>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<Container>
|
|
70
|
+
<section id="all-articles">
|
|
71
|
+
{listHeading && <h2 class="text-2xl sm:text-3xl font-normal font-display text-foreground mb-6">{listHeading}</h2>}
|
|
72
|
+
<slot name="grid" />
|
|
73
|
+
<slot name="pagination" />
|
|
74
|
+
</section>
|
|
75
|
+
<slot name="go-deeper" /><slot name="places" /></Container>
|
|
76
|
+
|
|
77
|
+
{faqHtml && (
|
|
78
|
+
<Container size="measure" class="mt-16">
|
|
79
|
+
<Fragment set:html={faqHtml} />
|
|
80
|
+
</Container>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{authors && authors.items.length > 0 && (
|
|
84
|
+
<Container class="mt-16">
|
|
85
|
+
<section id="authors" aria-labelledby="topic-authors-heading">
|
|
86
|
+
<h2 id="topic-authors-heading" class="text-xl font-semibold font-display text-foreground mb-5">{authors.heading}</h2>
|
|
87
|
+
<div class="grid gap-4 sm:grid-cols-3">
|
|
88
|
+
{authors.items.map((a) => (
|
|
89
|
+
<a href={a.href} class="block rounded-xl border border-border bg-card p-5 hover:border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50">
|
|
90
|
+
<span class="block font-semibold text-foreground">{a.name}</span>
|
|
91
|
+
<span class="mt-1 block text-xs text-muted-foreground">{a.countLabel}</span>
|
|
92
|
+
{a.credentials && <span class="mt-2 block text-sm text-muted-foreground line-clamp-3">{a.credentials}</span>}
|
|
93
|
+
</a>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
</Container>
|
|
98
|
+
)}
|
|
99
|
+
</ArticlesListingPage>
|
|
100
|
+
) : (
|
|
101
|
+
<ArticlesListingPage eyebrow={eyebrow} heading={heading} lede={lede}>
|
|
102
|
+
<slot name="breadcrumb" slot="breadcrumb" />
|
|
103
|
+
<Container>
|
|
104
|
+
<ArticleCategoryNav articles={articles} categories={categories} current={category} class="mb-10" />
|
|
105
|
+
<slot name="grid" />
|
|
106
|
+
<slot name="pagination" />
|
|
107
|
+
</Container>
|
|
108
|
+
</ArticlesListingPage>
|
|
109
|
+
)}
|
package/src/editorial/index.ts
CHANGED
|
@@ -13,7 +13,9 @@ export { default as AuthorCard } from './AuthorCard.astro';
|
|
|
13
13
|
export { default as ArticleCard } from './ArticleCard.astro';
|
|
14
14
|
export { default as ArticleGrid } from './ArticleGrid.astro';
|
|
15
15
|
export { default as ArticleCategoryNav } from './ArticleCategoryNav.astro';
|
|
16
|
-
export
|
|
16
|
+
export { default as ArticleTopicPage } from './ArticleTopicPage.astro';
|
|
17
|
+
export { rankByCount } from './topic';
|
|
18
|
+
export type { AuthorSummary, ArticleSummary, KeyFact, SourceItem, ArticleCategoryLink, ArticleCategoryMeta, TopicAuthorCard } from './types';
|
|
17
19
|
export { default as InsiderTips } from './InsiderTips.astro';
|
|
18
20
|
export type { InsiderTip, InsiderTipAuthor, InsiderTipKinds, InsiderTipTone } from './types';
|
|
19
21
|
export { default as AuthorStrip } from './AuthorStrip.astro';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys that occur most often, with their counts, most frequent first. Ties keep first-seen order, so a topic hub
|
|
3
|
+
* ranks its authors or themed collections the same way on every build.
|
|
4
|
+
*/
|
|
5
|
+
export function rankByCount(keys: Iterable<string>, limit: number): [string, number][] {
|
|
6
|
+
const counts = new Map<string, number>();
|
|
7
|
+
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
8
|
+
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
9
|
+
}
|
package/src/editorial/types.ts
CHANGED
|
@@ -42,6 +42,14 @@ export interface ArticleCategoryMeta {
|
|
|
42
42
|
icon?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** One author card at the foot of an article topic hub. */
|
|
46
|
+
export interface TopicAuthorCard {
|
|
47
|
+
href: string;
|
|
48
|
+
name: string;
|
|
49
|
+
countLabel: string;
|
|
50
|
+
credentials?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/** One category chip in an article topic nav. */
|
|
46
54
|
export interface ArticleCategoryLink {
|
|
47
55
|
href: string;
|