@sarimarcus/content-sites-tourism 0.11.0 → 0.12.1

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,14 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.12.1 (2026-10-01)
6
+
7
+ - `ItineraryTeaser` takes Granada's journey design (owner decision): badge, numbered stops with an optional duration each, large CTA (SLA-2739).
8
+
9
+ ## 0.12.0 (2026-10-01)
10
+
11
+ - `./sections`: WalkStops, RankedListingRow, WhoItsFor, NeighborhoodCategory, SituationalNav, NeighborhoodHubHero (bundled controller, once per instance), ItineraryTeaser, HomeMonuments. `./templates`: NeighborhoodFacetPage, ProximityFacetPage, TierFacetPage — the route keeps data, paths, SEO and schema; the template owns the repeated markup (SLA-2739).
12
+
5
13
  ## 0.11.0 (2026-10-01)
6
14
 
7
15
  - First release with components: `./cards` exports MonumentCard (also as MuseumCard), WalkCard, DishCard, RestaurantCard, BarCard, NeighborhoodCard, HotelCard and ActivityCard. Every visible string, image, href and data-key label arrives from the site adapter; the cards carry layout and theme roles only (SLA-2739).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-tourism",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "City-tourism vertical: city-entity components, pages templates and utilities for the tourism sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "peerDependencies": {
36
36
  "astro": "^7.0.6",
37
37
  "tailwindcss": "^4.1.18",
38
- "@sarimarcus/content-sites-core": "0.11.0",
39
- "@sarimarcus/content-sites-ui": "0.11.0"
38
+ "@sarimarcus/content-sites-core": "0.12.1",
39
+ "@sarimarcus/content-sites-ui": "0.12.1"
40
40
  }
41
41
  }
