astro-component-kit 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -36
- package/package.json +17 -4
- package/src/components/index.ts +18 -202
- package/src/components/lib/Galaxy3D.astro +249 -137
- package/src/components/lib/app/AppSidebar.astro +249 -0
- package/src/components/lib/app/AppTopbar.astro +284 -0
- package/src/components/lib/app/ArrivalsBoard.astro +176 -0
- package/src/components/lib/app/AuthCard.astro +213 -0
- package/src/components/lib/app/DataTable.astro +346 -0
- package/src/components/lib/app/GuestProfileCard.astro +133 -0
- package/src/components/lib/app/HousekeepingBoard.astro +189 -0
- package/src/components/lib/app/PageHeader.astro +157 -0
- package/src/components/lib/app/PlanUsageCard.astro +112 -0
- package/src/components/lib/app/RoomRack.astro +207 -0
- package/src/components/lib/app/SettingsSection.astro +96 -0
- package/src/components/lib/app/SwitchRow.astro +71 -0
- package/src/components/lib/app/TeamMembers.astro +190 -0
- package/src/components/lib/app/app-icons.ts +52 -0
- package/src/components/lib/app/index.ts +19 -0
- package/src/components/lib/buttons/NeonBorderButton.astro +162 -0
- package/src/components/lib/buttons/RippleButton.astro +199 -0
- package/src/components/lib/buttons/SlideRevealButton.astro +159 -0
- package/src/components/lib/buttons/index.ts +4 -0
- package/src/components/lib/cards/GithubRepoCard.astro +176 -0
- package/src/components/lib/cards/PricingCompareMiniCard.astro +196 -0
- package/src/components/lib/cards/StatsTrendCard.astro +149 -0
- package/src/components/lib/cards/index.ts +3 -0
- package/src/components/lib/charts/ActivityRings.astro +105 -0
- package/src/components/lib/charts/AreaChart.astro +4 -4
- package/src/components/lib/charts/BarChart.astro +3 -3
- package/src/components/lib/charts/BoxPlotChart.astro +109 -0
- package/src/components/lib/charts/BubbleChart.astro +101 -0
- package/src/components/lib/charts/BulletChart.astro +160 -0
- package/src/components/lib/charts/BumpChart.astro +92 -0
- package/src/components/lib/charts/CandlestickChart.astro +105 -0
- package/src/components/lib/charts/ChartFrame.astro +373 -0
- package/src/components/lib/charts/DivergingBarChart.astro +98 -0
- package/src/components/lib/charts/DonutChart.astro +2 -2
- package/src/components/lib/charts/DumbbellChart.astro +92 -0
- package/src/components/lib/charts/ForecastLineChart.astro +125 -0
- package/src/components/lib/charts/FunnelChart.astro +122 -0
- package/src/components/lib/charts/GaugeChart.astro +111 -0
- package/src/components/lib/charts/GoalProgressChart.astro +97 -0
- package/src/components/lib/charts/GroupedBarChart.astro +125 -0
- package/src/components/lib/charts/HeatmapChart.astro +131 -0
- package/src/components/lib/charts/HistogramChart.astro +111 -0
- package/src/components/lib/charts/HorizontalBarChart.astro +155 -0
- package/src/components/lib/charts/KpiStatTile.astro +116 -0
- package/src/components/lib/charts/KpiTrendGrid.astro +60 -0
- package/src/components/lib/charts/LineChart.astro +160 -0
- package/src/components/lib/charts/LinearProgressGauge.astro +136 -0
- package/src/components/lib/charts/LollipopChart.astro +100 -0
- package/src/components/lib/charts/MeterGauge.astro +122 -0
- package/src/components/lib/charts/MetricDeltaTable.astro +125 -0
- package/src/components/lib/charts/PieChart.astro +135 -0
- package/src/components/lib/charts/PolarAreaChart.astro +85 -0
- package/src/components/lib/charts/PopulationPyramid.astro +122 -0
- package/src/components/lib/charts/PunchCardChart.astro +89 -0
- package/src/components/lib/charts/RadarChart.astro +94 -0
- package/src/components/lib/charts/RadarMetricChart.astro +168 -0
- package/src/components/lib/charts/RadialBarChart.astro +124 -0
- package/src/components/lib/charts/RangeAreaChart.astro +113 -0
- package/src/components/lib/charts/RetentionCohortTable.astro +126 -0
- package/src/components/lib/charts/SankeyChart.astro +128 -0
- package/src/components/lib/charts/ScatterPlot.astro +139 -0
- package/src/components/lib/charts/SegmentedBarChart.astro +119 -0
- package/src/components/lib/charts/SlopeChart.astro +105 -0
- package/src/components/lib/charts/SparklineChart.astro +87 -0
- package/src/components/lib/charts/StackedAreaChart.astro +130 -0
- package/src/components/lib/charts/StackedBarChart.astro +136 -0
- package/src/components/lib/charts/SunburstChart.astro +103 -0
- package/src/components/lib/charts/TreemapChart.astro +94 -0
- package/src/components/lib/charts/WaffleChart.astro +121 -0
- package/src/components/lib/charts/WaterfallChart.astro +118 -0
- package/src/components/lib/charts/chart-tooltip.ts +75 -0
- package/src/components/lib/charts/chart-utils.ts +271 -0
- package/src/components/lib/charts/index.ts +46 -0
- package/src/components/lib/data/ActivityHeatmapGrid.astro +157 -0
- package/src/components/lib/data/MetricComparisonRow.astro +109 -0
- package/src/components/lib/data/StatusTimelineFeed.astro +168 -0
- package/src/components/lib/data/index.ts +3 -0
- package/src/components/lib/ecommerce/CartDrawer.astro +483 -0
- package/src/components/lib/ecommerce/CartPageList.astro +290 -0
- package/src/components/lib/ecommerce/CartToast.astro +276 -0
- package/src/components/lib/ecommerce/CategoryTiles.astro +143 -0
- package/src/components/lib/ecommerce/CheckoutForm.astro +423 -0
- package/src/components/lib/ecommerce/CountdownBanner.astro +230 -0
- package/src/components/lib/ecommerce/FilterBar.astro +278 -0
- package/src/components/lib/ecommerce/FilterSidebar.astro +405 -0
- package/src/components/lib/ecommerce/LiveActivityToast.astro +213 -0
- package/src/components/lib/ecommerce/OrderSummary.astro +295 -0
- package/src/components/lib/ecommerce/ProductBuyBox.astro +247 -0
- package/src/components/lib/ecommerce/ProductCardMinimal.astro +229 -0
- package/src/components/lib/ecommerce/ProductGallery.astro +145 -0
- package/src/components/lib/ecommerce/ProductGrid.astro +118 -0
- package/src/components/lib/ecommerce/ProductMedia.astro +113 -0
- package/src/components/lib/ecommerce/ProductRating.astro +78 -0
- package/src/components/lib/ecommerce/ProductToolbar.astro +93 -0
- package/src/components/lib/ecommerce/PromoBar.astro +145 -0
- package/src/components/lib/ecommerce/StockIndicator.astro +127 -0
- package/src/components/lib/ecommerce/StoreHeader.astro +446 -0
- package/src/components/lib/ecommerce/StoreHero.astro +260 -0
- package/src/components/lib/ecommerce/StoreProductCard.astro +307 -0
- package/src/components/lib/ecommerce/TrustBadges.astro +101 -0
- package/src/components/lib/ecommerce/VariantPicker.astro +145 -0
- package/src/components/lib/ecommerce/cart-store.ts +151 -0
- package/src/components/lib/ecommerce/catalog-filter.ts +62 -0
- package/src/components/lib/ecommerce/index.ts +34 -0
- package/src/components/lib/ecommerce/product-icons.ts +28 -0
- package/src/components/lib/ecommerce/types.ts +70 -0
- package/src/components/lib/feedback/ProgressRingCounter.astro +118 -0
- package/src/components/lib/feedback/ToastNotificationGroup.astro +173 -0
- package/src/components/lib/feedback/index.ts +2 -0
- package/src/components/lib/forms/AnimatedInput.astro +4 -2
- package/src/components/lib/forms/CustomRadioGroup.astro +3 -1
- package/src/components/lib/forms/GlassInlineToggleGroup.astro +161 -0
- package/src/components/lib/forms/PasswordStrengthInput.astro +242 -0
- package/src/components/lib/forms/PhoneCountryInput.astro +145 -0
- package/src/components/lib/forms/TagsAutocompleteInput.astro +249 -0
- package/src/components/lib/forms/index.ts +5 -0
- package/src/components/lib/index.ts +2 -0
- package/src/components/lib/layout/BentoGrid3.astro +6 -4
- package/src/components/lib/layout/CenterFixedBox.astro +2 -2
- package/src/components/lib/layout/CenteredContainer.astro +4 -2
- package/src/components/lib/layout/ClusterHorizontal.astro +2 -2
- package/src/components/lib/layout/DiagonalSectionDivider.astro +69 -0
- package/src/components/lib/layout/FeatureHero.astro +4 -2
- package/src/components/lib/layout/FooterCenteredMinimal.astro +4 -2
- package/src/components/lib/layout/FullBleedSection.astro +2 -2
- package/src/components/lib/layout/GalleryGrid4.astro +2 -2
- package/src/components/lib/layout/HolyGrailGrid.astro +127 -0
- package/src/components/lib/layout/LandingSectionSplit.astro +5 -3
- package/src/components/lib/layout/MasonryGrid.astro +2 -2
- package/src/components/lib/layout/MinimalFooterGrid.astro +1 -1
- package/src/components/lib/layout/ScrollSnapSection.astro +1 -1
- package/src/components/lib/layout/SidebarLayoutWrapper.astro +4 -2
- package/src/components/lib/layout/SplitScreenLayout.astro +3 -3
- package/src/components/lib/layout/StepLayoutVertical.astro +1 -1
- package/src/components/lib/layout/StickySidebarWrapper.astro +4 -2
- package/src/components/lib/layout/index.ts +3 -1
- package/src/components/lib/navigation/QuickJumpDock.astro +125 -0
- package/src/components/lib/navigation/StepBreadcrumbBar.astro +151 -0
- package/src/components/lib/navigation/SubNavbarPills.astro +102 -0
- package/src/components/lib/navigation/index.ts +3 -0
- package/src/components/lib/overlays/CookiePreferencesDrawer.astro +226 -0
- package/src/components/lib/overlays/FloatingFeedbackWidget.astro +203 -0
- package/src/components/lib/overlays/ImageCompareSlider.astro +172 -0
- package/src/components/lib/overlays/index.ts +3 -0
- package/src/components/lib/sections/AgencyContactBrief.astro +194 -0
- package/src/components/lib/sections/AgencyFooterCta.astro +94 -0
- package/src/components/lib/sections/AgencyHeader.astro +161 -0
- package/src/components/lib/sections/AgencyHeroKinetic.astro +172 -0
- package/src/components/lib/sections/AgencyServicesList.astro +113 -0
- package/src/components/lib/sections/AgencyTeamGrid.astro +111 -0
- package/src/components/lib/sections/AgencyWorkGrid.astro +147 -0
- package/src/components/lib/sections/BlogGridSection.astro +133 -0
- package/src/components/lib/sections/ButtonsShowcase.astro +20 -18
- package/src/components/lib/sections/CardsShowcase.astro +28 -26
- package/src/components/lib/sections/CareersOpenings.astro +141 -0
- package/src/components/lib/sections/ChangelogSection.astro +151 -0
- package/src/components/lib/sections/ContactGlassForm.astro +10 -8
- package/src/components/lib/sections/ContactSplitMap.astro +141 -0
- package/src/components/lib/sections/CtaAppDownload.astro +13 -11
- package/src/components/lib/sections/CtaGlowBanner.astro +12 -10
- package/src/components/lib/sections/CtaNewsletterSection.astro +165 -0
- package/src/components/lib/sections/CtaSplitImage.astro +149 -0
- package/src/components/lib/sections/DataShowcase.astro +23 -21
- package/src/components/lib/sections/FaqAccordionSection.astro +8 -8
- package/src/components/lib/sections/FaqTwoColumn.astro +121 -0
- package/src/components/lib/sections/FeatureAlternatingRows.astro +192 -0
- package/src/components/lib/sections/FeatureBentoGrid.astro +13 -11
- package/src/components/lib/sections/FeatureIconGrid.astro +155 -0
- package/src/components/lib/sections/FeatureTabsShowcase.astro +182 -0
- package/src/components/lib/sections/FeatureTimelineFlow.astro +8 -6
- package/src/components/lib/sections/FeedbackShowcase.astro +15 -13
- package/src/components/lib/sections/FooterMegaSection.astro +157 -0
- package/src/components/lib/sections/FormsShowcase.astro +22 -20
- package/src/components/lib/sections/HeroDashboardPreview.astro +182 -0
- package/src/components/lib/sections/HeroGlass.astro +22 -20
- package/src/components/lib/sections/HeroGradient.astro +15 -13
- package/src/components/lib/sections/HeroMinimalCentered.astro +175 -0
- package/src/components/lib/sections/HeroShowcaseSplit.astro +306 -0
- package/src/components/lib/sections/HeroSplit.astro +13 -11
- package/src/components/lib/sections/HeroTerminal.astro +253 -0
- package/src/components/lib/sections/HeroWaitlist.astro +231 -0
- package/src/components/lib/sections/HowItWorksSteps.astro +180 -0
- package/src/components/lib/sections/IntegrationsGrid.astro +167 -0
- package/src/components/lib/sections/LayoutShowcase.astro +21 -19
- package/src/components/lib/sections/LogoCloudSection.astro +96 -0
- package/src/components/lib/sections/LogoWallGrid.astro +93 -0
- package/src/components/lib/sections/NavigationShowcase.astro +15 -13
- package/src/components/lib/sections/OverlaysShowcase.astro +21 -19
- package/src/components/lib/sections/PricingComparisonMatrix.astro +15 -13
- package/src/components/lib/sections/PricingSinglePlan.astro +159 -0
- package/src/components/lib/sections/PricingTierCards.astro +11 -9
- package/src/components/lib/sections/PricingToggleCards.astro +171 -0
- package/src/components/lib/sections/ShopCategoryHero.astro +103 -0
- package/src/components/lib/sections/ShopCollectionMosaic.astro +131 -0
- package/src/components/lib/sections/ShopFooterWordmark.astro +100 -0
- package/src/components/lib/sections/ShopHeaderEditorial.astro +396 -0
- package/src/components/lib/sections/ShopHeroEditorial.astro +151 -0
- package/src/components/lib/sections/ShopLookbook.astro +250 -0
- package/src/components/lib/sections/ShopNewsletterEditorial.astro +128 -0
- package/src/components/lib/sections/ShopProductRail.astro +150 -0
- package/src/components/lib/sections/ShopReviewsEditorial.astro +133 -0
- package/src/components/lib/sections/ShopValuesMarquee.astro +91 -0
- package/src/components/lib/sections/StatsCounterBand.astro +124 -0
- package/src/components/lib/sections/StatsDashboardSection.astro +85 -0
- package/src/components/lib/sections/StatsMetricShowcase.astro +9 -7
- package/src/components/lib/sections/TeamGridSection.astro +12 -10
- package/src/components/lib/sections/TestimonialCarousel.astro +167 -0
- package/src/components/lib/sections/TestimonialFeatured.astro +122 -0
- package/src/components/lib/sections/TestimonialGridSection.astro +188 -0
- package/src/components/lib/sections/TestimonialWallGlass.astro +11 -9
- package/src/components/lib/sections/TypographyShowcase.astro +10 -8
- package/src/components/lib/sections/index.ts +39 -0
- package/src/components/lib/templates/ecommerce/StoreCategoryPage.astro +160 -0
- package/src/components/lib/templates/ecommerce/StoreCheckoutPage.astro +98 -0
- package/src/components/lib/templates/ecommerce/StoreHomePage.astro +191 -0
- package/src/components/lib/templates/ecommerce/StoreLayout.astro +169 -0
- package/src/components/lib/templates/ecommerce/StoreProductPage.astro +165 -0
- package/src/components/lib/templates/ecommerce/data.ts +187 -0
- package/src/components/lib/templates/ecommerce/index.ts +14 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialBagPage.astro +52 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialCheckoutPage.astro +123 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialHomePage.astro +102 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialLayout.astro +216 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialProductPage.astro +205 -0
- package/src/components/lib/templates/ecommerce-editorial/EditorialShopPage.astro +112 -0
- package/src/components/lib/templates/ecommerce-editorial/data.ts +125 -0
- package/src/components/lib/templates/ecommerce-editorial/index.ts +16 -0
- package/src/components/lib/templates/saas-hotel/AppLayout.astro +83 -0
- package/src/components/lib/templates/saas-hotel/DashboardPage.astro +66 -0
- package/src/components/lib/templates/saas-hotel/GuestsPage.astro +65 -0
- package/src/components/lib/templates/saas-hotel/LandingPage.astro +213 -0
- package/src/components/lib/templates/saas-hotel/ReportsPage.astro +63 -0
- package/src/components/lib/templates/saas-hotel/ReservationsPage.astro +49 -0
- package/src/components/lib/templates/saas-hotel/RoomsPage.astro +48 -0
- package/src/components/lib/templates/saas-hotel/SettingsPage.astro +115 -0
- package/src/components/lib/templates/saas-hotel/SignInPage.astro +50 -0
- package/src/components/lib/templates/saas-hotel/data.ts +229 -0
- package/src/components/lib/templates/saas-hotel/index.ts +19 -0
- package/src/components/lib/templates/saas-hotel/lobby-theme.css +90 -0
- package/src/components/lib/typography/AnimatedCounterText.astro +102 -0
- package/src/components/lib/typography/CurvedPathHeading.astro +86 -0
- package/src/components/lib/typography/GradientTextMarquee.astro +105 -0
- package/src/components/lib/typography/HackerDecodeText.astro +85 -0
- package/src/components/lib/typography/index.ts +4 -0
- package/src/styles/global.css +113 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ShopLookbook — "Shop the look": a large editorial image with numbered hotspots. Each hotspot
|
|
4
|
+
* opens a small product card (name, price, link and add to bag) and highlights it in the side list.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} eyebrow - Small label. Default 'Lookbook'.
|
|
7
|
+
* @param {string} title - Heading (use *asterisks* for italics). Default 'Shop the *look*'.
|
|
8
|
+
* @param {string} text - Intro copy.
|
|
9
|
+
* @param {{icon?: string, hue?: number, image?: string, alt?: string}} media - Scene visual.
|
|
10
|
+
* @param {Array<{x: number, y: number, product: Product, href?: string}>} spots - Hotspots; x/y in % of the image.
|
|
11
|
+
* @param {string} currency - ISO currency. Default 'EUR'.
|
|
12
|
+
* @param {string} locale - Number format locale. Default 'en-GB'.
|
|
13
|
+
* @param {string} cartKey - Cart storage key. Default 'ack-cart'.
|
|
14
|
+
* @param {string} variant - Layout (image-left|image-right). Default 'image-left'.
|
|
15
|
+
* @param {string} id - Base id. Default 'lookbook'.
|
|
16
|
+
* @param {string} className - Extra class.
|
|
17
|
+
*
|
|
18
|
+
* Accessibility: hotspots are buttons with full labels and aria-expanded; the same products are
|
|
19
|
+
* listed as plain links next to the image, so the look is usable without the hotspots.
|
|
20
|
+
*/
|
|
21
|
+
import ProductMedia from '../ecommerce/ProductMedia.astro';
|
|
22
|
+
import { formatMoney } from '../ecommerce/cart-store';
|
|
23
|
+
import type { Product } from '../ecommerce/types';
|
|
24
|
+
|
|
25
|
+
interface Spot { x: number; y: number; product: Product; href?: string }
|
|
26
|
+
interface Props {
|
|
27
|
+
eyebrow?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
media?: { icon?: string; hue?: number; image?: string; alt?: string };
|
|
31
|
+
spots?: Spot[];
|
|
32
|
+
currency?: string;
|
|
33
|
+
locale?: string;
|
|
34
|
+
cartKey?: string;
|
|
35
|
+
variant?: 'image-left' | 'image-right';
|
|
36
|
+
id?: string;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
eyebrow = 'Lookbook',
|
|
42
|
+
title = 'Shop the *look*',
|
|
43
|
+
text = 'A Sunday uniform: a soft overshirt, a tote that goes everywhere and the mug that starts the day.',
|
|
44
|
+
media = { icon: 'jacket', hue: 22 },
|
|
45
|
+
spots = [
|
|
46
|
+
{ x: 38, y: 34, product: { slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', icon: 'shirt', hue: 30 } },
|
|
47
|
+
{ x: 66, y: 62, product: { slug: 'field-tote', name: 'Field Tote', price: 120, category: 'bags', icon: 'bag', hue: 18 } },
|
|
48
|
+
{ x: 24, y: 76, product: { slug: 'kiln-mug', name: 'Kiln Mug', price: 32, category: 'objects', icon: 'mug', hue: 14 } },
|
|
49
|
+
],
|
|
50
|
+
currency = 'EUR',
|
|
51
|
+
locale = 'en-GB',
|
|
52
|
+
cartKey = 'ack-cart',
|
|
53
|
+
variant = 'image-left',
|
|
54
|
+
id = 'lookbook',
|
|
55
|
+
className = '',
|
|
56
|
+
} = Astro.props;
|
|
57
|
+
|
|
58
|
+
const money = { currency, locale };
|
|
59
|
+
const parts = title.split(/\*(.+?)\*/g);
|
|
60
|
+
const total = spots.reduce((n, s) => n + s.product.price, 0);
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
<div class="slb-cq">
|
|
64
|
+
<section class:list={['slb', `slb--${variant}`, className]} data-lookbook>
|
|
65
|
+
<div class="slb__stage">
|
|
66
|
+
<ProductMedia image={media.image} alt={media.alt ?? ''} icon={media.icon} hue={media.hue} ratio="4 / 5" variant="soft" size="lg" />
|
|
67
|
+
{spots.map((s, i) => (
|
|
68
|
+
<div class="slb__spot" style={`left: ${s.x}%; top: ${s.y}%`}>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="slb__dot"
|
|
72
|
+
aria-expanded="false"
|
|
73
|
+
aria-controls={`${id}-pop-${i}`}
|
|
74
|
+
aria-label={`${i + 1}: ${s.product.name}, ${formatMoney(s.product.price, money)}`}
|
|
75
|
+
data-spot={i}
|
|
76
|
+
>{i + 1}</button>
|
|
77
|
+
<div class:list={['slb__pop', { 'slb__pop--left': s.x > 60 }]} id={`${id}-pop-${i}`} hidden data-pop>
|
|
78
|
+
<p class="slb__pop-name">{s.product.name}</p>
|
|
79
|
+
<p class="slb__pop-price">{formatMoney(s.product.price, money)}</p>
|
|
80
|
+
<div class="slb__pop-actions">
|
|
81
|
+
<a href={s.href ?? '#'}>View</a>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
data-add-to-cart={JSON.stringify({ id: s.product.slug, name: s.product.name, price: s.product.price, icon: s.product.icon, hue: s.product.hue, variant: s.product.colors?.[0]?.name })}
|
|
85
|
+
data-cart-key={cartKey}
|
|
86
|
+
data-open-cart="false"
|
|
87
|
+
data-added-label="Added"
|
|
88
|
+
><span data-label>Add to bag</span></button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="slb__copy">
|
|
96
|
+
{eyebrow && <p class="slb__eyebrow">{eyebrow}</p>}
|
|
97
|
+
<h2 class="slb__title">{parts.map((p, i) => (i % 2 ? <em>{p}</em> : p))}</h2>
|
|
98
|
+
{text && <p class="slb__text">{text}</p>}
|
|
99
|
+
<ol class="slb__list">
|
|
100
|
+
{spots.map((s, i) => (
|
|
101
|
+
<li class="slb__row" data-row={i}>
|
|
102
|
+
<span class="slb__row-num" aria-hidden="true">{String(i + 1).padStart(2, '0')}</span>
|
|
103
|
+
<a class="slb__row-name" href={s.href ?? '#'}>{s.product.name}</a>
|
|
104
|
+
<span class="slb__row-price">{formatMoney(s.product.price, money)}</span>
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ol>
|
|
108
|
+
<p class="slb__total"><span>The complete look</span><strong>{formatMoney(total, money)}</strong></p>
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<script>
|
|
114
|
+
import { bindAddToCart } from '../ecommerce/cart-store';
|
|
115
|
+
bindAddToCart(document);
|
|
116
|
+
|
|
117
|
+
document.querySelectorAll<HTMLElement>('[data-lookbook]:not([data-bound])').forEach((lb) => {
|
|
118
|
+
lb.dataset.bound = '';
|
|
119
|
+
const dots = Array.from(lb.querySelectorAll<HTMLButtonElement>('[data-spot]'));
|
|
120
|
+
const rows = Array.from(lb.querySelectorAll<HTMLElement>('[data-row]'));
|
|
121
|
+
const close = () => {
|
|
122
|
+
dots.forEach((d) => {
|
|
123
|
+
d.setAttribute('aria-expanded', 'false');
|
|
124
|
+
const p = document.getElementById(d.getAttribute('aria-controls') || '');
|
|
125
|
+
if (p) p.hidden = true;
|
|
126
|
+
});
|
|
127
|
+
rows.forEach((r) => r.classList.remove('is-active'));
|
|
128
|
+
};
|
|
129
|
+
dots.forEach((d, i) => {
|
|
130
|
+
d.addEventListener('click', (e) => {
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
const open = d.getAttribute('aria-expanded') !== 'true';
|
|
133
|
+
close();
|
|
134
|
+
if (!open) return;
|
|
135
|
+
d.setAttribute('aria-expanded', 'true');
|
|
136
|
+
const p = document.getElementById(d.getAttribute('aria-controls') || '');
|
|
137
|
+
if (p) p.hidden = false;
|
|
138
|
+
rows[i]?.classList.add('is-active');
|
|
139
|
+
});
|
|
140
|
+
rows[i]?.addEventListener('mouseenter', () => d.classList.add('is-hint'));
|
|
141
|
+
rows[i]?.addEventListener('mouseleave', () => d.classList.remove('is-hint'));
|
|
142
|
+
});
|
|
143
|
+
document.addEventListener('click', (e) => { if (!lb.contains(e.target as Node)) close(); });
|
|
144
|
+
lb.addEventListener('keydown', (e) => {
|
|
145
|
+
if (e.key !== 'Escape') return;
|
|
146
|
+
const open = dots.find((d) => d.getAttribute('aria-expanded') === 'true');
|
|
147
|
+
close();
|
|
148
|
+
open?.focus();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<style>
|
|
154
|
+
.slb-cq { container-type: inline-size; }
|
|
155
|
+
.slb {
|
|
156
|
+
display: grid;
|
|
157
|
+
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
|
158
|
+
gap: clamp(1.5rem, 5vw, 5rem);
|
|
159
|
+
align-items: center;
|
|
160
|
+
color: var(--c-text-1, #fff);
|
|
161
|
+
}
|
|
162
|
+
.slb--image-right .slb__stage { order: 2; }
|
|
163
|
+
.slb__stage { position: relative; border-radius: var(--r-sm, 2px); }
|
|
164
|
+
.slb__stage > :global(.p-media) { border-radius: var(--r-sm, 2px); }
|
|
165
|
+
.slb__spot { position: absolute; z-index: 2; transform: translate(-50%, -50%); }
|
|
166
|
+
.slb__dot {
|
|
167
|
+
position: relative;
|
|
168
|
+
display: grid;
|
|
169
|
+
place-items: center;
|
|
170
|
+
width: 40px;
|
|
171
|
+
height: 40px;
|
|
172
|
+
border: 0;
|
|
173
|
+
border-radius: 50%;
|
|
174
|
+
background: var(--c-text-1, #fff);
|
|
175
|
+
color: var(--c-bg, #050505);
|
|
176
|
+
font: inherit;
|
|
177
|
+
font-size: 0.8rem;
|
|
178
|
+
font-weight: 700;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
box-shadow: 0 10px 30px -8px rgba(0, 0, 0, 0.6);
|
|
181
|
+
transition: transform 0.2s ease, background 0.2s ease, color 0.2s ease;
|
|
182
|
+
}
|
|
183
|
+
.slb__dot::before { content: ''; position: absolute; inset: -6px; border-radius: 50%; border: 1px solid var(--c-text-1, #fff); opacity: 0.6; animation: slb-ring 2.4s ease-out infinite; }
|
|
184
|
+
.slb__dot:hover, .slb__dot.is-hint, .slb__dot[aria-expanded='true'] { transform: scale(1.12); background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
|
|
185
|
+
@keyframes slb-ring { 0% { transform: scale(0.8); opacity: 0.7; } 100% { transform: scale(1.5); opacity: 0; } }
|
|
186
|
+
|
|
187
|
+
.slb__pop {
|
|
188
|
+
position: absolute;
|
|
189
|
+
top: 50%;
|
|
190
|
+
left: calc(100% + 12px);
|
|
191
|
+
width: 220px;
|
|
192
|
+
padding: var(--sp-4, 1rem);
|
|
193
|
+
transform: translateY(-50%);
|
|
194
|
+
background: var(--c-bg-1, #0a0a0a);
|
|
195
|
+
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12));
|
|
196
|
+
box-shadow: 0 24px 50px -20px rgba(0, 0, 0, 0.6);
|
|
197
|
+
animation: slb-pop 0.2s ease-out;
|
|
198
|
+
}
|
|
199
|
+
.slb__pop--left { left: auto; right: calc(100% + 12px); }
|
|
200
|
+
.slb__pop[hidden] { display: none; }
|
|
201
|
+
@keyframes slb-pop { from { opacity: 0; transform: translateY(-46%); } }
|
|
202
|
+
.slb__pop-name { margin: 0; font-family: var(--font-display, Georgia, serif); font-size: 1.05rem; }
|
|
203
|
+
.slb__pop-price { margin: 2px 0 var(--sp-3, 0.75rem); font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
|
|
204
|
+
.slb__pop-actions { display: flex; gap: var(--sp-2, 0.5rem); }
|
|
205
|
+
.slb__pop-actions a, .slb__pop-actions button {
|
|
206
|
+
flex: 1;
|
|
207
|
+
display: inline-grid;
|
|
208
|
+
place-items: center;
|
|
209
|
+
min-height: 40px;
|
|
210
|
+
border: 1px solid var(--c-text-1, #fff);
|
|
211
|
+
border-radius: var(--r-sm, 2px);
|
|
212
|
+
background: transparent;
|
|
213
|
+
color: var(--c-text-1, #fff);
|
|
214
|
+
font: inherit;
|
|
215
|
+
font-size: 0.75rem;
|
|
216
|
+
font-weight: 600;
|
|
217
|
+
letter-spacing: 0.06em;
|
|
218
|
+
text-transform: uppercase;
|
|
219
|
+
text-decoration: none;
|
|
220
|
+
cursor: pointer;
|
|
221
|
+
}
|
|
222
|
+
.slb__pop-actions button { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
|
|
223
|
+
.slb__pop-actions button.is-added { background: var(--shop-accent, var(--c-primary-light, #d4a017)); border-color: transparent; color: var(--c-on-primary, #050505); }
|
|
224
|
+
.slb :is(a, button):focus-visible { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }
|
|
225
|
+
|
|
226
|
+
.slb__eyebrow { margin: 0 0 var(--sp-3, 0.75rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
|
|
227
|
+
.slb__title { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2.4rem, 5.5vw, 4.5rem); font-weight: 400; letter-spacing: -0.04em; line-height: 0.95; }
|
|
228
|
+
.slb__title em { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
|
|
229
|
+
.slb__text { max-width: 28rem; margin: var(--sp-5, 1.25rem) 0 0; line-height: 1.65; color: var(--c-text-2, #a3a3a3); }
|
|
230
|
+
.slb__list { margin: var(--sp-8, 2rem) 0 0; padding: 0; list-style: none; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); }
|
|
231
|
+
.slb__row { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: var(--sp-3, 0.75rem); min-height: 56px; border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); transition: background 0.2s ease, padding 0.2s ease; }
|
|
232
|
+
.slb__row.is-active { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); padding-inline: var(--sp-2, 0.5rem); }
|
|
233
|
+
.slb__row-num { font-size: 0.75rem; color: var(--c-text-2, #a3a3a3); font-variant-numeric: tabular-nums; }
|
|
234
|
+
.slb__row-name { font-family: var(--font-display, Georgia, serif); font-size: 1.15rem; color: inherit; text-decoration: none; }
|
|
235
|
+
.slb__row-name:hover { color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
|
|
236
|
+
.slb__row-price { font-variant-numeric: tabular-nums; }
|
|
237
|
+
.slb__total { display: flex; justify-content: space-between; margin: var(--sp-4, 1rem) 0 0; font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
|
|
238
|
+
.slb__total strong { font-size: 1.1rem; color: var(--c-text-1, #fff); }
|
|
239
|
+
|
|
240
|
+
@container (max-width: 860px) {
|
|
241
|
+
.slb { grid-template-columns: 1fr; }
|
|
242
|
+
.slb--image-right .slb__stage { order: 0; }
|
|
243
|
+
.slb__pop { left: 50%; top: calc(100% + 10px); transform: translateX(-50%); width: 200px; }
|
|
244
|
+
.slb__pop--left { right: auto; left: 50%; }
|
|
245
|
+
@keyframes slb-pop { from { opacity: 0; } }
|
|
246
|
+
}
|
|
247
|
+
@media (prefers-reduced-motion: reduce) {
|
|
248
|
+
.slb__dot::before, .slb__pop { animation: none; }
|
|
249
|
+
}
|
|
250
|
+
</style>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ShopNewsletterEditorial — Oversized editorial sign-up: large serif statement on one side,
|
|
4
|
+
* underline-style email field, consent note and inline success/error messages on the other.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} title - Statement (use *asterisks* for italics). Default 'Letters from the *studio*'.
|
|
7
|
+
* @param {string} text - Supporting copy.
|
|
8
|
+
* @param {string} action - Form action. With '#' the form validates and confirms locally. Default '#'.
|
|
9
|
+
* @param {string} buttonLabel - Submit label. Default 'Subscribe'.
|
|
10
|
+
* @param {string} consent - Small print under the field.
|
|
11
|
+
* @param {string} successText - Message after a valid submit.
|
|
12
|
+
* @param {string} variant - Style (plain|inverted|accent). Default 'plain'.
|
|
13
|
+
* @param {string} size - Type scale (sm|md|lg). Default 'md'.
|
|
14
|
+
* @param {string} id - Base id. Default 'shop-news'.
|
|
15
|
+
* @param {string} className - Extra class.
|
|
16
|
+
*
|
|
17
|
+
* Accessibility: labelled email input with autocomplete, aria-invalid + described error,
|
|
18
|
+
* messages in a polite live region.
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
title?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
action?: string;
|
|
24
|
+
buttonLabel?: string;
|
|
25
|
+
consent?: string;
|
|
26
|
+
successText?: string;
|
|
27
|
+
variant?: 'plain' | 'inverted' | 'accent';
|
|
28
|
+
size?: 'sm' | 'md' | 'lg';
|
|
29
|
+
id?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
title = 'Letters from the *studio*',
|
|
35
|
+
text = 'One email a month: new pieces before anyone else, the people who make them, and 10% off your first order.',
|
|
36
|
+
action = '#',
|
|
37
|
+
buttonLabel = 'Subscribe',
|
|
38
|
+
consent = 'By subscribing you accept our privacy policy. Unsubscribe in one click.',
|
|
39
|
+
successText = 'Thank you — your first letter is on its way.',
|
|
40
|
+
variant = 'plain',
|
|
41
|
+
size = 'md',
|
|
42
|
+
id = 'shop-news',
|
|
43
|
+
className = '',
|
|
44
|
+
} = Astro.props;
|
|
45
|
+
|
|
46
|
+
const parts = title.split(/\*(.+?)\*/g);
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
<div class="sne-cq">
|
|
50
|
+
<section class:list={['sne', `sne--${variant}`, `sne--${size}`, className]} aria-labelledby={`${id}-title`}>
|
|
51
|
+
<h2 class="sne__title" id={`${id}-title`}>{parts.map((p, i) => (i % 2 ? <em>{p}</em> : p))}</h2>
|
|
52
|
+
<div class="sne__side">
|
|
53
|
+
{text && <p class="sne__text">{text}</p>}
|
|
54
|
+
<form class="sne__form" action={action} method="post" novalidate data-news data-success={successText}>
|
|
55
|
+
<label class="sne__label" for={`${id}-email`}>Email address</label>
|
|
56
|
+
<div class="sne__field">
|
|
57
|
+
<input id={`${id}-email`} name="email" type="email" required autocomplete="email" placeholder="you@example.com" aria-describedby={`${id}-msg`} />
|
|
58
|
+
<button type="submit">{buttonLabel} <span aria-hidden="true">→</span></button>
|
|
59
|
+
</div>
|
|
60
|
+
<p class="sne__msg" id={`${id}-msg`} aria-live="polite" data-msg></p>
|
|
61
|
+
{consent && <p class="sne__consent">{consent}</p>}
|
|
62
|
+
</form>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<script>
|
|
68
|
+
document.querySelectorAll<HTMLFormElement>('form[data-news]:not([data-bound])').forEach((form) => {
|
|
69
|
+
form.dataset.bound = '';
|
|
70
|
+
const input = form.querySelector<HTMLInputElement>('input[type="email"]')!;
|
|
71
|
+
const msg = form.querySelector<HTMLElement>('[data-msg]')!;
|
|
72
|
+
form.addEventListener('submit', (e) => {
|
|
73
|
+
const local = form.getAttribute('action') === '#';
|
|
74
|
+
if (!input.checkValidity()) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
input.setAttribute('aria-invalid', 'true');
|
|
77
|
+
msg.dataset.state = 'error';
|
|
78
|
+
msg.textContent = 'Please enter a valid email address.';
|
|
79
|
+
input.focus();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
input.removeAttribute('aria-invalid');
|
|
83
|
+
if (local) {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
msg.dataset.state = 'ok';
|
|
86
|
+
msg.textContent = form.dataset.success || 'Subscribed.';
|
|
87
|
+
form.reset();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<style>
|
|
94
|
+
.sne-cq { container-type: inline-size; }
|
|
95
|
+
.sne {
|
|
96
|
+
display: grid;
|
|
97
|
+
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
|
98
|
+
gap: clamp(1.5rem, 5vw, 5rem);
|
|
99
|
+
align-items: end;
|
|
100
|
+
padding: clamp(2.5rem, 6vw, 5rem) clamp(1rem, 3vw, 2.5rem);
|
|
101
|
+
color: var(--c-text-1, #fff);
|
|
102
|
+
}
|
|
103
|
+
.sne--inverted { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
|
|
104
|
+
.sne--accent { background: var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
|
|
105
|
+
.sne__title { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2.6rem, 7vw, 6.5rem); font-weight: 400; letter-spacing: -0.045em; line-height: 0.92; text-wrap: balance; }
|
|
106
|
+
.sne--sm .sne__title { font-size: clamp(2rem, 5vw, 4rem); }
|
|
107
|
+
.sne--lg .sne__title { font-size: clamp(3rem, 9vw, 8.5rem); }
|
|
108
|
+
.sne__title em { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
|
|
109
|
+
.sne--accent .sne__title em { color: inherit; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 0.12em; }
|
|
110
|
+
.sne__text { margin: 0 0 var(--sp-6, 1.5rem); max-width: 26rem; line-height: 1.65; opacity: 0.8; }
|
|
111
|
+
.sne__label { display: block; margin-bottom: var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.75; }
|
|
112
|
+
.sne__field { display: flex; align-items: stretch; border-bottom: 1.5px solid currentColor; }
|
|
113
|
+
.sne__field input { flex: 1; min-width: 0; min-height: 56px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-size: 1.15rem; outline: none; }
|
|
114
|
+
.sne__field input::placeholder { color: currentColor; opacity: 0.45; }
|
|
115
|
+
.sne__field input[aria-invalid='true'] { color: var(--c-danger, #f87171); }
|
|
116
|
+
.sne__field button { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0 0 0 var(--sp-4, 1rem); border: 0; background: none; color: inherit; font: inherit; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; }
|
|
117
|
+
.sne__field button:hover { color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
|
|
118
|
+
.sne--accent .sne__field button:hover { color: inherit; text-decoration: underline; }
|
|
119
|
+
.sne__field:focus-within { border-bottom-width: 2.5px; }
|
|
120
|
+
.sne__field button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
121
|
+
.sne__msg { min-height: 1.4em; margin: var(--sp-2, 0.5rem) 0 0; font-size: 0.85rem; }
|
|
122
|
+
.sne__msg[data-state='error'] { color: var(--c-danger, #f87171); }
|
|
123
|
+
.sne__msg[data-state='ok'] { color: var(--c-success, #34d399); }
|
|
124
|
+
.sne--accent .sne__msg, .sne--inverted .sne__msg { color: inherit; font-weight: 600; }
|
|
125
|
+
.sne__consent { margin: var(--sp-2, 0.5rem) 0 0; font-size: 0.75rem; opacity: 0.6; }
|
|
126
|
+
|
|
127
|
+
@container (max-width: 860px) { .sne { grid-template-columns: 1fr; } }
|
|
128
|
+
</style>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ShopProductRail — Horizontal, scroll-snapping product carousel of ProductCardMinimal cards with
|
|
4
|
+
* numbered items, prev/next buttons and a progress line.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} eyebrow - Small label. Default 'Just in'.
|
|
7
|
+
* @param {string} title - Heading. Default 'New this *week*'.
|
|
8
|
+
* @param {Product[]} products - Products to show.
|
|
9
|
+
* @param {string} productBase - Product URL prefix (`${productBase}${slug}/`). Default '#'.
|
|
10
|
+
* @param {{label: string, href: string}} link - "View all" link (optional).
|
|
11
|
+
* @param {boolean} numbered - Show "N° 01" on cards. Default true.
|
|
12
|
+
* @param {string} currency - ISO currency. Default 'EUR'.
|
|
13
|
+
* @param {string} locale - Number format locale. Default 'en-GB'.
|
|
14
|
+
* @param {string} cartKey - Cart storage key. Default 'ack-cart'.
|
|
15
|
+
* @param {string} id - Rail id (unique per page). Default 'product-rail'.
|
|
16
|
+
* @param {string} size - Card width (sm|md|lg). Default 'md'.
|
|
17
|
+
* @param {string} className - Extra class.
|
|
18
|
+
*
|
|
19
|
+
* Accessibility: the rail is a labelled region (role="region") scrollable with keyboard;
|
|
20
|
+
* buttons have labels and disable at the ends; snapping is disabled with reduced motion.
|
|
21
|
+
*/
|
|
22
|
+
import ProductCardMinimal from '../ecommerce/ProductCardMinimal.astro';
|
|
23
|
+
import type { Product } from '../ecommerce/types';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
eyebrow?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
products?: Product[];
|
|
29
|
+
productBase?: string;
|
|
30
|
+
link?: { label: string; href: string };
|
|
31
|
+
numbered?: boolean;
|
|
32
|
+
currency?: string;
|
|
33
|
+
locale?: string;
|
|
34
|
+
cartKey?: string;
|
|
35
|
+
id?: string;
|
|
36
|
+
size?: 'sm' | 'md' | 'lg';
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
eyebrow = 'Just in',
|
|
42
|
+
title = 'New this *week*',
|
|
43
|
+
products = [
|
|
44
|
+
{ slug: 'linen-overshirt', name: 'Linen Overshirt', price: 145, category: 'clothing', badge: 'New', sizes: ['S', 'M', 'L'], colors: [{ name: 'Oat', hex: '#d9cbb3' }, { name: 'Ink', hex: '#22252b' }], icon: 'shirt', hue: 30 },
|
|
45
|
+
{ slug: 'merino-crew', name: 'Merino Crew Knit', price: 160, category: 'clothing', sizes: ['S', 'M', 'L'], colors: [{ name: 'Sage', hex: '#8a9a7b' }], icon: 'jacket', hue: 95 },
|
|
46
|
+
{ slug: 'field-tote', name: 'Field Tote', price: 120, category: 'bags', colors: [{ name: 'Rust', hex: '#9c3d1c' }], icon: 'bag', hue: 18 },
|
|
47
|
+
{ slug: 'ash-vase', name: 'Ash Vase', price: 68, category: 'objects', icon: 'bottle', hue: 40 },
|
|
48
|
+
{ slug: 'dune-loafer', name: 'Dune Loafer', price: 210, category: 'shoes', sizes: ['39', '40', '41', '42'], icon: 'sneaker', hue: 28 },
|
|
49
|
+
],
|
|
50
|
+
productBase = '#',
|
|
51
|
+
link = { label: 'View all', href: '#' },
|
|
52
|
+
numbered = true,
|
|
53
|
+
currency = 'EUR',
|
|
54
|
+
locale = 'en-GB',
|
|
55
|
+
cartKey = 'ack-cart',
|
|
56
|
+
id = 'product-rail',
|
|
57
|
+
size = 'md',
|
|
58
|
+
className = '',
|
|
59
|
+
} = Astro.props;
|
|
60
|
+
|
|
61
|
+
const parts = title.split(/\*(.+?)\*/g);
|
|
62
|
+
const plainTitle = title.replace(/\*/g, '');
|
|
63
|
+
const hrefFor = (slug: string) => (productBase === '#' ? '#' : `${productBase}${slug}/`);
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
<section class:list={['spr', `spr--${size}`, className]} data-rail>
|
|
67
|
+
<header class="spr__head">
|
|
68
|
+
<div>
|
|
69
|
+
{eyebrow && <p class="spr__eyebrow">{eyebrow}</p>}
|
|
70
|
+
<h2 class="spr__title">{parts.map((p, i) => (i % 2 ? <em>{p}</em> : p))}</h2>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="spr__controls">
|
|
73
|
+
{link && <a class="spr__all" href={link.href}>{link.label}</a>}
|
|
74
|
+
<button type="button" class="spr__arrow" aria-label="Previous products" aria-controls={id} data-prev>←</button>
|
|
75
|
+
<button type="button" class="spr__arrow" aria-label="Next products" aria-controls={id} data-next>→</button>
|
|
76
|
+
</div>
|
|
77
|
+
</header>
|
|
78
|
+
<ul class="spr__track" id={id} role="region" aria-label={plainTitle} tabindex="0" data-track>
|
|
79
|
+
{products.map((p, i) => (
|
|
80
|
+
<li class="spr__slide">
|
|
81
|
+
<ProductCardMinimal product={p} href={hrefFor(p.slug)} index={numbered ? i + 1 : undefined} currency={currency} locale={locale} cartKey={cartKey} />
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
<div class="spr__progress" aria-hidden="true"><span data-progress></span></div>
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
<script>
|
|
89
|
+
document.querySelectorAll<HTMLElement>('[data-rail]:not([data-bound])').forEach((rail) => {
|
|
90
|
+
rail.dataset.bound = '';
|
|
91
|
+
const track = rail.querySelector<HTMLElement>('[data-track]')!;
|
|
92
|
+
const prev = rail.querySelector<HTMLButtonElement>('[data-prev]')!;
|
|
93
|
+
const next = rail.querySelector<HTMLButtonElement>('[data-next]')!;
|
|
94
|
+
const bar = rail.querySelector<HTMLElement>('[data-progress]')!;
|
|
95
|
+
const step = () => (track.querySelector<HTMLElement>('.spr__slide')?.offsetWidth ?? 300) + 16;
|
|
96
|
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
97
|
+
const update = () => {
|
|
98
|
+
const max = track.scrollWidth - track.clientWidth;
|
|
99
|
+
prev.disabled = track.scrollLeft <= 2;
|
|
100
|
+
next.disabled = track.scrollLeft >= max - 2;
|
|
101
|
+
const visible = track.clientWidth / track.scrollWidth;
|
|
102
|
+
bar.style.width = `${Math.max(10, visible * 100)}%`;
|
|
103
|
+
bar.style.transform = `translateX(${max > 0 ? (track.scrollLeft / max) * ((1 / Math.max(visible, 0.1)) - 1) * 100 : 0}%)`;
|
|
104
|
+
};
|
|
105
|
+
prev.addEventListener('click', () => track.scrollBy({ left: -step(), behavior: reduced ? 'auto' : 'smooth' }));
|
|
106
|
+
next.addEventListener('click', () => track.scrollBy({ left: step(), behavior: reduced ? 'auto' : 'smooth' }));
|
|
107
|
+
track.addEventListener('scroll', update, { passive: true });
|
|
108
|
+
window.addEventListener('resize', update);
|
|
109
|
+
update();
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
112
|
+
|
|
113
|
+
<style>
|
|
114
|
+
.spr { container-type: inline-size; --slide-w: clamp(220px, 24vw, 320px); color: var(--c-text-1, #fff); min-width: 0; }
|
|
115
|
+
.spr--sm { --slide-w: clamp(180px, 18vw, 240px); }
|
|
116
|
+
.spr--lg { --slide-w: clamp(260px, 30vw, 420px); }
|
|
117
|
+
.spr__head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-4, 1rem); margin-bottom: var(--sp-6, 1.5rem); }
|
|
118
|
+
.spr__eyebrow { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
|
|
119
|
+
.spr__title { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(2rem, 4.5vw, 3.4rem); font-weight: 400; letter-spacing: -0.035em; line-height: 1; }
|
|
120
|
+
.spr__title em { font-style: italic; color: var(--shop-accent, var(--c-primary-light, #d4a017)); }
|
|
121
|
+
.spr__controls { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); }
|
|
122
|
+
.spr__all { margin-right: var(--sp-3, 0.75rem); min-height: 44px; display: inline-flex; align-items: center; font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: inherit; text-decoration: underline; text-underline-offset: 5px; }
|
|
123
|
+
.spr__arrow { width: 48px; height: 48px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); border-radius: 50%; background: transparent; color: inherit; font-size: 1.1rem; cursor: pointer; transition: background 0.2s ease, color 0.2s ease, opacity 0.2s ease; }
|
|
124
|
+
.spr__arrow:hover:not(:disabled) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
|
|
125
|
+
.spr__arrow:disabled { opacity: 0.3; cursor: default; }
|
|
126
|
+
.spr :is(a, button):focus-visible, .spr__track:focus-visible { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }
|
|
127
|
+
|
|
128
|
+
.spr__track {
|
|
129
|
+
display: grid;
|
|
130
|
+
grid-auto-flow: column;
|
|
131
|
+
grid-auto-columns: var(--slide-w);
|
|
132
|
+
gap: 16px;
|
|
133
|
+
margin: 0;
|
|
134
|
+
padding: 0 0 var(--sp-2, 0.5rem);
|
|
135
|
+
list-style: none;
|
|
136
|
+
overflow-x: auto;
|
|
137
|
+
scroll-snap-type: x mandatory;
|
|
138
|
+
scrollbar-width: none;
|
|
139
|
+
}
|
|
140
|
+
.spr__track::-webkit-scrollbar { display: none; }
|
|
141
|
+
.spr__slide { scroll-snap-align: start; min-width: 0; }
|
|
142
|
+
.spr__progress { position: relative; height: 1px; margin-top: var(--sp-5, 1.25rem); background: var(--c-border, rgba(255, 255, 255, 0.15)); overflow: hidden; }
|
|
143
|
+
.spr__progress span { position: absolute; inset: 0 auto 0 0; height: 100%; background: var(--c-text-1, #fff); transition: transform 0.15s linear; }
|
|
144
|
+
|
|
145
|
+
@container (max-width: 640px) {
|
|
146
|
+
.spr__track { grid-auto-columns: 72%; }
|
|
147
|
+
.spr__arrow, .spr__all { display: none; }
|
|
148
|
+
}
|
|
149
|
+
@media (prefers-reduced-motion: reduce) { .spr__track { scroll-snap-type: none; } }
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* ShopReviewsEditorial — Customer voices as one large serif quote at a time, with product
|
|
4
|
+
* context, star rating, numbered pager and an overall score.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} eyebrow - Small label. Default 'Customer notes'.
|
|
7
|
+
* @param {number} score - Average rating shown on the side. Default 4.9.
|
|
8
|
+
* @param {number} count - Total reviews. Default 1284.
|
|
9
|
+
* @param {Array<{quote: string, name: string, place?: string, product?: string, rating?: number}>} reviews - Quotes.
|
|
10
|
+
* @param {number} interval - Auto-advance in ms (0 = manual only). Default 7000.
|
|
11
|
+
* @param {string} variant - Style (plain|boxed). Default 'plain'.
|
|
12
|
+
* @param {string} className - Extra class.
|
|
13
|
+
*
|
|
14
|
+
* Accessibility: a labelled carousel region; slides are figures with figcaption; auto-advance
|
|
15
|
+
* pauses on hover/focus, is off with reduced motion, and pager buttons use aria-current.
|
|
16
|
+
*/
|
|
17
|
+
import ProductRating from '../ecommerce/ProductRating.astro';
|
|
18
|
+
|
|
19
|
+
interface Review { quote: string; name: string; place?: string; product?: string; rating?: number }
|
|
20
|
+
interface Props {
|
|
21
|
+
eyebrow?: string;
|
|
22
|
+
score?: number;
|
|
23
|
+
count?: number;
|
|
24
|
+
reviews?: Review[];
|
|
25
|
+
interval?: number;
|
|
26
|
+
variant?: 'plain' | 'boxed';
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
eyebrow = 'Customer notes',
|
|
32
|
+
score = 4.9,
|
|
33
|
+
count = 1284,
|
|
34
|
+
reviews = [
|
|
35
|
+
{ quote: 'The overshirt has been washed forty times and it only gets softer. It is the first thing I reach for.', name: 'Inés M.', place: 'Valencia', product: 'Linen Overshirt', rating: 5 },
|
|
36
|
+
{ quote: 'Packaging without plastic, a handwritten note, and a mug that feels made for my hands.', name: 'Jonas P.', place: 'Hamburg', product: 'Kiln Mug', rating: 5 },
|
|
37
|
+
{ quote: 'They repaired the strap of my tote for free, two years after I bought it. That is rare.', name: 'Amira S.', place: 'Lyon', product: 'Field Tote', rating: 5 },
|
|
38
|
+
],
|
|
39
|
+
interval = 7000,
|
|
40
|
+
variant = 'plain',
|
|
41
|
+
className = '',
|
|
42
|
+
} = Astro.props;
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<div class="sre-cq">
|
|
46
|
+
<section class:list={['sre', `sre--${variant}`, className]} aria-roledescription="carousel" aria-label={eyebrow} data-reviews data-interval={interval}>
|
|
47
|
+
<div class="sre__side">
|
|
48
|
+
<p class="sre__eyebrow">{eyebrow}</p>
|
|
49
|
+
<p class="sre__score">{score.toFixed(1)}<span>/5</span></p>
|
|
50
|
+
<ProductRating value={score} count={count} showValue={false} size="md" />
|
|
51
|
+
</div>
|
|
52
|
+
<div class="sre__main">
|
|
53
|
+
<div class="sre__slides">
|
|
54
|
+
{reviews.map((r, i) => (
|
|
55
|
+
<figure class:list={['sre__slide', { 'is-active': i === 0 }]} hidden={i !== 0} aria-roledescription="slide" aria-label={`${i + 1} of ${reviews.length}`} data-slide>
|
|
56
|
+
<blockquote class="sre__quote"><p>“{r.quote}”</p></blockquote>
|
|
57
|
+
<figcaption class="sre__by">
|
|
58
|
+
<strong>{r.name}</strong>{r.place && <span>, {r.place}</span>}
|
|
59
|
+
{r.product && <span class="sre__product">on {r.product}</span>}
|
|
60
|
+
</figcaption>
|
|
61
|
+
</figure>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
{reviews.length > 1 && (
|
|
65
|
+
<div class="sre__pager" role="group" aria-label="Choose a review">
|
|
66
|
+
{reviews.map((_, i) => (
|
|
67
|
+
<button type="button" class="sre__page" aria-label={`Review ${i + 1}`} aria-current={i === 0 ? 'true' : undefined} data-page={i}>{String(i + 1).padStart(2, '0')}</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
document.querySelectorAll<HTMLElement>('[data-reviews]:not([data-bound])').forEach((root) => {
|
|
77
|
+
root.dataset.bound = '';
|
|
78
|
+
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
|
|
79
|
+
const pages = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-page]'));
|
|
80
|
+
let index = 0;
|
|
81
|
+
let paused = false;
|
|
82
|
+
const go = (i: number) => {
|
|
83
|
+
index = (i + slides.length) % slides.length;
|
|
84
|
+
slides.forEach((s, n) => { s.hidden = n !== index; s.classList.toggle('is-active', n === index); });
|
|
85
|
+
pages.forEach((p, n) => { if (n === index) p.setAttribute('aria-current', 'true'); else p.removeAttribute('aria-current'); });
|
|
86
|
+
};
|
|
87
|
+
pages.forEach((p, i) => p.addEventListener('click', () => go(i)));
|
|
88
|
+
root.addEventListener('mouseenter', () => { paused = true; });
|
|
89
|
+
root.addEventListener('mouseleave', () => { paused = false; });
|
|
90
|
+
root.addEventListener('focusin', () => { paused = true; });
|
|
91
|
+
root.addEventListener('focusout', () => { paused = false; });
|
|
92
|
+
const ms = Number(root.dataset.interval) || 0;
|
|
93
|
+
if (ms > 0 && slides.length > 1 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
94
|
+
window.setInterval(() => { if (!paused && !document.hidden) go(index + 1); }, ms);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<style>
|
|
100
|
+
.sre-cq { container-type: inline-size; }
|
|
101
|
+
.sre {
|
|
102
|
+
display: grid;
|
|
103
|
+
grid-template-columns: 240px minmax(0, 1fr);
|
|
104
|
+
gap: clamp(1.5rem, 5vw, 5rem);
|
|
105
|
+
padding-block: clamp(2rem, 5vw, 4rem);
|
|
106
|
+
border-top: 1px solid var(--c-text-1, #fff);
|
|
107
|
+
color: var(--c-text-1, #fff);
|
|
108
|
+
}
|
|
109
|
+
.sre--boxed { padding: clamp(1.5rem, 4vw, 3.5rem); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); background: var(--c-bg-1, #0a0a0a); }
|
|
110
|
+
.sre__eyebrow { margin: 0 0 var(--sp-4, 1rem); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
|
|
111
|
+
.sre__score { margin: 0 0 var(--sp-2, 0.5rem); font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: 4.5rem; line-height: 1; letter-spacing: -0.04em; }
|
|
112
|
+
.sre__score span { font-size: 1.2rem; color: var(--c-text-2, #a3a3a3); }
|
|
113
|
+
.sre__side :global(.p-rating) { --p-rating-star: var(--shop-accent, var(--c-primary-light, #d4a017)); }
|
|
114
|
+
|
|
115
|
+
.sre__slides { display: grid; min-height: 12rem; }
|
|
116
|
+
.sre__slide { grid-area: 1 / 1; margin: 0; }
|
|
117
|
+
.sre__slide[hidden] { display: none; }
|
|
118
|
+
.sre__slide.is-active { animation: sre-in 0.6s cubic-bezier(0.16, 1, 0.3, 1); }
|
|
119
|
+
@keyframes sre-in { from { opacity: 0; transform: translateY(10px); } }
|
|
120
|
+
.sre__quote { margin: 0; }
|
|
121
|
+
.sre__quote p { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: clamp(1.6rem, 3.4vw, 2.8rem); font-weight: 400; font-style: italic; line-height: 1.2; letter-spacing: -0.02em; text-wrap: balance; }
|
|
122
|
+
.sre__by { margin-top: var(--sp-5, 1.25rem); font-size: 0.9rem; }
|
|
123
|
+
.sre__by span { color: var(--c-text-2, #a3a3a3); }
|
|
124
|
+
.sre__product { display: block; margin-top: 2px; font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase; }
|
|
125
|
+
|
|
126
|
+
.sre__pager { display: flex; gap: var(--sp-4, 1rem); margin-top: var(--sp-8, 2rem); }
|
|
127
|
+
.sre__page { min-width: 44px; min-height: 44px; padding: 0; border: 0; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.2)); background: none; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.8rem; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; }
|
|
128
|
+
.sre__page[aria-current='true'] { border-top: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); color: var(--c-text-1, #fff); }
|
|
129
|
+
.sre__page:focus-visible { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 3px; }
|
|
130
|
+
|
|
131
|
+
@container (max-width: 760px) { .sre { grid-template-columns: 1fr; } }
|
|
132
|
+
@media (prefers-reduced-motion: reduce) { .sre__slide.is-active { animation: none; } }
|
|
133
|
+
</style>
|