@sarimarcus/content-sites-tourism 0.10.0 → 0.11.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/cards/ActivityCard.astro +138 -0
- package/src/cards/BarCard.astro +223 -0
- package/src/cards/DishCard.astro +138 -0
- package/src/cards/HotelCard.astro +182 -0
- package/src/cards/MonumentCard.astro +151 -0
- package/src/cards/NeighborhoodCard.astro +242 -0
- package/src/cards/RestaurantCard.astro +215 -0
- package/src/cards/WalkCard.astro +145 -0
- package/src/cards/index.ts +20 -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.11.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- 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).
|
|
8
|
+
|
|
5
9
|
## 0.10.0 (2026-09-30)
|
|
6
10
|
|
|
7
11
|
- No changes; lockstep release with ui (SLA-2732).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-tourism",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.11.0",
|
|
39
|
+
"@sarimarcus/content-sites-ui": "0.11.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ActivityCard — activity teaser on the ui EntityCard frame (poster variant). The category key picks the badge colour;
|
|
4
|
+
* its label, the location label (a neighbourhood name, or the site's out-of-town wording), the price line and every
|
|
5
|
+
* other visible string come from the site adapter, which also resolves the image, href and placeholder.
|
|
6
|
+
*/
|
|
7
|
+
import type { ImageMetadata } from 'astro';
|
|
8
|
+
import { ArrowLabel, Badge } from '@sarimarcus/content-sites-ui/primitives';
|
|
9
|
+
import { EntityCard } from '@sarimarcus/content-sites-ui/composed';
|
|
10
|
+
|
|
11
|
+
export interface ActivityCardStrings {
|
|
12
|
+
/** Screen-reader labels before each meta value. */
|
|
13
|
+
duration: string;
|
|
14
|
+
price: string;
|
|
15
|
+
location: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
href: string;
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
image?: ImageMetadata;
|
|
23
|
+
/** Shown when `image` is absent. */
|
|
24
|
+
fallbackSrc: string;
|
|
25
|
+
imageAlt: string;
|
|
26
|
+
category: string;
|
|
27
|
+
categoryLabel: string;
|
|
28
|
+
/** Badge beside the category; omitted hides it. */
|
|
29
|
+
freeLabel?: string | null;
|
|
30
|
+
duration?: string;
|
|
31
|
+
/** e.g. "From €25"; omitted hides the price line. */
|
|
32
|
+
priceLabel?: string | null;
|
|
33
|
+
tagLabels?: string[];
|
|
34
|
+
locationLabel: string;
|
|
35
|
+
strings: ActivityCardStrings;
|
|
36
|
+
ctaLabel: string;
|
|
37
|
+
index?: number;
|
|
38
|
+
class?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
href,
|
|
43
|
+
name,
|
|
44
|
+
description,
|
|
45
|
+
image,
|
|
46
|
+
fallbackSrc,
|
|
47
|
+
imageAlt,
|
|
48
|
+
category,
|
|
49
|
+
categoryLabel,
|
|
50
|
+
freeLabel,
|
|
51
|
+
duration,
|
|
52
|
+
priceLabel,
|
|
53
|
+
tagLabels = [],
|
|
54
|
+
locationLabel,
|
|
55
|
+
strings,
|
|
56
|
+
ctaLabel,
|
|
57
|
+
index,
|
|
58
|
+
class: className,
|
|
59
|
+
} = Astro.props;
|
|
60
|
+
|
|
61
|
+
const categoryColors: Record<string, string> = {
|
|
62
|
+
'guided-tour': 'bg-overlay text-overlay-foreground',
|
|
63
|
+
experience: 'bg-overlay text-overlay-foreground',
|
|
64
|
+
show: 'bg-overlay text-overlay-foreground',
|
|
65
|
+
gastronomy: 'bg-overlay text-overlay-foreground',
|
|
66
|
+
wellness: 'bg-overlay text-overlay-foreground',
|
|
67
|
+
tour: 'bg-overlay text-overlay-foreground',
|
|
68
|
+
};
|
|
69
|
+
const categoryColor = categoryColors[category] || 'bg-accent text-foreground';
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
<EntityCard variant="poster" href={href} image={image} fallbackSrc={fallbackSrc} imageAlt={imageAlt} index={index} class={className}>
|
|
73
|
+
<Fragment slot="overlay">
|
|
74
|
+
<div class="absolute inset-0 bg-gradient-to-t from-overlay/95 via-overlay/20 to-transparent"></div>
|
|
75
|
+
|
|
76
|
+
<div class="absolute top-3 right-3 flex flex-wrap gap-2">
|
|
77
|
+
<Badge tone="custom" toneClass={categoryColor} size="sm" lines={0} class="!py-1">
|
|
78
|
+
{categoryLabel}
|
|
79
|
+
</Badge>
|
|
80
|
+
{freeLabel && (
|
|
81
|
+
<Badge tone="secondary" size="md" lines={1} class="!px-2">{freeLabel}</Badge>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="absolute bottom-0 left-0 right-0 p-4">
|
|
86
|
+
<h3
|
|
87
|
+
class="text-xl sm:text-2xl font-semibold text-overlay-foreground leading-tight font-display"
|
|
88
|
+
>
|
|
89
|
+
{name}
|
|
90
|
+
</h3>
|
|
91
|
+
</div>
|
|
92
|
+
</Fragment>
|
|
93
|
+
|
|
94
|
+
<p class="text-base text-muted-foreground line-clamp-2 mb-4">
|
|
95
|
+
{description}
|
|
96
|
+
</p>
|
|
97
|
+
|
|
98
|
+
<div class="flex flex-wrap gap-3 text-sm text-muted-foreground mb-4">
|
|
99
|
+
{duration && (
|
|
100
|
+
<span class="inline-flex items-center gap-1.5">
|
|
101
|
+
<svg class="w-3.5 h-3.5 text-accent-strong" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
102
|
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
|
103
|
+
</svg>
|
|
104
|
+
<span class="sr-only">{strings.duration}</span>
|
|
105
|
+
<span>{duration}</span>
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
{priceLabel && (
|
|
109
|
+
<span class="inline-flex items-center gap-1.5">
|
|
110
|
+
<svg class="w-3.5 h-3.5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
111
|
+
<path d="M4 10h12"/><path d="M4 14h9"/><path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2"/>
|
|
112
|
+
</svg>
|
|
113
|
+
<span class="sr-only">{strings.price}</span>
|
|
114
|
+
<span>{priceLabel}</span>
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{tagLabels.length > 0 && (
|
|
120
|
+
<div class="flex flex-wrap gap-1.5 mb-3">
|
|
121
|
+
{tagLabels.map((label) => (
|
|
122
|
+
<Badge tone="primary" size="sm">{label}</Badge>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<span slot="footer-meta" class="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
128
|
+
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
129
|
+
<path d="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" /><circle cx="12" cy="10" r="3" />
|
|
130
|
+
</svg>
|
|
131
|
+
<span class="sr-only">{strings.location}</span>
|
|
132
|
+
{locationLabel}
|
|
133
|
+
</span>
|
|
134
|
+
|
|
135
|
+
<ArrowLabel slot="footer-cta" class="text-sm font-medium text-accent-strong group-hover:text-accent transition-colors">
|
|
136
|
+
{ctaLabel}
|
|
137
|
+
</ArrowLabel>
|
|
138
|
+
</EntityCard>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* BarCard — bar teaser. The category key picks the badge colour; its label, the feature labels and icons (sites store
|
|
4
|
+
* different feature keys), the closing line and every other visible string come from the site adapter, which also
|
|
5
|
+
* resolves the image, href and placeholder. The price pill renders only when the value is label-length.
|
|
6
|
+
*/
|
|
7
|
+
import { Picture } from 'astro:assets';
|
|
8
|
+
import type { ImageMetadata } from 'astro';
|
|
9
|
+
import { Badge, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';
|
|
10
|
+
import type { CardRating } from './RestaurantCard.astro';
|
|
11
|
+
|
|
12
|
+
export interface BarFeature {
|
|
13
|
+
/** Raw data key, rendered as the chip's title. */
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** SVG path data for a 24×24 stroke icon. */
|
|
17
|
+
iconPath?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
href: string;
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
image?: ImageMetadata;
|
|
25
|
+
/** Shown when `image` is absent. */
|
|
26
|
+
fallbackSrc: string;
|
|
27
|
+
imageAlt: string;
|
|
28
|
+
category: string;
|
|
29
|
+
categoryLabel: string;
|
|
30
|
+
priceLabel: string;
|
|
31
|
+
atmosphere?: string;
|
|
32
|
+
/** First three shown. */
|
|
33
|
+
features?: BarFeature[];
|
|
34
|
+
/** Shown after the features when more than three exist, e.g. "+2 more". */
|
|
35
|
+
featuresMoreLabel?: string | null;
|
|
36
|
+
rating?: CardRating;
|
|
37
|
+
tagLabels?: string[];
|
|
38
|
+
neighborhood: string;
|
|
39
|
+
/** e.g. "Open until 02:00 (Fri–Sat)". */
|
|
40
|
+
closingLabel?: string | null;
|
|
41
|
+
ctaLabel: string;
|
|
42
|
+
index?: number;
|
|
43
|
+
class?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
href,
|
|
48
|
+
name,
|
|
49
|
+
description,
|
|
50
|
+
image,
|
|
51
|
+
fallbackSrc,
|
|
52
|
+
imageAlt,
|
|
53
|
+
category,
|
|
54
|
+
categoryLabel,
|
|
55
|
+
priceLabel,
|
|
56
|
+
atmosphere,
|
|
57
|
+
features = [],
|
|
58
|
+
featuresMoreLabel,
|
|
59
|
+
rating,
|
|
60
|
+
tagLabels = [],
|
|
61
|
+
neighborhood,
|
|
62
|
+
closingLabel,
|
|
63
|
+
ctaLabel,
|
|
64
|
+
index = 999,
|
|
65
|
+
class: className,
|
|
66
|
+
} = Astro.props;
|
|
67
|
+
|
|
68
|
+
const categoryColors: Record<string, { bg: string; text: string }> = {
|
|
69
|
+
tapas: { bg: 'bg-primary', text: 'text-overlay-foreground' },
|
|
70
|
+
cocktails: { bg: 'bg-overlay', text: 'text-overlay-foreground' },
|
|
71
|
+
wine: { bg: 'bg-accent-strong', text: 'text-overlay-foreground' },
|
|
72
|
+
beer: { bg: 'bg-secondary', text: 'text-overlay-foreground' },
|
|
73
|
+
lgbt: { bg: 'bg-primary', text: 'text-overlay-foreground' },
|
|
74
|
+
rooftop: { bg: 'bg-secondary', text: 'text-overlay-foreground' },
|
|
75
|
+
nightlife: { bg: 'bg-overlay', text: 'text-overlay-foreground' },
|
|
76
|
+
flamenco: { bg: 'bg-primary', text: 'text-overlay-foreground' },
|
|
77
|
+
traditional: { bg: 'bg-accent-strong', text: 'text-overlay-foreground' },
|
|
78
|
+
};
|
|
79
|
+
const categoryStyle = categoryColors[category] || { bg: 'bg-muted', text: 'text-muted-foreground' };
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
<article class:list={['group relative bg-card rounded-xl overflow-hidden card-lift border border-border flex flex-col h-full', className]}>
|
|
83
|
+
<a href={href} class="flex flex-col flex-1 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded-xl">
|
|
84
|
+
<div class="relative h-56 overflow-hidden img-placeholder shrink-0">
|
|
85
|
+
{image ? (
|
|
86
|
+
<Picture
|
|
87
|
+
src={image}
|
|
88
|
+
alt={imageAlt}
|
|
89
|
+
widths={[240, 320, 400, 480, 640]}
|
|
90
|
+
sizes="(max-width: 1023px) 100vw, 50vw"
|
|
91
|
+
formats={['avif', 'webp']}
|
|
92
|
+
fallbackFormat="webp"
|
|
93
|
+
pictureAttributes={{ class: 'block w-full h-full' }}
|
|
94
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
95
|
+
loading={index < 3 ? "eager" : "lazy"}
|
|
96
|
+
fetchpriority={index === 0 ? "high" : undefined}
|
|
97
|
+
decoding="async"
|
|
98
|
+
/>
|
|
99
|
+
) : (
|
|
100
|
+
<img
|
|
101
|
+
src={fallbackSrc}
|
|
102
|
+
alt={imageAlt}
|
|
103
|
+
width="320"
|
|
104
|
+
height="192"
|
|
105
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
106
|
+
loading={index < 3 ? "eager" : "lazy"}
|
|
107
|
+
fetchpriority={index === 0 ? "high" : undefined}
|
|
108
|
+
decoding="async"
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
<div class="absolute top-3 left-3">
|
|
113
|
+
<Badge tone="custom" toneClass={`${categoryStyle.bg} ${categoryStyle.text}`} size="lg" lines={0} class="shadow-md backdrop-blur-sm">
|
|
114
|
+
{categoryLabel}
|
|
115
|
+
</Badge>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{isBadgeLength(priceLabel, PRICE_LABEL_MAX) && (
|
|
119
|
+
<div class="absolute bottom-3 left-3 max-w-[calc(100%-1.5rem)]">
|
|
120
|
+
<Badge tone="onImage" size="lg" lines={0} class="!px-2 !py-1">
|
|
121
|
+
<svg class="w-3 h-3 text-accent shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
122
|
+
<path d="M4 10h12"/><path d="M4 14h9"/><path d="M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2"/>
|
|
123
|
+
</svg>{priceLabel}
|
|
124
|
+
</Badge>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div class="flex-1 p-4 sm:p-5 flex flex-col">
|
|
130
|
+
<div class="flex-1">
|
|
131
|
+
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-1 sm:gap-2 mb-2">
|
|
132
|
+
<h3
|
|
133
|
+
class="text-lg sm:text-xl font-semibold text-card-foreground leading-tight font-display"
|
|
134
|
+
>
|
|
135
|
+
{name}
|
|
136
|
+
</h3>
|
|
137
|
+
{rating && (
|
|
138
|
+
<div class="flex items-center gap-1.5">
|
|
139
|
+
<div class="flex items-center gap-0.5" role="img" aria-label={rating.ariaLabel}>
|
|
140
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
141
|
+
<svg
|
|
142
|
+
class={`w-4 h-4 ${star <= Math.round(rating.value) ? 'text-accent fill-current' : 'text-muted fill-current'}`}
|
|
143
|
+
aria-hidden="true"
|
|
144
|
+
viewBox="0 0 24 24"
|
|
145
|
+
>
|
|
146
|
+
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
|
|
147
|
+
</svg>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
<span class="text-sm text-muted-foreground">{rating.text}</span>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<p class="text-sm sm:text-base text-muted-foreground line-clamp-2 mb-3">
|
|
156
|
+
{description}
|
|
157
|
+
</p>
|
|
158
|
+
|
|
159
|
+
{atmosphere && (
|
|
160
|
+
<div class="mb-3">
|
|
161
|
+
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-muted/90 text-muted-foreground rounded">
|
|
162
|
+
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
163
|
+
<path d="M9 18V5l12-2v13M6 18c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3zm12 0c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3z" />
|
|
164
|
+
</svg>
|
|
165
|
+
{atmosphere}
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{features.length > 0 && (
|
|
171
|
+
<div class="flex flex-wrap gap-1.5 mb-4">
|
|
172
|
+
{features.slice(0, 3).map((feature) => (
|
|
173
|
+
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-muted/90 text-muted-foreground rounded" title={feature.key}>
|
|
174
|
+
{feature.iconPath && (
|
|
175
|
+
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
176
|
+
<path d={feature.iconPath} />
|
|
177
|
+
</svg>
|
|
178
|
+
)}
|
|
179
|
+
{feature.label}
|
|
180
|
+
</span>
|
|
181
|
+
))}
|
|
182
|
+
{featuresMoreLabel && (
|
|
183
|
+
<span class="inline-flex items-center px-2 py-0.5 text-xs bg-muted/60 text-muted-foreground rounded">
|
|
184
|
+
{featuresMoreLabel}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{tagLabels.length > 0 && (
|
|
191
|
+
<div class="flex flex-wrap gap-1.5 mb-3">
|
|
192
|
+
{tagLabels.map((label) => (
|
|
193
|
+
<Badge tone="primary" size="sm">{label}</Badge>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div class="flex items-center justify-between pt-3 border-t border-border">
|
|
200
|
+
<div class="flex flex-col gap-1">
|
|
201
|
+
<span class="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
202
|
+
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
203
|
+
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /><circle cx="12" cy="10" r="3" />
|
|
204
|
+
</svg>
|
|
205
|
+
{neighborhood}
|
|
206
|
+
</span>
|
|
207
|
+
{closingLabel && (
|
|
208
|
+
<span class="text-sm text-muted-foreground">
|
|
209
|
+
{closingLabel}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<span class="inline-flex items-center gap-2 text-sm font-medium text-primary group-hover:text-primary-strong transition-colors">
|
|
215
|
+
{ctaLabel}
|
|
216
|
+
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
217
|
+
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
|
218
|
+
</svg>
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</a>
|
|
223
|
+
</article>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* DishCard — dish teaser. The category key picks the badge colour; the label, tags and every other visible string
|
|
4
|
+
* come from the site adapter, which also resolves the image, href and placeholder.
|
|
5
|
+
*/
|
|
6
|
+
import { Picture } from 'astro:assets';
|
|
7
|
+
import type { ImageMetadata } from 'astro';
|
|
8
|
+
import { Badge } from '@sarimarcus/content-sites-ui/primitives';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
href: string;
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
image?: ImageMetadata;
|
|
15
|
+
/** Shown when `image` is absent. */
|
|
16
|
+
fallbackSrc: string;
|
|
17
|
+
imageAlt: string;
|
|
18
|
+
category: string;
|
|
19
|
+
categoryLabel: string;
|
|
20
|
+
/** Plain keyword chips (first three shown). */
|
|
21
|
+
tags?: string[];
|
|
22
|
+
/** Audience badges (first three shown). */
|
|
23
|
+
tagLabels?: string[];
|
|
24
|
+
ctaLabel: string;
|
|
25
|
+
index?: number;
|
|
26
|
+
eager?: number;
|
|
27
|
+
priority?: boolean;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
href,
|
|
33
|
+
name,
|
|
34
|
+
description,
|
|
35
|
+
image,
|
|
36
|
+
fallbackSrc,
|
|
37
|
+
imageAlt,
|
|
38
|
+
category,
|
|
39
|
+
categoryLabel,
|
|
40
|
+
tags = [],
|
|
41
|
+
tagLabels = [],
|
|
42
|
+
ctaLabel,
|
|
43
|
+
index = 999,
|
|
44
|
+
eager = 1,
|
|
45
|
+
priority = false,
|
|
46
|
+
class: className,
|
|
47
|
+
} = Astro.props;
|
|
48
|
+
|
|
49
|
+
const fetchPriority = priority || index === 0 ? "high" : "auto";
|
|
50
|
+
const loadingStrategy = index < eager ? "eager" : "lazy";
|
|
51
|
+
|
|
52
|
+
// Contrast is AA for 12px text on every pairing.
|
|
53
|
+
const categoryColors: Record<string, string> = {
|
|
54
|
+
'starter': 'bg-primary text-overlay-foreground',
|
|
55
|
+
'main-course': 'bg-secondary text-overlay-foreground',
|
|
56
|
+
'dessert': 'bg-secondary text-overlay-foreground',
|
|
57
|
+
'tapa': 'bg-overlay text-overlay-foreground',
|
|
58
|
+
'drink': 'bg-accent-strong text-overlay-foreground',
|
|
59
|
+
};
|
|
60
|
+
const categoryColor = categoryColors[category] || 'bg-accent text-foreground';
|
|
61
|
+
const audienceLabels = tagLabels.slice(0, 3);
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
<article class:list={['group relative bg-card rounded-xl overflow-hidden card-lift border border-border flex flex-col h-full', className]}>
|
|
65
|
+
<a href={href} class="flex flex-col h-full focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded-xl">
|
|
66
|
+
<div class="relative h-56 overflow-hidden img-placeholder shrink-0">
|
|
67
|
+
{image ? (
|
|
68
|
+
<Picture
|
|
69
|
+
src={image}
|
|
70
|
+
alt={imageAlt}
|
|
71
|
+
widths={[240, 320, 400, 480, 640]}
|
|
72
|
+
sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw"
|
|
73
|
+
formats={['avif', 'webp']}
|
|
74
|
+
fallbackFormat="webp"
|
|
75
|
+
pictureAttributes={{ class: 'block w-full h-full' }}
|
|
76
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
77
|
+
loading={loadingStrategy}
|
|
78
|
+
fetchpriority={fetchPriority}
|
|
79
|
+
decoding="async"
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<img
|
|
83
|
+
src={fallbackSrc}
|
|
84
|
+
alt={imageAlt}
|
|
85
|
+
width="640"
|
|
86
|
+
height="384"
|
|
87
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
88
|
+
loading={loadingStrategy}
|
|
89
|
+
fetchpriority={fetchPriority}
|
|
90
|
+
decoding="async"
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
<div class="absolute top-3 left-3">
|
|
95
|
+
<Badge tone="custom" toneClass={categoryColor} size="lg" lines={0}>
|
|
96
|
+
{categoryLabel}
|
|
97
|
+
</Badge>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="p-4 sm:p-5 flex flex-col flex-1">
|
|
102
|
+
<h3
|
|
103
|
+
class="text-xl font-semibold text-card-foreground mb-2 group-hover:text-primary transition-colors font-display"
|
|
104
|
+
>
|
|
105
|
+
{name}
|
|
106
|
+
</h3>
|
|
107
|
+
|
|
108
|
+
<p class="text-base text-muted-foreground line-clamp-3 mb-4">
|
|
109
|
+
{description}
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
{tags.length > 0 && (
|
|
113
|
+
<div class="flex flex-wrap gap-1.5 mb-4">
|
|
114
|
+
{tags.slice(0, 3).map((tag) => (
|
|
115
|
+
<span class="px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded">
|
|
116
|
+
{tag}
|
|
117
|
+
</span>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{audienceLabels.length > 0 && (
|
|
123
|
+
<div class="flex flex-wrap gap-1.5 mb-3">
|
|
124
|
+
{audienceLabels.map((label) => (
|
|
125
|
+
<Badge tone="primary" size="sm">{label}</Badge>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<div class="flex items-center gap-2 text-sm font-medium text-primary group-hover:text-primary-strong transition-colors mt-auto">
|
|
131
|
+
<span>{ctaLabel}</span>
|
|
132
|
+
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
133
|
+
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
|
|
134
|
+
</svg>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</a>
|
|
138
|
+
</article>
|