@datlv-trustshop/shopify-inapp-components 0.2.3 → 0.2.5
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/dist/components/GrowApps.js +106 -47
- package/dist/components/WhatsNew.js +41 -14
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/useFloatingCardActions.d.ts +17 -0
- package/dist/hooks/useFloatingCardActions.js +54 -0
- package/dist/hooks/useFloatingCardEngine.d.ts +21 -0
- package/dist/hooks/useFloatingCardEngine.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/provider/DashboardProvider.js +5 -0
- package/dist/utils/cls-monitor.d.ts +50 -0
- package/dist/utils/cls-monitor.js +184 -0
- package/dist/utils/injectStyles.d.ts +14 -0
- package/dist/utils/injectStyles.js +105 -0
- package/package.json +1 -1
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useRef, useCallback, useEffect } from "react";
|
|
3
|
-
import { Card, Text, BlockStack, Box, InlineStack, Button, Popover, ActionList, } from "@shopify/polaris";
|
|
2
|
+
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
|
3
|
+
import { Card, Text, BlockStack, Box, InlineStack, Button, Popover, ActionList, SkeletonBodyText, SkeletonThumbnail, } from "@shopify/polaris";
|
|
4
4
|
import { ExternalIcon, MenuHorizontalIcon } from "@shopify/polaris-icons";
|
|
5
5
|
import { useDashboard } from "../hooks/useDashboard";
|
|
6
6
|
import { useTranslation } from "../hooks/useTranslations";
|
|
7
7
|
const DEFAULT_DISMISS_KEY = "ts-dashboard-growapps-dismissed";
|
|
8
8
|
const DEFAULT_DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
// Fixed dimensions to prevent layout shifts
|
|
10
|
+
const CARD_WIDTH = 282;
|
|
11
|
+
const CARD_MIN_HEIGHT = 140; // Reduced from 180 to avoid excessive white space
|
|
12
|
+
const ICON_SIZE = 60;
|
|
13
|
+
const GAP_SIZE = 20;
|
|
9
14
|
export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = DEFAULT_DISMISS_KEY, dismissDuration = DEFAULT_DISMISS_DURATION, showDismiss = true, showNavigation = true, maxItems = 6, }) => {
|
|
10
15
|
const slidesContainerRef = useRef(null);
|
|
11
|
-
const
|
|
12
|
-
const { data } = useDashboard();
|
|
16
|
+
const [currentPosition, setCurrentPosition] = useState(0);
|
|
17
|
+
const { data, loading } = useDashboard();
|
|
13
18
|
const growAppsTranslations = useTranslation("growApps");
|
|
14
19
|
const allGrowApps = data?.grow_apps || [];
|
|
15
20
|
const displayLimit = data?.grow_apps_display_limit || maxItems;
|
|
@@ -18,9 +23,13 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
18
23
|
const [maxIndex, setMaxIndex] = useState(0);
|
|
19
24
|
const [popoverActive, setPopoverActive] = useState(false);
|
|
20
25
|
const [isVisible, setIsVisible] = useState(true);
|
|
26
|
+
const [isContentReady, setIsContentReady] = useState(false);
|
|
27
|
+
const [touchDelta, setTouchDelta] = useState(0);
|
|
28
|
+
const [isSwiping, setIsSwiping] = useState(false);
|
|
21
29
|
const touchStartX = useRef(null);
|
|
22
30
|
const touchEndX = useRef(null);
|
|
23
31
|
const swiping = useRef(false);
|
|
32
|
+
const lastDelta = useRef(0);
|
|
24
33
|
const togglePopoverActive = () => setPopoverActive((active) => !active);
|
|
25
34
|
const handleDismiss = () => {
|
|
26
35
|
setIsVisible(false);
|
|
@@ -50,6 +59,15 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
}, [dismissKey, dismissDuration]);
|
|
62
|
+
// Mark content as ready when data loads
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!loading && growApps.length >= 0) {
|
|
65
|
+
// Small delay to ensure DOM is ready
|
|
66
|
+
requestAnimationFrame(() => {
|
|
67
|
+
setIsContentReady(true);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}, [loading, growApps.length]);
|
|
53
71
|
const handleGetAppClick = (app) => {
|
|
54
72
|
const url = app.button_install_link || app.app_url || app.get_app || "#";
|
|
55
73
|
if (url && url !== "#") {
|
|
@@ -66,8 +84,8 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
66
84
|
return;
|
|
67
85
|
const containerWidth = slidesContainerRef.current.offsetWidth;
|
|
68
86
|
const totalWidth = slideWrap.scrollWidth;
|
|
69
|
-
const slideWidth =
|
|
70
|
-
const gap =
|
|
87
|
+
const slideWidth = CARD_WIDTH;
|
|
88
|
+
const gap = GAP_SIZE;
|
|
71
89
|
const slideStep = slideWidth + gap;
|
|
72
90
|
const maxSlides = Math.max(0, Math.ceil((totalWidth - containerWidth) / slideStep));
|
|
73
91
|
const newIndex = Math.max(0, Math.min(currentIndex + direction, maxSlides));
|
|
@@ -76,21 +94,30 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
96
|
const newPosition = -newIndex * slideStep;
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
// Remove direct DOM manipulation - let React handle it
|
|
98
|
+
setCurrentPosition(newPosition);
|
|
81
99
|
setCurrentIndex(newIndex);
|
|
82
100
|
setMaxIndex(maxSlides);
|
|
83
101
|
}, [currentIndex]);
|
|
84
|
-
const handleTouchStart = (e) => {
|
|
102
|
+
const handleTouchStart = useCallback((e) => {
|
|
85
103
|
touchStartX.current = e.touches[0].clientX;
|
|
104
|
+
touchEndX.current = e.touches[0].clientX; // Initialize end position
|
|
86
105
|
swiping.current = true;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
setIsSwiping(true);
|
|
107
|
+
setTouchDelta(0);
|
|
108
|
+
}, []);
|
|
109
|
+
const handleTouchMove = useCallback((e) => {
|
|
110
|
+
if (!swiping.current || touchStartX.current === null)
|
|
90
111
|
return;
|
|
91
112
|
touchEndX.current = e.touches[0].clientX;
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
const delta = touchEndX.current - touchStartX.current;
|
|
114
|
+
// Only update if delta changed significantly (reduce jitter)
|
|
115
|
+
if (Math.abs(delta - lastDelta.current) > 1) {
|
|
116
|
+
lastDelta.current = delta;
|
|
117
|
+
setTouchDelta(delta);
|
|
118
|
+
}
|
|
119
|
+
}, []);
|
|
120
|
+
const handleTouchEnd = useCallback(() => {
|
|
94
121
|
if (!swiping.current)
|
|
95
122
|
return;
|
|
96
123
|
const swipeThreshold = 50;
|
|
@@ -103,10 +130,17 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
103
130
|
handleSlide(-1);
|
|
104
131
|
}
|
|
105
132
|
}
|
|
133
|
+
else {
|
|
134
|
+
// Snap back if swipe wasn't far enough
|
|
135
|
+
setTouchDelta(0);
|
|
136
|
+
}
|
|
106
137
|
swiping.current = false;
|
|
107
138
|
touchStartX.current = null;
|
|
108
139
|
touchEndX.current = null;
|
|
109
|
-
|
|
140
|
+
lastDelta.current = 0;
|
|
141
|
+
setIsSwiping(false);
|
|
142
|
+
setTouchDelta(0);
|
|
143
|
+
}, [handleSlide]);
|
|
110
144
|
useEffect(() => {
|
|
111
145
|
const calculateMaxIndex = () => {
|
|
112
146
|
if (!slidesContainerRef.current)
|
|
@@ -116,23 +150,48 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
116
150
|
return;
|
|
117
151
|
const containerWidth = slidesContainerRef.current.offsetWidth;
|
|
118
152
|
const totalWidth = slideWrap.scrollWidth;
|
|
119
|
-
const
|
|
120
|
-
const gap = 20;
|
|
121
|
-
const slideStep = slideWidth + gap;
|
|
153
|
+
const slideStep = CARD_WIDTH + GAP_SIZE;
|
|
122
154
|
const maxSlides = Math.max(0, Math.ceil((totalWidth - containerWidth) / slideStep));
|
|
123
155
|
setMaxIndex(maxSlides);
|
|
124
156
|
};
|
|
125
|
-
setTimeout
|
|
126
|
-
const
|
|
157
|
+
// Use requestAnimationFrame instead of setTimeout for better performance
|
|
158
|
+
const rafId = requestAnimationFrame(calculateMaxIndex);
|
|
159
|
+
const handleResize = () => {
|
|
160
|
+
requestAnimationFrame(calculateMaxIndex);
|
|
161
|
+
};
|
|
127
162
|
window.addEventListener("resize", handleResize);
|
|
128
163
|
return () => {
|
|
164
|
+
cancelAnimationFrame(rafId);
|
|
129
165
|
window.removeEventListener("resize", handleResize);
|
|
130
166
|
};
|
|
131
167
|
}, [growApps.length]);
|
|
132
|
-
|
|
168
|
+
// Memoize navigation button styles to prevent recalculation
|
|
169
|
+
// Must be defined before any returns to follow React hooks rules
|
|
170
|
+
const navButtonStyle = useMemo(() => ({
|
|
171
|
+
display: "flex",
|
|
172
|
+
alignItems: "center",
|
|
173
|
+
justifyContent: "center",
|
|
174
|
+
width: "24px",
|
|
175
|
+
height: "24px",
|
|
176
|
+
padding: 0,
|
|
177
|
+
border: "none",
|
|
178
|
+
borderRadius: "4px",
|
|
179
|
+
backgroundColor: "transparent",
|
|
180
|
+
transition: "opacity 0.2s ease", // Only animate opacity
|
|
181
|
+
}), []);
|
|
182
|
+
// Don't render at all if dismissed to prevent CLS
|
|
133
183
|
if (!isVisible) {
|
|
134
184
|
return null;
|
|
135
185
|
}
|
|
186
|
+
// Show skeleton while loading to reserve space
|
|
187
|
+
if (loading || !isContentReady) {
|
|
188
|
+
return (_jsx("div", { className: className, style: { width: "100%" }, children: _jsx(Card, { children: _jsx(Box, { children: _jsxs(BlockStack, { gap: "200", children: [_jsxs(BlockStack, { gap: "100", children: [_jsx(SkeletonBodyText, { lines: 1 }), _jsx(SkeletonBodyText, { lines: 1 })] }), _jsx("div", { style: { display: "flex", gap: `${GAP_SIZE}px`, overflow: "hidden" }, children: [1, 2, 3].map((idx) => (_jsx("div", { style: {
|
|
189
|
+
flexShrink: 0,
|
|
190
|
+
width: `${CARD_WIDTH}px`,
|
|
191
|
+
minHeight: `${CARD_MIN_HEIGHT}px`,
|
|
192
|
+
}, children: _jsx(Card, { children: _jsx(Box, { children: _jsxs(BlockStack, { gap: "300", children: [_jsx(SkeletonThumbnail, { size: "large" }), _jsxs(BlockStack, { gap: "200", children: [_jsx(SkeletonBodyText, { lines: 1 }), _jsx(SkeletonBodyText, { lines: 2 })] })] }) }) }) }, idx))) })] }) }) }) }));
|
|
193
|
+
}
|
|
194
|
+
// Handle empty state with reserved space
|
|
136
195
|
if (growApps.length === 0) {
|
|
137
196
|
return (_jsx("div", { className: className, style: { width: "100%" }, children: _jsx(Card, { children: _jsx(Box, { children: _jsx(Text, { variant: "bodyMd", tone: "subdued", as: "p", children: growAppsTranslations?.noData || "No apps available" }) }) }) }));
|
|
138
197
|
}
|
|
@@ -155,39 +214,29 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
155
214
|
borderRadius: "8px",
|
|
156
215
|
backgroundColor: "#e3e3e3",
|
|
157
216
|
}, children: [_jsx("button", { onClick: () => handleSlide(-1), disabled: currentIndex === 0, style: {
|
|
158
|
-
|
|
159
|
-
alignItems: "center",
|
|
160
|
-
justifyContent: "center",
|
|
161
|
-
width: "24px",
|
|
162
|
-
height: "24px",
|
|
163
|
-
padding: 0,
|
|
164
|
-
border: "none",
|
|
165
|
-
borderRadius: "4px",
|
|
166
|
-
backgroundColor: "transparent",
|
|
217
|
+
...navButtonStyle,
|
|
167
218
|
cursor: currentIndex === 0 ? "not-allowed" : "pointer",
|
|
168
|
-
transition: "background-color 0.2s ease",
|
|
169
219
|
opacity: currentIndex === 0 ? 0.5 : 1,
|
|
170
220
|
}, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M10.5 13L5.5 8l5-5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) }) }), _jsx("button", { onClick: () => handleSlide(1), disabled: currentIndex >= maxIndex, style: {
|
|
171
|
-
|
|
172
|
-
alignItems: "center",
|
|
173
|
-
justifyContent: "center",
|
|
174
|
-
width: "24px",
|
|
175
|
-
height: "24px",
|
|
176
|
-
padding: 0,
|
|
177
|
-
border: "none",
|
|
178
|
-
borderRadius: "4px",
|
|
179
|
-
backgroundColor: "transparent",
|
|
221
|
+
...navButtonStyle,
|
|
180
222
|
cursor: currentIndex >= maxIndex ? "not-allowed" : "pointer",
|
|
181
|
-
transition: "background-color 0.2s ease",
|
|
182
223
|
opacity: currentIndex >= maxIndex ? 0.5 : 1,
|
|
183
224
|
}, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5.5 13l5-5-5-5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) }) })] }))] })] }), _jsx("div", { className: "slides-container", style: {
|
|
184
225
|
overflow: "hidden",
|
|
185
226
|
width: "100%",
|
|
227
|
+
touchAction: "pan-y pinch-zoom", // Allow vertical scroll and pinch, handle horizontal
|
|
228
|
+
WebkitOverflowScrolling: "touch", // iOS momentum scrolling
|
|
229
|
+
userSelect: "none", // Prevent text selection during swipe
|
|
230
|
+
WebkitUserSelect: "none"
|
|
231
|
+
// Removed minHeight to avoid excessive white space
|
|
186
232
|
}, ref: slidesContainerRef, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, children: _jsx("div", { className: "slides-wrapper", style: {
|
|
187
233
|
display: "flex",
|
|
188
|
-
gap:
|
|
189
|
-
transition: "transform 0.3s
|
|
190
|
-
transform:
|
|
234
|
+
gap: `${GAP_SIZE}px`,
|
|
235
|
+
transition: isSwiping ? "none" : "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // Better easing
|
|
236
|
+
transform: `translate3d(${currentPosition + touchDelta}px, 0, 0)`, // Use translate3d for GPU
|
|
237
|
+
willChange: isSwiping ? "transform" : "auto", // Only during interaction
|
|
238
|
+
backfaceVisibility: "hidden", // Force GPU acceleration
|
|
239
|
+
perspective: 1000, // Create 3D rendering context
|
|
191
240
|
}, children: growApps.map((app, index) => {
|
|
192
241
|
const iconUrl = app.icon_url || app.imageUrl || "";
|
|
193
242
|
const buttonText = app.button_install_text ||
|
|
@@ -197,10 +246,17 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
197
246
|
"Get app";
|
|
198
247
|
return (_jsx("div", { "data-index": index, style: {
|
|
199
248
|
flexShrink: 0,
|
|
200
|
-
width:
|
|
201
|
-
transition
|
|
202
|
-
}, children: _jsx(Card, { children: _jsx(Box, { minWidth:
|
|
249
|
+
width: `${CARD_WIDTH}px`,
|
|
250
|
+
// Remove per-card transition that causes CLS
|
|
251
|
+
}, children: _jsx(Card, { children: _jsx(Box, { minWidth: `${CARD_WIDTH - 32}px`, children: _jsxs(BlockStack, { gap: "300", children: [_jsx(InlineStack, { gap: "400", blockAlign: "start", children: iconUrl ? (_jsx("img", { src: iconUrl, alt: app.title, width: ICON_SIZE, height: ICON_SIZE, style: {
|
|
252
|
+
borderRadius: "8px",
|
|
253
|
+
flexShrink: 0,
|
|
254
|
+
objectFit: "cover",
|
|
255
|
+
}, loading: "eager", decoding: "async" })) : (_jsx("div", { style: {
|
|
256
|
+
width: `${ICON_SIZE}px`,
|
|
257
|
+
height: `${ICON_SIZE}px`,
|
|
203
258
|
borderRadius: "8px",
|
|
259
|
+
backgroundColor: "#f0f0f0",
|
|
204
260
|
flexShrink: 0,
|
|
205
261
|
} })) }), _jsxs(BlockStack, { gap: "200", children: [_jsx("div", { style: {
|
|
206
262
|
display: "-webkit-box",
|
|
@@ -211,6 +267,7 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
211
267
|
WebkitBoxOrient: "vertical",
|
|
212
268
|
overflow: "hidden",
|
|
213
269
|
textOverflow: "ellipsis",
|
|
270
|
+
minHeight: "20px", // Reserve space
|
|
214
271
|
}, children: app.title }), _jsx("div", { style: {
|
|
215
272
|
display: "-webkit-box",
|
|
216
273
|
WebkitLineClamp: 2,
|
|
@@ -220,8 +277,10 @@ export const GrowApps = ({ className = "", onAppClick, onDismiss, dismissKey = D
|
|
|
220
277
|
fontSize: "13px",
|
|
221
278
|
color: "#616161",
|
|
222
279
|
lineHeight: 1.4,
|
|
280
|
+
minHeight: "36px", // Reserve space for 2 lines
|
|
223
281
|
}, children: app.content }), _jsx("div", { style: {
|
|
224
282
|
alignSelf: "flex-start",
|
|
283
|
+
minHeight: "36px", // Reserve space for button
|
|
225
284
|
}, children: _jsx(Button, { icon: ExternalIcon, variant: "secondary", size: "medium", onClick: () => handleGetAppClick(app), children: buttonText }) })] })] }) }) }) }, app.id || index));
|
|
226
285
|
}) }) })] }) }) }) }));
|
|
227
286
|
};
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import { Button, Tabs, Text, BlockStack, InlineStack, Box, Card, } from "@shopify/polaris";
|
|
2
|
+
import { useState, useRef, useEffect, useMemo } from "react";
|
|
3
|
+
import { Button, Tabs, Text, BlockStack, InlineStack, Box, Card, SkeletonBodyText, SkeletonThumbnail, } from "@shopify/polaris";
|
|
4
4
|
import { ExternalIcon } from "@shopify/polaris-icons";
|
|
5
5
|
import { ArticleList } from "./ArticleList";
|
|
6
6
|
import ImageLoading from "./ImageLoading";
|
|
7
7
|
import { useArticles, useWhatsNew } from "../hooks";
|
|
8
8
|
import { useTranslation } from "../hooks/useTranslations";
|
|
9
9
|
import { sdkStyles, mergeStyles } from "./styles";
|
|
10
|
+
// Fixed dimensions to prevent CLS - reduced to avoid excessive white space
|
|
11
|
+
const CONTAINER_MIN_HEIGHT = 300; // Reduced from 450
|
|
10
12
|
export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick, onViewAllUpdates, onViewAllArticles, maxUpdates = 5, showNavigation = true, showViewAllButton = true, navigate, }) => {
|
|
11
13
|
const [productUpdateIndex, setProductUpdateIndex] = useState(0);
|
|
12
14
|
const [articleIndex, setArticleIndex] = useState(0);
|
|
13
15
|
const [selectedTab, setSelectedTab] = useState(0);
|
|
14
16
|
const [isMobile, setIsMobile] = useState(false);
|
|
17
|
+
const [isContentReady, setIsContentReady] = useState(false);
|
|
18
|
+
const [touchDelta, setTouchDelta] = useState(0);
|
|
19
|
+
const [isSwiping, setIsSwiping] = useState(false);
|
|
15
20
|
const touchStartX = useRef(null);
|
|
16
21
|
const touchEndX = useRef(null);
|
|
17
22
|
const swiping = useRef(false);
|
|
@@ -19,7 +24,7 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
19
24
|
const articles = useArticles();
|
|
20
25
|
const whatsNew = useWhatsNew();
|
|
21
26
|
const t = useTranslation('whatsNew');
|
|
22
|
-
const limitedUpdates = whatsNew.updates.slice(0, maxUpdates);
|
|
27
|
+
const limitedUpdates = useMemo(() => whatsNew.updates.slice(0, maxUpdates), [whatsNew.updates, maxUpdates]);
|
|
23
28
|
useEffect(() => {
|
|
24
29
|
const handleResize = () => {
|
|
25
30
|
setIsMobile(window.innerWidth <= 768);
|
|
@@ -28,10 +33,14 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
28
33
|
window.addEventListener("resize", handleResize);
|
|
29
34
|
return () => window.removeEventListener("resize", handleResize);
|
|
30
35
|
}, []);
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
// Mark content as ready when data loads
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (limitedUpdates.length > 0 || articlesList.length > 0) {
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
setIsContentReady(true);
|
|
41
|
+
});
|
|
33
42
|
}
|
|
34
|
-
}, [limitedUpdates]);
|
|
43
|
+
}, [limitedUpdates.length, articles.articles.length]);
|
|
35
44
|
const articlesList = articles.articles;
|
|
36
45
|
const nextSlide = () => {
|
|
37
46
|
if (selectedTab === 0) {
|
|
@@ -72,11 +81,15 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
72
81
|
const handleTouchStart = (e) => {
|
|
73
82
|
touchStartX.current = e.touches[0].clientX;
|
|
74
83
|
swiping.current = true;
|
|
84
|
+
setIsSwiping(true);
|
|
85
|
+
setTouchDelta(0);
|
|
75
86
|
};
|
|
76
87
|
const handleTouchMove = (e) => {
|
|
77
|
-
if (!swiping.current)
|
|
88
|
+
if (!swiping.current || touchStartX.current === null)
|
|
78
89
|
return;
|
|
79
90
|
touchEndX.current = e.touches[0].clientX;
|
|
91
|
+
const delta = touchEndX.current - touchStartX.current;
|
|
92
|
+
setTouchDelta(delta);
|
|
80
93
|
};
|
|
81
94
|
const handleTouchEnd = () => {
|
|
82
95
|
if (!swiping.current)
|
|
@@ -94,6 +107,8 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
94
107
|
swiping.current = false;
|
|
95
108
|
touchStartX.current = null;
|
|
96
109
|
touchEndX.current = null;
|
|
110
|
+
setIsSwiping(false);
|
|
111
|
+
setTouchDelta(0);
|
|
97
112
|
};
|
|
98
113
|
const handleProductUpdateClick = (update) => {
|
|
99
114
|
const tryUrl = update.button_try_url || update.link;
|
|
@@ -171,13 +186,18 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
171
186
|
return articleIndex >= maxIndex;
|
|
172
187
|
}
|
|
173
188
|
})();
|
|
189
|
+
// Show skeleton while loading
|
|
190
|
+
if (!isContentReady) {
|
|
191
|
+
return (_jsx("div", { style: { ...sdkStyles.dashboardWhatsNew, minHeight: `${CONTAINER_MIN_HEIGHT}px` }, className: className, children: _jsx(Card, { children: _jsxs(BlockStack, { gap: "400", children: [_jsxs(BlockStack, { gap: "100", children: [_jsx(SkeletonBodyText, { lines: 1 }), _jsx(InlineStack, { gap: "300", blockAlign: "center", align: "space-between", children: _jsx("div", { style: { width: "200px" }, children: _jsx(SkeletonBodyText, { lines: 1 }) }) })] }), _jsx(Box, { children: _jsxs(BlockStack, { gap: "400", children: [_jsx(SkeletonThumbnail, { size: "large" }), _jsx(SkeletonBodyText, { lines: 3 })] }) })] }) }) }));
|
|
192
|
+
}
|
|
193
|
+
// Empty state
|
|
174
194
|
if (limitedUpdates.length === 0 && articlesList.length === 0) {
|
|
175
|
-
return (_jsx("div", { style:
|
|
195
|
+
return (_jsx("div", { style: { ...sdkStyles.dashboardWhatsNew, minHeight: `${CONTAINER_MIN_HEIGHT}px` }, className: className, children: _jsx(Card, { children: _jsx(BlockStack, { gap: "400", children: _jsx(Text, { variant: "bodyMd", tone: "subdued", as: "p", children: t.noData || "No updates available" }) }) }) }));
|
|
176
196
|
}
|
|
177
197
|
const mobileStyles = isMobile
|
|
178
198
|
? {
|
|
179
199
|
slidesContainer: {
|
|
180
|
-
|
|
200
|
+
overflow: "auto",
|
|
181
201
|
scrollSnapType: "x mandatory",
|
|
182
202
|
WebkitOverflowScrolling: "touch",
|
|
183
203
|
scrollbarWidth: "none",
|
|
@@ -190,6 +210,7 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
190
210
|
scrollSnapAlign: "center",
|
|
191
211
|
flex: "0 0 85%",
|
|
192
212
|
maxWidth: "85%",
|
|
213
|
+
minHeight: "280px",
|
|
193
214
|
},
|
|
194
215
|
slideImage: {
|
|
195
216
|
display: "none",
|
|
@@ -231,16 +252,20 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
231
252
|
? handleViewAllUpdates
|
|
232
253
|
: handleViewAllArticles, children: selectedTab === 0
|
|
233
254
|
? (t.buttonViewAll?.productUpdate || "View all updates")
|
|
234
|
-
: (t.buttonViewAll?.article || "View all articles") })), showNavigation &&
|
|
255
|
+
: (t.buttonViewAll?.article || "View all articles") })), showNavigation && (_jsxs("div", { style: sdkStyles.slideNavigation, children: [_jsx("button", { onClick: prevSlide, disabled: isPrevDisabled, style: mergeStyles(sdkStyles.slideButton, isPrevDisabled ? sdkStyles.slideButtonDisabled : {}, {
|
|
235
256
|
cursor: isPrevDisabled ? "not-allowed" : "pointer",
|
|
236
257
|
opacity: isPrevDisabled ? 0.5 : 1,
|
|
237
258
|
}), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M10.5 13L5.5 8l5-5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) }) }), _jsx("button", { onClick: nextSlide, disabled: isNextDisabled, style: mergeStyles(sdkStyles.slideButton, isNextDisabled ? sdkStyles.slideButtonDisabled : {}, {
|
|
238
259
|
cursor: isNextDisabled ? "not-allowed" : "pointer",
|
|
239
260
|
opacity: isNextDisabled ? 0.5 : 1,
|
|
240
|
-
}), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5.5 13l5-5-5-5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) }) })] }))] })] })] }), _jsx(Box, { children: selectedTab === 0 ? (_jsx("div", { ref: slideContainerRef, style: mergeStyles(sdkStyles.slidesContainer, mobileStyles.slidesContainer, {
|
|
261
|
+
}), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5.5 13l5-5-5-5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" }) }) })] }))] })] })] }), _jsx(Box, { children: selectedTab === 0 ? (_jsx("div", { ref: slideContainerRef, style: mergeStyles(sdkStyles.slidesContainer, mobileStyles.slidesContainer, {
|
|
262
|
+
scrollbarWidth: "none",
|
|
263
|
+
}), className: isMobile ? "whats-new-mobile-scrollbar" : "", onTouchStart: isMobile ? handleTouchStart : undefined, onTouchMove: isMobile ? handleTouchMove : undefined, onTouchEnd: isMobile ? handleTouchEnd : undefined, children: _jsx("div", { style: mergeStyles(sdkStyles.slidesWrapper, mobileStyles.slidesWrapper, {
|
|
241
264
|
transform: isMobile
|
|
242
|
-
?
|
|
243
|
-
: `translateX(-${productUpdateIndex * 90}%)`,
|
|
265
|
+
? `translateX(calc(-${productUpdateIndex * 85}% + ${touchDelta}px))` // 85% for mobile with swipe delta
|
|
266
|
+
: `translateX(calc(-${productUpdateIndex * 90}% + ${touchDelta}px))`,
|
|
267
|
+
transition: isSwiping ? "none" : "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
268
|
+
willChange: "transform", // GPU acceleration
|
|
244
269
|
}), children: limitedUpdates.map((update) => (_jsxs("div", { style: mergeStyles(sdkStyles.slideBox, isMobile ? mobileStyles.slideBox : {}), children: [_jsxs("div", { style: sdkStyles.slideContent, children: [_jsx(Text, { variant: "headingMd", as: "h3", children: update.title }), (update.releaseDate || update.date_time) && (_jsx(Text, { variant: "bodySm", tone: "subdued", as: "p", children: update.releaseDate || update.date_time })), (update.description || update.content?.text) && (_jsx(Text, { as: "p", children: update.description || update.content?.text })), update.features && update.features.length > 0 && (_jsx("ul", { style: { paddingLeft: "20px", margin: 0 }, children: update.features.map((item, idx) => (_jsx("li", { style: {
|
|
245
270
|
fontSize: "13px",
|
|
246
271
|
lineHeight: 1.4,
|
|
@@ -254,5 +279,7 @@ export const WhatsNew = ({ className = "", onProductUpdateClick, onArticleClick,
|
|
|
254
279
|
}
|
|
255
280
|
}, children: update.button_learn_more || "Learn more" }))] })] }), _jsx("div", { style: mergeStyles(sdkStyles.slideImage, isMobile ? mobileStyles.slideImage : {}), children: _jsx(ImageLoading, { src: update.imageUrl ||
|
|
256
281
|
update.image_url ||
|
|
257
|
-
"https://asset.trustshop.io/dashboard/news-review-summary.png", alt: `what-new-${update.id}`, width: 712, height: 400, borderRadius: 12 }) })] }, update.id))) }) })) : (_jsx("div", { ref: slideContainerRef, style: mergeStyles(sdkStyles.slidesContainer, {
|
|
282
|
+
"https://asset.trustshop.io/dashboard/news-review-summary.png", alt: `what-new-${update.id}`, width: 712, height: 400, borderRadius: 12 }) })] }, update.id))) }) })) : (_jsx("div", { ref: slideContainerRef, style: mergeStyles(sdkStyles.slidesContainer, {
|
|
283
|
+
scrollbarWidth: "none",
|
|
284
|
+
}, mobileStyles.slidesContainer), className: isMobile ? "whats-new-mobile-scrollbar" : "", onTouchStart: isMobile ? handleTouchStart : undefined, onTouchMove: isMobile ? handleTouchMove : undefined, onTouchEnd: isMobile ? handleTouchEnd : undefined, children: _jsx(ArticleList, { layout: "slide", onArticleClick: handleArticleClick, showThumbnail: true, showAuthor: true, showDate: true, currentIndex: articleIndex, onSlideChange: setArticleIndex, showNavigation: false }) })) })] }) })] }));
|
|
258
285
|
};
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FloatingCardData } from '../components/FloatingCard';
|
|
2
|
+
export interface FloatingCardActionsConfig {
|
|
3
|
+
navigate: (path: string) => void;
|
|
4
|
+
onOpenPricing?: () => void;
|
|
5
|
+
onDismiss?: (cardId: string | number) => void;
|
|
6
|
+
specialRoutes?: {
|
|
7
|
+
[key: string]: () => void;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export interface FloatingCardActionsReturn {
|
|
11
|
+
handlePrimaryAction: (card: FloatingCardData) => void;
|
|
12
|
+
handleSecondaryAction: (card: FloatingCardData) => void;
|
|
13
|
+
handleDismiss: (cardId: string | number) => void;
|
|
14
|
+
resolveAction: (action: FloatingCardData['primary_action'] | FloatingCardData['secondary_action']) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare const useFloatingCardActions: (config: FloatingCardActionsConfig) => FloatingCardActionsReturn;
|
|
17
|
+
export default useFloatingCardActions;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
export const useFloatingCardActions = (config) => {
|
|
3
|
+
const { navigate, onOpenPricing, onDismiss, specialRoutes = {} } = config;
|
|
4
|
+
const resolveAction = useCallback((action) => {
|
|
5
|
+
if (!action?.url)
|
|
6
|
+
return;
|
|
7
|
+
const url = action.url;
|
|
8
|
+
// Check for special routes first
|
|
9
|
+
if (url === '/pricing' || url.startsWith('/pricing?')) {
|
|
10
|
+
if (onOpenPricing) {
|
|
11
|
+
onOpenPricing();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Check custom special routes
|
|
16
|
+
for (const [route, handler] of Object.entries(specialRoutes)) {
|
|
17
|
+
if (url === route || url.startsWith(`${route}?`)) {
|
|
18
|
+
handler();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Handle internal routes (starts with /)
|
|
23
|
+
if (url.startsWith('/')) {
|
|
24
|
+
navigate(url);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Handle external links
|
|
28
|
+
if ((url.startsWith('http://') || url.startsWith('https://')) &&
|
|
29
|
+
action.external !== false) {
|
|
30
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Fallback to navigation for relative paths or unknown formats
|
|
34
|
+
navigate(url);
|
|
35
|
+
}, [navigate, onOpenPricing, specialRoutes]);
|
|
36
|
+
const handlePrimaryAction = useCallback((card) => {
|
|
37
|
+
resolveAction(card.primary_action);
|
|
38
|
+
}, [resolveAction]);
|
|
39
|
+
const handleSecondaryAction = useCallback((card) => {
|
|
40
|
+
resolveAction(card.secondary_action);
|
|
41
|
+
}, [resolveAction]);
|
|
42
|
+
const handleDismiss = useCallback((cardId) => {
|
|
43
|
+
if (onDismiss) {
|
|
44
|
+
onDismiss(cardId);
|
|
45
|
+
}
|
|
46
|
+
}, [onDismiss]);
|
|
47
|
+
return {
|
|
48
|
+
handlePrimaryAction,
|
|
49
|
+
handleSecondaryAction,
|
|
50
|
+
handleDismiss,
|
|
51
|
+
resolveAction,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export default useFloatingCardActions;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FloatingCardActionsConfig } from './useFloatingCardActions';
|
|
2
|
+
import { FloatingCardData } from '../components/FloatingCard';
|
|
3
|
+
export interface FloatingCardEngineConfig extends Omit<FloatingCardActionsConfig, 'onDismiss'> {
|
|
4
|
+
shopId?: string;
|
|
5
|
+
currentRoute?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FloatingCardEngineReturn {
|
|
8
|
+
visibleCards: FloatingCardData[];
|
|
9
|
+
loading: boolean;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
handlePrimaryAction: (card: FloatingCardData) => void;
|
|
12
|
+
handleSecondaryAction: (card: FloatingCardData) => void;
|
|
13
|
+
handleDismiss: (cardId: string | number) => void;
|
|
14
|
+
resolveAction: (action: FloatingCardData['primary_action'] | FloatingCardData['secondary_action']) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Comprehensive hook that combines floating card data fetching with action handling.
|
|
18
|
+
* This provides a complete solution for floating card functionality.
|
|
19
|
+
*/
|
|
20
|
+
export declare const useFloatingCardEngine: (config: FloatingCardEngineConfig) => FloatingCardEngineReturn;
|
|
21
|
+
export default useFloatingCardEngine;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useVisibleFloatingCards } from './useFloatingCards';
|
|
2
|
+
import { useFloatingCardActions } from './useFloatingCardActions';
|
|
3
|
+
/**
|
|
4
|
+
* Comprehensive hook that combines floating card data fetching with action handling.
|
|
5
|
+
* This provides a complete solution for floating card functionality.
|
|
6
|
+
*/
|
|
7
|
+
export const useFloatingCardEngine = (config) => {
|
|
8
|
+
const { shopId, currentRoute, navigate, onOpenPricing, specialRoutes } = config;
|
|
9
|
+
// Fetch visible floating cards
|
|
10
|
+
const { visibleCards, dismissCard, loading, error } = useVisibleFloatingCards({
|
|
11
|
+
shopId,
|
|
12
|
+
currentRoute,
|
|
13
|
+
});
|
|
14
|
+
// Set up action handlers with dismiss integrated
|
|
15
|
+
const actions = useFloatingCardActions({
|
|
16
|
+
navigate,
|
|
17
|
+
onOpenPricing,
|
|
18
|
+
specialRoutes,
|
|
19
|
+
onDismiss: (cardId) => {
|
|
20
|
+
// Convert to number if needed for compatibility
|
|
21
|
+
const id = typeof cardId === 'string' ? parseInt(cardId, 10) : cardId;
|
|
22
|
+
if (!isNaN(id)) {
|
|
23
|
+
dismissCard(id);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
// Data
|
|
29
|
+
visibleCards,
|
|
30
|
+
loading,
|
|
31
|
+
error,
|
|
32
|
+
// Actions
|
|
33
|
+
handlePrimaryAction: actions.handlePrimaryAction,
|
|
34
|
+
handleSecondaryAction: actions.handleSecondaryAction,
|
|
35
|
+
handleDismiss: actions.handleDismiss,
|
|
36
|
+
resolveAction: actions.resolveAction,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export default useFloatingCardEngine;
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { DashboardProvider, DashboardContext, useDashboardContext, } from "./pro
|
|
|
7
7
|
export type { DashboardProviderProps, DashboardContextValue, } from "./provider/DashboardProvider";
|
|
8
8
|
export * from "./hooks";
|
|
9
9
|
export * from "./components";
|
|
10
|
+
export { CLSMonitor, startCLSMonitoring, stopCLSMonitoring, getCLSMetrics } from "./utils/cls-monitor";
|
|
11
|
+
export type { CLSMetrics, CLSMonitorOptions } from "./utils/cls-monitor";
|
|
10
12
|
export type { SDKTranslations } from './types/translations';
|
|
11
13
|
export { defaultTranslations } from './translations/default';
|
|
12
14
|
export { useTranslations, useTranslation } from './hooks/useTranslations';
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ export * from "./types";
|
|
|
6
6
|
export { DashboardProvider, DashboardContext, useDashboardContext, } from "./provider/DashboardProvider";
|
|
7
7
|
export * from "./hooks";
|
|
8
8
|
export * from "./components";
|
|
9
|
+
// Export CLS monitoring utilities (optional)
|
|
10
|
+
export { CLSMonitor, startCLSMonitoring, stopCLSMonitoring, getCLSMetrics } from "./utils/cls-monitor";
|
|
9
11
|
export { defaultTranslations } from './translations/default';
|
|
10
12
|
export { useTranslations, useTranslation } from './hooks/useTranslations';
|
|
11
13
|
// Campaign Tracking
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { createContext, useEffect, useState, useCallback, useMemo, useRef, } from "react";
|
|
3
3
|
import { GlobalDashboardManager } from "../core/global-manager";
|
|
4
4
|
import { defaultTranslations } from "../translations/default";
|
|
5
|
+
import { injectClsStyles } from "../utils/injectStyles";
|
|
5
6
|
export const DashboardContext = createContext(null);
|
|
6
7
|
export const useDashboardContext = () => {
|
|
7
8
|
const context = React.useContext(DashboardContext);
|
|
@@ -38,6 +39,10 @@ export const DashboardProvider = ({ children, config, locale, translations, auto
|
|
|
38
39
|
...config,
|
|
39
40
|
locale: locale || config.locale || "en",
|
|
40
41
|
}), [config, locale]);
|
|
42
|
+
// Inject CLS fix styles on mount (only once globally)
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
injectClsStyles();
|
|
45
|
+
}, []);
|
|
41
46
|
// Register this provider
|
|
42
47
|
useEffect(() => {
|
|
43
48
|
const providerId = providerIdRef.current;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLS (Cumulative Layout Shift) Monitoring Utility
|
|
3
|
+
* Tracks and reports layout shifts for performance optimization
|
|
4
|
+
*/
|
|
5
|
+
interface LayoutShiftEntry extends PerformanceEntry {
|
|
6
|
+
value: number;
|
|
7
|
+
hadRecentInput: boolean;
|
|
8
|
+
sources?: Array<{
|
|
9
|
+
node?: Node;
|
|
10
|
+
previousRect: DOMRectReadOnly;
|
|
11
|
+
currentRect: DOMRectReadOnly;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export interface CLSMetrics {
|
|
15
|
+
value: number;
|
|
16
|
+
entries: LayoutShiftEntry[];
|
|
17
|
+
timestamp: number;
|
|
18
|
+
}
|
|
19
|
+
export interface CLSMonitorOptions {
|
|
20
|
+
threshold?: number;
|
|
21
|
+
onShift?: (metrics: CLSMetrics) => void;
|
|
22
|
+
onThresholdExceeded?: (metrics: CLSMetrics) => void;
|
|
23
|
+
debug?: boolean;
|
|
24
|
+
reportToConsole?: boolean;
|
|
25
|
+
reportToAnalytics?: (metrics: CLSMetrics) => void;
|
|
26
|
+
}
|
|
27
|
+
export declare class CLSMonitor {
|
|
28
|
+
private observer;
|
|
29
|
+
private clsValue;
|
|
30
|
+
private clsEntries;
|
|
31
|
+
private options;
|
|
32
|
+
private sessionValue;
|
|
33
|
+
private sessionEntries;
|
|
34
|
+
private lastShiftTime;
|
|
35
|
+
private readonly SESSION_GAP;
|
|
36
|
+
constructor(options?: CLSMonitorOptions);
|
|
37
|
+
start(): void;
|
|
38
|
+
private processLayoutShift;
|
|
39
|
+
private logShift;
|
|
40
|
+
stop(): void;
|
|
41
|
+
reset(): void;
|
|
42
|
+
getCurrentCLS(): CLSMetrics;
|
|
43
|
+
private supportsLayoutShift;
|
|
44
|
+
static identifyProblematicElements(entries: LayoutShiftEntry[]): Element[];
|
|
45
|
+
static getAttribution(entries: LayoutShiftEntry[]): Map<string, number>;
|
|
46
|
+
}
|
|
47
|
+
export declare function startCLSMonitoring(options?: CLSMonitorOptions): CLSMonitor;
|
|
48
|
+
export declare function stopCLSMonitoring(): void;
|
|
49
|
+
export declare function getCLSMetrics(): CLSMetrics | null;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLS (Cumulative Layout Shift) Monitoring Utility
|
|
3
|
+
* Tracks and reports layout shifts for performance optimization
|
|
4
|
+
*/
|
|
5
|
+
export class CLSMonitor {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.observer = null;
|
|
8
|
+
this.clsValue = 0;
|
|
9
|
+
this.clsEntries = [];
|
|
10
|
+
this.sessionValue = 0;
|
|
11
|
+
this.sessionEntries = [];
|
|
12
|
+
this.lastShiftTime = 0;
|
|
13
|
+
this.SESSION_GAP = 5000; // 5 seconds
|
|
14
|
+
this.options = {
|
|
15
|
+
threshold: options.threshold || 0.1, // Good CLS threshold
|
|
16
|
+
onShift: options.onShift || (() => { }),
|
|
17
|
+
onThresholdExceeded: options.onThresholdExceeded || (() => { }),
|
|
18
|
+
debug: options.debug || false,
|
|
19
|
+
reportToConsole: options.reportToConsole || false,
|
|
20
|
+
reportToAnalytics: options.reportToAnalytics || (() => { }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
start() {
|
|
24
|
+
if (!this.supportsLayoutShift()) {
|
|
25
|
+
console.warn('PerformanceObserver for layout-shift not supported');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
this.observer = new PerformanceObserver((list) => {
|
|
30
|
+
const entries = list.getEntries();
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
// Ignore shifts with recent input (user-initiated)
|
|
33
|
+
if (!entry.hadRecentInput) {
|
|
34
|
+
this.processLayoutShift(entry);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
this.observer.observe({
|
|
39
|
+
type: 'layout-shift',
|
|
40
|
+
buffered: true
|
|
41
|
+
});
|
|
42
|
+
if (this.options.debug) {
|
|
43
|
+
console.log('CLS Monitor started');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
console.error('Failed to start CLS Monitor:', error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
processLayoutShift(entry) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
// Check if this is part of a new session
|
|
53
|
+
if (now - this.lastShiftTime > this.SESSION_GAP) {
|
|
54
|
+
// Start new session
|
|
55
|
+
this.sessionValue = 0;
|
|
56
|
+
this.sessionEntries = [];
|
|
57
|
+
}
|
|
58
|
+
this.lastShiftTime = now;
|
|
59
|
+
this.sessionValue += entry.value;
|
|
60
|
+
this.sessionEntries.push(entry);
|
|
61
|
+
// Update cumulative value (max session value)
|
|
62
|
+
if (this.sessionValue > this.clsValue) {
|
|
63
|
+
this.clsValue = this.sessionValue;
|
|
64
|
+
this.clsEntries = [...this.sessionEntries];
|
|
65
|
+
}
|
|
66
|
+
const metrics = {
|
|
67
|
+
value: this.clsValue,
|
|
68
|
+
entries: this.clsEntries,
|
|
69
|
+
timestamp: now,
|
|
70
|
+
};
|
|
71
|
+
// Report shift
|
|
72
|
+
this.options.onShift(metrics);
|
|
73
|
+
// Check threshold
|
|
74
|
+
if (this.clsValue > this.options.threshold) {
|
|
75
|
+
this.options.onThresholdExceeded(metrics);
|
|
76
|
+
}
|
|
77
|
+
// Debug logging
|
|
78
|
+
if (this.options.debug || this.options.reportToConsole) {
|
|
79
|
+
this.logShift(entry, metrics);
|
|
80
|
+
}
|
|
81
|
+
// Report to analytics
|
|
82
|
+
this.options.reportToAnalytics(metrics);
|
|
83
|
+
}
|
|
84
|
+
logShift(entry, metrics) {
|
|
85
|
+
const affectedElements = entry.sources?.map(source => {
|
|
86
|
+
if (source.node && source.node.nodeType === 1) {
|
|
87
|
+
const element = source.node;
|
|
88
|
+
return {
|
|
89
|
+
element: element.tagName,
|
|
90
|
+
id: element.id,
|
|
91
|
+
className: element.className,
|
|
92
|
+
previousRect: source.previousRect,
|
|
93
|
+
currentRect: source.currentRect,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}).filter(Boolean) || [];
|
|
98
|
+
console.group(`🎯 Layout Shift Detected`);
|
|
99
|
+
console.log('Shift Value:', entry.value.toFixed(4));
|
|
100
|
+
console.log('Session CLS:', this.sessionValue.toFixed(4));
|
|
101
|
+
console.log('Total CLS:', metrics.value.toFixed(4));
|
|
102
|
+
console.log('Threshold:', this.options.threshold);
|
|
103
|
+
console.log('Status:', metrics.value > this.options.threshold ? '❌ Bad' : '✅ Good');
|
|
104
|
+
if (affectedElements.length > 0) {
|
|
105
|
+
console.log('Affected Elements:', affectedElements);
|
|
106
|
+
}
|
|
107
|
+
console.groupEnd();
|
|
108
|
+
}
|
|
109
|
+
stop() {
|
|
110
|
+
if (this.observer) {
|
|
111
|
+
this.observer.disconnect();
|
|
112
|
+
this.observer = null;
|
|
113
|
+
if (this.options.debug) {
|
|
114
|
+
console.log('CLS Monitor stopped');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
reset() {
|
|
119
|
+
this.clsValue = 0;
|
|
120
|
+
this.clsEntries = [];
|
|
121
|
+
this.sessionValue = 0;
|
|
122
|
+
this.sessionEntries = [];
|
|
123
|
+
this.lastShiftTime = 0;
|
|
124
|
+
}
|
|
125
|
+
getCurrentCLS() {
|
|
126
|
+
return {
|
|
127
|
+
value: this.clsValue,
|
|
128
|
+
entries: this.clsEntries,
|
|
129
|
+
timestamp: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
supportsLayoutShift() {
|
|
133
|
+
return typeof PerformanceObserver !== 'undefined' &&
|
|
134
|
+
PerformanceObserver.supportedEntryTypes?.includes('layout-shift');
|
|
135
|
+
}
|
|
136
|
+
// Helper method to identify problematic elements
|
|
137
|
+
static identifyProblematicElements(entries) {
|
|
138
|
+
const problematicElements = new Set();
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.sources) {
|
|
141
|
+
for (const source of entry.sources) {
|
|
142
|
+
if (source.node && source.node.nodeType === 1) {
|
|
143
|
+
problematicElements.add(source.node);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Array.from(problematicElements);
|
|
149
|
+
}
|
|
150
|
+
// Helper to get CLS attribution
|
|
151
|
+
static getAttribution(entries) {
|
|
152
|
+
const attribution = new Map();
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.sources) {
|
|
155
|
+
for (const source of entry.sources) {
|
|
156
|
+
if (source.node && source.node.nodeType === 1) {
|
|
157
|
+
const element = source.node;
|
|
158
|
+
const key = `${element.tagName}${element.id ? '#' + element.id : ''}${element.className ? '.' + element.className.split(' ').join('.') : ''}`;
|
|
159
|
+
attribution.set(key, (attribution.get(key) || 0) + entry.value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return attribution;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Singleton instance for easy global usage
|
|
168
|
+
let globalMonitor = null;
|
|
169
|
+
export function startCLSMonitoring(options) {
|
|
170
|
+
if (!globalMonitor) {
|
|
171
|
+
globalMonitor = new CLSMonitor(options);
|
|
172
|
+
globalMonitor.start();
|
|
173
|
+
}
|
|
174
|
+
return globalMonitor;
|
|
175
|
+
}
|
|
176
|
+
export function stopCLSMonitoring() {
|
|
177
|
+
if (globalMonitor) {
|
|
178
|
+
globalMonitor.stop();
|
|
179
|
+
globalMonitor = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function getCLSMetrics() {
|
|
183
|
+
return globalMonitor ? globalMonitor.getCurrentCLS() : null;
|
|
184
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatically inject CLS fix styles into the document
|
|
3
|
+
* This ensures the styles are always applied without requiring manual imports
|
|
4
|
+
*/
|
|
5
|
+
export declare const polarisClsFixStyles = "\n/* Critical Polaris CLS Fixes */\n.Polaris-ShadowBevel {\n min-height: inherit !important;\n will-change: contents;\n}\n\n.Polaris-Card {\n min-height: 100px;\n contain: layout style;\n}\n\n.Polaris-Tabs__Panel {\n min-height: 350px;\n contain: layout;\n}\n\n.Polaris-Tabs__TabPanel {\n min-height: inherit;\n}\n\n.Polaris-Box {\n /* Prevent height changes from dynamic content */\n contain: style;\n}\n\n/* Skeleton state improvements */\n.Polaris-SkeletonBodyText,\n.Polaris-SkeletonDisplayText,\n.Polaris-SkeletonThumbnail {\n animation: none !important;\n background: linear-gradient(\n 90deg,\n #f0f0f0 25%,\n #f8f8f8 50%,\n #f0f0f0 75%\n );\n background-size: 200% 100%;\n animation: pulse 1.5s ease-in-out infinite !important;\n}\n\n@keyframes pulse {\n 0% {\n background-position: 200% 0;\n }\n 100% {\n background-position: -200% 0;\n }\n}\n\n/* Prevent layout shifts from Polaris transitions */\n.Polaris-Card,\n.Polaris-Banner,\n.Polaris-CalloutCard {\n transform: translateZ(0);\n will-change: transform;\n}\n\n/* Fix for navigation tabs */\n.custom-tabs-wrapper-dashboard {\n min-height: 400px;\n}\n\n/* GPU acceleration for transforms */\n[style*=\"transform\"] {\n will-change: transform;\n}\n";
|
|
6
|
+
/**
|
|
7
|
+
* Inject CLS fix styles into the document head
|
|
8
|
+
* This is called automatically when the SDK initializes
|
|
9
|
+
*/
|
|
10
|
+
export declare function injectClsStyles(): void;
|
|
11
|
+
/**
|
|
12
|
+
* Remove injected styles (for cleanup)
|
|
13
|
+
*/
|
|
14
|
+
export declare function removeClsStyles(): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatically inject CLS fix styles into the document
|
|
3
|
+
* This ensures the styles are always applied without requiring manual imports
|
|
4
|
+
*/
|
|
5
|
+
const STYLE_ID = 'ts-shopify-polaris-cls-fixes';
|
|
6
|
+
export const polarisClsFixStyles = `
|
|
7
|
+
/* Critical Polaris CLS Fixes */
|
|
8
|
+
.Polaris-ShadowBevel {
|
|
9
|
+
min-height: inherit !important;
|
|
10
|
+
will-change: contents;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.Polaris-Card {
|
|
14
|
+
min-height: 100px;
|
|
15
|
+
contain: layout style;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.Polaris-Tabs__Panel {
|
|
19
|
+
min-height: 350px;
|
|
20
|
+
contain: layout;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.Polaris-Tabs__TabPanel {
|
|
24
|
+
min-height: inherit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.Polaris-Box {
|
|
28
|
+
/* Prevent height changes from dynamic content */
|
|
29
|
+
contain: style;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Skeleton state improvements */
|
|
33
|
+
.Polaris-SkeletonBodyText,
|
|
34
|
+
.Polaris-SkeletonDisplayText,
|
|
35
|
+
.Polaris-SkeletonThumbnail {
|
|
36
|
+
animation: none !important;
|
|
37
|
+
background: linear-gradient(
|
|
38
|
+
90deg,
|
|
39
|
+
#f0f0f0 25%,
|
|
40
|
+
#f8f8f8 50%,
|
|
41
|
+
#f0f0f0 75%
|
|
42
|
+
);
|
|
43
|
+
background-size: 200% 100%;
|
|
44
|
+
animation: pulse 1.5s ease-in-out infinite !important;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@keyframes pulse {
|
|
48
|
+
0% {
|
|
49
|
+
background-position: 200% 0;
|
|
50
|
+
}
|
|
51
|
+
100% {
|
|
52
|
+
background-position: -200% 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Prevent layout shifts from Polaris transitions */
|
|
57
|
+
.Polaris-Card,
|
|
58
|
+
.Polaris-Banner,
|
|
59
|
+
.Polaris-CalloutCard {
|
|
60
|
+
transform: translateZ(0);
|
|
61
|
+
will-change: transform;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Fix for navigation tabs */
|
|
65
|
+
.custom-tabs-wrapper-dashboard {
|
|
66
|
+
min-height: 400px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* GPU acceleration for transforms */
|
|
70
|
+
[style*="transform"] {
|
|
71
|
+
will-change: transform;
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
/**
|
|
75
|
+
* Inject CLS fix styles into the document head
|
|
76
|
+
* This is called automatically when the SDK initializes
|
|
77
|
+
*/
|
|
78
|
+
export function injectClsStyles() {
|
|
79
|
+
// Check if styles are already injected
|
|
80
|
+
if (typeof document === 'undefined') {
|
|
81
|
+
return; // SSR safety
|
|
82
|
+
}
|
|
83
|
+
if (document.getElementById(STYLE_ID)) {
|
|
84
|
+
return; // Already injected
|
|
85
|
+
}
|
|
86
|
+
// Create and inject style element
|
|
87
|
+
const styleElement = document.createElement('style');
|
|
88
|
+
styleElement.id = STYLE_ID;
|
|
89
|
+
styleElement.textContent = polarisClsFixStyles;
|
|
90
|
+
// Add to head (or body as fallback)
|
|
91
|
+
const target = document.head || document.body;
|
|
92
|
+
target.appendChild(styleElement);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove injected styles (for cleanup)
|
|
96
|
+
*/
|
|
97
|
+
export function removeClsStyles() {
|
|
98
|
+
if (typeof document === 'undefined') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const styleElement = document.getElementById(STYLE_ID);
|
|
102
|
+
if (styleElement) {
|
|
103
|
+
styleElement.remove();
|
|
104
|
+
}
|
|
105
|
+
}
|