@@ -0,0 +1,70 @@
1
+ ---
2
+ /**
3
+ * HomeMonuments — the home page's monuments band: badge, heading, tagline and a view-all link over a card grid, then
4
+ * an optional panel of context guides. The site adapter renders its monument cards in the default slot and its guide
5
+ * cards in the `guides` slot; the panel shows only when `guidesHeading` is given and that slot renders something.
6
+ */
7
+ import { Badge, Icon, LinkArrow } from '@sarimarcus/content-sites-ui/primitives';
8
+ import { Container } from '@sarimarcus/content-sites-ui/layout';
9
+
10
+ interface Props {
11
+ /** Section anchor id. */
12
+ id: string;
13
+ badgeLabel: string;
14
+ heading: string;
15
+ tagline: string;
16
+ viewAllHref: string;
17
+ viewAllLabel: string;
18
+ guidesHeading?: string;
19
+ guidesNote?: string;
20
+ }
21
+
22
+ const { id, badgeLabel, heading, tagline, viewAllHref, viewAllLabel, guidesHeading, guidesNote } = Astro.props;
23
+
24
+ // Astro.slots.has() is true for a conditional slot whose condition is false, so test what the slot renders.
25
+ const guidesHtml = guidesHeading && Astro.slots.has('guides') ? (await Astro.slots.render('guides')).trim() : '';
26
+ const showGuides = guidesHtml !== '';
27
+ ---
28
+
29
+ <section id={id} data-guide-section class="py-16 lg:py-24">
30
+ <Container>
31
+ <div class="mb-10 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
32
+ <div>
33
+ <Badge tone="custom" toneClass="bg-primary/10 text-primary" size="lg" lines={0} class="mb-4 !py-1 ![font-weight:inherit] !gap-2">
34
+ <Icon name="building" class="h-4 w-4" />
35
+ {badgeLabel}
36
+ </Badge>
37
+ <h2 class="text-3xl sm:text-4xl lg:text-5xl font-normal text-foreground font-display">{heading}</h2>
38
+ <p class="mt-3 max-w-deck text-muted-foreground">
39
+ {tagline}
40
+ </p>
41
+ </div>
42
+ <LinkArrow href={viewAllHref} size="base" color="primary" class="shrink-0">
43
+ {viewAllLabel}
44
+ </LinkArrow>
45
+ </div>
46
+
47
+ <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
48
+ <slot />
49
+ </div>
50
+
51
+ {showGuides && (
52
+ <div class="mt-8 rounded-2xl border border-border bg-card p-5 lg:p-6">
53
+ <div class="mb-4 flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
54
+ <div>
55
+ <h3 class="text-xl font-semibold font-display">{guidesHeading}</h3>
56
+ </div>
57
+ {guidesNote && (
58
+ <p class="max-w-deck text-sm text-muted-foreground">
59
+ {guidesNote}
60
+ </p>
61
+ )}
62
+ </div>
63
+
64
+ <div class="grid gap-4 md:grid-cols-3">
65
+ <Fragment set:html={guidesHtml} />
66
+ </div>
67
+ </div>
68
+ )}
69
+ </Container>
70
+ </section>
@@ -0,0 +1,99 @@
1
+ ---
2
+ /**
3
+ * ItineraryTeaser — dark gradient panel on the neighbourhoods hub: a numbered multi-stop journey joined by a line on
4
+ * desktop, one card per stop and a large CTA. A stop's duration row renders only when the site has a duration for it.
5
+ * Hrefs and strings come from the site adapter.
6
+ */
7
+ import { Badge, Icon } from '@sarimarcus/content-sites-ui/primitives';
8
+
9
+ export interface ItineraryTeaserStop {
10
+ name: string;
11
+ href: string;
12
+ description: string;
13
+ duration?: string;
14
+ }
15
+
16
+ interface Props {
17
+ badgeLabel: string;
18
+ title: string;
19
+ description: string;
20
+ stops: ItineraryTeaserStop[];
21
+ ctaLabel: string;
22
+ ctaHref: string;
23
+ class?: string;
24
+ }
25
+
26
+ const {
27
+ badgeLabel,
28
+ title,
29
+ description,
30
+ stops,
31
+ ctaLabel,
32
+ ctaHref,
33
+ class: className,
34
+ } = Astro.props;
35
+ ---
36
+
37
+ <section class:list={['relative py-12 px-6 sm:px-8 bg-gradient-to-br from-overlay to-muted-foreground rounded-2xl overflow-hidden', className]}>
38
+ <div class="absolute top-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl" aria-hidden="true"></div>
39
+ <div class="absolute bottom-0 left-0 w-48 h-48 bg-accent/10 rounded-full blur-3xl" aria-hidden="true"></div>
40
+
41
+ <div class="relative">
42
+ <div class="mb-8">
43
+ <Badge tone="custom" toneClass="bg-accent/20 text-accent" size="lg" lines={0} class="mb-4 !gap-2">
44
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
45
+ <path d="M9 20l-5.447-2.724A1 1 0 0 1 3 16.382V5.618a1 1 0 0 1 1.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0 0 21 18.382V7.618a1 1 0 0 0-.553-.894L15 4m0 13V4m0 0L9 7" />
46
+ </svg>
47
+ {badgeLabel}
48
+ </Badge>
49
+ <h2 class="text-2xl sm:text-3xl font-display font-medium text-overlay-foreground mb-3">
50
+ {title}
51
+ </h2>
52
+ <p class="text-overlay-foreground/70 max-w-deck">
53
+ {description}
54
+ </p>
55
+ </div>
56
+
57
+ <div class="relative mt-12">
58
+ <div class="hidden md:block absolute top-8 left-8 right-8 h-0.5 bg-gradient-to-r from-primary via-accent to-secondary" aria-hidden="true"></div>
59
+
60
+ <div class="grid gap-6 md:grid-cols-3 relative">
61
+ {stops.map((stop, index) => (
62
+ <div class="relative">
63
+ <div class="absolute -top-4 left-0 md:left-1/2 md:-translate-x-1/2 w-8 h-8 rounded-full bg-overlay-foreground flex items-center justify-center text-foreground font-display font-medium text-sm z-10">
64
+ {index + 1}
65
+ </div>
66
+
67
+ <a
68
+ href={stop.href}
69
+ class="block mt-6 p-5 bg-overlay-foreground/5 hover:bg-overlay-foreground/10 border border-overlay-foreground/10 hover:border-overlay-foreground/20 rounded-xl transition-all duration-200 group"
70
+ >
71
+ <h3 class="text-lg font-display font-medium text-overlay-foreground mb-2 group-hover:text-accent transition-colors">
72
+ {stop.name}
73
+ </h3>
74
+ <p class:list={['text-sm text-overlay-foreground/60', stop.duration && 'mb-3']}>
75
+ {stop.description}
76
+ </p>
77
+ {stop.duration && (
78
+ <div class="flex items-center gap-2 text-xs text-overlay-foreground/50">
79
+ <Icon name="clock" class="w-3.5 h-3.5" />
80
+ {stop.duration}
81
+ </div>
82
+ )}
83
+ </a>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ </div>
88
+
89
+ <div class="mt-10 flex justify-center">
90
+ <a
91
+ href={ctaHref}
92
+ class="inline-flex items-center gap-3 px-6 py-3 bg-primary hover:bg-primary-strong text-overlay-foreground rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-overlay"
93
+ >
94
+ {ctaLabel}
95
+ <Icon name="arrow-right" class="w-4 h-4 transition-transform group-hover:translate-x-1" />
96
+ </a>
97
+ </div>
98
+ </div>
99
+ </section>
@@ -0,0 +1,90 @@
1
+ ---
2
+ /**
3
+ * NeighborhoodCategory — a thematic group of neighbourhood cards on the neighbourhoods hub, with a category-tinted
4
+ * header. Cards go in the default slot. The site adapter supplies the title, description and count label.
5
+ */
6
+
7
+ export type NeighborhoodCategoryType = 'historic' | 'authentic' | 'modern';
8
+
9
+ interface Props {
10
+ id: string;
11
+ title: string;
12
+ description: string;
13
+ category: NeighborhoodCategoryType;
14
+ /** e.g. "3 neighborhoods" */
15
+ countLabel: string;
16
+ class?: string;
17
+ }
18
+
19
+ const {
20
+ id,
21
+ title,
22
+ description,
23
+ category,
24
+ countLabel,
25
+ class: className,
26
+ } = Astro.props;
27
+
28
+ const categoryConfig: Record<NeighborhoodCategoryType, {
29
+ accentColor: string;
30
+ iconBg: string;
31
+ }> = {
32
+ 'historic': {
33
+ accentColor: 'text-primary',
34
+ iconBg: 'bg-primary/10',
35
+ },
36
+ 'authentic': {
37
+ accentColor: 'text-secondary',
38
+ iconBg: 'bg-secondary/10',
39
+ },
40
+ 'modern': {
41
+ accentColor: 'text-accent-strong',
42
+ iconBg: 'bg-accent/10',
43
+ },
44
+ };
45
+
46
+ const config = categoryConfig[category];
47
+
48
+ const categoryIcons: Record<NeighborhoodCategoryType, string> = {
49
+ 'historic': 'M3 21h18M3 7v1a3 3 0 0 0 6 0V7m0 1a3 3 0 0 0 6 0V7m0 1a3 3 0 0 0 6 0V7M6 21V7m6 0v14m6-14v14M4 7h16M4 21h16',
50
+ 'authentic': 'M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z M12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
51
+ 'modern': 'M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2 M18 14h-8M15 18h-5',
52
+ };
53
+ ---
54
+
55
+ <section
56
+ id={id}
57
+ class:list={['scroll-mt-20', className]}
58
+ data-category-section={category}
59
+ >
60
+ <div class="mb-8 pb-6">
61
+ <div class="flex items-start gap-4 mb-3">
62
+ <div class:list={['flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center', config.iconBg]}>
63
+ <svg class:list={['w-5 h-5', config.accentColor]} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64
+ <path d={categoryIcons[category]} />
65
+ </svg>
66
+ </div>
67
+
68
+ <div class="flex-1 min-w-0">
69
+ <h2 class:list={['text-2xl sm:text-3xl font-display font-normal', config.accentColor]}>
70
+ {title}
71
+ </h2>
72
+
73
+ <div class="mt-1.5">
74
+ <span class="inline-flex items-center text-xs font-medium text-muted-foreground">
75
+ {countLabel}
76
+ </span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <p class="text-base text-muted-foreground leading-relaxed">
82
+ {description}
83
+ </p>
84
+
85
+ </div>
86
+
87
+ <div class="grid gap-6 sm:gap-8 md:grid-cols-2 lg:grid-cols-3">
88
+ <slot />
89
+ </div>
90
+ </section>
@@ -0,0 +1,89 @@
1
+ ---
2
+ /**
3
+ * NeighborhoodHubHero — hero for the neighbourhoods hub: badge, title, narrative and in-page category pills that
4
+ * smooth-scroll via ./hubHero.ts. The site's own motif goes in the `decoration` slot (an absolutely positioned,
5
+ * aria-hidden layer). Every string comes from the site adapter.
6
+ */
7
+ import { Badge, Icon } from '@sarimarcus/content-sites-ui/primitives';
8
+ import { Container } from '@sarimarcus/content-sites-ui/layout';
9
+
10
+ export interface HubCategoryPill {
11
+ /** Id of the in-page section the pill scrolls to. */
12
+ id: string;
13
+ label: string;
14
+ count: number;
15
+ }
16
+
17
+ interface Props {
18
+ title: string;
19
+ subtitle: string;
20
+ narrativeText: string;
21
+ /** e.g. "8 unique neighborhoods" */
22
+ badgeLabel: string;
23
+ categories: HubCategoryPill[];
24
+ /** aria-label of the category pill nav. */
25
+ navLabel: string;
26
+ class?: string;
27
+ }
28
+
29
+ const {
30
+ title,
31
+ subtitle,
32
+ narrativeText,
33
+ badgeLabel,
34
+ categories,
35
+ navLabel,
36
+ class: className,
37
+ } = Astro.props;
38
+ ---
39
+
40
+ <section class:list={['relative overflow-hidden bg-gradient-to-br from-secondary via-secondary-strong to-overlay', className]}>
41
+ <slot name="decoration" />
42
+ <Container class="relative py-16 lg:py-24">
43
+ <div class="max-w-measure-wide mx-auto">
44
+ <div class="space-y-6">
45
+ <Badge tone="custom" toneClass="bg-overlay-foreground/10 text-overlay-foreground/90 backdrop-blur-sm border border-overlay-foreground/10" size="lg" lines={0} class="!px-4 !py-2 !gap-2">
46
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
47
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
48
+ <circle cx="12" cy="10" r="3" />
49
+ </svg>
50
+ {badgeLabel}
51
+ </Badge>
52
+
53
+ <h1 class="text-4xl sm:text-5xl lg:text-6xl font-display font-medium text-overlay-foreground leading-[1.1] tracking-tight">
54
+ {title}
55
+ </h1>
56
+
57
+ <p class="text-xl sm:text-2xl text-accent font-display italic">
58
+ {subtitle}
59
+ </p>
60
+
61
+ <p class="text-lg text-overlay-foreground/80 leading-relaxed max-w-deck">
62
+ {narrativeText}
63
+ </p>
64
+
65
+ <nav class="flex flex-wrap gap-3 pt-4" aria-label={navLabel} data-hub-category-nav>
66
+ {categories.map((cat) => (
67
+ <a
68
+ href={`#${cat.id}`}
69
+ class="group inline-flex items-center gap-2 px-4 py-2.5 bg-overlay-foreground/5 hover:bg-overlay-foreground/15 text-overlay-foreground border border-overlay-foreground/20 hover:border-overlay-foreground/40 rounded-lg text-sm font-medium transition-all duration-200 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-secondary"
70
+ >
71
+ <span>{cat.label}</span>
72
+ <span class="text-xs text-overlay-foreground/50 group-hover:text-overlay-foreground/70 transition-colors">
73
+ {cat.count}
74
+ </span>
75
+ <Icon name="chevron-down" class="w-3.5 h-3.5 opacity-50 group-hover:opacity-100 group-hover:translate-y-0.5 transition-all" />
76
+ </a>
77
+ ))}
78
+ </nav>
79
+ </div>
80
+ </div>
81
+ </Container>
82
+ </section>
83
+
84
+ <script>
85
+ import { initHubCategoryNavs } from './hubHero.ts';
86
+
87
+ initHubCategoryNavs();
88
+ document.addEventListener('astro:page-load', initHubCategoryNavs);
89
+ </script>
@@ -0,0 +1,145 @@
1
+ ---
2
+ /**
3
+ * RankedListingRow — one entry in a numbered editorial ranking list, e.g. a proximity page that reads top-to-bottom by
4
+ * distance. Rendered inside the caller's `<ol>`. The whole row is one real link, so the name stays selectable text.
5
+ * The site adapter resolves the image and supplies every visible string.
6
+ */
7
+ import { Picture } from 'astro:assets';
8
+ import type { ImageMetadata } from 'astro';
9
+ import { Icon } from '@sarimarcus/content-sites-ui/primitives';
10
+
11
+ interface Props {
12
+ rank: number;
13
+ href: string;
14
+ image?: ImageMetadata;
15
+ /** Shown when `image` is absent. */
16
+ fallbackSrc: string;
17
+ imageAlt: string;
18
+ name: string;
19
+ distanceText: string;
20
+ /** e.g. "6 min walk" */
21
+ walkLabel: string;
22
+ rating?: number;
23
+ /** Screen-reader suffix after the rating, e.g. "out of 5". */
24
+ ratingSuffix: string;
25
+ /** Cuisine, category or star line. */
26
+ meta?: string;
27
+ priceLine?: string;
28
+ specialties?: string[];
29
+ description: string;
30
+ ctaLabel: string;
31
+ }
32
+
33
+ const {
34
+ rank,
35
+ href,
36
+ image,
37
+ fallbackSrc,
38
+ imageAlt,
39
+ name,
40
+ distanceText,
41
+ walkLabel,
42
+ rating,
43
+ ratingSuffix,
44
+ meta,
45
+ priceLine,
46
+ specialties = [],
47
+ description,
48
+ ctaLabel,
49
+ } = Astro.props;
50
+
51
+ const paddedRank = String(rank).padStart(2, '0');
52
+ ---
53
+
54
+ <li class="relative group border-b border-border last:border-b-0">
55
+ <a
56
+ href={href}
57
+ class="grid grid-cols-[auto_1fr] gap-x-4 sm:gap-x-6 py-6 sm:py-7 items-start focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary rounded-sm"
58
+ >
59
+ <span
60
+ class="font-display text-2xl sm:text-3xl text-primary/70 tabular-nums leading-none pt-1 w-10 sm:w-14 shrink-0"
61
+ aria-hidden="true"
62
+ >
63
+ {paddedRank}
64
+ </span>
65
+
66
+ <div class="col-start-2 grid grid-cols-[5.5rem_1fr] sm:grid-cols-[9rem_1fr] gap-4 sm:gap-6 w-full min-w-0">
67
+ <div class="relative aspect-square rounded-lg overflow-hidden img-placeholder border border-border shrink-0">
68
+ {image ? (
69
+ <Picture
70
+ src={image}
71
+ alt={imageAlt}
72
+ widths={[160, 240, 320]}
73
+ sizes="(max-width: 639px) 88px, 144px"
74
+ formats={['avif', 'webp']}
75
+ fallbackFormat="webp"
76
+ pictureAttributes={{ class: 'block w-full h-full' }}
77
+ class="w-full h-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
78
+ loading={rank <= 2 ? 'eager' : 'lazy'}
79
+ decoding="async"
80
+ />
81
+ ) : (
82
+ <img
83
+ src={fallbackSrc}
84
+ alt={imageAlt}
85
+ width="320"
86
+ height="320"
87
+ class="w-full h-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
88
+ loading={rank <= 2 ? 'eager' : 'lazy'}
89
+ decoding="async"
90
+ />
91
+ )}
92
+ </div>
93
+
94
+ <div class="min-w-0">
95
+ <div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
96
+ <h3 class="text-lg font-semibold font-display text-card-foreground leading-tight group-hover:text-primary-strong transition-colors">
97
+ {name}
98
+ </h3>
99
+ {typeof rating === 'number' && (
100
+ <span class="inline-flex items-center gap-1 text-sm tabular-nums">
101
+ <Icon name="star" class="w-3.5 h-3.5 text-accent fill-current" />
102
+ <span class="font-medium">{rating.toFixed(1)}</span>
103
+ <span class="sr-only">{ratingSuffix}</span>
104
+ </span>
105
+ )}
106
+ </div>
107
+
108
+ <p class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
109
+ <span class="inline-flex items-center gap-1.5 font-medium text-secondary">
110
+ <Icon name="footprints" class="w-4 h-4" />
111
+ {walkLabel}
112
+ </span>
113
+ <span class="text-muted-foreground tabular-nums" aria-hidden="true">·</span>
114
+ <span class="text-muted-foreground tabular-nums">{distanceText}</span>
115
+ {meta && (
116
+ <>
117
+ <span class="text-muted-foreground" aria-hidden="true">·</span>
118
+ <span class="text-muted-foreground">{meta}</span>
119
+ </>
120
+ )}
121
+ </p>
122
+
123
+ <p class="mt-2 text-sm sm:text-base text-muted-foreground leading-relaxed line-clamp-2 text-pretty">
124
+ {description}
125
+ </p>
126
+
127
+ {(priceLine || specialties.length > 0) && (
128
+ <p class="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
129
+ {priceLine && (
130
+ <span class="font-medium text-card-foreground tabular-nums">{priceLine}</span>
131
+ )}
132
+ {specialties.length > 0 && (
133
+ <span class="text-muted-foreground truncate">{specialties.slice(0, 3).join(', ')}</span>
134
+ )}
135
+ </p>
136
+ )}
137
+
138
+ <span class="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary group-hover:text-primary-strong transition-colors">
139
+ {ctaLabel}
140
+ <Icon name="arrow-right" class="w-4 h-4 transition-transform group-hover:translate-x-1" />
141
+ </span>
142
+ </div>
143
+ </div>
144
+ </a>
145
+ </li>
@@ -0,0 +1,120 @@
1
+ ---
2
+ /**
3
+ * SituationalNav — visitor-scenario cards on the neighbourhoods hub ("one day", "with family"…), each naming the
4
+ * neighbourhoods that suit it. Every scenario, href and string comes from the site adapter. A CTA pointing at an
5
+ * in-page anchor gets a down chevron, any other href a right arrow.
6
+ */
7
+ import { Icon } from '@sarimarcus/content-sites-ui/primitives';
8
+
9
+ export interface SituationalScenario {
10
+ title: string;
11
+ description: string;
12
+ /** Display names, joined with a middle dot. */
13
+ neighborhoods: string[];
14
+ cta: { label: string; href: string };
15
+ secondaryLink?: { label: string; href: string };
16
+ /** SVG path data for the 24×24 icon. */
17
+ icon: string;
18
+ tone: 'primary' | 'secondary' | 'accent';
19
+ }
20
+
21
+ interface Props {
22
+ heading: string;
23
+ intro: string;
24
+ scenarios: SituationalScenario[];
25
+ class?: string;
26
+ }
27
+
28
+ const { heading, intro, scenarios, class: className } = Astro.props;
29
+
30
+ const toneConfig = {
31
+ primary: {
32
+ bg: 'bg-primary/5 hover:bg-primary/10',
33
+ border: 'border-primary/20 hover:border-primary/40',
34
+ icon: 'text-primary',
35
+ iconBg: 'bg-primary/10',
36
+ text: 'text-primary-strong',
37
+ cta: 'text-primary hover:text-primary-strong',
38
+ },
39
+ secondary: {
40
+ bg: 'bg-secondary/5 hover:bg-secondary/10',
41
+ border: 'border-secondary/20 hover:border-secondary/40',
42
+ icon: 'text-secondary',
43
+ iconBg: 'bg-secondary/10',
44
+ text: 'text-secondary-strong',
45
+ cta: 'text-secondary hover:text-secondary-strong',
46
+ },
47
+ accent: {
48
+ bg: 'bg-accent/5 hover:bg-accent/10',
49
+ border: 'border-accent/20 hover:border-accent/40',
50
+ icon: 'text-accent-strong',
51
+ iconBg: 'bg-accent/10',
52
+ text: 'text-accent-strong',
53
+ cta: 'text-accent-strong hover:text-foreground',
54
+ },
55
+ };
56
+ ---
57
+
58
+ <section class:list={['py-12', className]}>
59
+ <div class="mb-6">
60
+ <h2 class="text-2xl sm:text-3xl font-display font-normal text-foreground mb-3">
61
+ {heading}
62
+ </h2>
63
+ <p class="text-muted-foreground max-w-deck">
64
+ {intro}
65
+ </p>
66
+ </div>
67
+
68
+ <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
69
+ {scenarios.map((scenario) => {
70
+ const colors = toneConfig[scenario.tone];
71
+ return (
72
+ <article class:list={[
73
+ 'relative p-6 rounded-xl border transition-all duration-200',
74
+ colors.bg,
75
+ colors.border
76
+ ]}>
77
+ <div class:list={['w-12 h-12 rounded-xl flex items-center justify-center mb-4', colors.iconBg]}>
78
+ <svg class:list={['w-6 h-6', colors.icon]} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
79
+ <path d={scenario.icon} />
80
+ </svg>
81
+ </div>
82
+
83
+ <h3 class:list={['text-lg font-display font-medium mb-2', colors.text]}>
84
+ {scenario.title}
85
+ </h3>
86
+ <p class="text-base text-muted-foreground mb-4">
87
+ {scenario.description}
88
+ </p>
89
+
90
+ <p class="text-xs text-muted-foreground mb-4">
91
+ {scenario.neighborhoods.join(' · ')}
92
+ </p>
93
+
94
+ <a
95
+ href={scenario.cta.href}
96
+ class:list={['inline-flex items-center gap-2 text-sm font-medium transition-colors', colors.cta]}
97
+ >
98
+ {scenario.cta.label}
99
+ {scenario.cta.href.startsWith('#') ? (
100
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
101
+ <path d="M19 14l-7 7-7-7" />
102
+ </svg>
103
+ ) : (
104
+ <Icon name="arrow-right" class="w-4 h-4" />
105
+ )}
106
+ </a>
107
+ {scenario.secondaryLink && (
108
+ <a
109
+ href={scenario.secondaryLink.href}
110
+ class="mt-2 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
111
+ >
112
+ {scenario.secondaryLink.label}
113
+ <Icon name="arrow-right" class="w-3 h-3" />
114
+ </a>
115
+ )}
116
+ </article>
117
+ );
118
+ })}
119
+ </div>
120
+ </section>