@sarimarcus/content-sites-tourism 0.11.0 → 0.12.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 +4 -0
- package/package.json +3 -3
- package/src/sections/HomeMonuments.astro +70 -0
- package/src/sections/ItineraryTeaser.astro +73 -0
- package/src/sections/NeighborhoodCategory.astro +90 -0
- package/src/sections/NeighborhoodHubHero.astro +89 -0
- package/src/sections/RankedListingRow.astro +145 -0
- package/src/sections/SituationalNav.astro +120 -0
- package/src/sections/WalkStops.astro +89 -0
- package/src/sections/WhoItsFor.astro +54 -0
- package/src/sections/hubHero.ts +23 -0
- package/src/sections/index.ts +15 -2
- package/src/templates/FacetEmpty.astro +20 -0
- package/src/templates/FacetHero.astro +44 -0
- package/src/templates/NeighborhoodFacetPage.astro +99 -0
- package/src/templates/ProximityFacetPage.astro +187 -0
- package/src/templates/TierFacetPage.astro +133 -0
- package/src/templates/facet.ts +14 -0
- package/src/templates/index.ts +8 -2
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.12.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- `./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).
|
|
8
|
+
|
|
5
9
|
## 0.11.0 (2026-10-01)
|
|
6
10
|
|
|
7
11
|
- 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.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -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.
|
|
39
|
-
"@sarimarcus/content-sites-ui": "0.
|
|
38
|
+
"@sarimarcus/content-sites-core": "0.12.0",
|
|
39
|
+
"@sarimarcus/content-sites-ui": "0.12.0"
|
|
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,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ItineraryTeaser — compact bordered strip on the neighbourhoods hub: a multi-neighbourhood route with one CTA and a
|
|
4
|
+
* numbered link per stop. Hrefs and strings come from the site adapter.
|
|
5
|
+
*/
|
|
6
|
+
import { Icon } from '@sarimarcus/content-sites-ui/primitives';
|
|
7
|
+
|
|
8
|
+
export interface ItineraryTeaserStop {
|
|
9
|
+
name: string;
|
|
10
|
+
href: string;
|
|
11
|
+
highlight: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
stops: ItineraryTeaserStop[];
|
|
18
|
+
ctaLabel: string;
|
|
19
|
+
ctaHref: string;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
stops,
|
|
27
|
+
ctaLabel,
|
|
28
|
+
ctaHref,
|
|
29
|
+
class: className,
|
|
30
|
+
} = Astro.props;
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<section class:list={['relative py-6 px-6 sm:px-8 border border-border rounded-xl bg-muted/30', className]}>
|
|
34
|
+
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
|
35
|
+
<div>
|
|
36
|
+
<div class="flex items-center gap-2 mb-1">
|
|
37
|
+
<svg class="w-4 h-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
38
|
+
<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" />
|
|
39
|
+
</svg>
|
|
40
|
+
<h2 class="text-lg font-display font-normal text-foreground">
|
|
41
|
+
{title}
|
|
42
|
+
</h2>
|
|
43
|
+
</div>
|
|
44
|
+
<p class="text-sm text-muted-foreground">
|
|
45
|
+
{description}
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
<a
|
|
49
|
+
href={ctaHref}
|
|
50
|
+
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary hover:text-primary-strong border border-primary/30 hover:border-primary/50 rounded-lg transition-colors whitespace-nowrap focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
|
51
|
+
>
|
|
52
|
+
{ctaLabel}
|
|
53
|
+
<Icon name="arrow-right" class="w-3.5 h-3.5" />
|
|
54
|
+
</a>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="flex flex-col sm:flex-row gap-3">
|
|
58
|
+
{stops.map((stop, index) => (
|
|
59
|
+
<a
|
|
60
|
+
href={stop.href}
|
|
61
|
+
class="flex-1 flex items-start gap-3 p-3 bg-background hover:bg-muted/50 border border-border rounded-lg transition-colors group"
|
|
62
|
+
>
|
|
63
|
+
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-display font-medium text-muted-foreground">
|
|
64
|
+
{index + 1}
|
|
65
|
+
</span>
|
|
66
|
+
<div class="min-w-0">
|
|
67
|
+
<p class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">{stop.name}</p>
|
|
68
|
+
<p class="text-xs text-muted-foreground line-clamp-1">{stop.highlight}</p>
|
|
69
|
+
</div>
|
|
70
|
+
</a>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
</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>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* WalkStops — numbered stop-by-stop route for a walk detail page. The site adapter resolves each stop's href and type
|
|
4
|
+
* label and supplies the heading and tip label.
|
|
5
|
+
*/
|
|
6
|
+
import { Badge, Icon } from '@sarimarcus/content-sites-ui/primitives';
|
|
7
|
+
|
|
8
|
+
export interface WalkStop {
|
|
9
|
+
order: number;
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
typeLabel: string;
|
|
13
|
+
/** Omitted when the stop has no page of its own. */
|
|
14
|
+
href?: string;
|
|
15
|
+
duration: string;
|
|
16
|
+
tip?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
stops: WalkStop[];
|
|
21
|
+
heading: string;
|
|
22
|
+
/** Prefix before a stop's tip, e.g. "Tip:". */
|
|
23
|
+
tipLabel: string;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { stops, heading, tipLabel, class: className } = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<section class:list={['walk-stops', className]}>
|
|
31
|
+
<h2 class="text-2xl sm:text-3xl font-display font-semibold text-foreground mb-8">
|
|
32
|
+
{heading}
|
|
33
|
+
</h2>
|
|
34
|
+
|
|
35
|
+
<ol class="relative space-y-0">
|
|
36
|
+
{stops.map((stop, i) => {
|
|
37
|
+
const isLast = i === stops.length - 1;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<li class="relative pl-14 pb-10 group">
|
|
41
|
+
{!isLast && (
|
|
42
|
+
<div class="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border group-hover:bg-primary/30 transition-colors" />
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
<div class="absolute left-0 top-0 w-10 h-10 rounded-full bg-primary text-overlay-foreground flex items-center justify-center font-bold text-sm shrink-0 shadow-md">
|
|
46
|
+
{stop.order}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="bg-card rounded-xl border border-border p-5 sm:p-6 hover:border-primary/30 transition-colors">
|
|
50
|
+
<div class="flex flex-wrap items-start justify-between gap-2 mb-2">
|
|
51
|
+
<div>
|
|
52
|
+
{stop.href ? (
|
|
53
|
+
<a href={stop.href} class="text-lg sm:text-xl font-display font-semibold text-foreground hover:text-primary transition-colors">
|
|
54
|
+
{stop.name}
|
|
55
|
+
</a>
|
|
56
|
+
) : (
|
|
57
|
+
<h3 class="text-lg sm:text-xl font-display font-semibold text-foreground">
|
|
58
|
+
{stop.name}
|
|
59
|
+
</h3>
|
|
60
|
+
)}
|
|
61
|
+
<div class="flex flex-wrap items-center gap-2 mt-1">
|
|
62
|
+
<Badge tone="custom" toneClass="bg-muted text-muted-foreground" size="sm" lines={0} class="![font-weight:inherit] !gap-1">
|
|
63
|
+
{stop.typeLabel}
|
|
64
|
+
</Badge>
|
|
65
|
+
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
66
|
+
<Icon name="clock" class="w-3 h-3" />
|
|
67
|
+
{stop.duration}
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<p class="text-muted-foreground leading-relaxed mt-3">
|
|
74
|
+
{stop.description}
|
|
75
|
+
</p>
|
|
76
|
+
|
|
77
|
+
{stop.tip && (
|
|
78
|
+
<div class="mt-4 p-3 bg-accent/5 border border-accent/20 rounded-lg">
|
|
79
|
+
<p class="text-sm text-foreground">
|
|
80
|
+
<span class="font-semibold text-accent-strong">{tipLabel}</span> {stop.tip}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</li>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</ol>
|
|
89
|
+
</section>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* WhoItsFor — an entity's audience and theme tags as a row of links to the pages that own those intents. The site
|
|
4
|
+
* adapter resolves the links (dropping tags with nowhere to go) and supplies the label and aria-label. Fewer than two
|
|
5
|
+
* links render nothing: one tag is an orphan, not a row.
|
|
6
|
+
*/
|
|
7
|
+
import { LinkArrow } from '@sarimarcus/content-sites-ui/primitives';
|
|
8
|
+
|
|
9
|
+
export interface WhoItsForLink {
|
|
10
|
+
label: string;
|
|
11
|
+
href: string;
|
|
12
|
+
tone: 'primary' | 'secondary';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
links: WhoItsForLink[];
|
|
17
|
+
label: string;
|
|
18
|
+
ariaLabel: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { links, label, ariaLabel, class: className = '' } = Astro.props;
|
|
23
|
+
|
|
24
|
+
const show = links.length >= 2;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
{show && (
|
|
28
|
+
<nav
|
|
29
|
+
aria-label={ariaLabel}
|
|
30
|
+
class:list={['my-10', className]}
|
|
31
|
+
data-pagefind-ignore
|
|
32
|
+
>
|
|
33
|
+
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3">
|
|
34
|
+
{label}
|
|
35
|
+
</p>
|
|
36
|
+
<ul class="flex flex-wrap gap-2">
|
|
37
|
+
{links.map((link) => (
|
|
38
|
+
<li>
|
|
39
|
+
<LinkArrow
|
|
40
|
+
href={link.href}
|
|
41
|
+
color={link.tone}
|
|
42
|
+
class={`min-h-11 px-3 py-2 rounded-lg ${
|
|
43
|
+
link.tone === 'primary'
|
|
44
|
+
? 'bg-primary/10 hover:bg-primary/15'
|
|
45
|
+
: 'bg-secondary/10 hover:bg-secondary/15'
|
|
46
|
+
}`}
|
|
47
|
+
>
|
|
48
|
+
{link.label}
|
|
49
|
+
</LinkArrow>
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</nav>
|
|
54
|
+
)}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// NeighborhoodHubHero's controller: in-page category pills scroll smoothly and update the hash without a jump. Each
|
|
2
|
+
// nav is wired once (data-hub-category-nav-ready), so a re-run after astro:page-load is a no-op.
|
|
3
|
+
|
|
4
|
+
function initNav(nav: HTMLElement): void {
|
|
5
|
+
nav.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((link) => {
|
|
6
|
+
link.addEventListener('click', (e) => {
|
|
7
|
+
const targetId = link.getAttribute('href')?.slice(1);
|
|
8
|
+
if (!targetId) return;
|
|
9
|
+
const target = document.getElementById(targetId);
|
|
10
|
+
if (!target) return;
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
13
|
+
history.pushState(null, '', `#${targetId}`);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function initHubCategoryNavs(): void {
|
|
19
|
+
document.querySelectorAll<HTMLElement>('[data-hub-category-nav]:not([data-hub-category-nav-ready])').forEach((nav) => {
|
|
20
|
+
nav.setAttribute('data-hub-category-nav-ready', '');
|
|
21
|
+
initNav(nav);
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/sections/index.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-tourism/sections.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-tourism/sections.
|
|
2
|
+
export { default as WalkStops } from './WalkStops.astro';
|
|
3
|
+
export { default as RankedListingRow } from './RankedListingRow.astro';
|
|
4
|
+
export { default as WhoItsFor } from './WhoItsFor.astro';
|
|
5
|
+
export { default as NeighborhoodCategory } from './NeighborhoodCategory.astro';
|
|
6
|
+
export { default as SituationalNav } from './SituationalNav.astro';
|
|
7
|
+
export { default as NeighborhoodHubHero } from './NeighborhoodHubHero.astro';
|
|
8
|
+
export { default as ItineraryTeaser } from './ItineraryTeaser.astro';
|
|
9
|
+
export { default as HomeMonuments } from './HomeMonuments.astro';
|
|
10
|
+
export type { WalkStop } from './WalkStops.astro';
|
|
11
|
+
export type { WhoItsForLink } from './WhoItsFor.astro';
|
|
12
|
+
export type { NeighborhoodCategoryType } from './NeighborhoodCategory.astro';
|
|
13
|
+
export type { SituationalScenario } from './SituationalNav.astro';
|
|
14
|
+
export type { HubCategoryPill } from './NeighborhoodHubHero.astro';
|
|
15
|
+
export type { ItineraryTeaserStop } from './ItineraryTeaser.astro';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FacetEmpty — internal to the facet templates: the state shown when a facet has no items.
|
|
4
|
+
*/
|
|
5
|
+
import { Icon } from '@sarimarcus/content-sites-ui/primitives';
|
|
6
|
+
import type { FacetEmptyState } from './facet';
|
|
7
|
+
|
|
8
|
+
type Props = FacetEmptyState;
|
|
9
|
+
|
|
10
|
+
const { icon, message, link } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<div class="text-center py-16">
|
|
14
|
+
{icon && <Icon name={icon} class="w-10 h-10 text-muted-foreground/40 mx-auto mb-4" />}
|
|
15
|
+
<p class="text-lg text-muted-foreground mb-6">{message}</p>
|
|
16
|
+
<a href={link.href} class="inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-strong transition-colors">
|
|
17
|
+
<Icon name="arrow-left" class="w-4 h-4" />
|
|
18
|
+
{link.label}
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FacetHero — internal to the facet templates: the breadcrumb strip and the hero (badge, h1, lede). The badge icon is
|
|
4
|
+
* an Icon name (`icon`) or, without it, the `icon` slot.
|
|
5
|
+
*/
|
|
6
|
+
import { Badge, Icon } from '@sarimarcus/content-sites-ui/primitives';
|
|
7
|
+
import { Container } from '@sarimarcus/content-sites-ui/layout';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Hero background classes, e.g. a full `bg-gradient-to-b from-… to-…` string. */
|
|
11
|
+
bgClass: string;
|
|
12
|
+
/** Badge toneClass. */
|
|
13
|
+
badgeToneClass: string;
|
|
14
|
+
badgeLabel: string;
|
|
15
|
+
heading: string;
|
|
16
|
+
lede: string;
|
|
17
|
+
icon?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { bgClass, badgeToneClass, badgeLabel, heading, lede, icon } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<div class="pt-16 bg-muted/50 border-b border-border">
|
|
24
|
+
<Container class="py-3">
|
|
25
|
+
<slot name="breadcrumb" />
|
|
26
|
+
</Container>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<section class={`relative py-16 lg:py-24 ${bgClass} text-overlay-foreground overflow-hidden`}>
|
|
30
|
+
<Container class="relative">
|
|
31
|
+
<div class="max-w-measure">
|
|
32
|
+
<Badge tone="custom" toneClass={badgeToneClass} size="lg" lines={0} class="mb-6 !py-1 ![font-weight:inherit] !gap-2">
|
|
33
|
+
{icon ? <Icon name={icon} class="w-4 h-4" /> : <slot name="icon" />}
|
|
34
|
+
{badgeLabel}
|
|
35
|
+
</Badge>
|
|
36
|
+
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-medium mb-6 font-display">
|
|
37
|
+
{heading}
|
|
38
|
+
</h1>
|
|
39
|
+
<p class="text-lg sm:text-xl text-overlay-foreground/90 leading-relaxed">
|
|
40
|
+
{lede}
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
</Container>
|
|
44
|
+
</section>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* NeighborhoodFacetPage — one content type inside one neighbourhood (/neighborhood/<slug>/<type>). The route keeps
|
|
4
|
+
* paths, data, SEO and schema, and renders the site Layout around this template. It passes the site Breadcrumb in
|
|
5
|
+
* the `breadcrumb` slot, an optional block above the grid in `before-grid`, and the cards, rendered through its own
|
|
6
|
+
* card adapters, as default-slot children. The hero badge icon is an Icon name (`icon`); a custom glyph goes in the
|
|
7
|
+
* `icon` slot instead. Every string arrives as a prop.
|
|
8
|
+
*/
|
|
9
|
+
import { ArrowLabel, Icon } from '@sarimarcus/content-sites-ui/primitives';
|
|
10
|
+
import { Container } from '@sarimarcus/content-sites-ui/layout';
|
|
11
|
+
import FacetHero from './FacetHero.astro';
|
|
12
|
+
import type { TrustedHtml } from '@sarimarcus/content-sites-ui/types';
|
|
13
|
+
import type { FacetLink } from './facet';
|
|
14
|
+
|
|
15
|
+
export type NeighborhoodFacetTone = 'overlay-secondary' | 'secondary' | 'primary-overlay' | 'accent-overlay';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Hero gradient, top to bottom. */
|
|
19
|
+
tone: NeighborhoodFacetTone;
|
|
20
|
+
/** Icon name for the hero badge; without it the `icon` slot supplies the glyph. */
|
|
21
|
+
icon?: string;
|
|
22
|
+
badgeLabel: string;
|
|
23
|
+
heading: string;
|
|
24
|
+
lede: string;
|
|
25
|
+
/** The neighbourhood's own intro for this type: textAsHtml() for plain text, trustedHtml() for rendered markdown. */
|
|
26
|
+
introHtml?: TrustedHtml;
|
|
27
|
+
/** Back to the neighbourhood page, above the grid. */
|
|
28
|
+
backLink: FacetLink;
|
|
29
|
+
/** First bottom link, back to the neighbourhood guide. */
|
|
30
|
+
guideLink: FacetLink;
|
|
31
|
+
/** Further bottom links (the type's listing, all neighbourhoods). */
|
|
32
|
+
moreLinks: FacetLink[];
|
|
33
|
+
columns?: 2 | 3;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { tone, icon, badgeLabel, heading, lede, introHtml, backLink, guideLink, moreLinks, columns = 2 } = Astro.props;
|
|
37
|
+
|
|
38
|
+
const heroTones: Record<NeighborhoodFacetTone, string> = {
|
|
39
|
+
'overlay-secondary': 'bg-gradient-to-b from-overlay to-secondary-strong',
|
|
40
|
+
secondary: 'bg-gradient-to-b from-secondary to-secondary-strong',
|
|
41
|
+
'primary-overlay': 'bg-gradient-to-b from-primary to-overlay',
|
|
42
|
+
'accent-overlay': 'bg-gradient-to-b from-accent-strong to-overlay',
|
|
43
|
+
};
|
|
44
|
+
const gridClass = columns === 3 ? 'grid gap-6 sm:grid-cols-2 lg:grid-cols-3' : 'grid gap-6 lg:grid-cols-2';
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<FacetHero
|
|
48
|
+
bgClass={heroTones[tone]}
|
|
49
|
+
badgeToneClass="bg-overlay-foreground/20 text-overlay-foreground"
|
|
50
|
+
badgeLabel={badgeLabel}
|
|
51
|
+
heading={heading}
|
|
52
|
+
lede={lede}
|
|
53
|
+
icon={icon}
|
|
54
|
+
>
|
|
55
|
+
<slot name="breadcrumb" slot="breadcrumb" />
|
|
56
|
+
<slot name="icon" slot="icon" />
|
|
57
|
+
</FacetHero>
|
|
58
|
+
|
|
59
|
+
<main class="py-12 lg:py-20">
|
|
60
|
+
<Container>
|
|
61
|
+
<a
|
|
62
|
+
href={backLink.href}
|
|
63
|
+
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors mb-10"
|
|
64
|
+
>
|
|
65
|
+
<Icon name="arrow-left" class="w-4 h-4" />
|
|
66
|
+
{backLink.label}
|
|
67
|
+
</a>
|
|
68
|
+
|
|
69
|
+
{introHtml && (
|
|
70
|
+
<div class="mb-10 p-6 lg:p-8 bg-muted/30 rounded-2xl">
|
|
71
|
+
<p class="text-foreground/80 leading-relaxed" set:html={introHtml} />
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<slot name="before-grid" />
|
|
76
|
+
|
|
77
|
+
<div class={gridClass}>
|
|
78
|
+
<slot />
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="mt-12 flex flex-wrap gap-4 justify-center">
|
|
82
|
+
<a
|
|
83
|
+
href={guideLink.href}
|
|
84
|
+
class="group inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-strong transition-colors"
|
|
85
|
+
>
|
|
86
|
+
<Icon name="arrow-left" class="w-4 h-4" />
|
|
87
|
+
{guideLink.label}
|
|
88
|
+
</a>
|
|
89
|
+
{moreLinks.map((link) => (
|
|
90
|
+
<a
|
|
91
|
+
href={link.href}
|
|
92
|
+
class="group inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-primary transition-colors"
|
|
93
|
+
>
|
|
94
|
+
<ArrowLabel>{link.label}</ArrowLabel>
|
|
95
|
+
</a>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</Container>
|
|
99
|
+
</main>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ProximityFacetPage — places within walking distance of an area's landmarks (/hotels/near/<area>,
|
|
4
|
+
* /restaurants/near/<area>). The route keeps the distance ranking, SEO, schema and URLs, and renders the site Layout
|
|
5
|
+
* around this template. Site components arrive through slots: the Breadcrumb (`breadcrumb`), the map facts panel
|
|
6
|
+
* (`orientation`), the ranked rows (default slot), an optional block under the ranking (`after-ranking`), the FAQ
|
|
7
|
+
* (`faq`) and the related links (`related`). Intro and editorial HTML is rendered by the route. Every string arrives
|
|
8
|
+
* as a prop.
|
|
9
|
+
*/
|
|
10
|
+
import { Icon, SectionDivider } from '@sarimarcus/content-sites-ui/primitives';
|
|
11
|
+
import { Container } from '@sarimarcus/content-sites-ui/layout';
|
|
12
|
+
import type { TrustedHtml } from '@sarimarcus/content-sites-ui/types';
|
|
13
|
+
import FacetHero from './FacetHero.astro';
|
|
14
|
+
import FacetEmpty from './FacetEmpty.astro';
|
|
15
|
+
import type { FacetEmptyState, FacetLink } from './facet';
|
|
16
|
+
|
|
17
|
+
export interface ProximityIntroSection {
|
|
18
|
+
/** Anchor id of the subsection. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Subsection heading, set only when the area groups several landmarks. */
|
|
21
|
+
heading?: string;
|
|
22
|
+
html: TrustedHtml;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProximityEditorialGroup {
|
|
26
|
+
/** Group heading, set only when the area groups several landmarks; the group's sections then nest under it. */
|
|
27
|
+
heading?: string;
|
|
28
|
+
sections: { heading: string; html: TrustedHtml }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
badgeLabel: string;
|
|
33
|
+
/** Icon name for the hero badge. */
|
|
34
|
+
icon: string;
|
|
35
|
+
heading: string;
|
|
36
|
+
lede: string;
|
|
37
|
+
intro?: { heading: string; sections: ProximityIntroSection[] };
|
|
38
|
+
/** Prose classes for the intro and editorial HTML, between `prose` and `prose-lg`. */
|
|
39
|
+
proseClass: string;
|
|
40
|
+
ranking: { id: string; heading: string; lede: string; allLink: FacetLink };
|
|
41
|
+
/** Ranked rows in the default slot; 0 shows `empty` instead. */
|
|
42
|
+
itemCount: number;
|
|
43
|
+
empty: FacetEmptyState;
|
|
44
|
+
editorial?: ProximityEditorialGroup[];
|
|
45
|
+
areaNav: { label: string; heading: string; links: FacetLink[] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const {
|
|
49
|
+
badgeLabel,
|
|
50
|
+
icon,
|
|
51
|
+
heading,
|
|
52
|
+
lede,
|
|
53
|
+
intro,
|
|
54
|
+
proseClass,
|
|
55
|
+
ranking,
|
|
56
|
+
itemCount,
|
|
57
|
+
empty,
|
|
58
|
+
editorial = [],
|
|
59
|
+
areaNav,
|
|
60
|
+
} = Astro.props;
|
|
61
|
+
|
|
62
|
+
const introSections = intro?.sections ?? [];
|
|
63
|
+
const hasEditorial = editorial.some((g) => g.sections.length > 0);
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
<FacetHero
|
|
67
|
+
bgClass="bg-secondary-strong"
|
|
68
|
+
badgeToneClass="bg-overlay-foreground/10 text-overlay-foreground/90"
|
|
69
|
+
badgeLabel={badgeLabel}
|
|
70
|
+
heading={heading}
|
|
71
|
+
lede={lede}
|
|
72
|
+
icon={icon}
|
|
73
|
+
>
|
|
74
|
+
<slot name="breadcrumb" slot="breadcrumb" />
|
|
75
|
+
</FacetHero>
|
|
76
|
+
|
|
77
|
+
<main class="py-12 lg:py-20">
|
|
78
|
+
<Container>
|
|
79
|
+
{intro && introSections.length > 0 && (
|
|
80
|
+
<section class="mb-12 lg:mb-16">
|
|
81
|
+
<div class="max-w-measure">
|
|
82
|
+
<h2 class="font-display text-2xl sm:text-3xl text-foreground mb-6">
|
|
83
|
+
{intro.heading}
|
|
84
|
+
</h2>
|
|
85
|
+
<div class:list={['prose', proseClass, 'prose-lg']}>
|
|
86
|
+
{introSections.map((s, i) => (
|
|
87
|
+
<>
|
|
88
|
+
{i > 0 && <SectionDivider variant="dots" />}
|
|
89
|
+
{s.heading && (
|
|
90
|
+
<h3 id={s.id} class="scroll-mt-28">{s.heading}</h3>
|
|
91
|
+
)}
|
|
92
|
+
<div set:html={s.html} />
|
|
93
|
+
</>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<div class="mb-14 lg:mb-20">
|
|
101
|
+
<slot name="orientation" />
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<section aria-labelledby={ranking.id} class="scroll-mt-28">
|
|
105
|
+
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3 mb-8">
|
|
106
|
+
<div>
|
|
107
|
+
<h2 id={ranking.id} class="font-display text-2xl sm:text-3xl text-foreground">
|
|
108
|
+
{ranking.heading}
|
|
109
|
+
</h2>
|
|
110
|
+
<p class="mt-2 text-muted-foreground max-w-deck">
|
|
111
|
+
{ranking.lede}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
<a
|
|
115
|
+
href={ranking.allLink.href}
|
|
116
|
+
class="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary-strong transition-colors"
|
|
117
|
+
>
|
|
118
|
+
{ranking.allLink.label}
|
|
119
|
+
<Icon name="arrow-right" class="w-4 h-4" />
|
|
120
|
+
</a>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{itemCount > 0 && (
|
|
124
|
+
<ol class="list-none border-t border-border">
|
|
125
|
+
<slot />
|
|
126
|
+
</ol>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{itemCount === 0 && <FacetEmpty {...empty} />}
|
|
130
|
+
</section>
|
|
131
|
+
|
|
132
|
+
<slot name="after-ranking" />
|
|
133
|
+
|
|
134
|
+
{hasEditorial && (
|
|
135
|
+
<div class="mt-16 lg:mt-24 max-w-measure">
|
|
136
|
+
{editorial.map((group) => {
|
|
137
|
+
if (!group.sections.length) return null;
|
|
138
|
+
const nested = group.heading !== undefined;
|
|
139
|
+
const SectionHeading = nested ? 'h3' : 'h2';
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
{nested && (
|
|
143
|
+
<h2 class="font-display text-2xl sm:text-3xl text-foreground mt-16 first:mt-0 mb-2">
|
|
144
|
+
{group.heading}
|
|
145
|
+
</h2>
|
|
146
|
+
)}
|
|
147
|
+
{group.sections.map((sec) => (
|
|
148
|
+
<section class="mt-12 first:mt-0">
|
|
149
|
+
<SectionHeading
|
|
150
|
+
class:list={[
|
|
151
|
+
'font-display text-foreground mb-5 scroll-mt-28',
|
|
152
|
+
nested ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl',
|
|
153
|
+
]}
|
|
154
|
+
>
|
|
155
|
+
{sec.heading}
|
|
156
|
+
</SectionHeading>
|
|
157
|
+
<div class:list={['prose', proseClass, 'prose-lg']} set:html={sec.html} />
|
|
158
|
+
</section>
|
|
159
|
+
))}
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<slot name="faq" />
|
|
167
|
+
|
|
168
|
+
<slot name="related" />
|
|
169
|
+
|
|
170
|
+
<nav aria-label={areaNav.label} class="mt-16 pt-8 border-t border-border">
|
|
171
|
+
<p class="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">{areaNav.heading}</p>
|
|
172
|
+
<ul class="flex flex-wrap gap-x-6 gap-y-2 list-none p-0 m-0">
|
|
173
|
+
{areaNav.links.map((link) => (
|
|
174
|
+
<li>
|
|
175
|
+
<a
|
|
176
|
+
href={link.href}
|
|
177
|
+
class="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary transition-colors"
|
|
178
|
+
>
|
|
179
|
+
{link.label}
|
|
180
|
+
<Icon name="arrow-right" class="w-3 h-3" />
|
|
181
|
+
</a>
|
|
182
|
+
</li>
|
|
183
|
+
))}
|
|
184
|
+
</ul>
|
|
185
|
+
</nav>
|
|
186
|
+
</Container>
|
|
187
|
+
</main>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TierFacetPage — one price tier of a content type (/activities/price/<tier>, /restaurants/price/<tier>). The route
|
|
4
|
+
* keeps the tiers, filtering, SEO, schema and URLs, and renders the site Layout around this template. It passes the
|
|
5
|
+
* site Breadcrumb in the `breadcrumb` slot, the cards (through its card adapters) as default-slot children and its
|
|
6
|
+
* closing links in `footer`. Every string arrives as a prop.
|
|
7
|
+
*/
|
|
8
|
+
import { Badge, Chip, Icon } from '@sarimarcus/content-sites-ui/primitives';
|
|
9
|
+
import { Container } from '@sarimarcus/content-sites-ui/layout';
|
|
10
|
+
import type { TrustedHtml } from '@sarimarcus/content-sites-ui/types';
|
|
11
|
+
import FacetHero from './FacetHero.astro';
|
|
12
|
+
import FacetEmpty from './FacetEmpty.astro';
|
|
13
|
+
import type { FacetEmptyState, FacetLink } from './facet';
|
|
14
|
+
|
|
15
|
+
export type TierFacetTone = 'secondary' | 'secondary-overlay';
|
|
16
|
+
|
|
17
|
+
export interface TierNavItem {
|
|
18
|
+
href: string;
|
|
19
|
+
label: string;
|
|
20
|
+
/** Range shown after the label, punctuation included, e.g. "(Under €25)". */
|
|
21
|
+
detail: string;
|
|
22
|
+
count: number;
|
|
23
|
+
active: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TierNav {
|
|
27
|
+
/** `panel`: boxed, headed, large outline chips. `inline`: bare row of muted chips. */
|
|
28
|
+
variant: 'panel' | 'inline';
|
|
29
|
+
/** Panel heading; `panel` only. */
|
|
30
|
+
heading?: string;
|
|
31
|
+
/** aria-label of the chip nav. */
|
|
32
|
+
label: string;
|
|
33
|
+
/** The unfiltered listing. */
|
|
34
|
+
all: { href: string; label: string; count: number };
|
|
35
|
+
tiers: TierNavItem[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
tone: TierFacetTone;
|
|
40
|
+
/** Icon name for the hero badge. */
|
|
41
|
+
icon: string;
|
|
42
|
+
badgeLabel: string;
|
|
43
|
+
heading: string;
|
|
44
|
+
lede: string;
|
|
45
|
+
nav: TierNav;
|
|
46
|
+
introHtml?: TrustedHtml;
|
|
47
|
+
backLink: FacetLink;
|
|
48
|
+
/** Cards in the default slot; 0 shows `empty` under the grid. */
|
|
49
|
+
itemCount: number;
|
|
50
|
+
empty: FacetEmptyState;
|
|
51
|
+
columns?: 2 | 3;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { tone, icon, badgeLabel, heading, lede, nav, introHtml, backLink, itemCount, empty, columns = 2 } = Astro.props;
|
|
55
|
+
|
|
56
|
+
const heroTones: Record<TierFacetTone, string> = {
|
|
57
|
+
secondary: 'bg-gradient-to-b from-secondary to-secondary-strong',
|
|
58
|
+
'secondary-overlay': 'bg-gradient-to-b from-secondary to-overlay',
|
|
59
|
+
};
|
|
60
|
+
const panel = nav.variant === 'panel';
|
|
61
|
+
const NavWrap = panel ? 'section' : 'div';
|
|
62
|
+
const navWrapClass = panel
|
|
63
|
+
? 'mb-10 p-6 bg-gradient-to-r from-secondary/5 via-primary/5 to-secondary/5 rounded-2xl border border-border'
|
|
64
|
+
: 'mb-10';
|
|
65
|
+
const chip = panel
|
|
66
|
+
? { size: 'lg' as const, tone: 'outline' as const, accent: 'primary' as const }
|
|
67
|
+
: { size: 'md' as const, tone: 'muted' as const, accent: 'primary' as const };
|
|
68
|
+
const allChipProps = panel ? { href: nav.all.href, ...chip, class: 'gap-2' } : { href: nav.all.href, ...chip };
|
|
69
|
+
const tierChipProps = (t: TierNavItem) => ({
|
|
70
|
+
href: t.href,
|
|
71
|
+
...chip,
|
|
72
|
+
active: t.active,
|
|
73
|
+
'aria-current': t.active ? ('page' as const) : undefined,
|
|
74
|
+
...(panel ? { class: t.active ? 'gap-2 shadow-md' : 'gap-2' } : {}),
|
|
75
|
+
});
|
|
76
|
+
const countTone = (active: boolean) => (active ? 'bg-overlay-foreground/20' : panel ? 'bg-muted' : 'bg-background');
|
|
77
|
+
const countClass = panel ? '![font-weight:inherit]' : '!px-1.5 ![font-weight:inherit]';
|
|
78
|
+
const gridClass = columns === 3 ? 'grid gap-6 sm:grid-cols-2 lg:grid-cols-3' : 'grid gap-6 lg:grid-cols-2';
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
<FacetHero
|
|
82
|
+
bgClass={heroTones[tone]}
|
|
83
|
+
badgeToneClass="bg-overlay-foreground/20 text-overlay-foreground"
|
|
84
|
+
badgeLabel={badgeLabel}
|
|
85
|
+
heading={heading}
|
|
86
|
+
lede={lede}
|
|
87
|
+
icon={icon}
|
|
88
|
+
>
|
|
89
|
+
<slot name="breadcrumb" slot="breadcrumb" />
|
|
90
|
+
</FacetHero>
|
|
91
|
+
|
|
92
|
+
<main class="py-12 lg:py-20">
|
|
93
|
+
<Container>
|
|
94
|
+
<NavWrap class={navWrapClass}>
|
|
95
|
+
{panel && <h2 class="text-lg font-normal text-foreground font-display mb-5">{nav.heading}</h2>}
|
|
96
|
+
<nav class={panel ? 'flex flex-wrap gap-3' : 'flex flex-wrap gap-2'} aria-label={nav.label}>
|
|
97
|
+
<Chip {...allChipProps}>
|
|
98
|
+
{nav.all.label}
|
|
99
|
+
<Badge tone="custom" toneClass={countTone(false)} size="sm" lines={0} class={countClass}>{nav.all.count}</Badge>
|
|
100
|
+
</Chip>
|
|
101
|
+
{nav.tiers.map((t) => (
|
|
102
|
+
<Chip {...tierChipProps(t)}>
|
|
103
|
+
{t.label}<span class="text-xs opacity-75">{t.detail}</span>
|
|
104
|
+
<Badge tone="custom" toneClass={countTone(t.active)} size="sm" lines={0} class={countClass}>{t.count}</Badge>
|
|
105
|
+
</Chip>
|
|
106
|
+
))}
|
|
107
|
+
</nav>
|
|
108
|
+
</NavWrap>
|
|
109
|
+
|
|
110
|
+
{introHtml && (
|
|
111
|
+
<div class="mb-10 p-6 lg:p-8 bg-muted/30 rounded-2xl">
|
|
112
|
+
<div class="prose prose-lg max-w-measure-wide">
|
|
113
|
+
<p class="text-foreground/80 leading-relaxed" set:html={introHtml} />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
<a
|
|
119
|
+
href={backLink.href}
|
|
120
|
+
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors mb-8"
|
|
121
|
+
>
|
|
122
|
+
<Icon name="arrow-left" class="w-4 h-4" />
|
|
123
|
+
{backLink.label}
|
|
124
|
+
</a>
|
|
125
|
+
|
|
126
|
+
<div class={gridClass}>
|
|
127
|
+
<slot />
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{itemCount === 0 && <FacetEmpty {...empty} />}
|
|
131
|
+
<slot name="footer" />
|
|
132
|
+
</Container>
|
|
133
|
+
</main>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Types shared by the facet page templates.
|
|
2
|
+
|
|
3
|
+
export interface FacetLink {
|
|
4
|
+
href: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** What a facet with no items shows instead of its list. */
|
|
9
|
+
export interface FacetEmptyState {
|
|
10
|
+
/** Icon name above the message. */
|
|
11
|
+
icon?: string;
|
|
12
|
+
message: string;
|
|
13
|
+
link: FacetLink;
|
|
14
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
// Public surface of @sarimarcus/content-sites-tourism/templates.
|
|
2
|
-
export {};
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-tourism/templates.
|
|
2
|
+
export { default as NeighborhoodFacetPage } from './NeighborhoodFacetPage.astro';
|
|
3
|
+
export { default as ProximityFacetPage } from './ProximityFacetPage.astro';
|
|
4
|
+
export { default as TierFacetPage } from './TierFacetPage.astro';
|
|
5
|
+
export type { FacetEmptyState, FacetLink } from './facet';
|
|
6
|
+
export type { NeighborhoodFacetTone } from './NeighborhoodFacetPage.astro';
|
|
7
|
+
export type { ProximityIntroSection, ProximityEditorialGroup } from './ProximityFacetPage.astro';
|
|
8
|
+
export type { TierFacetTone, TierNav, TierNavItem } from './TierFacetPage.astro';
|