@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,190 @@
1
+ import React from "react";
2
+
3
+ export interface DragHintAnimationProps {
4
+ /** The position to center the animation */
5
+ position: { x: number; y: number };
6
+ /** Whether the drag direction is horizontal or vertical */
7
+ direction: "horizontal" | "vertical";
8
+ }
9
+
10
+ /**
11
+ * DragHintAnimation - Animated hand gesture hint for draggable elements
12
+ *
13
+ * Displays an animated hand icon that demonstrates drag interaction.
14
+ * Automatically fades in and out with a smooth animation cycle.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <DragHintAnimation
19
+ * position={{ x: 0, y: 10 }}
20
+ * direction="horizontal"
21
+ * />
22
+ * ```
23
+ */
24
+ export function DragHintAnimation({ position, direction }: DragHintAnimationProps) {
25
+ return (
26
+ <div
27
+ className="drag-hint-container"
28
+ style={{
29
+ position: "absolute",
30
+ top: "50%",
31
+ left: "50%",
32
+ transform:
33
+ direction === "horizontal"
34
+ ? `translate(calc(-50% + ${position.x}px), -50%)`
35
+ : `translate(-50%, calc(-50% + ${position.y}px))`,
36
+ pointerEvents: "none",
37
+ zIndex: 3,
38
+ animation: "fadeInOut 2.2s ease-in-out forwards",
39
+ }}
40
+ >
41
+ <style>{`
42
+ @keyframes fadeInOut {
43
+ 0% {
44
+ opacity: 0;
45
+ }
46
+ 15% {
47
+ opacity: 1;
48
+ }
49
+ 65% {
50
+ opacity: 1;
51
+ }
52
+ 100% {
53
+ opacity: 0;
54
+ }
55
+ }
56
+
57
+ @keyframes handAnim {
58
+ 0% {
59
+ left: 0;
60
+ opacity: 0;
61
+ }
62
+ 10% {
63
+ left: 0;
64
+ opacity: 1;
65
+ }
66
+ 35% {
67
+ left: 20px;
68
+ opacity: 1;
69
+ }
70
+ 60% {
71
+ left: -20px;
72
+ opacity: 1;
73
+ }
74
+ 80% {
75
+ left: 0;
76
+ opacity: 1;
77
+ }
78
+ 100% {
79
+ left: 0;
80
+ opacity: 0;
81
+ }
82
+ }
83
+
84
+ @keyframes handAnimVertical {
85
+ 0% {
86
+ top: 0;
87
+ opacity: 0;
88
+ }
89
+ 10% {
90
+ top: 0;
91
+ opacity: 1;
92
+ }
93
+ 35% {
94
+ top: 20px;
95
+ opacity: 1;
96
+ }
97
+ 60% {
98
+ top: -20px;
99
+ opacity: 1;
100
+ }
101
+ 80% {
102
+ top: 0;
103
+ opacity: 1;
104
+ }
105
+ 100% {
106
+ top: 0;
107
+ opacity: 0;
108
+ }
109
+ }
110
+
111
+ @keyframes circleAnim {
112
+ 0% {
113
+ transform: scale(0);
114
+ opacity: 0;
115
+ }
116
+ 15% {
117
+ transform: scale(1);
118
+ opacity: 0.5;
119
+ }
120
+ 30% {
121
+ transform: scale(1.5);
122
+ opacity: 0;
123
+ }
124
+ }
125
+
126
+ .hand-icon {
127
+ position: relative;
128
+ }
129
+
130
+ .hand {
131
+ background: var(--color-background, #fff);
132
+ width: 5px;
133
+ height: 16px;
134
+ border-radius: 20px;
135
+ position: relative;
136
+ left: -5px;
137
+ margin-bottom: 17px;
138
+ transform: rotate(0deg);
139
+ animation: ${
140
+ direction === "horizontal" ? "handAnim" : "handAnimVertical"
141
+ } 2s ease-in-out infinite;
142
+ }
143
+
144
+ .hand.vertical {
145
+ transform: rotate(90deg);
146
+ animation: handAnimVertical 2s ease-in-out infinite;
147
+ }
148
+
149
+ .hand:after {
150
+ content: "";
151
+ background: var(--color-background, #fff);
152
+ width: 17px;
153
+ height: 18px;
154
+ border-radius: 4px 8px 38px 15px;
155
+ transform: rotate(6deg) skewY(10deg);
156
+ position: absolute;
157
+ top: 13px;
158
+ left: -1px;
159
+ }
160
+
161
+ .hand:before {
162
+ content: "";
163
+ background: var(--color-background, #fff);
164
+ width: 6px;
165
+ height: 17px;
166
+ border-radius: 2px 40px 20px 20px;
167
+ position: absolute;
168
+ top: 12px;
169
+ left: -6px;
170
+ transform: rotate(-38deg);
171
+ }
172
+
173
+ .hand .circle {
174
+ background-color: var(--color-background, #fff);
175
+ width: 20px;
176
+ height: 20px;
177
+ border-radius: 50%;
178
+ position: absolute;
179
+ top: -7px;
180
+ left: -7px;
181
+ opacity: 0;
182
+ animation: circleAnim 0.5s ease-out forwards;
183
+ }
184
+ `}</style>
185
+ <div className="hand-icon">
186
+ <div className={`hand ${direction === "vertical" ? "vertical" : ""}`} />
187
+ </div>
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import React, { useEffect } from "react";
4
+
5
+ export interface EdgeSwipeGuardsProps {
6
+ /** Width of the edge zones in pixels (default: 20) */
7
+ width?: number;
8
+ /** Custom className for the guards */
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * EdgeSwipeGuards - Prevents iOS Safari's back/forward navigation gestures
14
+ * by intercepting horizontal touch-moves that start near the screen edges.
15
+ *
16
+ * Uses document-level touch listeners that check the touch origin position
17
+ * instead of overlay divs. This avoids blocking taps on UI elements (buttons,
18
+ * links) that happen to sit near the edges.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * <EdgeSwipeGuards />
23
+ * <EdgeSwipeGuards width={30} />
24
+ * ```
25
+ */
26
+ export function EdgeSwipeGuards({
27
+ width = 20,
28
+ }: EdgeSwipeGuardsProps) {
29
+ useEffect(() => {
30
+ let touchStartX = -1;
31
+ let isEdgeTouch = false;
32
+
33
+ const handleTouchStart = (e: TouchEvent) => {
34
+ const x = e.touches[0].clientX;
35
+ const screenWidth = window.innerWidth;
36
+ isEdgeTouch = x <= width || x >= screenWidth - width;
37
+ touchStartX = x;
38
+ };
39
+
40
+ const handleTouchMove = (e: TouchEvent) => {
41
+ if (!isEdgeTouch) return;
42
+ const deltaX = Math.abs(e.touches[0].clientX - touchStartX);
43
+ // Only block horizontal swipes, not vertical scrolling
44
+ if (deltaX > 5) {
45
+ e.preventDefault();
46
+ }
47
+ };
48
+
49
+ document.addEventListener("touchstart", handleTouchStart, { passive: true });
50
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
51
+
52
+ return () => {
53
+ document.removeEventListener("touchstart", handleTouchStart);
54
+ document.removeEventListener("touchmove", handleTouchMove);
55
+ };
56
+ }, [width]);
57
+
58
+ // No DOM elements needed — this is purely event-based
59
+ return null;
60
+ }
@@ -0,0 +1,176 @@
1
+ import React from "react";
2
+
3
+ export interface FloatingActionItem {
4
+ id: string;
5
+ label: string;
6
+ children?: React.ReactNode;
7
+ onClick?: (e?: any) => void;
8
+ selected?: boolean;
9
+ disabled?: boolean;
10
+ variant?: "circular" | "pill";
11
+ }
12
+
13
+ export interface FloatingActionGroupProps {
14
+ items: FloatingActionItem[];
15
+ className?: string;
16
+ scrollable?: boolean;
17
+ leftAligned?: boolean;
18
+ justify?: "start" | "end" | "center";
19
+ ariaLabel?: string;
20
+ }
21
+
22
+ /**
23
+ * FloatingActionGroup - A primitive component for displaying a horizontal group of action buttons
24
+ *
25
+ * Creates a row of circular or pill-shaped buttons with selection states and disabled states.
26
+ * Commonly used for filters, tabs, or quick actions. Supports horizontal scrolling for
27
+ * overflow scenarios.
28
+ *
29
+ * Features:
30
+ * - Two button variants: circular (icon-only) or pill (text-based)
31
+ * - Visual selection states with background effects
32
+ * - Optional horizontal scrolling with hidden scrollbars
33
+ * - Smart alignment modes (left, center, right)
34
+ * - Accessible with ARIA roles and labels
35
+ * - Dark mode support
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // Simple button group
40
+ * <FloatingActionGroup
41
+ * items={[
42
+ * { id: '1', label: 'All', selected: true, onClick: () => setFilter('all') },
43
+ * { id: '2', label: 'Active', onClick: () => setFilter('active') },
44
+ * { id: '3', label: 'Done', onClick: () => setFilter('done') }
45
+ * ]}
46
+ * ariaLabel="Filter tasks"
47
+ * />
48
+ * ```
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * // Scrollable with custom icons
53
+ * <FloatingActionGroup
54
+ * scrollable={true}
55
+ * leftAligned={true}
56
+ * items={[
57
+ * { id: 'edit', label: 'Edit', variant: 'circular', children: <PencilIcon /> },
58
+ * { id: 'delete', label: 'Delete', variant: 'circular', children: <TrashIcon />, disabled: true }
59
+ * ]}
60
+ * />
61
+ * ```
62
+ *
63
+ * @param items - Array of action items to display
64
+ * @param className - Additional CSS classes
65
+ * @param scrollable - Enable horizontal scrolling for overflow (default: false)
66
+ * @param leftAligned - Add left padding in scrollable mode (default: false)
67
+ * @param justify - Horizontal alignment ("start", "end", or "center", default: "start")
68
+ * @param ariaLabel - Accessible label for the button group
69
+ */
70
+ export function FloatingActionGroup({
71
+ items,
72
+ className = "",
73
+ scrollable = false,
74
+ leftAligned = false,
75
+ justify = "start",
76
+ ariaLabel,
77
+ }: FloatingActionGroupProps) {
78
+ const baseContainerClass = "flex gap-2";
79
+
80
+ const containerClass = scrollable
81
+ ? `${baseContainerClass} overflow-x-auto scrollbar-hide ${
82
+ leftAligned ? "pl-6" : ""
83
+ }`
84
+ : baseContainerClass;
85
+
86
+ const justifyClass = {
87
+ start: "justify-start",
88
+ end: "justify-end",
89
+ center: "justify-center",
90
+ }[justify];
91
+
92
+ return (
93
+ <div
94
+ className={`${containerClass} ${justifyClass} ${className}`}
95
+ role="group"
96
+ aria-label={ariaLabel}
97
+ >
98
+ {items.map((item, index) => (
99
+ <FloatingActionButton
100
+ key={item.id}
101
+ {...item}
102
+ isFirstButton={index === 0}
103
+ leftAligned={leftAligned}
104
+ scrollable={scrollable}
105
+ />
106
+ ))}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function FloatingActionButton({
112
+ label,
113
+ children,
114
+ onClick,
115
+ selected = false,
116
+ disabled = false,
117
+ variant = "circular",
118
+ isFirstButton = false,
119
+ leftAligned = false,
120
+ scrollable = false,
121
+ }: FloatingActionItem & {
122
+ isFirstButton?: boolean;
123
+ leftAligned?: boolean;
124
+ scrollable?: boolean;
125
+ }) {
126
+ const baseButtonClass =
127
+ "flex items-center justify-center transition-all text-sm font-medium";
128
+
129
+ // Use rounded-fixed-full for circular to ensure it stays circular regardless of theme
130
+ // (e.g., brutalist themes set rounded-full to 0)
131
+ const shapeClass = variant === "circular" ? "rounded-fixed-full" : "rounded-button";
132
+
133
+ // When children are provided, don't apply padding/border - let the children define the size
134
+ const variantClass = children
135
+ ? ""
136
+ : variant === "circular"
137
+ ? "w-10 h-10"
138
+ : "px-4 py-2 border-2";
139
+
140
+ // When children are provided, don't apply background/border/opacity styles - let children control appearance
141
+ const stateClass = children
142
+ ? ""
143
+ : selected
144
+ ? "bg-background text-foreground border-primary"
145
+ : "bg-background text-foreground border-border/30 hover:border-border/60 opacity-60 hover:opacity-75";
146
+
147
+ const disabledClass = disabled
148
+ ? "opacity-50 cursor-not-allowed"
149
+ : "cursor-pointer";
150
+
151
+ // Smart alignment: add margin when first button is selected in left-aligned scrollable mode
152
+ const marginClass =
153
+ leftAligned && scrollable && isFirstButton && selected ? "ml-4" : "";
154
+
155
+ return (
156
+ <button
157
+ type="button"
158
+ onClick={disabled ? undefined : (e) => onClick?.(e)}
159
+ className={`${baseButtonClass} ${shapeClass} ${variantClass} ${stateClass} ${disabledClass} ${marginClass}`}
160
+ aria-label={label}
161
+ aria-pressed={selected}
162
+ disabled={disabled}
163
+ >
164
+ {children ? (
165
+ children
166
+ ) : (
167
+ <span className="relative">
168
+ {label}
169
+ {disabled && (
170
+ <span className="absolute inset-0 top-1/2 -translate-y-1/2 h-px bg-border opacity-50 pointer-events-none" aria-hidden="true" />
171
+ )}
172
+ </span>
173
+ )}
174
+ </button>
175
+ );
176
+ }
@@ -0,0 +1,171 @@
1
+ import React from "react";
2
+ import {
3
+ describeProductPrice,
4
+ formatPrice,
5
+ type ProductPriceOptions,
6
+ } from "@snowcone-app/sdk";
7
+ import { useProduct } from "../patterns/Product";
8
+
9
+ export interface ProductPriceProps extends ProductPriceOptions {
10
+ contextPrice?: number;
11
+ showCents?: boolean;
12
+ }
13
+
14
+ /**
15
+ * ProductPrice - Formatted price display with currency and locale support
16
+ *
17
+ * A primitive component for displaying product prices with automatic currency
18
+ * formatting, smart cents display, and seamless Product context integration.
19
+ * Uses semantic HTML for machine-readable price data.
20
+ *
21
+ * Features:
22
+ * - Automatic currency formatting based on locale
23
+ * - Smart cents display (hides .00, shows actual cents)
24
+ * - Semantic HTML using `<data>` element with value attribute
25
+ * - Accessible with ARIA labels for screen readers
26
+ * - Works standalone or within Product context
27
+ * - Automatically uses currentPrice from context (reflects variant selection)
28
+ * - Supports international currencies and locales
29
+ * - Machine-readable price in value attribute for SEO/scrapers
30
+ *
31
+ * **Price Format:**
32
+ * - Prices are stored in cents to avoid floating point issues
33
+ * - Example: 2999 cents = $29.99
34
+ * - Automatically converts to display format
35
+ *
36
+ * **Context Integration:**
37
+ * - When used in `Product` context, automatically shows current price
38
+ * - Updates when user selects different variants
39
+ * - Falls back to prop value when used standalone
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * // Standalone usage with explicit price
44
+ * <ProductPrice price={2999} showCents={true} />
45
+ * // Output: $29.99
46
+ * ```
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * // Within Product context (automatically uses current price)
51
+ * <Product productId="shirt-123">
52
+ * <ProductOptions />
53
+ * <ProductPrice showCents={false} />
54
+ * </Product>
55
+ * // Output: $29 (or $35 if user selects a pricier variant)
56
+ * ```
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * // International currency and locale
61
+ * <ProductPrice
62
+ * price={2999}
63
+ * currency="EUR"
64
+ * locale="de-DE"
65
+ * showCurrency={true}
66
+ * />
67
+ * // Output: €29,99
68
+ * ```
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * // Custom styling for sale prices
73
+ * <div className="flex items-center gap-2">
74
+ * <ProductPrice
75
+ * price={1999}
76
+ * className="text-2xl font-bold text-green-600"
77
+ * />
78
+ * <ProductPrice
79
+ * price={2999}
80
+ * showCents={false}
81
+ * className="text-sm line-through text-gray-400"
82
+ * />
83
+ * </div>
84
+ * ```
85
+ *
86
+ * @param price - Price in cents (e.g., 2999 = $29.99). Optional if used within Product context
87
+ * @param currency - ISO 4217 currency code (default: "USD")
88
+ * @param locale - BCP 47 locale string for formatting (default: "en-US")
89
+ * @param showCurrency - Display full currency symbol (default: false, shows $ only)
90
+ * @param showCents - Display cents portion (default: true, hides if .00)
91
+ * @param className - Additional CSS classes for styling
92
+ * @param contextPrice - Internal: Override context price (used by Product component)
93
+ */
94
+ export function ProductPrice({
95
+ price,
96
+ currency = "USD",
97
+ locale = "en-US",
98
+ showCurrency = false,
99
+ className,
100
+ contextPrice,
101
+ showCents = true,
102
+ }: ProductPriceProps) {
103
+ // Try to get price from context
104
+ let priceValue = contextPrice || price;
105
+ if (!priceValue) {
106
+ try {
107
+ const context = useProduct();
108
+ // Use the currentPrice from context which is already calculated
109
+ priceValue = context.currentPrice || context.product?.price;
110
+ } catch {
111
+ // Not in a Product context, that's OK
112
+ }
113
+ }
114
+
115
+ const descriptor = describeProductPrice(
116
+ { price, currency, locale, showCurrency, className },
117
+ priceValue
118
+ );
119
+
120
+ if (!descriptor) {
121
+ return null;
122
+ }
123
+
124
+ // Convert cents to dollars for display
125
+ const dollars = Math.floor(descriptor.price / 100);
126
+ const cents = descriptor.price % 100;
127
+
128
+ // Format with currency symbol if needed
129
+ const currencySymbol = showCurrency
130
+ ? new Intl.NumberFormat(descriptor.locale, {
131
+ style: "currency",
132
+ currency: descriptor.currency,
133
+ })
134
+ .format(0)
135
+ .replace(/[\d.,\s]/g, "")
136
+ : "$";
137
+
138
+ // Apply font-display only when no custom className is provided.
139
+ // When consumers pass className, they own the typography.
140
+ const priceClassName = descriptor.className || "font-display";
141
+
142
+ // If showing cents and there are cents to show
143
+ if (showCents && cents > 0) {
144
+ return (
145
+ <data
146
+ value={descriptor.price / 100}
147
+ className={priceClassName}
148
+ aria-label={`Price: ${currencySymbol}${dollars}.${cents.toString().padStart(2, "0")}`}
149
+ >
150
+ <span aria-hidden="true">
151
+ {currencySymbol}
152
+ {dollars}.{cents.toString().padStart(2, "0")}
153
+ </span>
154
+ </data>
155
+ );
156
+ }
157
+
158
+ // Just show dollars
159
+ return (
160
+ <data
161
+ value={descriptor.price / 100}
162
+ className={priceClassName}
163
+ aria-label={`Price: ${currencySymbol}${dollars}`}
164
+ >
165
+ <span aria-hidden="true">
166
+ {currencySymbol}
167
+ {dollars}
168
+ </span>
169
+ </data>
170
+ );
171
+ }