@snowcone-app/ui 0.1.43 → 0.2.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 (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. package/dist/styles.css +0 -1
@@ -0,0 +1,365 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import React from 'react';
3
+ import { Icon } from '@iconify/react';
4
+ import { Product } from '../../patterns/Product';
5
+ import { ProductOptions } from '../../composed/ProductOptions';
6
+ import { AddToCart } from '../../composed/AddToCart';
7
+ import {
8
+ Accordion,
9
+ AccordionItem,
10
+ AccordionTrigger,
11
+ AccordionContent,
12
+ } from '../../primitives/accordion';
13
+ import type { CatalogProduct } from '@snowcone-app/sdk';
14
+
15
+ /**
16
+ * PDPLayout - Complete Product Detail Page Layout
17
+ *
18
+ * The actual PDP layout where:
19
+ * - Hero images are FULL WIDTH and stack vertically
20
+ * - Info panel OVERLAPS on the right side (fixed/sticky position)
21
+ * - Uses REAL ProductOptions and AddToCart components
22
+ *
23
+ * This matches the real next-ecommerce implementation.
24
+ */
25
+
26
+ // Helper to generate all combinations for sizes and colors
27
+ const sizes = ['XS', 'S', 'M', 'L', 'XL', '2XL'];
28
+ const colors = ['Black', 'White', 'Navy', 'Heather Gray'];
29
+
30
+ const generateCombinations = (sizeList: string[], colorList: string[], price: number) => {
31
+ const combinations: { price: number; variantId: string; Size: string; Color: string }[] = [];
32
+ for (const size of sizeList) {
33
+ for (const color of colorList) {
34
+ combinations.push({
35
+ price,
36
+ variantId: `${size.toLowerCase()}-${color.toLowerCase().replace(' ', '-')}`,
37
+ Size: size,
38
+ Color: color,
39
+ });
40
+ }
41
+ }
42
+ return combinations;
43
+ };
44
+
45
+ // Mock product data
46
+ const mockProduct: CatalogProduct = {
47
+ id: 'classic-cotton-tshirt',
48
+ name: 'Classic Cotton T-Shirt',
49
+ slug: 'classic-cotton-tshirt',
50
+ price: 2999, // $29.99 in cents
51
+ options: {
52
+ attributes: {
53
+ Size: {
54
+ type: 'select',
55
+ affectsCombinations: true,
56
+ choices: sizes.map(label => ({ label })),
57
+ },
58
+ Color: {
59
+ type: 'swatch',
60
+ affectsCombinations: true,
61
+ choices: [
62
+ { label: 'Black', hex: '#1a1a1a' },
63
+ { label: 'White', hex: '#ffffff' },
64
+ { label: 'Navy', hex: '#1e3a5f' },
65
+ { label: 'Heather Gray', hex: '#9ca3af' },
66
+ ],
67
+ },
68
+ },
69
+ combinations: generateCombinations(sizes, colors, 2999),
70
+ attributesList: ['Size', 'Color'],
71
+ },
72
+ description: ['Premium quality with exceptional comfort and style.'],
73
+ };
74
+
75
+ const demoMockups = [
76
+ { id: 'front', label: 'Front' },
77
+ { id: 'back', label: 'Back' },
78
+ { id: 'detail', label: 'Detail' },
79
+ ];
80
+
81
+ interface PDPLayoutProps {
82
+ product?: CatalogProduct;
83
+ tag?: string;
84
+ showArtworkCustomizer?: boolean;
85
+ }
86
+
87
+ const PDPLayout = ({
88
+ product = mockProduct,
89
+ tag = 'Upgraded',
90
+ showArtworkCustomizer = false,
91
+ }: PDPLayoutProps) => {
92
+ const [isFavorite, setIsFavorite] = React.useState(false);
93
+
94
+ // Format price from cents
95
+ const formatPrice = (cents: number) => `$${(cents / 100).toFixed(2)}`;
96
+
97
+ return (
98
+ <Product productData={product} initialSelection={{ Size: 'M', Color: product.options?.attributes?.Color?.choices?.[0]?.label || 'Black' }}>
99
+ <div className="min-h-screen bg-background">
100
+ {/* Header */}
101
+ <header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-md border-b border-border">
102
+ <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
103
+ <div className="flex items-center gap-4">
104
+ <button className="p-2 hover:bg-muted rounded-lg">
105
+ <Icon icon="gravity-ui:bars" className="w-5 h-5" />
106
+ </button>
107
+ <span className="font-bold text-lg">Store</span>
108
+ </div>
109
+ <div className="flex items-center gap-2">
110
+ <button className="p-2 hover:bg-muted rounded-lg">
111
+ <Icon icon="gravity-ui:magnifier" className="w-5 h-5" />
112
+ </button>
113
+ <button className="p-2 hover:bg-muted rounded-lg relative">
114
+ <Icon icon="gravity-ui:shopping-cart" className="w-5 h-5" />
115
+ <span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-primary text-primary-foreground text-xs rounded-full flex items-center justify-center">
116
+ 2
117
+ </span>
118
+ </button>
119
+ </div>
120
+ </div>
121
+ </header>
122
+
123
+ {/* Main Content - Images are full width, panel overlaps */}
124
+ <main className="pt-16 relative">
125
+ {/* Full-width Hero Images - content centered in visible area (left of panel) */}
126
+ <div className="w-full">
127
+ {demoMockups.map((mockup) => (
128
+ <div
129
+ key={mockup.id}
130
+ className="relative w-full"
131
+ style={{ height: '70vh' }}
132
+ >
133
+ {/* Placeholder - in real app this is HeroProductImage with object-fit: cover */}
134
+ <div className="w-full h-full bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center">
135
+ {/* Content offset to left to center in visible area (accounting for ~35% panel on right) */}
136
+ <div
137
+ className="text-center text-muted-foreground"
138
+ style={{ marginRight: '30%' }} // Offset to center in visible area
139
+ >
140
+ <Icon
141
+ icon="gravity-ui:t-shirt"
142
+ className="w-32 h-32 mx-auto mb-4 opacity-20"
143
+ />
144
+ <p className="text-xl font-medium opacity-60">{mockup.label} View</p>
145
+ <p className="text-sm opacity-40">Product mockup</p>
146
+ </div>
147
+ </div>
148
+
149
+ </div>
150
+ ))}
151
+ </div>
152
+
153
+ {/* Overlapping Info Panel - fixed on right side */}
154
+ <div
155
+ className="hidden md:block fixed right-8 top-24 w-[30%] xl:w-[35%] max-w-96 z-10"
156
+ style={{ maxHeight: 'calc(100vh - 8rem)' }}
157
+ >
158
+ <div className="bg-background/85 backdrop-blur-sm rounded-3xl p-6 shadow-lg overflow-y-auto max-h-[calc(100vh-10rem)]">
159
+ <div className="flex flex-col gap-6">
160
+ {/* Tag, Product Name and Price */}
161
+ <div className="flex flex-col gap-3">
162
+ {/* Tag */}
163
+ {tag && (
164
+ <span className="text-sm font-medium text-primary">{tag}</span>
165
+ )}
166
+
167
+ {/* Name and Price */}
168
+ <div className="flex items-end justify-between gap-4">
169
+ <h1 className="text-2xl font-bold text-foreground leading-tight">
170
+ {product.name}
171
+ </h1>
172
+ <div className="flex items-baseline gap-2 shrink-0">
173
+ <span className="text-lg font-semibold text-foreground">{formatPrice(product.price)}</span>
174
+ </div>
175
+ </div>
176
+
177
+ {/* Rating and Favorite */}
178
+ <div className="flex items-center justify-start gap-2">
179
+ <button
180
+ onClick={() => setIsFavorite(!isFavorite)}
181
+ className="w-10 h-10 bg-foreground/5 rounded-full flex items-center justify-center hover:bg-foreground/10 transition-colors"
182
+ >
183
+ <Icon
184
+ icon={isFavorite ? 'gravity-ui:heart-fill' : 'gravity-ui:heart'}
185
+ className={`w-5 h-5 ${isFavorite ? 'text-red-500' : 'text-muted-foreground/60'}`}
186
+ />
187
+ </button>
188
+ <div className="h-10 bg-foreground/5 rounded-full px-3 flex items-center gap-1 text-sm font-caption text-muted-foreground">
189
+ <Icon icon="gravity-ui:star" className="w-4 h-4 text-muted-foreground" />
190
+ <span className="font-medium">4.2</span>
191
+ <span>(143)</span>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ {/* Artwork Customizer Placeholder */}
197
+ {showArtworkCustomizer && (
198
+ <div className="aspect-square bg-muted rounded-2xl flex items-center justify-center border-2 border-dashed border-border">
199
+ <div className="text-center text-muted-foreground p-4">
200
+ <Icon icon="gravity-ui:hand-pointer-fill" className="w-12 h-12 mx-auto mb-2 opacity-50" />
201
+ <p className="text-sm font-medium">Artwork Customizer</p>
202
+ <p className="text-xs opacity-70">Drag to position your design</p>
203
+ </div>
204
+ </div>
205
+ )}
206
+
207
+ {/* Product Options - REAL COMPONENT */}
208
+ <ProductOptions />
209
+
210
+ {/* Add to Cart - REAL COMPONENT */}
211
+ <AddToCart />
212
+
213
+ {/* Product Details Accordion */}
214
+ <Accordion type="single" collapsible className="w-full [&>*]:border-0">
215
+ <AccordionItem value="description">
216
+ <AccordionTrigger className="text-left text-base font-bold text-foreground hover:no-underline">
217
+ Description
218
+ </AccordionTrigger>
219
+ <AccordionContent>
220
+ <p className="text-sm text-muted-foreground leading-relaxed">
221
+ Premium quality with exceptional comfort and style.
222
+ </p>
223
+ </AccordionContent>
224
+ </AccordionItem>
225
+
226
+ <AccordionItem value="materials">
227
+ <AccordionTrigger className="text-left text-base font-bold text-foreground hover:no-underline">
228
+ Materials
229
+ </AccordionTrigger>
230
+ <AccordionContent>
231
+ <p className="text-sm text-muted-foreground leading-relaxed">
232
+ 100% organic cotton with sustainable manufacturing.
233
+ </p>
234
+ </AccordionContent>
235
+ </AccordionItem>
236
+
237
+ <AccordionItem value="shipping">
238
+ <AccordionTrigger className="text-left text-base font-bold text-foreground hover:no-underline">
239
+ Shipping & Returns
240
+ </AccordionTrigger>
241
+ <AccordionContent>
242
+ <p className="text-sm text-muted-foreground leading-relaxed">
243
+ Free shipping on orders over $50. 30-day returns.
244
+ </p>
245
+ </AccordionContent>
246
+ </AccordionItem>
247
+ </Accordion>
248
+ </div>
249
+ </div>
250
+ </div>
251
+
252
+ {/* Below the fold content */}
253
+ <section className="border-t border-border py-16 px-8 bg-background">
254
+ <div className="max-w-4xl mx-auto text-center">
255
+ <h2 className="text-3xl font-bold mb-4">Crafted with Precision</h2>
256
+ <p className="text-muted-foreground text-lg mb-8">
257
+ Every detail matters. Our products are designed with care.
258
+ </p>
259
+
260
+ <div className="grid grid-cols-3 gap-8">
261
+ <div className="text-center">
262
+ <div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
263
+ <Icon icon="gravity-ui:shield-check" className="w-8 h-8 text-primary" />
264
+ </div>
265
+ <h3 className="font-semibold mb-2">Quality Guaranteed</h3>
266
+ <p className="text-sm text-muted-foreground">Premium materials</p>
267
+ </div>
268
+ <div className="text-center">
269
+ <div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
270
+ <Icon icon="gravity-ui:leaf" className="w-8 h-8 text-primary" />
271
+ </div>
272
+ <h3 className="font-semibold mb-2">Eco-Friendly</h3>
273
+ <p className="text-sm text-muted-foreground">Sustainable</p>
274
+ </div>
275
+ <div className="text-center">
276
+ <div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
277
+ <Icon icon="gravity-ui:rocket" className="w-8 h-8 text-primary" />
278
+ </div>
279
+ <h3 className="font-semibold mb-2">Fast Shipping</h3>
280
+ <p className="text-sm text-muted-foreground">Quick delivery</p>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ </section>
285
+ </main>
286
+ </div>
287
+ </Product>
288
+ );
289
+ };
290
+
291
+ const meta: Meta<typeof PDPLayout> = {
292
+ title: 'Layouts/PDP Layout',
293
+ component: PDPLayout,
294
+ parameters: {
295
+ layout: 'fullscreen',
296
+ docs: {
297
+ description: {
298
+ component: `
299
+ # PDP Layout
300
+
301
+ The complete Product Detail Page layout using **real components** with Product context.
302
+
303
+ **Images are full-width** and the **info panel overlaps** on the right.
304
+
305
+ ## Layout Structure
306
+
307
+ \`\`\`
308
+ ┌─────────────────────────────────────────────────┐
309
+ │ Header (fixed) │
310
+ ├─────────────────────────────────────────────────┤
311
+ │ ┌───────────┐│
312
+ │ Full-width Hero Images │ Info Panel││
313
+ │ (70vh each, stacked) │ (fixed, ││
314
+ │ │ overlaps) ││
315
+ │ ┌─────────────────────────────┐ │ ││
316
+ │ │ Front Mockup │ │ • Name ││
317
+ │ └─────────────────────────────┘ │ • Price ││
318
+ │ ┌─────────────────────────────┐ │ • Options ││
319
+ │ │ Back Mockup │ │ • Add ││
320
+ │ └─────────────────────────────┘ │ ││
321
+ │ ┌─────────────────────────────┐ └───────────┘│
322
+ │ │ Detail Mockup │ │
323
+ │ └─────────────────────────────┘ │
324
+ ├─────────────────────────────────────────────────┤
325
+ │ Additional Content (reviews, etc) │
326
+ └─────────────────────────────────────────────────┘
327
+ \`\`\`
328
+
329
+ ## Key Points
330
+
331
+ - **Images are full-width** - they span the entire viewport
332
+ - **Image content is centered left** - centered in the visible area (left of panel), so right portion is cropped/hidden
333
+ - **Panel overlaps** - positioned fixed on the right, on top of images
334
+ - **Glassmorphism** - panel has backdrop blur so images show through
335
+ - **Sticky behavior** - panel stays visible while scrolling through images
336
+ - **Real components** - uses actual ProductOptions and AddToCart with context
337
+ `,
338
+ },
339
+ },
340
+ },
341
+ tags: ['autodocs'],
342
+ argTypes: {
343
+ tag: {
344
+ control: 'text',
345
+ description: 'Product tag/badge (e.g., "Upgraded", "New", "Sale")',
346
+ },
347
+ showArtworkCustomizer: {
348
+ control: 'boolean',
349
+ description: 'Show artwork customizer for POD products',
350
+ },
351
+ },
352
+ };
353
+
354
+ export default meta;
355
+ type Story = StoryObj<typeof meta>;
356
+
357
+ export const Default: Story = {
358
+ args: {},
359
+ };
360
+
361
+ export const WithArtworkCustomizer: Story = {
362
+ args: {
363
+ showArtworkCustomizer: true,
364
+ },
365
+ };
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import {
5
+ getBrand,
6
+ brandAssets,
7
+ type SupportedLanguage,
8
+ type BrandLocale,
9
+ type BrandAssets,
10
+ } from "../lib/locale";
11
+
12
+ export interface UseBrandResult {
13
+ /** Brand data for the requested locale. */
14
+ brand: BrandLocale;
15
+ /** Global brand assets (icon, colors). */
16
+ assets: BrandAssets;
17
+ }
18
+
19
+ /**
20
+ * React hook for accessing brand data. Throws if the locale is not in brand.json.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * function Header() {
25
+ * const { brand, assets } = useBrand("en");
26
+ * return (
27
+ * <header>
28
+ * <img src={assets.icon_svg} alt="" />
29
+ * <h1 translate="no" lang={brand.lang}>{brand.localized_name}</h1>
30
+ * <p>{brand.tagline}</p>
31
+ * </header>
32
+ * );
33
+ * }
34
+ * ```
35
+ */
36
+ export function useBrand(language: SupportedLanguage = "en"): UseBrandResult {
37
+ return useMemo(
38
+ () => ({ brand: getBrand(language), assets: brandAssets }),
39
+ [language]
40
+ );
41
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, useMemo, useState } from 'react';
4
+ import { useProductOptional, type Artwork } from '../patterns/Product';
5
+ import { useRealtimeOptional } from '../patterns/RealtimeProvider';
6
+
7
+ /**
8
+ * useCanvasContext - Non-reactive context access for canvas components
9
+ *
10
+ * This hook provides ref-based access to ProductContext and RealtimeContext values
11
+ * that are needed during canvas drag operations. Components using this hook will NOT
12
+ * re-render when context changes - they always have access to the latest values via refs.
13
+ *
14
+ * **Why this exists:**
15
+ * During canvas drag operations, any React re-render causes lag. The standard
16
+ * `useProductOptional()` hook subscribes to the entire context, which means
17
+ * components re-render whenever any context value changes (including mockupResults).
18
+ *
19
+ * This hook solves that by:
20
+ * 1. Subscribing to context once on mount
21
+ * 2. Keeping refs updated with latest values (ref updates don't trigger re-renders)
22
+ * 3. Returning a stable object that never changes reference
23
+ *
24
+ * **Usage:**
25
+ * ```tsx
26
+ * const { sendCanvasBlobRef, isRealtimeEnabledRef, mockupCountRef } = useCanvasContext();
27
+ *
28
+ * const handleExport = useCallback(async (exports) => {
29
+ * if (isRealtimeEnabledRef.current && sendCanvasBlobRef.current) {
30
+ * sendCanvasBlobRef.current(placement, blob, mockupCountRef.current, 500);
31
+ * }
32
+ * }, []); // Empty deps - callback never recreated
33
+ * ```
34
+ *
35
+ * **IMPORTANT:** This hook still subscribes to context (can't avoid that with React Context).
36
+ * To prevent re-renders, the PARENT component using this hook should be wrapped in React.memo
37
+ * with a custom comparison function that ignores context-driven prop changes.
38
+ *
39
+ * For truly zero re-renders, pass required values as props from a parent that does subscribe,
40
+ * and DON'T call useCanvasContext or useProductOptional in the canvas component itself.
41
+ *
42
+ * @returns Object containing refs to context values - object reference is stable
43
+ */
44
+ export function useCanvasContext() {
45
+ const context = useProductOptional();
46
+ const realtimeContext = useRealtimeOptional();
47
+
48
+ // Store context values in refs - ref updates don't trigger re-renders
49
+ const sendCanvasBlobRef = useRef(realtimeContext?.sendCanvasBlob);
50
+ const isRealtimeEnabledRef = useRef(realtimeContext?.isEnabled ?? false);
51
+ const isConfiguredRef = useRef(realtimeContext?.isConfigured ?? false);
52
+ const mockupCountRef = useRef(context?.product?.mockups?.length ?? 1);
53
+ const selectedPlacementRef = useRef(context?.selectedPlacement);
54
+ const enableRealtimeRef = useRef(realtimeContext?.enableRealtime);
55
+ const selectedArtworkRef = useRef<Artwork | undefined>(context?.selectedArtwork);
56
+
57
+ // Keep refs current after every render (this effect has no deps, runs after every render)
58
+ // Ref updates don't cause re-renders, so this is safe
59
+ useEffect(() => {
60
+ sendCanvasBlobRef.current = realtimeContext?.sendCanvasBlob;
61
+ isRealtimeEnabledRef.current = realtimeContext?.isEnabled ?? false;
62
+ isConfiguredRef.current = realtimeContext?.isConfigured ?? false;
63
+ mockupCountRef.current = context?.product?.mockups?.length ?? 1;
64
+ selectedPlacementRef.current = context?.selectedPlacement;
65
+ enableRealtimeRef.current = realtimeContext?.enableRealtime;
66
+ selectedArtworkRef.current = context?.selectedArtwork;
67
+ });
68
+
69
+ // Return stable object that never changes reference (empty deps)
70
+ return useMemo(() => ({
71
+ /** Ref to sendCanvasBlob function - call via sendCanvasBlobRef.current?.(...) */
72
+ sendCanvasBlobRef,
73
+ /** Ref to isRealtimeEnabled boolean */
74
+ isRealtimeEnabledRef,
75
+ /** Ref to isConfigured boolean */
76
+ isConfiguredRef,
77
+ /** Ref to mockup count number */
78
+ mockupCountRef,
79
+ /** Ref to selected placement string */
80
+ selectedPlacementRef,
81
+ /** Ref to enableRealtime function */
82
+ enableRealtimeRef,
83
+ /** Ref to selected artwork */
84
+ selectedArtworkRef,
85
+
86
+ /**
87
+ * Debug helper - logs current ref values
88
+ * Only use for debugging, not in production code
89
+ */
90
+ __debug: () => ({
91
+ isRealtimeEnabled: isRealtimeEnabledRef.current,
92
+ isConfigured: isConfiguredRef.current,
93
+ mockupCount: mockupCountRef.current,
94
+ selectedPlacement: selectedPlacementRef.current,
95
+ hasSendCanvasBlob: !!sendCanvasBlobRef.current,
96
+ hasEnableRealtime: !!enableRealtimeRef.current,
97
+ selectedArtworkSrc: selectedArtworkRef.current?.src,
98
+ }),
99
+ }), []);
100
+ }
101
+
102
+ /**
103
+ * useCanvasContextStatic - Truly non-reactive context access
104
+ *
105
+ * This hook captures context values ONCE on mount and never updates.
106
+ * Use this when you need initial values but never want to re-render.
107
+ *
108
+ * **Warning:** Values will be stale if context changes after mount!
109
+ * Only use for values that don't change during the component's lifetime.
110
+ */
111
+ export function useCanvasContextStatic() {
112
+ const context = useProductOptional();
113
+ const realtimeContext = useRealtimeOptional();
114
+
115
+ // Capture values once on mount using lazy initialization
116
+ const [staticValues] = useState(() => ({
117
+ sendCanvasBlob: realtimeContext?.sendCanvasBlob,
118
+ isRealtimeEnabled: realtimeContext?.isEnabled ?? false,
119
+ isConfigured: realtimeContext?.isConfigured ?? false,
120
+ mockupCount: context?.product?.mockups?.length ?? 1,
121
+ selectedPlacement: context?.selectedPlacement,
122
+ enableRealtime: realtimeContext?.enableRealtime,
123
+ selectedArtwork: context?.selectedArtwork,
124
+ }));
125
+
126
+ return staticValues;
127
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ export interface DeviceDetectionResult {
6
+ /** Whether the device supports touch input */
7
+ isTouchDevice: boolean;
8
+ /** Whether the browser is Safari (not Chrome/Firefox on iOS) */
9
+ isSafari: boolean;
10
+ /** Safari on non-touch devices (macOS). Use for desktop-only Safari workarounds like mask repaint. */
11
+ isDesktopSafari: boolean;
12
+ }
13
+
14
+ /**
15
+ * Hook to detect device capabilities: touch support and Safari browser.
16
+ *
17
+ * These capabilities are detected once on mount and don't change during
18
+ * the session, so no resize listener is needed.
19
+ *
20
+ * @returns Object with isTouchDevice and isSafari booleans
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const { isTouchDevice, isSafari } = useDeviceDetection();
25
+ *
26
+ * // Use for Safari-specific workarounds (e.g., edge swipe prevention)
27
+ * if (isSafari && isTouchDevice) {
28
+ * // Apply iOS Safari workarounds
29
+ * }
30
+ * ```
31
+ */
32
+ export function useDeviceDetection(): DeviceDetectionResult {
33
+ const [isTouchDevice, setIsTouchDevice] = useState(false);
34
+ const [isSafari, setIsSafari] = useState(false);
35
+
36
+ useEffect(() => {
37
+ // These only need to run ONCE on mount - they don't change during the session
38
+ const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
39
+ setIsTouchDevice(hasTouch);
40
+
41
+ // Safari detection - detect actual Safari browsers (not Chrome on iOS/iPad)
42
+ const userAgent = navigator.userAgent;
43
+ const isSafariBrowser =
44
+ // Safari on macOS/iOS that's not Chrome
45
+ (userAgent.includes("Safari") &&
46
+ userAgent.includes("Version") &&
47
+ !userAgent.includes("Chrome") &&
48
+ !userAgent.includes("CriOS") && // Chrome on iOS
49
+ !userAgent.includes("FxiOS") && // Firefox on iOS
50
+ !userAgent.includes("EdgiOS")) || // Edge on iOS
51
+ // Additional check for Safari without Version string
52
+ (/Safari/.test(userAgent) &&
53
+ !/Chrome|CriOS|FxiOS|EdgiOS/.test(userAgent));
54
+ setIsSafari(isSafariBrowser);
55
+
56
+ // NO RESIZE LISTENER - device capabilities don't change on resize
57
+ }, []);
58
+
59
+ // Desktop Safari = Safari on non-touch device (macOS)
60
+ // Used for workarounds that should NOT apply to iPad/iPhone (e.g., mask repaint on window resize)
61
+ const isDesktopSafari = isSafari && !isTouchDevice;
62
+
63
+ return { isTouchDevice, isSafari, isDesktopSafari };
64
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ /**
6
+ * Hook to trap focus within a container element (for modals, drawers, etc.)
7
+ * Returns focus to the trigger element when the trap is deactivated
8
+ */
9
+ export function useFocusTrap(isActive: boolean, onClose?: () => void) {
10
+ const containerRef = useRef<HTMLDivElement>(null);
11
+ const previousActiveElement = useRef<HTMLElement | null>(null);
12
+
13
+ useEffect(() => {
14
+ if (!isActive) return;
15
+
16
+ // Store the element that was focused before the trap activated
17
+ previousActiveElement.current = document.activeElement as HTMLElement;
18
+
19
+ const container = containerRef.current;
20
+ if (!container) return;
21
+
22
+ // Focus the first focusable element in the container
23
+ const focusableElements = container.querySelectorAll<HTMLElement>(
24
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
25
+ );
26
+
27
+ if (focusableElements.length > 0) {
28
+ focusableElements[0].focus();
29
+ }
30
+
31
+ // Handle Tab and Shift+Tab to trap focus
32
+ const handleKeyDown = (e: KeyboardEvent) => {
33
+ if (e.key !== "Tab") return;
34
+
35
+ const focusableElements = container.querySelectorAll<HTMLElement>(
36
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
37
+ );
38
+
39
+ const firstElement = focusableElements[0];
40
+ const lastElement = focusableElements[focusableElements.length - 1];
41
+
42
+ if (e.shiftKey) {
43
+ // Shift + Tab
44
+ if (document.activeElement === firstElement) {
45
+ e.preventDefault();
46
+ lastElement?.focus();
47
+ }
48
+ } else {
49
+ // Tab
50
+ if (document.activeElement === lastElement) {
51
+ e.preventDefault();
52
+ firstElement?.focus();
53
+ }
54
+ }
55
+ };
56
+
57
+ container.addEventListener("keydown", handleKeyDown);
58
+
59
+ return () => {
60
+ container.removeEventListener("keydown", handleKeyDown);
61
+
62
+ // Return focus to the previously focused element
63
+ if (previousActiveElement.current) {
64
+ previousActiveElement.current.focus();
65
+ }
66
+ };
67
+ }, [isActive]);
68
+
69
+ return containerRef;
70
+ }