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.
Files changed (249) hide show
  1. package/README.md +133 -36
  2. package/package.json +17 -4
  3. package/src/components/index.ts +18 -202
  4. package/src/components/lib/Galaxy3D.astro +249 -137
  5. package/src/components/lib/app/AppSidebar.astro +249 -0
  6. package/src/components/lib/app/AppTopbar.astro +284 -0
  7. package/src/components/lib/app/ArrivalsBoard.astro +176 -0
  8. package/src/components/lib/app/AuthCard.astro +213 -0
  9. package/src/components/lib/app/DataTable.astro +346 -0
  10. package/src/components/lib/app/GuestProfileCard.astro +133 -0
  11. package/src/components/lib/app/HousekeepingBoard.astro +189 -0
  12. package/src/components/lib/app/PageHeader.astro +157 -0
  13. package/src/components/lib/app/PlanUsageCard.astro +112 -0
  14. package/src/components/lib/app/RoomRack.astro +207 -0
  15. package/src/components/lib/app/SettingsSection.astro +96 -0
  16. package/src/components/lib/app/SwitchRow.astro +71 -0
  17. package/src/components/lib/app/TeamMembers.astro +190 -0
  18. package/src/components/lib/app/app-icons.ts +52 -0
  19. package/src/components/lib/app/index.ts +19 -0
  20. package/src/components/lib/buttons/NeonBorderButton.astro +162 -0
  21. package/src/components/lib/buttons/RippleButton.astro +199 -0
  22. package/src/components/lib/buttons/SlideRevealButton.astro +159 -0
  23. package/src/components/lib/buttons/index.ts +4 -0
  24. package/src/components/lib/cards/GithubRepoCard.astro +176 -0
  25. package/src/components/lib/cards/PricingCompareMiniCard.astro +196 -0
  26. package/src/components/lib/cards/StatsTrendCard.astro +149 -0
  27. package/src/components/lib/cards/index.ts +3 -0
  28. package/src/components/lib/charts/ActivityRings.astro +105 -0
  29. package/src/components/lib/charts/AreaChart.astro +4 -4
  30. package/src/components/lib/charts/BarChart.astro +3 -3
  31. package/src/components/lib/charts/BoxPlotChart.astro +109 -0
  32. package/src/components/lib/charts/BubbleChart.astro +101 -0
  33. package/src/components/lib/charts/BulletChart.astro +160 -0
  34. package/src/components/lib/charts/BumpChart.astro +92 -0
  35. package/src/components/lib/charts/CandlestickChart.astro +105 -0
  36. package/src/components/lib/charts/ChartFrame.astro +373 -0
  37. package/src/components/lib/charts/DivergingBarChart.astro +98 -0
  38. package/src/components/lib/charts/DonutChart.astro +2 -2
  39. package/src/components/lib/charts/DumbbellChart.astro +92 -0
  40. package/src/components/lib/charts/ForecastLineChart.astro +125 -0
  41. package/src/components/lib/charts/FunnelChart.astro +122 -0
  42. package/src/components/lib/charts/GaugeChart.astro +111 -0
  43. package/src/components/lib/charts/GoalProgressChart.astro +97 -0
  44. package/src/components/lib/charts/GroupedBarChart.astro +125 -0
  45. package/src/components/lib/charts/HeatmapChart.astro +131 -0
  46. package/src/components/lib/charts/HistogramChart.astro +111 -0
  47. package/src/components/lib/charts/HorizontalBarChart.astro +155 -0
  48. package/src/components/lib/charts/KpiStatTile.astro +116 -0
  49. package/src/components/lib/charts/KpiTrendGrid.astro +60 -0
  50. package/src/components/lib/charts/LineChart.astro +160 -0
  51. package/src/components/lib/charts/LinearProgressGauge.astro +136 -0
  52. package/src/components/lib/charts/LollipopChart.astro +100 -0
  53. package/src/components/lib/charts/MeterGauge.astro +122 -0
  54. package/src/components/lib/charts/MetricDeltaTable.astro +125 -0
  55. package/src/components/lib/charts/PieChart.astro +135 -0
  56. package/src/components/lib/charts/PolarAreaChart.astro +85 -0
  57. package/src/components/lib/charts/PopulationPyramid.astro +122 -0
  58. package/src/components/lib/charts/PunchCardChart.astro +89 -0
  59. package/src/components/lib/charts/RadarChart.astro +94 -0
  60. package/src/components/lib/charts/RadarMetricChart.astro +168 -0
  61. package/src/components/lib/charts/RadialBarChart.astro +124 -0
  62. package/src/components/lib/charts/RangeAreaChart.astro +113 -0
  63. package/src/components/lib/charts/RetentionCohortTable.astro +126 -0
  64. package/src/components/lib/charts/SankeyChart.astro +128 -0
  65. package/src/components/lib/charts/ScatterPlot.astro +139 -0
  66. package/src/components/lib/charts/SegmentedBarChart.astro +119 -0
  67. package/src/components/lib/charts/SlopeChart.astro +105 -0
  68. package/src/components/lib/charts/SparklineChart.astro +87 -0
  69. package/src/components/lib/charts/StackedAreaChart.astro +130 -0
  70. package/src/components/lib/charts/StackedBarChart.astro +136 -0
  71. package/src/components/lib/charts/SunburstChart.astro +103 -0
  72. package/src/components/lib/charts/TreemapChart.astro +94 -0
  73. package/src/components/lib/charts/WaffleChart.astro +121 -0
  74. package/src/components/lib/charts/WaterfallChart.astro +118 -0
  75. package/src/components/lib/charts/chart-tooltip.ts +75 -0
  76. package/src/components/lib/charts/chart-utils.ts +271 -0
  77. package/src/components/lib/charts/index.ts +46 -0
  78. package/src/components/lib/data/ActivityHeatmapGrid.astro +157 -0
  79. package/src/components/lib/data/MetricComparisonRow.astro +109 -0
  80. package/src/components/lib/data/StatusTimelineFeed.astro +168 -0
  81. package/src/components/lib/data/index.ts +3 -0
  82. package/src/components/lib/ecommerce/CartDrawer.astro +483 -0
  83. package/src/components/lib/ecommerce/CartPageList.astro +290 -0
  84. package/src/components/lib/ecommerce/CartToast.astro +276 -0
  85. package/src/components/lib/ecommerce/CategoryTiles.astro +143 -0
  86. package/src/components/lib/ecommerce/CheckoutForm.astro +423 -0
  87. package/src/components/lib/ecommerce/CountdownBanner.astro +230 -0
  88. package/src/components/lib/ecommerce/FilterBar.astro +278 -0
  89. package/src/components/lib/ecommerce/FilterSidebar.astro +405 -0
  90. package/src/components/lib/ecommerce/LiveActivityToast.astro +213 -0
  91. package/src/components/lib/ecommerce/OrderSummary.astro +295 -0
  92. package/src/components/lib/ecommerce/ProductBuyBox.astro +247 -0
  93. package/src/components/lib/ecommerce/ProductCardMinimal.astro +229 -0
  94. package/src/components/lib/ecommerce/ProductGallery.astro +145 -0
  95. package/src/components/lib/ecommerce/ProductGrid.astro +118 -0
  96. package/src/components/lib/ecommerce/ProductMedia.astro +113 -0
  97. package/src/components/lib/ecommerce/ProductRating.astro +78 -0
  98. package/src/components/lib/ecommerce/ProductToolbar.astro +93 -0
  99. package/src/components/lib/ecommerce/PromoBar.astro +145 -0
  100. package/src/components/lib/ecommerce/StockIndicator.astro +127 -0
  101. package/src/components/lib/ecommerce/StoreHeader.astro +446 -0
  102. package/src/components/lib/ecommerce/StoreHero.astro +260 -0
  103. package/src/components/lib/ecommerce/StoreProductCard.astro +307 -0
  104. package/src/components/lib/ecommerce/TrustBadges.astro +101 -0
  105. package/src/components/lib/ecommerce/VariantPicker.astro +145 -0
  106. package/src/components/lib/ecommerce/cart-store.ts +151 -0
  107. package/src/components/lib/ecommerce/catalog-filter.ts +62 -0
  108. package/src/components/lib/ecommerce/index.ts +34 -0
  109. package/src/components/lib/ecommerce/product-icons.ts +28 -0
  110. package/src/components/lib/ecommerce/types.ts +70 -0
  111. package/src/components/lib/feedback/ProgressRingCounter.astro +118 -0
  112. package/src/components/lib/feedback/ToastNotificationGroup.astro +173 -0
  113. package/src/components/lib/feedback/index.ts +2 -0
  114. package/src/components/lib/forms/AnimatedInput.astro +4 -2
  115. package/src/components/lib/forms/CustomRadioGroup.astro +3 -1
  116. package/src/components/lib/forms/GlassInlineToggleGroup.astro +161 -0
  117. package/src/components/lib/forms/PasswordStrengthInput.astro +242 -0
  118. package/src/components/lib/forms/PhoneCountryInput.astro +145 -0
  119. package/src/components/lib/forms/TagsAutocompleteInput.astro +249 -0
  120. package/src/components/lib/forms/index.ts +5 -0
  121. package/src/components/lib/index.ts +2 -0
  122. package/src/components/lib/layout/BentoGrid3.astro +6 -4
  123. package/src/components/lib/layout/CenterFixedBox.astro +2 -2
  124. package/src/components/lib/layout/CenteredContainer.astro +4 -2
  125. package/src/components/lib/layout/ClusterHorizontal.astro +2 -2
  126. package/src/components/lib/layout/DiagonalSectionDivider.astro +69 -0
  127. package/src/components/lib/layout/FeatureHero.astro +4 -2
  128. package/src/components/lib/layout/FooterCenteredMinimal.astro +4 -2
  129. package/src/components/lib/layout/FullBleedSection.astro +2 -2
  130. package/src/components/lib/layout/GalleryGrid4.astro +2 -2
  131. package/src/components/lib/layout/HolyGrailGrid.astro +127 -0
  132. package/src/components/lib/layout/LandingSectionSplit.astro +5 -3
  133. package/src/components/lib/layout/MasonryGrid.astro +2 -2
  134. package/src/components/lib/layout/MinimalFooterGrid.astro +1 -1
  135. package/src/components/lib/layout/ScrollSnapSection.astro +1 -1
  136. package/src/components/lib/layout/SidebarLayoutWrapper.astro +4 -2
  137. package/src/components/lib/layout/SplitScreenLayout.astro +3 -3
  138. package/src/components/lib/layout/StepLayoutVertical.astro +1 -1
  139. package/src/components/lib/layout/StickySidebarWrapper.astro +4 -2
  140. package/src/components/lib/layout/index.ts +3 -1
  141. package/src/components/lib/navigation/QuickJumpDock.astro +125 -0
  142. package/src/components/lib/navigation/StepBreadcrumbBar.astro +151 -0
  143. package/src/components/lib/navigation/SubNavbarPills.astro +102 -0
  144. package/src/components/lib/navigation/index.ts +3 -0
  145. package/src/components/lib/overlays/CookiePreferencesDrawer.astro +226 -0
  146. package/src/components/lib/overlays/FloatingFeedbackWidget.astro +203 -0
  147. package/src/components/lib/overlays/ImageCompareSlider.astro +172 -0
  148. package/src/components/lib/overlays/index.ts +3 -0
  149. package/src/components/lib/sections/AgencyContactBrief.astro +194 -0
  150. package/src/components/lib/sections/AgencyFooterCta.astro +94 -0
  151. package/src/components/lib/sections/AgencyHeader.astro +161 -0
  152. package/src/components/lib/sections/AgencyHeroKinetic.astro +172 -0
  153. package/src/components/lib/sections/AgencyServicesList.astro +113 -0
  154. package/src/components/lib/sections/AgencyTeamGrid.astro +111 -0
  155. package/src/components/lib/sections/AgencyWorkGrid.astro +147 -0
  156. package/src/components/lib/sections/BlogGridSection.astro +133 -0
  157. package/src/components/lib/sections/ButtonsShowcase.astro +20 -18
  158. package/src/components/lib/sections/CardsShowcase.astro +28 -26
  159. package/src/components/lib/sections/CareersOpenings.astro +141 -0
  160. package/src/components/lib/sections/ChangelogSection.astro +151 -0
  161. package/src/components/lib/sections/ContactGlassForm.astro +10 -8
  162. package/src/components/lib/sections/ContactSplitMap.astro +141 -0
  163. package/src/components/lib/sections/CtaAppDownload.astro +13 -11
  164. package/src/components/lib/sections/CtaGlowBanner.astro +12 -10
  165. package/src/components/lib/sections/CtaNewsletterSection.astro +165 -0
  166. package/src/components/lib/sections/CtaSplitImage.astro +149 -0
  167. package/src/components/lib/sections/DataShowcase.astro +23 -21
  168. package/src/components/lib/sections/FaqAccordionSection.astro +8 -8
  169. package/src/components/lib/sections/FaqTwoColumn.astro +121 -0
  170. package/src/components/lib/sections/FeatureAlternatingRows.astro +192 -0
  171. package/src/components/lib/sections/FeatureBentoGrid.astro +13 -11
  172. package/src/components/lib/sections/FeatureIconGrid.astro +155 -0
  173. package/src/components/lib/sections/FeatureTabsShowcase.astro +182 -0
  174. package/src/components/lib/sections/FeatureTimelineFlow.astro +8 -6
  175. package/src/components/lib/sections/FeedbackShowcase.astro +15 -13
  176. package/src/components/lib/sections/FooterMegaSection.astro +157 -0
  177. package/src/components/lib/sections/FormsShowcase.astro +22 -20
  178. package/src/components/lib/sections/HeroDashboardPreview.astro +182 -0
  179. package/src/components/lib/sections/HeroGlass.astro +22 -20
  180. package/src/components/lib/sections/HeroGradient.astro +15 -13
  181. package/src/components/lib/sections/HeroMinimalCentered.astro +175 -0
  182. package/src/components/lib/sections/HeroShowcaseSplit.astro +306 -0
  183. package/src/components/lib/sections/HeroSplit.astro +13 -11
  184. package/src/components/lib/sections/HeroTerminal.astro +253 -0
  185. package/src/components/lib/sections/HeroWaitlist.astro +231 -0
  186. package/src/components/lib/sections/HowItWorksSteps.astro +180 -0
  187. package/src/components/lib/sections/IntegrationsGrid.astro +167 -0
  188. package/src/components/lib/sections/LayoutShowcase.astro +21 -19
  189. package/src/components/lib/sections/LogoCloudSection.astro +96 -0
  190. package/src/components/lib/sections/LogoWallGrid.astro +93 -0
  191. package/src/components/lib/sections/NavigationShowcase.astro +15 -13
  192. package/src/components/lib/sections/OverlaysShowcase.astro +21 -19
  193. package/src/components/lib/sections/PricingComparisonMatrix.astro +15 -13
  194. package/src/components/lib/sections/PricingSinglePlan.astro +159 -0
  195. package/src/components/lib/sections/PricingTierCards.astro +11 -9
  196. package/src/components/lib/sections/PricingToggleCards.astro +171 -0
  197. package/src/components/lib/sections/ShopCategoryHero.astro +103 -0
  198. package/src/components/lib/sections/ShopCollectionMosaic.astro +131 -0
  199. package/src/components/lib/sections/ShopFooterWordmark.astro +100 -0
  200. package/src/components/lib/sections/ShopHeaderEditorial.astro +396 -0
  201. package/src/components/lib/sections/ShopHeroEditorial.astro +151 -0
  202. package/src/components/lib/sections/ShopLookbook.astro +250 -0
  203. package/src/components/lib/sections/ShopNewsletterEditorial.astro +128 -0
  204. package/src/components/lib/sections/ShopProductRail.astro +150 -0
  205. package/src/components/lib/sections/ShopReviewsEditorial.astro +133 -0
  206. package/src/components/lib/sections/ShopValuesMarquee.astro +91 -0
  207. package/src/components/lib/sections/StatsCounterBand.astro +124 -0
  208. package/src/components/lib/sections/StatsDashboardSection.astro +85 -0
  209. package/src/components/lib/sections/StatsMetricShowcase.astro +9 -7
  210. package/src/components/lib/sections/TeamGridSection.astro +12 -10
  211. package/src/components/lib/sections/TestimonialCarousel.astro +167 -0
  212. package/src/components/lib/sections/TestimonialFeatured.astro +122 -0
  213. package/src/components/lib/sections/TestimonialGridSection.astro +188 -0
  214. package/src/components/lib/sections/TestimonialWallGlass.astro +11 -9
  215. package/src/components/lib/sections/TypographyShowcase.astro +10 -8
  216. package/src/components/lib/sections/index.ts +39 -0
  217. package/src/components/lib/templates/ecommerce/StoreCategoryPage.astro +160 -0
  218. package/src/components/lib/templates/ecommerce/StoreCheckoutPage.astro +98 -0
  219. package/src/components/lib/templates/ecommerce/StoreHomePage.astro +191 -0
  220. package/src/components/lib/templates/ecommerce/StoreLayout.astro +169 -0
  221. package/src/components/lib/templates/ecommerce/StoreProductPage.astro +165 -0
  222. package/src/components/lib/templates/ecommerce/data.ts +187 -0
  223. package/src/components/lib/templates/ecommerce/index.ts +14 -0
  224. package/src/components/lib/templates/ecommerce-editorial/EditorialBagPage.astro +52 -0
  225. package/src/components/lib/templates/ecommerce-editorial/EditorialCheckoutPage.astro +123 -0
  226. package/src/components/lib/templates/ecommerce-editorial/EditorialHomePage.astro +102 -0
  227. package/src/components/lib/templates/ecommerce-editorial/EditorialLayout.astro +216 -0
  228. package/src/components/lib/templates/ecommerce-editorial/EditorialProductPage.astro +205 -0
  229. package/src/components/lib/templates/ecommerce-editorial/EditorialShopPage.astro +112 -0
  230. package/src/components/lib/templates/ecommerce-editorial/data.ts +125 -0
  231. package/src/components/lib/templates/ecommerce-editorial/index.ts +16 -0
  232. package/src/components/lib/templates/saas-hotel/AppLayout.astro +83 -0
  233. package/src/components/lib/templates/saas-hotel/DashboardPage.astro +66 -0
  234. package/src/components/lib/templates/saas-hotel/GuestsPage.astro +65 -0
  235. package/src/components/lib/templates/saas-hotel/LandingPage.astro +213 -0
  236. package/src/components/lib/templates/saas-hotel/ReportsPage.astro +63 -0
  237. package/src/components/lib/templates/saas-hotel/ReservationsPage.astro +49 -0
  238. package/src/components/lib/templates/saas-hotel/RoomsPage.astro +48 -0
  239. package/src/components/lib/templates/saas-hotel/SettingsPage.astro +115 -0
  240. package/src/components/lib/templates/saas-hotel/SignInPage.astro +50 -0
  241. package/src/components/lib/templates/saas-hotel/data.ts +229 -0
  242. package/src/components/lib/templates/saas-hotel/index.ts +19 -0
  243. package/src/components/lib/templates/saas-hotel/lobby-theme.css +90 -0
  244. package/src/components/lib/typography/AnimatedCounterText.astro +102 -0
  245. package/src/components/lib/typography/CurvedPathHeading.astro +86 -0
  246. package/src/components/lib/typography/GradientTextMarquee.astro +105 -0
  247. package/src/components/lib/typography/HackerDecodeText.astro +85 -0
  248. package/src/components/lib/typography/index.ts +4 -0
  249. package/src/styles/global.css +113 -1
