@sarimarcus/content-sites-ui 0.25.0 → 0.26.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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.26.0 (2026-10-05)
6
+
7
+ - `./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).
8
+ ### Breaking
9
+
10
+ - `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).
11
+
12
+ - `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).
13
+
5
14
  ## 0.25.0 (2026-10-05)
6
15
 
7
16
  - `./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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.25.0",
3
+ "version": "0.26.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.25.0"
52
+ "@sarimarcus/content-sites-core": "0.26.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 { AffiliateCardLabels } from './types';
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
- "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",
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="absolute top-3 left-3 z-10 pointer-events-none !px-2.5 !py-1 !font-semibold"><Fragment set:html={templateLine(bestSeller)} /></Badge>
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="absolute top-3 left-3 z-10 pointer-events-none !px-2.5 !py-1 !font-semibold"><Fragment set:html={templateLine(topRated)} /></Badge>
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="p-4 sm:p-5 flex flex-col flex-1">
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="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"
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 type { AffiliateCardItem } from './types';
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?: Record<string, unknown>;
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 = 'py-12 bg-muted/30 border-t border-border',
32
- headingClass = 'text-3xl sm:text-4xl font-normal font-display',
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 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];
41
+ const imageSizes = bandImageSizes(visible.length);
56
42
  ---
57
43
 
58
44
  {
59
45
  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>
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?: Record<string, unknown>;
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
- "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
+ 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="absolute top-3 left-3 z-10 pointer-events-none !px-2.5 !py-1 !font-semibold">
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="p-4 sm:p-5 flex flex-col flex-1">
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="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
+ 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
- <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>
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
- type Kind = 'hotel' | 'activity' | 'train' | 'car';
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';
@@ -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';
@@ -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 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));
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
  }
@@ -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;