@sarimarcus/content-sites-tourism 0.15.1 → 0.16.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,10 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.16.0 (2026-10-01)
6
+
7
+ - `./sections`: `TourismNavigation`, the tourism header (Explore, Articles, Eat & Drink, Plan with a month strip, Itineraries, Events, mobile accordion) on the ui navigation shells, with every link, label and count passed by the site (`TourismNavigationProps`); `navMonths` and `upcomingEventLinks` helpers (SLA-2735).
8
+
5
9
  ## 0.15.1 (2026-10-01)
6
10
 
7
11
  - No changes; lockstep release with ui (SLA-2736).
package/README.md CHANGED
@@ -7,6 +7,10 @@ Named entry points: `cards`, `sections`, `templates`, `schema` and `types` (`src
7
7
  `schema` holds the city-entity JSON-LD builders (monument, museum, restaurant, bar, dish, neighborhood, guide item
8
8
  list) on core `schema`, and the `TourismCity` address policy they share.
9
9
  Page templates are rendered by thin route files in each site.
10
+ `sections` also holds `TourismNavigation`, the tourism header (Explore, Articles, Eat & Drink, Plan with a month
11
+ strip, Itineraries, Events and the mobile accordion) on the ui navigation shells. The site's `Navigation.astro`
12
+ passes every link, label and count as props (`TourismNavigationProps`) and its logo through the `logo` slot, so the
13
+ hrefs stay in the site where the link gate reads them.
10
14
 
11
15
  Depends on `@sarimarcus/content-sites-core` and `@sarimarcus/content-sites-ui` (exact lockstep peers).
12
16
  Nothing may depend on it. Sites import `@sarimarcus/content-sites-tourism/tailwind.css` the same way as ui's.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-tourism",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
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",
@@ -36,7 +36,7 @@
36
36
  "peerDependencies": {
37
37
  "astro": "^7.0.6",
38
38
  "tailwindcss": "^4.1.18",
39
- "@sarimarcus/content-sites-core": "0.15.1",
40
- "@sarimarcus/content-sites-ui": "0.15.1"
39
+ "@sarimarcus/content-sites-core": "0.16.0",
40
+ "@sarimarcus/content-sites-ui": "0.16.0"
41
41
  }
42
42
  }
@@ -0,0 +1,245 @@
1
+ ---
2
+ /**
3
+ * The tourism sites' header: six dropdowns (Explore, Articles, Eat & Drink, Plan with a month strip, Itineraries,
4
+ * Events) and the matching mobile accordion, on the ui navigation shells. The site passes every link and label
5
+ * (see tourismNavigation.ts) and its logo through the `logo` slot. A link is current when it is the page or a page
6
+ * below it; hub links ("all …") are current only on their own page.
7
+ */
8
+ import { normalizePagePath } from '@sarimarcus/content-sites-core/url';
9
+ import { Chip, Icon } from '@sarimarcus/content-sites-ui/primitives';
10
+ import { SiteHeader, NavDropdown, MobileNavSection, NavMenuLink, MobileNavLink, isActivePath, type NavLink } from '@sarimarcus/content-sites-ui/navigation';
11
+ import type { TourismNavigationProps } from './tourismNavigation.ts';
12
+
13
+ type Props = TourismNavigationProps;
14
+
15
+ const { explore, articles, eatDrink, plan, itineraries, events, search } = Astro.props;
16
+
17
+ const currentPath = normalizePagePath(Astro.url.pathname);
18
+ const on = (href: string) => isActivePath(currentPath, href);
19
+ const here = (href: string) => normalizePagePath(href) === currentPath;
20
+ const anyOn = (links: NavLink[]) => links.some((l) => on(l.href));
21
+
22
+ const exploreActive = anyOn(explore.links) || on(explore.allGuides.href) || on(explore.bestOf.href);
23
+ const articlesActive = on(articles.all.href);
24
+ const eatDrinkActive = anyOn(eatDrink.links);
25
+ const planActive = anyOn(plan.links) || on(plan.hub.href);
26
+ const itinerariesActive = itineraries.durations.some((d) => on(d.href)) || anyOn(itineraries.connections) || on(itineraries.all.href);
27
+ const eventsActive = anyOn(events.upcoming) || anyOn(events.guides) || on(events.all.href);
28
+
29
+ const panel = 'bg-card rounded-xl border border-border shadow-xl shadow-foreground/10 overflow-hidden';
30
+ const heading = 'text-xs font-semibold text-muted-foreground uppercase tracking-wide';
31
+ const monthTone = (m: { current?: boolean; peak?: boolean }, hover: boolean) =>
32
+ m.current
33
+ ? 'bg-secondary text-secondary-foreground'
34
+ : m.peak
35
+ ? `bg-secondary/15 text-secondary border border-secondary/30${hover ? ' hover:bg-secondary/25' : ''}`
36
+ : `bg-muted text-muted-foreground${hover ? ' hover:bg-muted/80 hover:text-foreground' : ''}`;
37
+ ---
38
+
39
+ <SiteHeader searchPath={search.path}>
40
+ <slot name="logo" slot="logo" />
41
+
42
+ <Fragment slot="desktop">
43
+ <NavDropdown id="nav-explore-menu" label={explore.label} icon={explore.icon} tone="primary" active={exploreActive} align="left">
44
+ <div class={`w-56 ${panel}`}>
45
+ <div class="p-2">
46
+ {explore.links.map((link) => <NavMenuLink link={link} active={on(link.href)} />)}
47
+ </div>
48
+ <div class="border-t border-border p-2">
49
+ <NavMenuLink link={explore.allGuides} align="center" emphasis active={here(explore.allGuides.href)} />
50
+ <NavMenuLink link={explore.bestOf} align="center" active={here(explore.bestOf.href)} />
51
+ </div>
52
+ </div>
53
+ </NavDropdown>
54
+
55
+ <NavDropdown id="nav-articles-menu" label={articles.label} icon={articles.icon} tone="primary" active={articlesActive} align="left">
56
+ <div class={`w-[34rem] ${panel}`}>
57
+ <div class="grid grid-cols-2">
58
+ <div class="p-2 border-r border-border">
59
+ <div class={`px-3 pt-2 pb-1 ${heading}`}>{articles.topicsHeading}</div>
60
+ {articles.topics.map((link) => <NavMenuLink link={link} size="sm" active={on(link.href)} />)}
61
+ </div>
62
+ <div class="p-2 flex flex-col">
63
+ <div class={`px-3 pt-2 pb-1 ${heading}`}>{articles.latestHeading}</div>
64
+ {articles.latest.map((link) => <NavMenuLink link={link} active={on(link.href)} />)}
65
+ <div class="mt-auto border-t border-border pt-2">
66
+ <NavMenuLink link={articles.all} align="center" emphasis active={here(articles.all.href)} />
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </NavDropdown>
72
+
73
+ <NavDropdown id="nav-eat-drink-menu" label={eatDrink.label} icon={eatDrink.icon} tone="primary" active={eatDrinkActive} align="left">
74
+ <div class={`w-56 ${panel}`}>
75
+ <div class="p-2">
76
+ {eatDrink.links.map((link) => <NavMenuLink link={link} active={on(link.href)} />)}
77
+ </div>
78
+ </div>
79
+ </NavDropdown>
80
+
81
+ <NavDropdown id="nav-plan-menu" label={plan.label} icon={plan.icon} tone="secondary" active={planActive} align="center">
82
+ <div class={`w-72 ${panel}`}>
83
+ <div class="p-2">
84
+ {plan.links.map((link) => <NavMenuLink link={link} tone="secondary" size="sm" active={on(link.href)} />)}
85
+ </div>
86
+ <div class="border-t border-border p-2">
87
+ <NavMenuLink link={plan.hub} tone="secondary" align="center" emphasis active={here(plan.hub.href)} />
88
+ </div>
89
+ <div class="border-t border-border px-3 py-2.5">
90
+ <div class={`${heading} mb-2`}>{plan.monthsHeading}</div>
91
+ <div class="flex flex-wrap gap-1.5">
92
+ {plan.months.map((m) => (
93
+ <Chip href={m.href} size="xs" tone="custom" accent="secondary" toneClass={monthTone(m, true)}>{m.label}</Chip>
94
+ ))}
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </NavDropdown>
99
+
100
+ <NavDropdown id="nav-itineraries-menu" label={itineraries.label} icon={itineraries.icon} tone="secondary" active={itinerariesActive} align="center">
101
+ <div class={`w-[32rem] ${panel}`}>
102
+ <div class="grid grid-cols-2">
103
+ <div class="p-3 border-r border-border">
104
+ <div class={`px-1 pt-1 pb-2 ${heading}`}>{itineraries.durationsHeading}</div>
105
+ <div class="flex flex-col gap-1.5">
106
+ {itineraries.durations.map((d) => (
107
+ <a
108
+ href={d.href}
109
+ aria-current={on(d.href) ? 'page' : undefined}
110
+ class:list={['flex items-center gap-3 p-2.5 rounded-lg border hover:bg-secondary/5 hover:border-secondary/25 transition-colors group focus-visible:ring-2 focus-visible:ring-secondary focus-visible:ring-offset-2', on(d.href) ? 'bg-secondary/8 border-secondary/30' : 'border-border bg-card']}
111
+ >
112
+ <div class="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary/10 group-hover:bg-secondary/20 transition-colors shrink-0">
113
+ <span class="text-sm font-bold text-secondary">{d.days}</span>
114
+ </div>
115
+ <div>
116
+ <span class="font-medium text-foreground group-hover:text-secondary transition-colors block text-sm">{d.label}</span>
117
+ <span class="text-xs text-muted-foreground">{d.description}</span>
118
+ </div>
119
+ </a>
120
+ ))}
121
+ </div>
122
+ <a href={itineraries.all.href} class="flex items-center gap-1.5 px-1 pt-3 text-xs font-medium text-secondary hover:text-secondary-strong transition-colors focus-visible:ring-2 focus-visible:ring-secondary focus-visible:ring-offset-2">
123
+ {itineraries.all.label}
124
+ <Icon name="arrow-right" class="w-3 h-3" />
125
+ </a>
126
+ </div>
127
+ <div class="p-3">
128
+ <div class={`px-1 pb-1 ${heading}`}>{itineraries.featuredHeading}</div>
129
+ <a
130
+ href={itineraries.featured.href}
131
+ aria-current={on(itineraries.featured.href) ? 'page' : undefined}
132
+ class:list={['flex items-start gap-3 p-2.5 rounded-lg bg-secondary/5 border border-secondary/15 hover:bg-secondary/10 transition-colors group focus-visible:ring-2 focus-visible:ring-secondary focus-visible:ring-offset-2 mb-3', on(itineraries.featured.href) ? 'border-secondary/30' : '']}
133
+ >
134
+ <div class="w-5 h-5 flex items-center justify-center shrink-0 mt-0.5">
135
+ <Icon name={itineraries.featured.icon ?? 'map'} class="w-3.5 h-3.5 text-secondary" />
136
+ </div>
137
+ <div>
138
+ <span class="font-medium text-secondary group-hover:text-secondary-strong transition-colors block text-sm">{itineraries.featured.label}</span>
139
+ {itineraries.featured.description && <span class="text-xs text-muted-foreground">{itineraries.featured.description}</span>}
140
+ </div>
141
+ </a>
142
+ <div class={`px-1 pb-1 ${heading}`}>{itineraries.connectionsHeading}</div>
143
+ {itineraries.connections.map((link) => <NavMenuLink link={link} tone="secondary" size="sm" active={on(link.href)} />)}
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </NavDropdown>
148
+
149
+ <NavDropdown id="nav-events-menu" label={events.label} icon={events.icon} tone="accent" active={eventsActive} align="right">
150
+ <div class={`w-56 ${panel}`}>
151
+ {events.upcoming.length > 0 && (
152
+ <div class="p-2">
153
+ <div class={`px-3 pt-1 pb-1 ${heading}`}>{events.upcomingHeading}</div>
154
+ {events.upcoming.map((link) => <NavMenuLink link={link} tone="accent" active={on(link.href)} />)}
155
+ </div>
156
+ )}
157
+ <div class="border-t border-border p-2">
158
+ <div class={`px-3 pt-1 pb-1 ${heading}`}>{events.guidesHeading}</div>
159
+ {events.guides.map((link) => <NavMenuLink link={link} tone="accent" size="sm" active={on(link.href)} />)}
160
+ </div>
161
+ <div class="border-t border-border p-2">
162
+ <NavMenuLink link={events.all} tone="accent" align="center" active={on(events.all.href)} />
163
+ </div>
164
+ </div>
165
+ </NavDropdown>
166
+
167
+ <a
168
+ href={search.href}
169
+ class="search-icon-desktop flex items-center gap-1.5 ml-2 h-9 px-2.5 xl:px-3 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted border border-border hover:border-primary/40 transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 no-underline"
170
+ data-search-trigger
171
+ aria-label={search.label}
172
+ aria-keyshortcuts="Control+K Meta+K"
173
+ >
174
+ <Icon name="search" class="w-4 h-4 shrink-0" />
175
+ <span class="sr-only xl:not-sr-only xl:text-sm xl:font-medium">{search.text}</span>
176
+ </a>
177
+ </Fragment>
178
+
179
+ <a
180
+ slot="mobile-actions"
181
+ href={search.href}
182
+ id="mobile-search-btn"
183
+ class="p-2 min-h-[44px] min-w-[44px] flex items-center justify-center rounded-lg transition-colors text-foreground hover:bg-muted cursor-pointer focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 no-underline"
184
+ aria-label={search.mobileLabel}
185
+ data-search-trigger
186
+ >
187
+ <Icon name="search" class="w-5 h-5" />
188
+ </a>
189
+
190
+ <Fragment slot="mobile">
191
+ <MobileNavSection label={explore.label} icon={explore.icon}>
192
+ {explore.links.map((link) => <MobileNavLink link={link} active={on(link.href)} />)}
193
+ <MobileNavLink link={explore.allGuides} emphasis="semibold" active={here(explore.allGuides.href)} />
194
+ <MobileNavLink link={explore.bestOf} iconStrong active={here(explore.bestOf.href)} />
195
+ </MobileNavSection>
196
+
197
+ <MobileNavSection label={articles.label} icon={articles.icon} class="border-t border-border">
198
+ <MobileNavLink link={{ ...articles.all, label: articles.all.mobileLabel ?? articles.all.label }} emphasis="semibold" active={here(articles.all.href)} />
199
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{articles.topicsHeading}</div>
200
+ {articles.topics.map((link) => <MobileNavLink link={link} active={on(link.href)} />)}
201
+ </MobileNavSection>
202
+
203
+ <MobileNavSection label={eatDrink.label} icon={eatDrink.icon} class="border-t border-border">
204
+ {eatDrink.links.map((link) => <MobileNavLink link={link} active={on(link.href)} />)}
205
+ </MobileNavSection>
206
+
207
+ <MobileNavSection label={plan.label} icon={plan.icon} iconClass="w-4 h-4 text-secondary" class="border-t border-border">
208
+ <MobileNavLink link={plan.hub} tone="secondary" emphasis="semibold" active={here(plan.hub.href)} />
209
+ {plan.links.map((link) => <MobileNavLink link={link} tone="secondary" active={on(link.href)} />)}
210
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{plan.monthsHeading}</div>
211
+ <div class="flex gap-2 px-4 pb-3 overflow-x-auto [&::-webkit-scrollbar]:hidden">
212
+ {plan.months.map((m) => (
213
+ <Chip href={m.href} size="sm" tone="custom" accent="secondary" class="whitespace-nowrap shrink-0" toneClass={monthTone(m, false)}>{m.label}</Chip>
214
+ ))}
215
+ </div>
216
+ </MobileNavSection>
217
+
218
+ <MobileNavSection label={itineraries.label} icon={itineraries.icon} iconClass="w-4 h-4 text-secondary" class="border-t border-border">
219
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{itineraries.durationsHeading}</div>
220
+ {itineraries.durations.map((d) => (
221
+ <MobileNavLink link={{ label: d.label, href: d.href }} tone="secondary" active={on(d.href)}>
222
+ <span slot="leading" class="w-5 h-5 flex items-center justify-center rounded bg-secondary/10 text-secondary text-xs font-bold shrink-0">{d.days}</span>
223
+ </MobileNavLink>
224
+ ))}
225
+ <MobileNavLink link={{ ...itineraries.featured, icon: itineraries.featured.icon ?? 'map', label: itineraries.featured.mobileLabel ?? itineraries.featured.label }} tone="secondary" emphasis="medium" active={here(itineraries.featured.href)} />
226
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{itineraries.connectionsHeading}</div>
227
+ {itineraries.connections.map((link) => <MobileNavLink link={link} tone="secondary" active={on(link.href)} />)}
228
+ <MobileNavLink link={{ ...itineraries.all, icon: itineraries.all.icon ?? 'route' }} tone="secondary" emphasis="medium" active={here(itineraries.all.href)} />
229
+ </MobileNavSection>
230
+
231
+ <MobileNavSection label={events.label} icon={events.icon} iconClass="w-4 h-4 text-accent" class="border-t border-border">
232
+ {events.upcoming.length > 0 && (
233
+ <>
234
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{events.upcomingHeading}</div>
235
+ {events.upcoming.map((link) => <MobileNavLink link={link} tone="accent" active={on(link.href)} />)}
236
+ </>
237
+ )}
238
+ <div class={`px-4 pt-2 pb-1 ${heading}`}>{events.guidesHeading}</div>
239
+ {events.guides.map((link) => <MobileNavLink link={link} tone="accent" active={on(link.href)} />)}
240
+ <MobileNavLink link={events.all} tone="accent" iconStrong active={on(events.all.href)} />
241
+ </MobileNavSection>
242
+
243
+ <div class="pointer-events-none sticky bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-background via-background/50 to-transparent mobile-scroll-fade"></div>
244
+ </Fragment>
245
+ </SiteHeader>
@@ -7,9 +7,12 @@ export { default as SituationalNav } from './SituationalNav.astro';
7
7
  export { default as NeighborhoodHubHero } from './NeighborhoodHubHero.astro';
8
8
  export { default as ItineraryTeaser } from './ItineraryTeaser.astro';
9
9
  export { default as HomeMonuments } from './HomeMonuments.astro';
10
+ export { default as TourismNavigation } from './TourismNavigation.astro';
10
11
  export type { WalkStop } from './WalkStops.astro';
11
12
  export type { WhoItsForLink } from './WhoItsFor.astro';
12
13
  export type { NeighborhoodCategoryType } from './NeighborhoodCategory.astro';
13
14
  export type { SituationalScenario } from './SituationalNav.astro';
14
15
  export type { HubCategoryPill } from './NeighborhoodHubHero.astro';
15
16
  export type { ItineraryTeaserStop } from './ItineraryTeaser.astro';
17
+ export { navMonths, upcomingEventLinks } from './tourismNavigation.ts';
18
+ export type { TourismNavigationProps, TourismNavHubLink, TourismNavMonth, TourismNavDuration } from './tourismNavigation.ts';
@@ -0,0 +1,84 @@
1
+ import type { NavLink } from '@sarimarcus/content-sites-ui/navigation';
2
+
3
+ /** A link the panel shows with a different label in the mobile menu. */
4
+ export interface TourismNavHubLink extends NavLink {
5
+ mobileLabel?: string;
6
+ }
7
+
8
+ export interface TourismNavMonth {
9
+ label: string;
10
+ href: string;
11
+ current?: boolean;
12
+ peak?: boolean;
13
+ }
14
+
15
+ export interface TourismNavDuration {
16
+ /** Short badge text: a day count or a symbol. */
17
+ days: string;
18
+ label: string;
19
+ href: string;
20
+ description: string;
21
+ }
22
+
23
+ interface Group {
24
+ label: string;
25
+ icon: string;
26
+ }
27
+
28
+ /**
29
+ * Everything the tourism header renders. The site owns every value: hrefs (so the link gate still reads them in the
30
+ * site's adapter), labels, headings and counts.
31
+ */
32
+ export interface TourismNavigationProps {
33
+ explore: Group & { links: NavLink[]; allGuides: NavLink; bestOf: NavLink };
34
+ articles: Group & { topicsHeading: string; topics: NavLink[]; latestHeading: string; latest: NavLink[]; all: TourismNavHubLink };
35
+ eatDrink: Group & { links: NavLink[] };
36
+ plan: Group & { links: NavLink[]; hub: NavLink; monthsHeading: string; months: TourismNavMonth[] };
37
+ itineraries: Group & {
38
+ durationsHeading: string;
39
+ durations: TourismNavDuration[];
40
+ all: NavLink;
41
+ featuredHeading: string;
42
+ featured: TourismNavHubLink;
43
+ connectionsHeading: string;
44
+ connections: NavLink[];
45
+ };
46
+ events: Group & { upcomingHeading: string; upcoming: NavLink[]; guidesHeading: string; guides: NavLink[]; all: NavLink };
47
+ search: { href: string; label: string; text: string; mobileLabel: string; path: string };
48
+ }
49
+
50
+ /** The month strip, with this month marked current. `hrefFor` keeps the URL scheme in the site. */
51
+ export function navMonths(
52
+ months: { label: string; slug: string; peak?: boolean }[],
53
+ hrefFor: (slug: string) => string,
54
+ now: Date = new Date(),
55
+ ): TourismNavMonth[] {
56
+ const current = months[now.getMonth()]?.slug;
57
+ return months.map((m) => ({ label: m.label, href: hrefFor(m.slug), current: m.slug === current, peak: m.peak }));
58
+ }
59
+
60
+ interface NavEvent {
61
+ data: { name: string; slug: string; period?: string; type?: string; startDate?: string; endDate?: string };
62
+ }
63
+
64
+ /** The next `limit` events that have not ended yet, soonest first, as panel links. */
65
+ export function upcomingEventLinks(
66
+ events: NavEvent[],
67
+ { hrefFor, icons, limit = 3, today = new Date().toISOString().slice(0, 10) }: {
68
+ hrefFor: (slug: string) => string;
69
+ icons: Record<string, string>;
70
+ limit?: number;
71
+ today?: string;
72
+ },
73
+ ): NavLink[] {
74
+ return events
75
+ .filter((e) => (e.data.endDate ?? e.data.startDate ?? '') >= today)
76
+ .sort((a, b) => (a.data.startDate ?? '').localeCompare(b.data.startDate ?? ''))
77
+ .slice(0, limit)
78
+ .map((e) => ({
79
+ label: e.data.name,
80
+ href: hrefFor(e.data.slug),
81
+ description: e.data.period,
82
+ icon: (e.data.type && icons[e.data.type]) || 'calendar',
83
+ }));
84
+ }