@kasuvia/sdk 0.0.8 → 0.0.9

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 (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -0,0 +1,598 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { motion, AnimatePresence, useMotionValue } from "framer-motion";
6
+ import { useGesture } from "@use-gesture/react";
7
+ import {
8
+ X,
9
+ ChevronLeft,
10
+ ChevronRight,
11
+ ZoomIn,
12
+ ZoomOut,
13
+ RotateCw,
14
+ Maximize,
15
+ Minimize,
16
+ RefreshCcw,
17
+ } from "lucide-react";
18
+ import { logger } from "../../utils/logger";
19
+ import SmartImage from "./SmartImage";
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export interface ImageViewerModalProps {
24
+ isOpen: boolean;
25
+ onClose: () => void;
26
+ images?: string[];
27
+ selectedImage: number;
28
+ setSelectedImage: (index: number) => void;
29
+ productName: string;
30
+ }
31
+
32
+ // ─── Component ────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * ImageViewerModal — Self-contained full-screen product image viewer.
36
+ *
37
+ * Designed to work standalone across any application (kasuvia-storefront, kasuvia, etc.)
38
+ * with zero reliance on external CSS purging or Tailwind utility scanning.
39
+ */
40
+ export function ImageViewerModal({
41
+ isOpen,
42
+ onClose,
43
+ images = [],
44
+ selectedImage,
45
+ setSelectedImage,
46
+ productName,
47
+ }: ImageViewerModalProps) {
48
+ // Motion Values for fluid gestures
49
+ const scale = useMotionValue(1);
50
+ const x = useMotionValue(0);
51
+ const y = useMotionValue(0);
52
+ const rotation = useMotionValue(0);
53
+
54
+ const [mounted, setMounted] = useState(false);
55
+ const [isZoomed, setIsZoomed] = useState(false);
56
+ const [currentZoomLevel, setCurrentZoomLevel] = useState(1);
57
+ const [isFullscreen, setIsFullscreen] = useState(false);
58
+ const [mainImgLoaded, setMainImgLoaded] = useState(false);
59
+ const containerRef = useRef<HTMLDivElement>(null);
60
+
61
+ const prevSelectedImageRef = useRef(selectedImage);
62
+ const prevIsOpenRef = useRef(isOpen);
63
+
64
+ // SSR Portal safety check
65
+ useEffect(() => {
66
+ setMounted(true);
67
+ }, []);
68
+
69
+ // Reset zoom & pan when image changes or modal opens
70
+ useEffect(() => {
71
+ if (prevSelectedImageRef.current !== selectedImage || prevIsOpenRef.current !== isOpen) {
72
+ setIsZoomed(false);
73
+ setCurrentZoomLevel(1);
74
+ setMainImgLoaded(false);
75
+ prevSelectedImageRef.current = selectedImage;
76
+ prevIsOpenRef.current = isOpen;
77
+ }
78
+ scale.set(1);
79
+ x.set(0);
80
+ y.set(0);
81
+ rotation.set(0);
82
+ }, [selectedImage, isOpen, scale, x, y, rotation]);
83
+
84
+ const handleNext = useCallback(() => {
85
+ if (!images.length) return;
86
+ setSelectedImage((selectedImage + 1) % images.length);
87
+ }, [selectedImage, images.length, setSelectedImage]);
88
+
89
+ const handlePrev = useCallback(() => {
90
+ if (!images.length) return;
91
+ setSelectedImage((selectedImage - 1 + images.length) % images.length);
92
+ }, [selectedImage, images.length, setSelectedImage]);
93
+
94
+ const handleZoomIn = useCallback(() => {
95
+ const next = Math.min(scale.get() + 0.5, 4);
96
+ scale.set(next);
97
+ setCurrentZoomLevel(next);
98
+ setIsZoomed(next > 1);
99
+ }, [scale]);
100
+
101
+ const handleZoomOut = useCallback(() => {
102
+ const next = Math.max(scale.get() - 0.5, 1);
103
+ scale.set(next);
104
+ setCurrentZoomLevel(next);
105
+ setIsZoomed(next > 1);
106
+ if (next === 1) {
107
+ x.set(0);
108
+ y.set(0);
109
+ }
110
+ }, [scale, x, y]);
111
+
112
+ const handleRotate = useCallback(() => {
113
+ rotation.set(rotation.get() + 90);
114
+ }, [rotation]);
115
+
116
+ const handleReset = useCallback(() => {
117
+ scale.set(1);
118
+ x.set(0);
119
+ y.set(0);
120
+ rotation.set(0);
121
+ setCurrentZoomLevel(1);
122
+ setIsZoomed(false);
123
+ }, [scale, x, y, rotation]);
124
+
125
+ const toggleFullscreen = useCallback(() => {
126
+ if (!containerRef.current) return;
127
+ if (!document.fullscreenElement) {
128
+ containerRef.current.requestFullscreen().catch((err: Error) => {
129
+ logger.error(`Error attempting to enable full-screen mode: ${err.message}`);
130
+ });
131
+ } else {
132
+ document.exitFullscreen().catch(() => {});
133
+ }
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ const handleFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
138
+ document.addEventListener("fullscreenchange", handleFullscreenChange);
139
+ return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
140
+ }, []);
141
+
142
+ // Keyboard navigation & body scroll lock
143
+ useEffect(() => {
144
+ if (!isOpen) return;
145
+ const handleKeyDown = (e: KeyboardEvent) => {
146
+ if (e.key === "Escape") onClose();
147
+ if (e.key === "ArrowLeft") handlePrev();
148
+ if (e.key === "ArrowRight") handleNext();
149
+ if (e.key === "+" || e.key === "=") handleZoomIn();
150
+ if (e.key === "-") handleZoomOut();
151
+ };
152
+ window.addEventListener("keydown", handleKeyDown);
153
+ document.body.style.overflow = "hidden";
154
+ return () => {
155
+ window.removeEventListener("keydown", handleKeyDown);
156
+ document.body.style.overflow = "";
157
+ };
158
+ }, [isOpen, onClose, handleNext, handlePrev, handleZoomIn, handleZoomOut]);
159
+
160
+ // Gestures for pan, pinch, swipe, double-tap
161
+ const bind = useGesture(
162
+ {
163
+ onDrag: ({ down, movement: [mx], offset: [ox, oy] }) => {
164
+ if (scale.get() > 1) {
165
+ if (down) {
166
+ x.set(ox);
167
+ y.set(oy);
168
+ }
169
+ } else {
170
+ if (!down) {
171
+ if (mx < -60) handleNext();
172
+ else if (mx > 60) handlePrev();
173
+ }
174
+ }
175
+ },
176
+ onPinch: ({ offset: [s] }) => {
177
+ scale.set(s);
178
+ setCurrentZoomLevel(s);
179
+ setIsZoomed(s > 1);
180
+ if (s <= 1) {
181
+ x.set(0);
182
+ y.set(0);
183
+ }
184
+ },
185
+ onWheel: ({ delta: [, dy], ctrlKey }) => {
186
+ if (ctrlKey) {
187
+ const next = Math.min(Math.max(1, scale.get() - dy / 100), 4);
188
+ scale.set(next);
189
+ setCurrentZoomLevel(next);
190
+ setIsZoomed(next > 1);
191
+ }
192
+ },
193
+ onDoubleClick: ({ event }) => {
194
+ event.stopPropagation();
195
+ if (scale.get() > 1) {
196
+ handleReset();
197
+ } else {
198
+ scale.set(2.5);
199
+ setCurrentZoomLevel(2.5);
200
+ setIsZoomed(true);
201
+ }
202
+ },
203
+ },
204
+ {
205
+ drag: { from: () => [x.get(), y.get()], threshold: 10 },
206
+ pinch: { scaleBounds: { min: 1, max: 4 }, modifierKey: null },
207
+ }
208
+ );
209
+
210
+ if (!images.length) return null;
211
+
212
+ const btnStyle: React.CSSProperties = {
213
+ background: "rgba(255, 255, 255, 0.1)",
214
+ border: "none",
215
+ borderRadius: "9999px",
216
+ padding: "8px",
217
+ color: "rgba(255, 255, 255, 0.9)",
218
+ cursor: "pointer",
219
+ display: "inline-flex",
220
+ alignItems: "center",
221
+ justifyContent: "center",
222
+ transition: "background-color 0.15s ease, color 0.15s ease",
223
+ };
224
+
225
+ const navBtnStyle: React.CSSProperties = {
226
+ position: "absolute",
227
+ top: "50%",
228
+ transform: "translateY(-50%)",
229
+ background: "rgba(0, 0, 0, 0.6)",
230
+ border: "1px solid rgba(255, 255, 255, 0.2)",
231
+ borderRadius: "9999px",
232
+ padding: "14px",
233
+ color: "#ffffff",
234
+ cursor: "pointer",
235
+ backdropFilter: "blur(8px)",
236
+ zIndex: 40,
237
+ display: "flex",
238
+ alignItems: "center",
239
+ justifyContent: "center",
240
+ boxShadow: "0 10px 25px rgba(0, 0, 0, 0.5)",
241
+ transition: "transform 0.15s ease, background-color 0.15s ease",
242
+ };
243
+
244
+ const modalContent = (
245
+ <AnimatePresence>
246
+ {isOpen && (
247
+ <motion.div
248
+ ref={containerRef}
249
+ initial={{ opacity: 0 }}
250
+ animate={{ opacity: 1 }}
251
+ exit={{ opacity: 0 }}
252
+ transition={{ duration: 0.15, ease: "easeOut" }}
253
+ style={{
254
+ position: "fixed",
255
+ top: 0,
256
+ left: 0,
257
+ right: 0,
258
+ bottom: 0,
259
+ width: "100vw",
260
+ height: "100vh",
261
+ zIndex: 99999,
262
+ backgroundColor: "#000000",
263
+ display: "flex",
264
+ flexDirection: "column",
265
+ userSelect: "none",
266
+ WebkitUserSelect: "none",
267
+ overflow: "hidden",
268
+ boxSizing: "border-box",
269
+ }}
270
+ className="fixed inset-0 z-[99999] bg-black flex flex-col select-none overflow-hidden"
271
+ onClick={onClose}
272
+ role="dialog"
273
+ aria-modal="true"
274
+ aria-label={`${productName} image viewer`}
275
+ >
276
+ {/* ── Top Header Toolbar ────────────────────────────────────────────── */}
277
+ <div
278
+ style={{
279
+ display: "flex",
280
+ justifyContent: "space-between",
281
+ alignItems: "center",
282
+ padding: "12px 20px",
283
+ zIndex: 50,
284
+ color: "#ffffff",
285
+ background: "linear-gradient(to bottom, rgba(0,0,0,0.85), rgba(0,0,0,0))",
286
+ flexShrink: 0,
287
+ width: "100%",
288
+ boxSizing: "border-box",
289
+ }}
290
+ onClick={(e) => e.stopPropagation()}
291
+ >
292
+ <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
293
+ <span
294
+ style={{
295
+ fontSize: "13px",
296
+ fontWeight: 500,
297
+ letterSpacing: "0.08em",
298
+ color: "rgba(255, 255, 255, 0.9)",
299
+ backgroundColor: "rgba(255, 255, 255, 0.12)",
300
+ padding: "4px 12px",
301
+ borderRadius: "9999px",
302
+ border: "1px solid rgba(255, 255, 255, 0.15)",
303
+ }}
304
+ >
305
+ {selectedImage + 1} / {images.length}
306
+ </span>
307
+ {productName && (
308
+ <span
309
+ style={{
310
+ fontSize: "13px",
311
+ fontWeight: 500,
312
+ color: "rgba(255, 255, 255, 0.7)",
313
+ whiteSpace: "nowrap",
314
+ overflow: "hidden",
315
+ textOverflow: "ellipsis",
316
+ maxWidth: "300px",
317
+ }}
318
+ className="hidden sm:inline"
319
+ >
320
+ {productName}
321
+ </span>
322
+ )}
323
+ </div>
324
+
325
+ <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
326
+ {currentZoomLevel > 1 && (
327
+ <span
328
+ style={{
329
+ fontSize: "11px",
330
+ fontFamily: "monospace",
331
+ color: "rgba(255, 255, 255, 0.7)",
332
+ marginRight: "4px",
333
+ }}
334
+ className="hidden sm:inline"
335
+ >
336
+ {Math.round(currentZoomLevel * 100)}%
337
+ </span>
338
+ )}
339
+
340
+ <button
341
+ type="button"
342
+ onClick={handleZoomOut}
343
+ style={btnStyle}
344
+ title="Zoom Out (-)"
345
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)")}
346
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)")}
347
+ >
348
+ <ZoomOut className="w-5 h-5" style={{ width: "20px", height: "20px" }} />
349
+ </button>
350
+ <button
351
+ type="button"
352
+ onClick={handleZoomIn}
353
+ style={btnStyle}
354
+ title="Zoom In (+)"
355
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)")}
356
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)")}
357
+ >
358
+ <ZoomIn className="w-5 h-5" style={{ width: "20px", height: "20px" }} />
359
+ </button>
360
+ <button
361
+ type="button"
362
+ onClick={handleRotate}
363
+ style={btnStyle}
364
+ title="Rotate 90°"
365
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)")}
366
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)")}
367
+ >
368
+ <RotateCw className="w-5 h-5" style={{ width: "20px", height: "20px" }} />
369
+ </button>
370
+ <button
371
+ type="button"
372
+ onClick={handleReset}
373
+ style={btnStyle}
374
+ title="Reset View"
375
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)")}
376
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)")}
377
+ >
378
+ <RefreshCcw className="w-5 h-5" style={{ width: "20px", height: "20px" }} />
379
+ </button>
380
+
381
+ <div style={{ width: "1px", height: "20px", backgroundColor: "rgba(255,255,255,0.2)", margin: "0 6px" }} />
382
+
383
+ <button
384
+ type="button"
385
+ onClick={toggleFullscreen}
386
+ style={btnStyle}
387
+ title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
388
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.25)")}
389
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(255,255,255,0.1)")}
390
+ >
391
+ {isFullscreen ? (
392
+ <Minimize className="w-5 h-5" style={{ width: "20px", height: "20px" }} />
393
+ ) : (
394
+ <Maximize className="w-5 h-5" style={{ width: "20px", height: "20px" }} />
395
+ )}
396
+ </button>
397
+
398
+ <div style={{ width: "1px", height: "20px", backgroundColor: "rgba(255,255,255,0.2)", margin: "0 6px" }} />
399
+
400
+ <button
401
+ type="button"
402
+ onClick={onClose}
403
+ style={{
404
+ ...btnStyle,
405
+ backgroundColor: "rgba(239, 68, 68, 0.25)",
406
+ color: "#f87171",
407
+ }}
408
+ title="Close (Esc)"
409
+ aria-label="Close viewer"
410
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(239, 68, 68, 0.45)")}
411
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(239, 68, 68, 0.25)")}
412
+ >
413
+ <X className="w-6 h-6" style={{ width: "24px", height: "24px" }} />
414
+ </button>
415
+ </div>
416
+ </div>
417
+
418
+ {/* ── Main Display Viewport ────────────────────────────────────────── */}
419
+ <div
420
+ style={{
421
+ flex: 1,
422
+ position: "relative",
423
+ display: "flex",
424
+ alignItems: "center",
425
+ justifyContent: "center",
426
+ width: "100%",
427
+ height: "100%",
428
+ minHeight: 0,
429
+ overflow: "hidden",
430
+ touchAction: "none",
431
+ }}
432
+ {...bind()}
433
+ onClick={(e) => e.stopPropagation()}
434
+ >
435
+ <motion.div
436
+ style={{
437
+ scale,
438
+ rotate: rotation,
439
+ x,
440
+ y,
441
+ cursor: isZoomed ? "grab" : "default",
442
+ width: "100%",
443
+ height: "100%",
444
+ padding: "16px",
445
+ display: "flex",
446
+ alignItems: "center",
447
+ justifyContent: "center",
448
+ boxSizing: "border-box",
449
+ }}
450
+ className="will-change-transform"
451
+ >
452
+ {/* Loading indicator */}
453
+ {!mainImgLoaded && (
454
+ <div
455
+ style={{
456
+ position: "absolute",
457
+ inset: 0,
458
+ display: "flex",
459
+ alignItems: "center",
460
+ justifyContent: "center",
461
+ zIndex: 10,
462
+ }}
463
+ >
464
+ <div
465
+ style={{
466
+ width: "160px",
467
+ height: "160px",
468
+ borderRadius: "16px",
469
+ backgroundColor: "rgba(255,255,255,0.05)",
470
+ border: "1px solid rgba(255,255,255,0.1)",
471
+ }}
472
+ />
473
+ </div>
474
+ )}
475
+
476
+ <motion.img
477
+ key={images[selectedImage]}
478
+ src={images[selectedImage] ?? undefined}
479
+ alt={`${productName} - Image ${selectedImage + 1}`}
480
+ style={{
481
+ maxWidth: "100%",
482
+ maxHeight: "100%",
483
+ objectFit: "contain",
484
+ userSelect: "none",
485
+ WebkitUserSelect: "none",
486
+ display: "block",
487
+ margin: "auto",
488
+ opacity: mainImgLoaded ? 1 : 0,
489
+ transition: "opacity 0.25s ease-out",
490
+ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.8)",
491
+ }}
492
+ draggable={false}
493
+ onLoad={() => setMainImgLoaded(true)}
494
+ onDragStart={(e) => e.preventDefault()}
495
+ onMouseDown={(e) => e.preventDefault()}
496
+ />
497
+ </motion.div>
498
+
499
+ {/* ── Navigation Chevrons ────────────────────────────────────────── */}
500
+ {images.length > 1 && (
501
+ <>
502
+ <button
503
+ type="button"
504
+ aria-label="Previous image"
505
+ style={{ ...navBtnStyle, left: "24px" }}
506
+ onClick={(e) => {
507
+ e.stopPropagation();
508
+ handlePrev();
509
+ }}
510
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.85)")}
511
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.6)")}
512
+ >
513
+ <ChevronLeft className="w-8 h-8" style={{ width: "32px", height: "32px" }} />
514
+ </button>
515
+
516
+ <button
517
+ type="button"
518
+ aria-label="Next image"
519
+ style={{ ...navBtnStyle, right: "24px" }}
520
+ onClick={(e) => {
521
+ e.stopPropagation();
522
+ handleNext();
523
+ }}
524
+ onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.85)")}
525
+ onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "rgba(0,0,0,0.6)")}
526
+ >
527
+ <ChevronRight className="w-8 h-8" style={{ width: "32px", height: "32px" }} />
528
+ </button>
529
+ </>
530
+ )}
531
+ </div>
532
+
533
+ {/* ── Bottom Thumbnail Toolbar ────────────────────────────────────── */}
534
+ {images.length > 1 && (
535
+ <div
536
+ style={{
537
+ padding: "16px 20px",
538
+ zIndex: 50,
539
+ background: "linear-gradient(to top, rgba(0, 0, 0, 0.95), rgba(0, 0, 0, 0))",
540
+ display: "flex",
541
+ justifyContent: "center",
542
+ flexShrink: 0,
543
+ width: "100%",
544
+ boxSizing: "border-box",
545
+ }}
546
+ onClick={(e) => e.stopPropagation()}
547
+ >
548
+ <div
549
+ style={{
550
+ display: "flex",
551
+ gap: "12px",
552
+ overflowX: "auto",
553
+ maxWidth: "100%",
554
+ paddingBottom: "4px",
555
+ }}
556
+ >
557
+ {images.map((img, idx) => (
558
+ <button
559
+ key={idx}
560
+ type="button"
561
+ onClick={() => setSelectedImage(idx)}
562
+ aria-label={`View image ${idx + 1}`}
563
+ style={{
564
+ position: "relative",
565
+ width: "60px",
566
+ height: "60px",
567
+ borderRadius: "10px",
568
+ overflow: "hidden",
569
+ flexShrink: 0,
570
+ border: selectedImage === idx ? "2px solid #ffffff" : "2px solid rgba(255, 255, 255, 0.2)",
571
+ opacity: selectedImage === idx ? 1 : 0.5,
572
+ cursor: "pointer",
573
+ transform: selectedImage === idx ? "scale(1.05)" : "scale(1)",
574
+ transition: "all 0.2s ease",
575
+ background: "none",
576
+ padding: 0,
577
+ }}
578
+ >
579
+ <SmartImage
580
+ src={img}
581
+ alt={`Thumbnail ${idx + 1}`}
582
+ className="w-full h-full object-cover"
583
+ />
584
+ </button>
585
+ ))}
586
+ </div>
587
+ </div>
588
+ )}
589
+ </motion.div>
590
+ )}
591
+ </AnimatePresence>
592
+ );
593
+
594
+ if (!mounted) return null;
595
+ return createPortal(modalContent, document.body);
596
+ }
597
+
598
+ export default ImageViewerModal;
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { LucideProps } from "lucide-react";
5
+ import * as LucideIcons from "lucide-react";
6
+
7
+ const toPascalCase = (str: string): string => {
8
+ return str
9
+ .replace(/[-_]+/g, " ")
10
+ .replace(/\s+(.)/g, (_, c) => c.toUpperCase())
11
+ .replace(/^(.)/, (_, c) => c.toUpperCase());
12
+ };
13
+
14
+ export interface LucideReactIconProps extends LucideProps {
15
+ icon: string | number | boolean | null | undefined | object;
16
+ }
17
+
18
+ export const LucideReactIcon: React.FC<LucideReactIconProps> = ({ icon, ...props }) => {
19
+ if (!icon || typeof icon === "boolean" || typeof icon === "number" || Array.isArray(icon)) {
20
+ return null;
21
+ }
22
+
23
+ if (typeof icon === "function" || (typeof icon === "object" && icon !== null && ("$$typeof" in icon || "render" in icon))) {
24
+ const Component = icon as React.ComponentType<LucideProps>;
25
+ return <Component size={props.size ?? 24} color={props.color ?? "currentColor"} strokeWidth={props.strokeWidth ?? 2} {...props} />;
26
+ }
27
+
28
+ const iconObj = typeof icon === "object" && icon !== null ? (icon as Record<string, unknown>) : null;
29
+ const rawName = typeof icon === "string" ? icon : (typeof iconObj?.name === "string" ? iconObj.name : typeof iconObj?.icon === "string" ? iconObj.icon : null);
30
+ if (!rawName) return null;
31
+
32
+ const pascalName = toPascalCase(rawName);
33
+ const IconComponent = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[pascalName] || (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[rawName];
34
+
35
+ if (!IconComponent) {
36
+ return (
37
+ <span
38
+ style={{
39
+ fontSize: props.size ?? 24,
40
+ color: props.color ?? "currentColor",
41
+ }}
42
+ className="flex items-center justify-center text-xs font-medium text-center break-all leading-tight"
43
+ >
44
+ {rawName}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ return <IconComponent size={props.size ?? 24} color={props.color ?? "currentColor"} strokeWidth={props.strokeWidth ?? 2} {...props} />;
50
+ };
51
+
52
+ export default LucideReactIcon;