@snowcone-app/ui 0.4.8 → 0.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/index.cjs +360 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.js +360 -97
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composed/HeroProductImage.tsx +39 -25
- package/src/composed/carousels/MobileProductCarousel.tsx +38 -0
- package/src/patterns/RealtimeProvider.tsx +414 -83
- package/src/patterns/__tests__/RealtimeProvider.cropDelivery.test.tsx +240 -0
- package/src/patterns/__tests__/croppedFrameComposite.test.ts +56 -0
- package/src/patterns/croppedFrameComposite.ts +122 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "React components for merchandise visualization and customization",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"react-instantsearch": "^7.15.5",
|
|
104
104
|
"react-zoom-pan-pinch": "^3.6.4",
|
|
105
105
|
"tailwind-merge": "^3.0.0",
|
|
106
|
-
"@snowcone-app/sdk": "0.20.
|
|
106
|
+
"@snowcone-app/sdk": "0.20.3"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -156,6 +156,14 @@ export interface HeroProductImageProps {
|
|
|
156
156
|
shimmerDelay?: number;
|
|
157
157
|
shimmerFadeDuration?: number;
|
|
158
158
|
darkShimmerFadeInDuration?: number;
|
|
159
|
+
|
|
160
|
+
/** Label shown when no artwork is applied. Host apps pass a localized
|
|
161
|
+
* string (this package stays i18n-framework-free). */
|
|
162
|
+
emptyText?: string;
|
|
163
|
+
/** Label shown when mockup generation fails. */
|
|
164
|
+
errorText?: string;
|
|
165
|
+
/** Label shown when the image fails to load with no stale image to keep. */
|
|
166
|
+
unavailableText?: string;
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
/**
|
|
@@ -201,6 +209,9 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
201
209
|
shimmerDelay = 200,
|
|
202
210
|
shimmerFadeDuration = 500,
|
|
203
211
|
darkShimmerFadeInDuration = 300,
|
|
212
|
+
emptyText = "No artwork",
|
|
213
|
+
errorText = "Error loading",
|
|
214
|
+
unavailableText = "Preview unavailable",
|
|
204
215
|
}: HeroProductImageProps) {
|
|
205
216
|
// Context
|
|
206
217
|
const context = useProductOptional();
|
|
@@ -904,14 +915,15 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
904
915
|
// instead of the browser's native broken-image glyph. Reset on every new
|
|
905
916
|
// URL attempt (below) so a fresh render can recover.
|
|
906
917
|
const [imgFailed, setImgFailed] = useState(false);
|
|
907
|
-
// One-shot cache-
|
|
908
|
-
// mockup from WebKit's disk cache as a CORS-less entry (cached by an
|
|
909
|
-
// non-crossorigin request), which then fails the crossorigin <img>
|
|
910
|
-
// paints a broken image. On the FIRST load failure for a URL we
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
918
|
+
// One-shot cache-recovery retry. A reload / Safari bfcache restore can serve
|
|
919
|
+
// a mockup from WebKit's disk cache as a CORS-less entry (cached by an
|
|
920
|
+
// earlier non-crossorigin request), which then fails the crossorigin <img>
|
|
921
|
+
// here and paints a broken image. On the FIRST load failure for a URL we
|
|
922
|
+
// re-prime the cache via `fetch(url, {cache:"reload"})` and remount the
|
|
923
|
+
// <img> (key bump) on the SAME canonical URL — never a mutated one, since
|
|
924
|
+
// the public resolver rejects unknown params and signed URLs fail signature
|
|
925
|
+
// verification with extras. Fires ONLY on failure, so the cached happy path
|
|
926
|
+
// is untouched; a second failure falls through to the clean fallback.
|
|
915
927
|
const [retryNonce, setRetryNonce] = useState(0);
|
|
916
928
|
const retriedUrlRef = useRef<string | null>(null);
|
|
917
929
|
const prevDisplayUrlRef = useRef<string | null>(displayUrl);
|
|
@@ -987,7 +999,7 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
987
999
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
988
1000
|
/>
|
|
989
1001
|
</svg>
|
|
990
|
-
<p className="text-xs">
|
|
1002
|
+
<p className="text-xs">{emptyText}</p>
|
|
991
1003
|
</div>
|
|
992
1004
|
</div>
|
|
993
1005
|
);
|
|
@@ -1002,7 +1014,7 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1002
1014
|
style={style}
|
|
1003
1015
|
>
|
|
1004
1016
|
<div className="text-red-600 text-center p-4">
|
|
1005
|
-
<p className="text-xs">
|
|
1017
|
+
<p className="text-xs">{errorText}</p>
|
|
1006
1018
|
</div>
|
|
1007
1019
|
</div>
|
|
1008
1020
|
);
|
|
@@ -1023,19 +1035,18 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1023
1035
|
covered NEW with OLD, then OLD faded back to NEW). */}
|
|
1024
1036
|
{renderedUrl && !imgFailed && (
|
|
1025
1037
|
<img
|
|
1038
|
+
// Remounts on retry (key bump) so the browser issues a fresh request
|
|
1039
|
+
// for the SAME canonical URL after the cache re-prime below. The URL
|
|
1040
|
+
// itself is never mutated: the public resolver 400s on any param
|
|
1041
|
+
// outside its grammar, and a signed URL fails signature verification
|
|
1042
|
+
// with extra params — the old `_cb` cache-bust retry hit both
|
|
1043
|
+
// (QA finding #0011).
|
|
1044
|
+
key={retryNonce}
|
|
1026
1045
|
alt={`Product mockup${placement ? ` - ${placement}` : ""}`}
|
|
1027
1046
|
crossOrigin="anonymous"
|
|
1028
1047
|
className="absolute inset-0 w-full h-full object-cover"
|
|
1029
1048
|
draggable={draggable}
|
|
1030
|
-
|
|
1031
|
-
// served from the poisoned (CORS-less) disk-cache entry. The base
|
|
1032
|
-
// `renderedUrl` is what we hand back to `onUrlGenerated` — never the
|
|
1033
|
-
// busted variant — so caches/state stay keyed on the canonical URL.
|
|
1034
|
-
src={
|
|
1035
|
-
retriedUrlRef.current === renderedUrl
|
|
1036
|
-
? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}_cb=${retryNonce}`
|
|
1037
|
-
: renderedUrl
|
|
1038
|
-
}
|
|
1049
|
+
src={renderedUrl}
|
|
1039
1050
|
loading="eager"
|
|
1040
1051
|
// eslint-disable-next-line react/no-unknown-property
|
|
1041
1052
|
fetchPriority="high"
|
|
@@ -1050,13 +1061,16 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1050
1061
|
onUrlGeneratedRef.current?.(renderedUrl);
|
|
1051
1062
|
}}
|
|
1052
1063
|
onError={() => {
|
|
1053
|
-
// First failure for this URL → retry once
|
|
1054
|
-
//
|
|
1055
|
-
// headers).
|
|
1056
|
-
//
|
|
1064
|
+
// First failure for this URL → retry once before giving up
|
|
1065
|
+
// (recovers a WebKit disk-cache entry cached without CORS
|
|
1066
|
+
// headers). `cache:"reload"` bypasses the poisoned entry and
|
|
1067
|
+
// stores a fresh CORS-tagged copy; the key-bumped <img> remount
|
|
1068
|
+
// then loads that copy under the same canonical URL.
|
|
1057
1069
|
if (renderedUrl && retriedUrlRef.current !== renderedUrl) {
|
|
1058
1070
|
retriedUrlRef.current = renderedUrl;
|
|
1059
|
-
|
|
1071
|
+
void fetch(renderedUrl, { mode: "cors", cache: "reload", credentials: "omit" })
|
|
1072
|
+
.catch(() => {})
|
|
1073
|
+
.then(() => setRetryNonce((n) => n + 1));
|
|
1060
1074
|
return;
|
|
1061
1075
|
}
|
|
1062
1076
|
// Retry also failed → genuine failure. Unmount the broken <img> so
|
|
@@ -1105,7 +1119,7 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1105
1119
|
beats a blank one — so we only show this when there's nothing else. */}
|
|
1106
1120
|
{imgFailed && !prevUrl && (
|
|
1107
1121
|
<div className="absolute inset-0 flex items-center justify-center bg-muted">
|
|
1108
|
-
<p className="text-muted-foreground text-xs">
|
|
1122
|
+
<p className="text-muted-foreground text-xs">{unavailableText}</p>
|
|
1109
1123
|
</div>
|
|
1110
1124
|
)}
|
|
1111
1125
|
|
|
@@ -161,6 +161,21 @@ export interface MobileProductCarouselProps {
|
|
|
161
161
|
* provider's mockup cache themselves.
|
|
162
162
|
*/
|
|
163
163
|
onMockupUrlGenerated?: (mockupId: string, url: string) => void;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Per-image action overlay, floated top-right of the slide (e.g. the
|
|
167
|
+
* draft preview's Pass button). Return null for images without actions.
|
|
168
|
+
* Taps on the action stop propagating so they don't trigger the
|
|
169
|
+
* carousel's tap-to-zoom or swipe handling.
|
|
170
|
+
*/
|
|
171
|
+
renderImageAction?: (image: CarouselImage, index: number) => React.ReactNode;
|
|
172
|
+
|
|
173
|
+
/** Passed through to HeroProductImage — label when mockup generation
|
|
174
|
+
* fails. Host apps pass a localized string. */
|
|
175
|
+
errorText?: string;
|
|
176
|
+
/** Passed through to HeroProductImage — label when the image fails to
|
|
177
|
+
* load with no stale image to keep. */
|
|
178
|
+
unavailableText?: string;
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
/**
|
|
@@ -183,6 +198,9 @@ export const MobileProductCarousel = memo(function MobileProductCarousel({
|
|
|
183
198
|
stickyHeroContext,
|
|
184
199
|
mockupWidth,
|
|
185
200
|
onMockupUrlGenerated,
|
|
201
|
+
renderImageAction,
|
|
202
|
+
errorText,
|
|
203
|
+
unavailableText,
|
|
186
204
|
}: MobileProductCarouselProps) {
|
|
187
205
|
const [activeIndex, setActiveIndex] = useState(currentIndex);
|
|
188
206
|
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(
|
|
@@ -896,6 +914,8 @@ export const MobileProductCarousel = memo(function MobileProductCarousel({
|
|
|
896
914
|
? (url) => handleUrlGenerated(image.mockupId!, url)
|
|
897
915
|
: undefined
|
|
898
916
|
}
|
|
917
|
+
errorText={errorText}
|
|
918
|
+
unavailableText={unavailableText}
|
|
899
919
|
className="w-full h-full object-cover cursor-pointer pointer-events-none"
|
|
900
920
|
/>
|
|
901
921
|
) : image.isRealMockup && !currentArtwork ? (
|
|
@@ -907,6 +927,24 @@ export const MobileProductCarousel = memo(function MobileProductCarousel({
|
|
|
907
927
|
className="w-full h-full object-cover cursor-pointer pointer-events-none"
|
|
908
928
|
/>
|
|
909
929
|
)}
|
|
930
|
+
{/* Per-image action (e.g. draft preview Pass). Stops
|
|
931
|
+
propagation so a tap doesn't open the zoom viewer or
|
|
932
|
+
read as a swipe. */}
|
|
933
|
+
{isWithinDecodeWindow &&
|
|
934
|
+
(() => {
|
|
935
|
+
const action = renderImageAction?.(image, index);
|
|
936
|
+
return action ? (
|
|
937
|
+
<div
|
|
938
|
+
className="absolute top-3 right-3 z-10"
|
|
939
|
+
onClick={(e) => e.stopPropagation()}
|
|
940
|
+
onTouchStart={(e) => e.stopPropagation()}
|
|
941
|
+
onTouchEnd={(e) => e.stopPropagation()}
|
|
942
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
943
|
+
>
|
|
944
|
+
{action}
|
|
945
|
+
</div>
|
|
946
|
+
) : null;
|
|
947
|
+
})()}
|
|
910
948
|
</div>
|
|
911
949
|
);
|
|
912
950
|
})}
|