@@ -0,0 +1,229 @@
1
+ ---
2
+ /**
3
+ * ProductCardMinimal — Editorial product card: tall media that swaps to a second view on hover,
4
+ * quick-add size buttons, name, price and colour count. Square corners, typography-led.
5
+ *
6
+ * @param {Product} product - Product data. A demo product is used by default.
7
+ * @param {string} href - Product page URL. Default '#'.
8
+ * @param {number} index - Optional catalogue number shown as "N° 01".
9
+ * @param {string} currency - ISO currency. Default 'EUR'.
10
+ * @param {string} locale - Number format locale. Default 'en-GB'.
11
+ * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
12
+ * @param {boolean} quickAdd - Show quick-add (sizes, or a single "Add" button). Default true.
13
+ * @param {string} ratio - Media aspect ratio. Default '3 / 4'.
14
+ * @param {string} variant - Style (plain|framed). Default 'plain'.
15
+ * @param {string} size - Type scale (sm|md|lg). Default 'md'.
16
+ * @param {string} headingLevel - Title element (h2|h3|h4). Default 'h3'.
17
+ * @param {string} className - Extra class.
18
+ *
19
+ * Accessibility: the title is a stretched link; quick-add buttons sit above it with their own
20
+ * labels ("Add size M"), are reachable by keyboard and appear on focus as well as hover.
21
+ */
22
+ import ProductMedia from './ProductMedia.astro';
23
+ import { formatMoney } from './cart-store';
24
+ import type { Product } from './types';
25
+
26
+ interface Props {
27
+ product?: Product;
28
+ href?: string;
29
+ index?: number;
30
+ currency?: string;
31
+ locale?: string;
32
+ cartKey?: string;
33
+ quickAdd?: boolean;
34
+ ratio?: string;
35
+ variant?: 'plain' | 'framed';
36
+ size?: 'sm' | 'md' | 'lg';
37
+ headingLevel?: 'h2' | 'h3' | 'h4';
38
+ className?: string;
39
+ }
40
+
41
+ const {
42
+ product = {
43
+ slug: 'linen-overshirt',
44
+ name: 'Linen Overshirt',
45
+ price: 145,
46
+ category: 'clothing',
47
+ badge: 'New',
48
+ colors: [{ name: 'Oat', hex: '#d9cbb3' }, { name: 'Ink', hex: '#22252b' }, { name: 'Rust', hex: '#9c3d1c' }],
49
+ sizes: ['XS', 'S', 'M', 'L', 'XL'],
50
+ icon: 'shirt',
51
+ hue: 30,
52
+ },
53
+ href = '#',
54
+ index,
55
+ currency = 'EUR',
56
+ locale = 'en-GB',
57
+ cartKey = 'ack-cart',
58
+ quickAdd = true,
59
+ ratio = '3 / 4',
60
+ variant = 'plain',
61
+ size = 'md',
62
+ headingLevel = 'h3',
63
+ className = '',
64
+ } = Astro.props;
65
+
66
+ const Heading = headingLevel;
67
+ const money = { currency, locale };
68
+ const inStock = product.inStock !== false;
69
+ const base = { id: product.slug, name: product.name, price: product.price, icon: product.icon, hue: product.hue, image: product.image };
70
+ const color = product.colors?.[0]?.name;
71
+ const titleId = `pcm-${product.slug}`;
72
+ ---
73
+
74
+ <article
75
+ class:list={['pcm', `pcm--${variant}`, `pcm--${size}`, className]}
76
+ aria-labelledby={titleId}
77
+ data-product
78
+ data-name={product.name.toLowerCase()}
79
+ data-category={product.category}
80
+ data-price={product.price}
81
+ data-rating={product.rating ?? 0}
82
+ data-colors={(product.colors ?? []).map((c) => c.name.toLowerCase()).join(',')}
83
+ data-stock={inStock ? 'in' : 'out'}
84
+ >
85
+ <div class="pcm__media">
86
+ <div class="pcm__view pcm__view--a">
87
+ <ProductMedia image={product.image} alt={product.name} icon={product.icon} hue={product.hue} ratio={ratio} variant="soft" size="lg" />
88
+ </div>
89
+ <div class="pcm__view pcm__view--b" aria-hidden="true">
90
+ <ProductMedia icon={product.icon} hue={(product.hue ?? 30) + 20} ratio={ratio} variant="glow" size="sm" />
91
+ </div>
92
+ {(product.badge || !inStock) && <span class="pcm__badge">{inStock ? product.badge : 'Sold out'}</span>}
93
+ {index !== undefined && <span class="pcm__index" aria-hidden="true">N° {String(index).padStart(2, '0')}</span>}
94
+
95
+ {quickAdd && inStock && (
96
+ <div class="pcm__quick" role="group" aria-label={`Quick add ${product.name}`}>
97
+ {product.sizes?.length ? (
98
+ <>
99
+ <span class="pcm__quick-label" aria-hidden="true">Quick add</span>
100
+ <div class="pcm__sizes">
101
+ {product.sizes.map((s) => (
102
+ <button
103
+ type="button"
104
+ class="pcm__size"
105
+ data-add-to-cart={JSON.stringify({ ...base, variant: [color, s].filter(Boolean).join(' / ') })}
106
+ data-cart-key={cartKey}
107
+ data-open-cart="false"
108
+ aria-label={`Add ${product.name}, size ${s}`}
109
+ ><span data-label>{s}</span></button>
110
+ ))}
111
+ </div>
112
+ </>
113
+ ) : (
114
+ <button
115
+ type="button"
116
+ class="pcm__size pcm__size--wide"
117
+ data-add-to-cart={JSON.stringify({ ...base, variant: color })}
118
+ data-cart-key={cartKey}
119
+ data-open-cart="false"
120
+ data-added-label="Added"
121
+ aria-label={`Add ${product.name} to bag`}
122
+ ><span data-label>Add to bag +</span></button>
123
+ )}
124
+ </div>
125
+ )}
126
+ </div>
127
+
128
+ <div class="pcm__info">
129
+ <Heading class="pcm__title" id={titleId}><a class="pcm__link" href={href}>{product.name}</a></Heading>
130
+ <p class="pcm__price">
131
+ <span>{formatMoney(product.price, money)}</span>
132
+ {product.compareAt && <s><span class="sr-only">Was </span>{formatMoney(product.compareAt, money)}</s>}
133
+ </p>
134
+ {product.colors && product.colors.length > 0 && (
135
+ <p class="pcm__colors">
136
+ <span class="pcm__dots" aria-hidden="true">{product.colors.slice(0, 4).map((c) => <i style={`--sw: ${c.hex}`}></i>)}</span>
137
+ {product.colors.length} {product.colors.length === 1 ? 'colour' : 'colours'}
138
+ </p>
139
+ )}
140
+ </div>
141
+ </article>
142
+
143
+ <script>
144
+ import { bindAddToCart } from './cart-store';
145
+ bindAddToCart(document);
146
+ </script>
147
+
148
+ <style>
149
+ .pcm { position: relative; display: flex; flex-direction: column; gap: var(--sp-3, 0.75rem); min-width: 0; }
150
+ .pcm--framed { padding: var(--sp-3, 0.75rem); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
151
+ .pcm__media { position: relative; overflow: hidden; border-radius: var(--r-sm, 2px); background: var(--c-bg-2, #111); }
152
+ .pcm__view { transition: opacity 0.5s ease, transform 0.8s cubic-bezier(0.16, 1, 0.3, 1); }
153
+ .pcm__view--b { position: absolute; inset: 0; opacity: 0; transform: scale(1.04); }
154
+ .pcm__view--b :global(.p-media__art) { transform: rotate(-12deg) scale(1.25); }
155
+ .pcm:hover .pcm__view--b, .pcm:focus-within .pcm__view--b { opacity: 1; transform: none; }
156
+ .pcm:hover .pcm__view--a { transform: scale(1.03); }
157
+
158
+ .pcm__badge, .pcm__index {
159
+ position: absolute;
160
+ top: var(--sp-3, 0.75rem);
161
+ z-index: 2;
162
+ font-size: 0.68rem;
163
+ font-weight: 700;
164
+ letter-spacing: 0.14em;
165
+ text-transform: uppercase;
166
+ }
167
+ .pcm__badge { left: var(--sp-3, 0.75rem); padding: 0.3rem 0.55rem; background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); }
168
+ .pcm__index { right: var(--sp-3, 0.75rem); color: var(--c-text-2, #a3a3a3); font-variant-numeric: tabular-nums; }
169
+
170
+ .pcm__quick {
171
+ position: absolute;
172
+ left: var(--sp-2, 0.5rem);
173
+ right: var(--sp-2, 0.5rem);
174
+ bottom: var(--sp-2, 0.5rem);
175
+ z-index: 3;
176
+ display: grid;
177
+ gap: 6px;
178
+ padding: var(--sp-3, 0.75rem);
179
+ background: rgb(var(--paper-rgb, 5 5 5) / 0.92);
180
+ backdrop-filter: blur(10px);
181
+ transform: translateY(calc(100% + 12px));
182
+ transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
183
+ }
184
+ .pcm:hover .pcm__quick, .pcm__quick:focus-within { transform: none; }
185
+ .pcm__quick-label { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
186
+ .pcm__sizes { display: flex; flex-wrap: wrap; gap: 4px; }
187
+ .pcm__size {
188
+ flex: 1 0 36px;
189
+ min-height: 36px;
190
+ padding: 0 6px;
191
+ border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.18);
192
+ border-radius: var(--r-sm, 2px);
193
+ background: transparent;
194
+ color: var(--c-text-1, #fff);
195
+ font: inherit;
196
+ font-size: 0.78rem;
197
+ font-weight: 600;
198
+ cursor: pointer;
199
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
200
+ }
201
+ .pcm__size:hover { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); border-color: var(--c-text-1, #fff); }
202
+ .pcm__size.is-added { background: var(--shop-accent, var(--c-primary-light, #d4a017)); border-color: transparent; color: var(--c-on-primary, #050505); }
203
+ .pcm__size--wide { flex: 1 1 100%; letter-spacing: 0.04em; }
204
+ .pcm__size:focus-visible { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
205
+
206
+ .pcm__info { display: grid; gap: 2px; }
207
+ .pcm__title { margin: 0; font-family: var(--font-display, Georgia, 'Times New Roman', serif); font-size: 1.05rem; font-weight: 500; letter-spacing: -0.01em; line-height: 1.25; }
208
+ .pcm--sm .pcm__title { font-size: 0.95rem; }
209
+ .pcm--lg .pcm__title { font-size: 1.3rem; }
210
+ .pcm__link { color: var(--c-text-1, #fff); text-decoration: none; }
211
+ .pcm__link::after { content: ''; position: absolute; inset: 0; z-index: 1; }
212
+ .pcm__link:focus-visible { outline: none; }
213
+ .pcm:focus-within:not(:has(.pcm__quick:focus-within)) { outline: 2px solid var(--shop-accent, var(--c-primary-light, #d4a017)); outline-offset: 4px; }
214
+ .pcm__price { display: flex; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.9rem; color: var(--c-text-1, #fff); font-variant-numeric: tabular-nums; }
215
+ .pcm__price s { color: var(--c-text-2, #a3a3a3); }
216
+ .pcm__colors { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 2px 0 0; font-size: 0.78rem; color: var(--c-text-2, #a3a3a3); }
217
+ .pcm__dots { display: inline-flex; }
218
+ .pcm__dots i { width: 10px; height: 10px; margin-right: -3px; border-radius: 50%; background: var(--sw); box-shadow: 0 0 0 1.5px var(--c-bg, #050505); }
219
+
220
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
221
+
222
+ @media (hover: none) {
223
+ .pcm__quick { position: static; transform: none; padding: var(--sp-2, 0.5rem) 0 0; background: transparent; backdrop-filter: none; }
224
+ .pcm__quick-label { display: none; }
225
+ }
226
+ @media (prefers-reduced-motion: reduce) {
227
+ .pcm__view, .pcm__quick { transition: none; }
228
+ }
229
+ </style>
@@ -0,0 +1,145 @@
1
+ ---
2
+ /**
3
+ * ProductGallery — Product image viewer with a large stage and thumbnail rail.
4
+ * Works with real photos (`images`) or generates several illustrated "angles".
5
+ *
6
+ * @param {string[]} images - Photo URLs. When empty, illustrated views are generated.
7
+ * @param {string} alt - Alt text base for photos. Default 'Product photo'.
8
+ * @param {string} icon - Illustration for generated views. Default 'headphones'.
9
+ * @param {number} hue - Backdrop hue for generated views. Default 38.
10
+ * @param {number} views - Number of generated views (2–5). Default 4.
11
+ * @param {string} layout - Thumbnail position (bottom|side). Default 'side'.
12
+ * @param {string} size - Stage ratio preset (sm|md|lg). Default 'md'.
13
+ * @param {string} className - Extra class.
14
+ *
15
+ * Accessibility: thumbnails are buttons with aria-pressed and labels; arrow keys move
16
+ * between thumbnails; the stage announces the current view number.
17
+ */
18
+ import ProductMedia from './ProductMedia.astro';
19
+
20
+ interface Props {
21
+ images?: string[];
22
+ alt?: string;
23
+ icon?: string;
24
+ hue?: number;
25
+ views?: number;
26
+ layout?: 'bottom' | 'side';
27
+ size?: 'sm' | 'md' | 'lg';
28
+ className?: string;
29
+ }
30
+
31
+ const {
32
+ images = [],
33
+ alt = 'Product photo',
34
+ icon = 'headphones',
35
+ hue = 38,
36
+ views = 4,
37
+ layout = 'side',
38
+ size = 'md',
39
+ className = '',
40
+ } = Astro.props;
41
+
42
+ const generated = Array.from({ length: Math.min(5, Math.max(2, views)) }, (_, i) => ({
43
+ hue: (hue + i * 18) % 360,
44
+ variant: (i % 2 === 0 ? 'glow' : 'soft') as 'glow' | 'soft',
45
+ turn: [0, -14, 12, -4, 20][i] ?? 0,
46
+ zoom: [1, 1.18, 0.92, 1.3, 1.05][i] ?? 1,
47
+ }));
48
+ const slides = images.length
49
+ ? images.map((src, i) => ({ src, label: `${alt} ${i + 1}` }))
50
+ : generated.map((g, i) => ({ ...g, label: `View ${i + 1}` }));
51
+ const ratio = size === 'sm' ? '1 / 1' : size === 'lg' ? '4 / 5' : '5 / 5.4';
52
+ ---
53
+
54
+ <div class="p-gallery-cq">
55
+ <div class:list={['p-gallery', `p-gallery--${layout}`, `p-gallery--${size}`, className]} data-gallery>
56
+ <div class="p-gallery__stage" aria-live="polite">
57
+ {slides.map((s: any, i) => (
58
+ <div class:list={['p-gallery__slide', { 'is-active': i === 0 }]} data-slide hidden={i !== 0} style={s.turn !== undefined ? `--turn: ${s.turn}deg; --zoom: ${s.zoom}` : ''}>
59
+ {s.src
60
+ ? <ProductMedia image={s.src} alt={s.label} ratio={ratio} />
61
+ : <ProductMedia icon={icon} hue={s.hue} variant={s.variant} ratio={ratio} size="lg" />}
62
+ <span class="sr-only">{s.label} of {slides.length}</span>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ <div class="p-gallery__thumbs" role="group" aria-label="Product views">
67
+ {slides.map((s: any, i) => (
68
+ <button class:list={['p-gallery__thumb']} type="button" aria-pressed={i === 0 ? 'true' : 'false'} aria-label={`Show ${s.label}`} data-thumb={i} style={s.turn !== undefined ? `--turn: ${s.turn}deg; --zoom: ${s.zoom}` : ''}>
69
+ {s.src
70
+ ? <ProductMedia image={s.src} alt="" ratio="1 / 1" />
71
+ : <ProductMedia icon={icon} hue={s.hue} variant={s.variant} ratio="1 / 1" size="sm" />}
72
+ </button>
73
+ ))}
74
+ </div>
75
+ </div>
76
+ </div>
77
+
78
+ <script>
79
+ document.querySelectorAll<HTMLElement>('[data-gallery]:not([data-bound])').forEach((g) => {
80
+ g.dataset.bound = '';
81
+ const slides = Array.from(g.querySelectorAll<HTMLElement>('[data-slide]'));
82
+ const thumbs = Array.from(g.querySelectorAll<HTMLButtonElement>('[data-thumb]'));
83
+ const show = (i: number, focus = false) => {
84
+ slides.forEach((s, n) => { s.hidden = n !== i; s.classList.toggle('is-active', n === i); });
85
+ thumbs.forEach((t, n) => t.setAttribute('aria-pressed', String(n === i)));
86
+ if (focus) thumbs[i]?.focus();
87
+ };
88
+ thumbs.forEach((t, i) => {
89
+ t.addEventListener('click', () => show(i));
90
+ t.addEventListener('keydown', (e) => {
91
+ const keys: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
92
+ if (!(e.key in keys)) return;
93
+ e.preventDefault();
94
+ show((i + keys[e.key] + thumbs.length) % thumbs.length, true);
95
+ });
96
+ });
97
+ });
98
+ </script>
99
+
100
+ <style>
101
+ .p-gallery-cq { container-type: inline-size; }
102
+ .p-gallery { display: grid; gap: var(--sp-3, 0.75rem); }
103
+ .p-gallery--side { grid-template-columns: 84px minmax(0, 1fr); }
104
+ .p-gallery--side .p-gallery__stage { order: 2; }
105
+ .p-gallery--side .p-gallery__thumbs { order: 1; flex-direction: column; }
106
+
107
+ .p-gallery__stage {
108
+ position: relative;
109
+ border-radius: var(--r-xl, 28px);
110
+ border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
111
+ overflow: hidden;
112
+ }
113
+ .p-gallery__slide[hidden] { display: none; }
114
+ .p-gallery__slide.is-active { animation: pg-in 0.35s ease-out; }
115
+ .p-gallery__slide :global(.p-media__art),
116
+ .p-gallery__thumb :global(.p-media__art) { transform: rotate(var(--turn, 0deg)) scale(var(--zoom, 1)); }
117
+
118
+ .p-gallery__thumbs { display: flex; gap: var(--sp-2, 0.5rem); }
119
+ .p-gallery__thumb {
120
+ flex: 0 0 84px;
121
+ width: 84px;
122
+ padding: 0;
123
+ border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
124
+ border-radius: var(--r-md, 12px);
125
+ overflow: hidden;
126
+ background: none;
127
+ cursor: pointer;
128
+ opacity: 0.6;
129
+ transition: opacity 0.2s ease, border-color 0.2s ease;
130
+ }
131
+ .p-gallery__thumb:hover { opacity: 1; }
132
+ .p-gallery__thumb[aria-pressed='true'] { opacity: 1; border-color: var(--c-primary-light, #d4a017); }
133
+ .p-gallery__thumb:focus-visible { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
134
+
135
+ @keyframes pg-in { from { opacity: 0.4; transform: scale(0.985); } }
136
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
137
+
138
+ @container (max-width: 700px) {
139
+ .p-gallery--side { grid-template-columns: 1fr; }
140
+ .p-gallery--side .p-gallery__stage { order: 1; }
141
+ .p-gallery--side .p-gallery__thumbs { order: 2; flex-direction: row; }
142
+ .p-gallery__thumb { flex-basis: 64px; width: 64px; }
143
+ }
144
+ @media (prefers-reduced-motion: reduce) { .p-gallery__slide.is-active { animation: none; } }
145
+ </style>
@@ -0,0 +1,118 @@
1
+ ---
2
+ /**
3
+ * ProductGrid — Responsive grid of StoreProductCard tiles with an empty state.
4
+ * Works on its own or as the target of FilterSidebar / ProductToolbar (same `id`).
5
+ *
6
+ * @param {Product[]} products - Products to render.
7
+ * @param {string} id - Grid id; FilterSidebar and ProductToolbar point at it. Default 'product-grid'.
8
+ * @param {string} productBase - Product URL prefix; links become `${productBase}${slug}/`. Default '#'.
9
+ * @param {Record<string, string>} categoryNames - Category id → label for the card eyebrow.
10
+ * @param {number} columns - Desktop columns (2–5). Default 4.
11
+ * @param {string} cardVariant - StoreProductCard variant (glass|minimal|bordered). Default 'glass'.
12
+ * @param {string} cardSize - StoreProductCard size (sm|md|lg). Default 'md'.
13
+ * @param {string} currency - ISO currency. Default 'USD'.
14
+ * @param {string} locale - Number format locale. Default 'en-US'.
15
+ * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
16
+ * @param {boolean} openCart - Open the drawer after adding (false = toast). Default true.
17
+ * @param {string} emptyTitle - Empty state heading. Default 'No products match'.
18
+ * @param {string} emptyText - Empty state copy.
19
+ * @param {string} className - Extra class.
20
+ *
21
+ * Accessibility: cards keep their own headings; the empty state is a polite status message.
22
+ */
23
+ import StoreProductCard from './StoreProductCard.astro';
24
+ import type { Product } from './types';
25
+
26
+ interface Props {
27
+ products?: Product[];
28
+ id?: string;
29
+ productBase?: string;
30
+ categoryNames?: Record<string, string>;
31
+ columns?: number;
32
+ cardVariant?: 'glass' | 'minimal' | 'bordered';
33
+ cardSize?: 'sm' | 'md' | 'lg';
34
+ currency?: string;
35
+ locale?: string;
36
+ cartKey?: string;
37
+ openCart?: boolean;
38
+ emptyTitle?: string;
39
+ emptyText?: string;
40
+ className?: string;
41
+ }
42
+
43
+ const {
44
+ products = [
45
+ { slug: 'aurora-headphones', name: 'Aurora Wireless Headphones', price: 189, compareAt: 229, category: 'audio', rating: 4.8, reviews: 312, badge: '-18%', badgeVariant: 'warning', icon: 'headphones', hue: 38 },
46
+ { slug: 'orbit-watch', name: 'Orbit Smart Watch', price: 249, category: 'wearables', rating: 4.6, reviews: 198, badge: 'New', badgeVariant: 'info', icon: 'watch', hue: 205 },
47
+ { slug: 'trail-backpack', name: 'Trail 24L Backpack', price: 119, category: 'bags', rating: 4.7, reviews: 87, icon: 'backpack', hue: 150 },
48
+ { slug: 'halo-lamp', name: 'Halo Desk Lamp', price: 79, category: 'home', rating: 4.5, reviews: 54, icon: 'lamp', hue: 25 },
49
+ ],
50
+ id = 'product-grid',
51
+ productBase = '#',
52
+ categoryNames = { audio: 'Audio', wearables: 'Wearables', bags: 'Bags', home: 'Home' },
53
+ columns = 4,
54
+ cardVariant = 'glass',
55
+ cardSize = 'md',
56
+ currency = 'USD',
57
+ locale = 'en-US',
58
+ cartKey = 'ack-cart',
59
+ openCart = true,
60
+ emptyTitle = 'No products match',
61
+ emptyText = 'Try removing a filter or widening the price range.',
62
+ className = '',
63
+ } = Astro.props;
64
+
65
+ const cols = Math.min(5, Math.max(2, columns));
66
+ const hrefFor = (slug: string) => (productBase === '#' ? '#' : `${productBase}${slug}/`);
67
+ ---
68
+
69
+ <div class:list={['p-grid-wrap', className]}>
70
+ <div class="p-grid" id={id} style={`--pg-cols: ${cols}`} data-product-list>
71
+ {products.map((p, i) => (
72
+ <StoreProductCard
73
+ product={p}
74
+ href={hrefFor(p.slug)}
75
+ categoryLabel={categoryNames[p.category] ?? ''}
76
+ variant={cardVariant}
77
+ size={cardSize}
78
+ currency={currency}
79
+ locale={locale}
80
+ cartKey={cartKey}
81
+ openCart={openCart}
82
+ />
83
+ ))}
84
+ </div>
85
+ <div class="p-grid__empty" role="status" data-product-empty hidden>
86
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/></svg>
87
+ <p><strong>{emptyTitle}</strong></p>
88
+ <p>{emptyText}</p>
89
+ </div>
90
+ </div>
91
+
92
+ <style>
93
+ .p-grid-wrap { container-type: inline-size; }
94
+ .p-grid {
95
+ display: grid;
96
+ grid-template-columns: repeat(var(--pg-cols), minmax(0, 1fr));
97
+ gap: var(--sp-5, 1.25rem);
98
+ }
99
+ .p-grid > :global([hidden]) { display: none; }
100
+ .p-grid__empty {
101
+ display: grid;
102
+ justify-items: center;
103
+ gap: var(--sp-2, 0.5rem);
104
+ padding: var(--sp-16, 4rem) var(--sp-6, 1.5rem);
105
+ border: 1px dashed rgb(var(--ink-rgb, 255 255 255) / 0.1);
106
+ border-radius: var(--r-lg, 20px);
107
+ text-align: center;
108
+ color: var(--c-text-2, #a3a3a3);
109
+ }
110
+ .p-grid__empty[hidden] { display: none; }
111
+ .p-grid__empty svg { color: var(--c-primary-light, #d4a017); }
112
+ .p-grid__empty p { margin: 0; }
113
+ .p-grid__empty strong { color: var(--c-text-1, #fff); font-size: 1.05rem; }
114
+
115
+ @container (max-width: 1100px) { .p-grid { grid-template-columns: repeat(min(var(--pg-cols), 3), minmax(0, 1fr)); } }
116
+ @container (max-width: 760px) { .p-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3, 0.75rem); } }
117
+ @container (max-width: 380px) { .p-grid { grid-template-columns: 1fr; } }
118
+ </style>
@@ -0,0 +1,113 @@
1
+ ---
2
+ /**
3
+ * ProductMedia — Product visual: a real photo, or a generated backdrop with a
4
+ * line-art illustration so demo catalogs look finished without external images.
5
+ *
6
+ * @param {string} image - Product photo URL. When set, replaces the illustration.
7
+ * @param {string} alt - Alternative text. Required for photos; illustrations are decorative.
8
+ * @param {string} icon - Built-in illustration (headphones|watch|sneaker|backpack|camera|lamp|keyboard|sunglasses|bottle|speaker|jacket|mug|plant|shirt|bag|phone).
9
+ * @param {number} hue - 0–360 hue of the generated backdrop. Default 40 (gold).
10
+ * @param {string} ratio - CSS aspect-ratio. Default '1 / 1'.
11
+ * @param {string} size - Illustration scale (sm|md|lg). Default 'md'.
12
+ * @param {string} variant - Backdrop style (glow|soft|flat). Default 'glow'.
13
+ * @param {string} className - Extra class.
14
+ *
15
+ * Accessibility: illustrations are aria-hidden; photos need `alt`.
16
+ */
17
+ import { productIcon } from './product-icons';
18
+
19
+ interface Props {
20
+ image?: string;
21
+ alt?: string;
22
+ icon?: string;
23
+ hue?: number;
24
+ ratio?: string;
25
+ size?: 'sm' | 'md' | 'lg';
26
+ variant?: 'glow' | 'soft' | 'flat';
27
+ className?: string;
28
+ }
29
+
30
+ const {
31
+ image,
32
+ alt = '',
33
+ icon = 'bag',
34
+ hue = 40,
35
+ ratio = '1 / 1',
36
+ size = 'md',
37
+ variant = 'glow',
38
+ className = '',
39
+ } = Astro.props;
40
+ ---
41
+
42
+ <div
43
+ class:list={['p-media', `p-media--${size}`, `p-media--${variant}`, className]}
44
+ style={`--pm-hue: ${hue}; --pm-ratio: ${ratio};`}
45
+ >
46
+ {image ? (
47
+ <img class="p-media__img" src={image} alt={alt} loading="lazy" decoding="async" />
48
+ ) : (
49
+ <svg class="p-media__art" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><Fragment set:html={productIcon(icon)} /></svg>
50
+ )}
51
+ <slot />
52
+ </div>
53
+
54
+ <style>
55
+ .p-media {
56
+ --pm-bg-1: hsl(var(--pm-hue) 70% 50% / 0.28);
57
+ --pm-bg-2: hsl(var(--pm-hue) 60% 30% / 0.14);
58
+ --pm-ink: hsl(var(--pm-hue) var(--pm-ink-s, 80%) var(--pm-ink-l, 80%));
59
+ position: relative;
60
+ display: grid;
61
+ place-items: center;
62
+ aspect-ratio: var(--pm-ratio);
63
+ width: 100%;
64
+ overflow: hidden;
65
+ border-radius: inherit;
66
+ background:
67
+ radial-gradient(circle at 50% 42%, var(--pm-bg-1), transparent 62%),
68
+ linear-gradient(160deg, var(--pm-bg-2), var(--c-bg-1, #0a0a0a) 75%);
69
+ isolation: isolate;
70
+ }
71
+ .p-media--soft {
72
+ background: linear-gradient(160deg, hsl(var(--pm-hue) var(--pm-soft-s, 40%) var(--pm-soft-l, 20%) / var(--pm-soft-a, 0.5)), var(--c-bg-1, #0a0a0a));
73
+ }
74
+ .p-media--flat { background: var(--c-bg-2, #111); }
75
+
76
+ .p-media::after {
77
+ content: '';
78
+ position: absolute;
79
+ inset: auto 22% 14% 22%;
80
+ height: 10%;
81
+ border-radius: 50%;
82
+ background: radial-gradient(ellipse, rgba(0, 0, 0, 0.55), transparent 70%);
83
+ z-index: -1;
84
+ }
85
+ .p-media--flat::after { display: none; }
86
+
87
+ .p-media__art {
88
+ width: 46%;
89
+ height: auto;
90
+ color: var(--pm-ink);
91
+ filter: drop-shadow(0 6px 18px hsl(var(--pm-hue) 80% 50% / 0.35));
92
+ transition: transform 0.45s cubic-bezier(0.16, 1, 0.3, 1);
93
+ }
94
+ .p-media--sm .p-media__art { width: 58%; stroke-width: 2.4; }
95
+ .p-media--lg .p-media__art { width: 40%; stroke-width: 1.4; }
96
+
97
+ .p-media__img {
98
+ position: absolute;
99
+ inset: 0;
100
+ width: 100%;
101
+ height: 100%;
102
+ object-fit: cover;
103
+ }
104
+
105
+ :global(.is-hoverable:hover) .p-media__art,
106
+ :global(.is-hoverable:focus-within) .p-media__art {
107
+ transform: translateY(-4%) rotate(-4deg) scale(1.04);
108
+ }
109
+
110
+ @media (prefers-reduced-motion: reduce) {
111
+ .p-media__art { transition: none; }
112
+ }
113
+ </style>
@@ -0,0 +1,78 @@
1
+ ---
2
+ /**
3
+ * ProductRating — Read-only star rating with value and review count.
4
+ *
5
+ * @param {number} value - Average rating, 0–5. Default 4.6.
6
+ * @param {number} count - Number of reviews (optional).
7
+ * @param {string} size - Visual size (sm|md|lg). Default 'sm'.
8
+ * @param {boolean} showValue - Print the numeric value. Default true.
9
+ * @param {string} label - Accessible label template; `{value}` and `{count}` are replaced.
10
+ * @param {string} className - Extra class.
11
+ *
12
+ * Accessibility: exposed as one `role="img"` with a full text label; stars are decorative.
13
+ */
14
+ interface Props {
15
+ value?: number;
16
+ count?: number;
17
+ size?: 'sm' | 'md' | 'lg';
18
+ showValue?: boolean;
19
+ label?: string;
20
+ className?: string;
21
+ }
22
+
23
+ const {
24
+ value = 4.6,
25
+ count,
26
+ size = 'sm',
27
+ showValue = true,
28
+ label = 'Rated {value} out of 5{count}',
29
+ className = '',
30
+ } = Astro.props;
31
+
32
+ const clamped = Math.max(0, Math.min(5, value));
33
+ const pct = `${(clamped / 5) * 100}%`;
34
+ const aria = label
35
+ .replace('{value}', clamped.toFixed(1))
36
+ .replace('{count}', count !== undefined ? `, ${count} reviews` : '');
37
+ const stars = '★★★★★';
38
+ ---
39
+
40
+ <span class:list={['p-rating', `p-rating--${size}`, className]} role="img" aria-label={aria}>
41
+ <span class="p-rating__stars" aria-hidden="true" style={`--p-rating-fill: ${pct}`}>
42
+ <span class="p-rating__base">{stars}</span>
43
+ <span class="p-rating__fill">{stars}</span>
44
+ </span>
45
+ {showValue && <span class="p-rating__value" aria-hidden="true">{clamped.toFixed(1)}</span>}
46
+ {count !== undefined && <span class="p-rating__count" aria-hidden="true">({count})</span>}
47
+ </span>
48
+
49
+ <style>
50
+ .p-rating {
51
+ --p-rating-star: var(--c-primary-light, #d4a017);
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: 0.35em;
55
+ font-size: 0.8rem;
56
+ line-height: 1;
57
+ color: var(--c-text-2, #a3a3a3);
58
+ }
59
+ .p-rating--md { font-size: 0.95rem; }
60
+ .p-rating--lg { font-size: 1.15rem; }
61
+
62
+ .p-rating__stars {
63
+ position: relative;
64
+ display: inline-block;
65
+ letter-spacing: 0.08em;
66
+ }
67
+ .p-rating__base { color: rgb(var(--ink-rgb, 255 255 255) / 0.14); }
68
+ .p-rating__fill {
69
+ position: absolute;
70
+ inset: 0 auto 0 0;
71
+ width: var(--p-rating-fill);
72
+ overflow: hidden;
73
+ white-space: nowrap;
74
+ color: var(--p-rating-star);
75
+ }
76
+ .p-rating__value { font-weight: 700; color: var(--c-text-1, #fff); }
77
+ .p-rating__count { color: var(--c-text-3, #737373); }
78
+ </style>