@snowcone-app/ui 0.1.42 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import {
|
|
5
|
+
useMediaQuery,
|
|
6
|
+
useViewportDimensions,
|
|
7
|
+
useResponsiveImageCap,
|
|
8
|
+
useWideMonitorMode,
|
|
9
|
+
type ViewportDimensions,
|
|
10
|
+
type ResponsiveImageCapInfo,
|
|
11
|
+
} from "../../hooks/viewport";
|
|
12
|
+
import { ImageEdgeBlur } from "./ImageEdgeBlur";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props passed to the hero image render function.
|
|
16
|
+
*/
|
|
17
|
+
export interface HeroImageRenderProps {
|
|
18
|
+
/** Maximum width in pixels for the image (responsive to ultra-wide monitors) */
|
|
19
|
+
maxWidth: number;
|
|
20
|
+
/** Whether we're in wide monitor mode (images don't cover full viewport) */
|
|
21
|
+
isWideMonitor: boolean;
|
|
22
|
+
/** Full viewport dimensions */
|
|
23
|
+
dimensions: ViewportDimensions;
|
|
24
|
+
/** Detailed image cap info */
|
|
25
|
+
imageCapInfo: ResponsiveImageCapInfo;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Props passed to the mobile carousel render function.
|
|
30
|
+
*/
|
|
31
|
+
export interface CarouselRenderProps {
|
|
32
|
+
/** Image URLs or CarouselImage objects */
|
|
33
|
+
images: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Props passed to the content render function.
|
|
38
|
+
*/
|
|
39
|
+
export interface ContentRenderProps {
|
|
40
|
+
/** Whether we're on desktop (true), mobile (false), or unknown/SSR (null) */
|
|
41
|
+
isDesktop: boolean | null;
|
|
42
|
+
/** Whether we're in wide monitor mode */
|
|
43
|
+
isWideMonitor: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PDPLayoutProps {
|
|
47
|
+
/**
|
|
48
|
+
* Render function for the hero/main product image on desktop.
|
|
49
|
+
* Receives responsive sizing info based on viewport.
|
|
50
|
+
*/
|
|
51
|
+
renderHeroImage: (props: HeroImageRenderProps) => React.ReactNode;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render function for the main content (options, cart button, etc.).
|
|
55
|
+
* This renders in both mobile and desktop layouts.
|
|
56
|
+
*/
|
|
57
|
+
renderContent: (props: ContentRenderProps) => React.ReactNode;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Optional render function for the mobile product carousel.
|
|
61
|
+
* If not provided, mobile will only show the content section.
|
|
62
|
+
*/
|
|
63
|
+
renderMobileCarousel?: (props: CarouselRenderProps) => React.ReactNode;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Image URLs for mobile carousel.
|
|
67
|
+
* Only used if renderMobileCarousel is provided.
|
|
68
|
+
*/
|
|
69
|
+
carouselImages?: string[];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sidebar width as a percentage (default: 0.35 for 35%).
|
|
73
|
+
* Used for wide monitor calculations.
|
|
74
|
+
*/
|
|
75
|
+
sidebarPercent?: number;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sidebar width as CSS value for desktop layout (default: "35%").
|
|
79
|
+
*/
|
|
80
|
+
sidebarWidth?: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether to show edge blur on wide monitors (default: true).
|
|
84
|
+
*/
|
|
85
|
+
showEdgeBlur?: boolean;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Current image URL for the edge blur effect.
|
|
89
|
+
* Required if showEdgeBlur is true.
|
|
90
|
+
*/
|
|
91
|
+
blurImageSrc?: string;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Height of the image container as CSS value (default: "83vw").
|
|
95
|
+
* Used for edge blur positioning.
|
|
96
|
+
*/
|
|
97
|
+
containerHeight?: string;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Additional class names for the layout container.
|
|
101
|
+
*/
|
|
102
|
+
className?: string;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Children rendered after the layout (floating elements, FABs, etc.).
|
|
106
|
+
*/
|
|
107
|
+
children?: React.ReactNode;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* PDPLayout - All-in-one Product Detail Page layout component.
|
|
112
|
+
*
|
|
113
|
+
* Handles:
|
|
114
|
+
* - Mobile vs desktop layout switching (SSR-safe)
|
|
115
|
+
* - Wide monitor detection and handling
|
|
116
|
+
* - Responsive image capping for ultra-wide monitors
|
|
117
|
+
* - Edge blur effect for image gaps
|
|
118
|
+
*
|
|
119
|
+
* Uses render props for maximum flexibility while handling the
|
|
120
|
+
* complex viewport-aware logic internally.
|
|
121
|
+
*
|
|
122
|
+
* @example Basic usage
|
|
123
|
+
* ```tsx
|
|
124
|
+
* <PDPLayout
|
|
125
|
+
* carouselImages={mockupUrls}
|
|
126
|
+
* renderHeroImage={({ maxWidth }) => (
|
|
127
|
+
* <HeroProductImage maxWidth={maxWidth} />
|
|
128
|
+
* )}
|
|
129
|
+
* renderContent={() => (
|
|
130
|
+
* <>
|
|
131
|
+
* <ProductOptions />
|
|
132
|
+
* <AddToCart />
|
|
133
|
+
* </>
|
|
134
|
+
* )}
|
|
135
|
+
* renderMobileCarousel={({ images }) => (
|
|
136
|
+
* <MobileProductCarousel images={images} />
|
|
137
|
+
* )}
|
|
138
|
+
* />
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* @example With edge blur
|
|
142
|
+
* ```tsx
|
|
143
|
+
* <PDPLayout
|
|
144
|
+
* showEdgeBlur
|
|
145
|
+
* blurImageSrc={currentMockupUrl}
|
|
146
|
+
* renderHeroImage={...}
|
|
147
|
+
* renderContent={...}
|
|
148
|
+
* >
|
|
149
|
+
* <FloatingActionGroup />
|
|
150
|
+
* </PDPLayout>
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function PDPLayout({
|
|
154
|
+
renderHeroImage,
|
|
155
|
+
renderContent,
|
|
156
|
+
renderMobileCarousel,
|
|
157
|
+
carouselImages = [],
|
|
158
|
+
sidebarPercent = 0.35,
|
|
159
|
+
sidebarWidth = "35%",
|
|
160
|
+
showEdgeBlur = true,
|
|
161
|
+
blurImageSrc,
|
|
162
|
+
containerHeight = "83vw",
|
|
163
|
+
className = "",
|
|
164
|
+
children,
|
|
165
|
+
}: PDPLayoutProps) {
|
|
166
|
+
// SSR-safe viewport detection
|
|
167
|
+
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
168
|
+
|
|
169
|
+
// Consolidated viewport dimensions (single resize listener on desktop, none on mobile)
|
|
170
|
+
const dimensions = useViewportDimensions(isDesktop);
|
|
171
|
+
|
|
172
|
+
// Responsive image capping for ultra-wide monitors
|
|
173
|
+
const imageCapInfo = useResponsiveImageCap(dimensions);
|
|
174
|
+
|
|
175
|
+
// Wide monitor mode detection
|
|
176
|
+
const isWideMonitor = useWideMonitorMode(imageCapInfo, sidebarPercent);
|
|
177
|
+
|
|
178
|
+
// Render props data
|
|
179
|
+
const heroProps: HeroImageRenderProps = {
|
|
180
|
+
maxWidth: imageCapInfo.maxWidthPx,
|
|
181
|
+
isWideMonitor,
|
|
182
|
+
dimensions,
|
|
183
|
+
imageCapInfo,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const contentProps: ContentRenderProps = {
|
|
187
|
+
isDesktop,
|
|
188
|
+
isWideMonitor,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className={`pdp-layout ${className}`}>
|
|
193
|
+
{/* Mobile Layout */}
|
|
194
|
+
{(isDesktop === false || isDesktop === null) && (
|
|
195
|
+
<div className="md:hidden">
|
|
196
|
+
{/* Mobile Carousel (if provided) */}
|
|
197
|
+
{renderMobileCarousel && (
|
|
198
|
+
<div className="mobile-carousel-container">
|
|
199
|
+
{renderMobileCarousel({ images: carouselImages })}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{/* Mobile Content */}
|
|
204
|
+
<div className="mobile-content p-4">
|
|
205
|
+
{renderContent(contentProps)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{/* Desktop Layout */}
|
|
211
|
+
{(isDesktop === true || isDesktop === null) && (
|
|
212
|
+
<div className="hidden md:block relative">
|
|
213
|
+
{/* Hero Image Section */}
|
|
214
|
+
<div className="desktop-hero relative">
|
|
215
|
+
{renderHeroImage(heroProps)}
|
|
216
|
+
|
|
217
|
+
{/* Edge Blur for Wide Monitors */}
|
|
218
|
+
{showEdgeBlur && blurImageSrc && isWideMonitor && (
|
|
219
|
+
<ImageEdgeBlur
|
|
220
|
+
imageSrc={blurImageSrc}
|
|
221
|
+
imageCapInfo={imageCapInfo}
|
|
222
|
+
sidebarPercent={sidebarPercent}
|
|
223
|
+
sidebarWidth={sidebarWidth}
|
|
224
|
+
containerHeight={containerHeight}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Desktop Sidebar/Content */}
|
|
230
|
+
<div
|
|
231
|
+
className="desktop-sidebar"
|
|
232
|
+
style={{
|
|
233
|
+
width: sidebarWidth,
|
|
234
|
+
maxWidth: "24rem",
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{renderContent(contentProps)}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* Floating Elements (FABs, etc.) */}
|
|
243
|
+
{children}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blur configuration - these values produce smooth edge blur effects
|
|
7
|
+
*/
|
|
8
|
+
const BLUR_CONFIG = {
|
|
9
|
+
blur: 24,
|
|
10
|
+
crossfade: 60,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Progressive blur layers for smooth sharp-to-blurred transition.
|
|
15
|
+
*/
|
|
16
|
+
const BLUR_LAYERS = [
|
|
17
|
+
{ blur: Math.round(BLUR_CONFIG.blur * 0.2), zone: 0.3 },
|
|
18
|
+
{ blur: Math.round(BLUR_CONFIG.blur * 0.5), zone: 0.6 },
|
|
19
|
+
{ blur: BLUR_CONFIG.blur, zone: 1.0 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate smooth fade-in mask gradient from left edge
|
|
24
|
+
*/
|
|
25
|
+
function getFadeInMask(crossfadeWidth: number): string {
|
|
26
|
+
return `linear-gradient(to right,
|
|
27
|
+
transparent 0px,
|
|
28
|
+
rgba(0,0,0,0.02) ${crossfadeWidth * 0.05}px,
|
|
29
|
+
rgba(0,0,0,0.08) ${crossfadeWidth * 0.15}px,
|
|
30
|
+
rgba(0,0,0,0.2) ${crossfadeWidth * 0.3}px,
|
|
31
|
+
rgba(0,0,0,0.4) ${crossfadeWidth * 0.5}px,
|
|
32
|
+
rgba(0,0,0,0.6) ${crossfadeWidth * 0.7}px,
|
|
33
|
+
rgba(0,0,0,0.8) ${crossfadeWidth * 0.85}px,
|
|
34
|
+
rgba(0,0,0,0.95) ${crossfadeWidth}px,
|
|
35
|
+
black ${crossfadeWidth + 10}px
|
|
36
|
+
)`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SimpleImageBlurProps {
|
|
40
|
+
/** Source URL of the image to blur */
|
|
41
|
+
imageSrc: string;
|
|
42
|
+
/** Width of the blur box in pixels */
|
|
43
|
+
width: number;
|
|
44
|
+
/** Height of the blur box (CSS value, e.g., "100%", "500px") */
|
|
45
|
+
height?: string;
|
|
46
|
+
/** Additional CSS class names */
|
|
47
|
+
className?: string;
|
|
48
|
+
/** Object position for the image (default: "center") */
|
|
49
|
+
objectPosition?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* SimpleImageBlur - A simple blur box component with explicit dimensions.
|
|
54
|
+
*
|
|
55
|
+
* Renders a box of specified dimensions filled with progressively blurred
|
|
56
|
+
* versions of the source image. The blur fades in from the left edge for
|
|
57
|
+
* seamless blending with adjacent sharp images.
|
|
58
|
+
*
|
|
59
|
+
* Usage: Position this component where you need blur, and specify exact dimensions.
|
|
60
|
+
* The caller is responsible for layout/positioning.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* // Position as overlay on right side of container
|
|
65
|
+
* <div className="relative">
|
|
66
|
+
* <img src={heroImage} className="w-full" />
|
|
67
|
+
* <div className="absolute top-0 right-0 bottom-0" style={{ width: 300 }}>
|
|
68
|
+
* <SimpleImageBlur
|
|
69
|
+
* imageSrc={heroImage}
|
|
70
|
+
* width={300}
|
|
71
|
+
* height="100%"
|
|
72
|
+
* />
|
|
73
|
+
* </div>
|
|
74
|
+
* </div>
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const SimpleImageBlur = React.memo(function SimpleImageBlur({
|
|
78
|
+
imageSrc,
|
|
79
|
+
width,
|
|
80
|
+
height = "100%",
|
|
81
|
+
className = "",
|
|
82
|
+
objectPosition = "center",
|
|
83
|
+
}: SimpleImageBlurProps) {
|
|
84
|
+
if (!imageSrc || width <= 0) return null;
|
|
85
|
+
|
|
86
|
+
// Generate fade-in mask for smooth transition from left
|
|
87
|
+
const fadeInMask = getFadeInMask(BLUR_CONFIG.crossfade);
|
|
88
|
+
|
|
89
|
+
// Extend width slightly to prevent blur edge artifacts
|
|
90
|
+
const extendedWidth = width + BLUR_CONFIG.blur * 2;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className={`relative overflow-hidden ${className}`}
|
|
95
|
+
style={{
|
|
96
|
+
width,
|
|
97
|
+
height,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{/* Blur layers with progressive blur amounts */}
|
|
101
|
+
{BLUR_LAYERS.map((layer, index) => {
|
|
102
|
+
// Each layer has a mask that reveals it in its zone
|
|
103
|
+
// Last layer has no mask (fills everything)
|
|
104
|
+
const layerMask =
|
|
105
|
+
index === BLUR_LAYERS.length - 1
|
|
106
|
+
? fadeInMask
|
|
107
|
+
: `linear-gradient(to right,
|
|
108
|
+
black 0%,
|
|
109
|
+
black ${layer.zone * 100}%,
|
|
110
|
+
transparent ${layer.zone * 100 + 10}%)`;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<img
|
|
114
|
+
key={`blur-${index}`}
|
|
115
|
+
src={imageSrc}
|
|
116
|
+
alt=""
|
|
117
|
+
crossOrigin="anonymous"
|
|
118
|
+
style={{
|
|
119
|
+
position: "absolute",
|
|
120
|
+
// Extend bounds slightly to prevent edge artifacts
|
|
121
|
+
top: "-5%",
|
|
122
|
+
left: "-5%",
|
|
123
|
+
bottom: "-5%",
|
|
124
|
+
width: `${extendedWidth * 1.1}px`,
|
|
125
|
+
height: "110%",
|
|
126
|
+
objectFit: "cover",
|
|
127
|
+
objectPosition,
|
|
128
|
+
filter: `blur(${layer.blur}px)`,
|
|
129
|
+
clipPath: "inset(0)",
|
|
130
|
+
maskImage: layerMask,
|
|
131
|
+
WebkitMaskImage: layerMask,
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export default SimpleImageBlur;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP (Product Detail Page) Layout Components
|
|
3
|
+
*
|
|
4
|
+
* A collection of components for building product detail pages with:
|
|
5
|
+
* - Mobile/desktop responsive switching
|
|
6
|
+
* - Ultra-wide monitor support
|
|
7
|
+
* - Render props for maximum flexibility
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { PDPLayout } from '@snowcone-app/ui';
|
|
12
|
+
*
|
|
13
|
+
* <PDPLayout
|
|
14
|
+
* renderHeroImage={({ maxWidth }) => <HeroProductImage maxWidth={maxWidth} />}
|
|
15
|
+
* renderContent={() => <ProductOptions />}
|
|
16
|
+
* renderMobileCarousel={({ images }) => <MobileProductCarousel images={images} />}
|
|
17
|
+
* carouselImages={mockupUrls}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export { PDPLayout } from "./PDPLayout";
|
|
23
|
+
export type {
|
|
24
|
+
PDPLayoutProps,
|
|
25
|
+
HeroImageRenderProps,
|
|
26
|
+
CarouselRenderProps,
|
|
27
|
+
ContentRenderProps,
|
|
28
|
+
} from "./PDPLayout";
|
|
29
|
+
|
|
30
|
+
export { ImageEdgeBlur } from "./ImageEdgeBlur";
|
|
31
|
+
export type { ImageEdgeBlurProps } from "./ImageEdgeBlur";
|
|
32
|
+
|
|
33
|
+
export { SimpleImageBlur } from "./SimpleImageBlur";
|
|
34
|
+
export type { SimpleImageBlurProps } from "./SimpleImageBlur";
|
|
35
|
+
|
|
36
|
+
export { EdgeBlurBox } from "./EdgeBlurBox";
|
|
37
|
+
export type { EdgeBlurBoxProps } from "./EdgeBlurBox";
|
|
38
|
+
|
|
39
|
+
export { ImageBlurExtension } from "./ImageBlurExtension";
|
|
40
|
+
export type { ImageBlurExtensionProps } from "./ImageBlurExtension";
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundler-agnostic env reader.
|
|
3
|
+
*
|
|
4
|
+
* `@snowcone-app/ui` ships browser code that must import cleanly under any
|
|
5
|
+
* bundler — Next, but also Vite/CRA/esbuild where `process` is not defined.
|
|
6
|
+
* A bare `process.env.X` at module top-level throws `ReferenceError: process
|
|
7
|
+
* is not defined` during module evaluation, which surfaces as a silent
|
|
8
|
+
* white-screen with zero console errors (an uncaught module-eval exception).
|
|
9
|
+
*
|
|
10
|
+
* `globalThis.process?.env?.[key]` never throws: it reads undefined instead of
|
|
11
|
+
* referencing a missing binding. Always use this instead of bare `process.env`.
|
|
12
|
+
*/
|
|
13
|
+
export function readEnv(key: string): string | undefined {
|
|
14
|
+
return globalThis.process?.env?.[key];
|
|
15
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import brandConfig from "@snowcone-app/brand";
|
|
2
|
+
import type {
|
|
3
|
+
SupportedLanguage,
|
|
4
|
+
SupportedCountry,
|
|
5
|
+
SupportedLocale,
|
|
6
|
+
BrandLocale,
|
|
7
|
+
BrandAssets,
|
|
8
|
+
} from "@snowcone-app/brand";
|
|
9
|
+
|
|
10
|
+
export type { SupportedLanguage, SupportedCountry, SupportedLocale, BrandLocale, BrandAssets };
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Brand access
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns brand data for a language. Throws if the language is missing —
|
|
18
|
+
* brand tokens must never silently fall back. See ADR-0035.
|
|
19
|
+
*/
|
|
20
|
+
export function getBrand(language: SupportedLanguage): BrandLocale {
|
|
21
|
+
const data = (brandConfig.locales as Record<string, BrandLocale>)[language];
|
|
22
|
+
if (!data) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`[brand] Missing language "${language}" in brand.json. ` +
|
|
25
|
+
`Available: ${Object.keys(brandConfig.locales).join(", ")}. ` +
|
|
26
|
+
`Add it to brand.json (requires trademark + marketing sign-off).`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Global brand assets shared across all locales. */
|
|
33
|
+
export const brandAssets: BrandAssets = brandConfig.global_assets;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Currency formatting
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export interface FormatCurrencyOptions {
|
|
40
|
+
/** Input is in cents (default: true). When false, input is whole dollars. */
|
|
41
|
+
inCents?: boolean;
|
|
42
|
+
/** ISO 4217 currency code (default: "USD"). */
|
|
43
|
+
currency?: string;
|
|
44
|
+
/** Minimum fraction digits (default: 0 — hides ".00"). */
|
|
45
|
+
minimumFractionDigits?: number;
|
|
46
|
+
/** Maximum fraction digits (default: 2). */
|
|
47
|
+
maximumFractionDigits?: number;
|
|
48
|
+
/** Fallback string when amount is null/undefined (default: "—"). */
|
|
49
|
+
fallback?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Locale-aware currency formatting. Language controls number formatting
|
|
54
|
+
* (decimal separator, digit grouping); currency is independent.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* formatCurrency(2999) // "$29.99" (cents, en, USD)
|
|
59
|
+
* formatCurrency(2900) // "$29" (hides .00)
|
|
60
|
+
* formatCurrency(29.99, "en", { inCents: false }) // "$29.99"
|
|
61
|
+
* formatCurrency(2999, "es", { currency: "EUR" }) // "29,99 €"
|
|
62
|
+
* formatCurrency(2999, "es", { currency: "USD" }) // "29,99 US$"
|
|
63
|
+
* formatCurrency(2999, "zh-CN") // "US$29.99"
|
|
64
|
+
* formatCurrency(null) // "—"
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function formatCurrency(
|
|
68
|
+
amount: number | null | undefined,
|
|
69
|
+
language: SupportedLanguage | string = "en",
|
|
70
|
+
options: FormatCurrencyOptions = {}
|
|
71
|
+
): string {
|
|
72
|
+
if (amount == null) return options.fallback ?? "—";
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
inCents = true,
|
|
76
|
+
currency = "USD",
|
|
77
|
+
minimumFractionDigits = 0,
|
|
78
|
+
maximumFractionDigits = 2,
|
|
79
|
+
} = options;
|
|
80
|
+
|
|
81
|
+
const value = inCents ? amount / 100 : amount;
|
|
82
|
+
|
|
83
|
+
// Map our language keys to BCP 47 tags that Intl understands
|
|
84
|
+
const bcp47 = languageToIntl(language);
|
|
85
|
+
|
|
86
|
+
return new Intl.NumberFormat(bcp47, {
|
|
87
|
+
style: "currency",
|
|
88
|
+
currency,
|
|
89
|
+
minimumFractionDigits,
|
|
90
|
+
maximumFractionDigits,
|
|
91
|
+
}).format(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Date formatting
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Locale-aware date formatting. Replaces all hardcoded `toLocaleDateString("en-US", ...)`.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* formatDate(new Date()) // "Apr 10, 2026" (en)
|
|
104
|
+
* formatDate("2026-04-10", "zh-CN") // "2026年4月10日"
|
|
105
|
+
* formatDate(new Date(), "en", { dateStyle: "full" }) // "Friday, April 10, 2026"
|
|
106
|
+
* formatDate(null) // "—"
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function formatDate(
|
|
110
|
+
date: Date | string | number | null | undefined,
|
|
111
|
+
language: SupportedLanguage | string = "en",
|
|
112
|
+
options?: Intl.DateTimeFormatOptions,
|
|
113
|
+
fallback = "—"
|
|
114
|
+
): string {
|
|
115
|
+
if (date == null) return fallback;
|
|
116
|
+
|
|
117
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
118
|
+
if (isNaN(d.getTime())) return fallback;
|
|
119
|
+
|
|
120
|
+
const bcp47 = languageToIntl(language);
|
|
121
|
+
const defaults: Intl.DateTimeFormatOptions = options ?? {
|
|
122
|
+
year: "numeric",
|
|
123
|
+
month: "short",
|
|
124
|
+
day: "numeric",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return new Intl.DateTimeFormat(bcp47, defaults).format(d);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Locale-aware date+time formatting.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* formatDateTime(new Date()) // "Apr 10, 2026, 3:45 PM"
|
|
136
|
+
* formatDateTime(new Date(), "zh-CN") // "2026年4月10日 下午3:45"
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function formatDateTime(
|
|
140
|
+
date: Date | string | number | null | undefined,
|
|
141
|
+
language: SupportedLanguage | string = "en",
|
|
142
|
+
options?: Intl.DateTimeFormatOptions,
|
|
143
|
+
fallback = "—"
|
|
144
|
+
): string {
|
|
145
|
+
const defaults: Intl.DateTimeFormatOptions = options ?? {
|
|
146
|
+
year: "numeric",
|
|
147
|
+
month: "short",
|
|
148
|
+
day: "numeric",
|
|
149
|
+
hour: "numeric",
|
|
150
|
+
minute: "2-digit",
|
|
151
|
+
};
|
|
152
|
+
return formatDate(date, language, defaults, fallback);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Internal helpers
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Maps our language keys (e.g. "en", "zh-CN", "es") to BCP 47 tags that Intl APIs need.
|
|
161
|
+
* "en" → "en-US", "es" → "es-ES", "zh-CN" stays "zh-CN".
|
|
162
|
+
*/
|
|
163
|
+
function languageToIntl(language: string): string {
|
|
164
|
+
if (language === "en") return "en-US";
|
|
165
|
+
if (language === "es") return "es-ES";
|
|
166
|
+
return language;
|
|
167
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The minimal navigation surface @snowcone-app/ui needs — framework-agnostic.
|
|
7
|
+
* ui never imports next/navigation directly; components go through this so the
|
|
8
|
+
* library resolves in any framework (Vite, Remix, Astro, plain React, Next).
|
|
9
|
+
*/
|
|
10
|
+
export interface UiRouter {
|
|
11
|
+
push(url: string): void;
|
|
12
|
+
replace(url: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Default: drive the browser's History API. Works everywhere; does a real
|
|
16
|
+
// navigation (not a no-op). SSR-guarded.
|
|
17
|
+
const historyRouter: UiRouter = {
|
|
18
|
+
push: (url) => {
|
|
19
|
+
if (typeof window !== "undefined") window.location.assign(url);
|
|
20
|
+
},
|
|
21
|
+
replace: (url) => {
|
|
22
|
+
if (typeof window !== "undefined") window.location.replace(url);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const UiRouterContext = createContext<UiRouter | null>(null);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Inject your framework's client-side router so ui navigation (e.g. search
|
|
30
|
+
* result clicks) is SPA-smooth instead of a full-page load. Next apps can use
|
|
31
|
+
* `UiNextRouterProvider` from `@snowcone-app/ui/next`; other frameworks pass
|
|
32
|
+
* their own `{ push, replace }`.
|
|
33
|
+
*/
|
|
34
|
+
export const UiRouterProvider = UiRouterContext.Provider;
|
|
35
|
+
|
|
36
|
+
/** Resolve the active router: the injected one, else the History default. */
|
|
37
|
+
export function useUiRouter(): UiRouter {
|
|
38
|
+
return useContext(UiRouterContext) ?? historyRouter;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read the current URL query params. Framework-agnostic, SSR-safe. */
|
|
42
|
+
export function useUiSearchParams(): URLSearchParams {
|
|
43
|
+
return new URLSearchParams(
|
|
44
|
+
typeof window !== "undefined" ? window.location.search : "",
|
|
45
|
+
);
|
|
46
|
+
}
|