@snowcone-app/ui 0.4.0 → 0.4.2
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 +18 -0
- package/dist/index.cjs +545 -473
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +435 -364
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composed/HeroProductImage.tsx +78 -10
- package/src/composed/SafeImg.tsx +46 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +42 -8
- package/src/composed/search/meilisearchAdapter.ts +1 -1
- package/src/composed/zoom/EnhancedImageViewer.tsx +2 -1
- package/src/index.ts +1 -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.2",
|
|
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.17.
|
|
106
|
+
"@snowcone-app/sdk": "0.17.2"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { useShopOptional } from "../patterns/ShopProvider";
|
|
17
17
|
import { useRealtimeOptional } from "../patterns/RealtimeProvider";
|
|
18
18
|
import { useMockupPriorityOptional } from "../patterns/MockupPriorityProvider";
|
|
19
|
+
import { LoadingOverlayPrismCandyInline } from "../components/LoadingOverlayPrismCandy";
|
|
19
20
|
import {
|
|
20
21
|
createDesignForPlacements,
|
|
21
22
|
type DesignElement,
|
|
@@ -880,8 +881,29 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
880
881
|
const [renderedUrl, setRenderedUrl] = useState<string | null>(displayUrl);
|
|
881
882
|
// `renderedUrl` is set as soon as a URL exists, but the <img> paints nothing
|
|
882
883
|
// until the render is actually fetched (seconds, on a cold render). Keep the
|
|
883
|
-
//
|
|
884
|
+
// rainbow loader up until the first image has painted.
|
|
884
885
|
const [firstImageLoaded, setFirstImageLoaded] = useState(false);
|
|
886
|
+
// The loader lingers one beat past `firstImageLoaded` so it can fade OUT —
|
|
887
|
+
// revealing the freshly-loaded image beneath in the same crossfade — instead
|
|
888
|
+
// of vanishing the instant the image paints. Unmounted on the overlay's
|
|
889
|
+
// onExited (fade-out complete).
|
|
890
|
+
const [loaderMounted, setLoaderMounted] = useState(true);
|
|
891
|
+
// The visible <img> failed to load (dead/expired URL — e.g. a revoked blob:
|
|
892
|
+
// from an in-progress design, or a stale signed mockup URL on a later visit).
|
|
893
|
+
// Tracked so we can UNMOUNT the broken <img> and show a clean fallback
|
|
894
|
+
// instead of the browser's native broken-image glyph. Reset on every new
|
|
895
|
+
// URL attempt (below) so a fresh render can recover.
|
|
896
|
+
const [imgFailed, setImgFailed] = useState(false);
|
|
897
|
+
// One-shot cache-bust retry. A reload / Safari bfcache restore can serve a
|
|
898
|
+
// mockup from WebKit's disk cache as a CORS-less entry (cached by an earlier
|
|
899
|
+
// non-crossorigin request), which then fails the crossorigin <img> here and
|
|
900
|
+
// paints a broken image. On the FIRST load failure for a URL we retry once
|
|
901
|
+
// with a `_cb` query param — a fresh URL can't hit the poisoned cache entry.
|
|
902
|
+
// This fires ONLY on failure, so the cached happy path (and realtime, which
|
|
903
|
+
// already cache-busts via `_t`) is untouched; only a genuinely-broken image
|
|
904
|
+
// pays the re-fetch. A second failure falls through to the clean fallback.
|
|
905
|
+
const [retryNonce, setRetryNonce] = useState(0);
|
|
906
|
+
const retriedUrlRef = useRef<string | null>(null);
|
|
885
907
|
const prevDisplayUrlRef = useRef<string | null>(displayUrl);
|
|
886
908
|
// Fire the L3 "you forgot signMockupUrl" dev hint at most once per component.
|
|
887
909
|
const signHintShownRef = useRef(false);
|
|
@@ -890,6 +912,10 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
890
912
|
if (!displayUrl || displayUrl === prevDisplayUrlRef.current) return;
|
|
891
913
|
const oldUrl = prevDisplayUrlRef.current;
|
|
892
914
|
prevDisplayUrlRef.current = displayUrl;
|
|
915
|
+
// New URL to try — clear any prior load failure so the <img> remounts,
|
|
916
|
+
// and give this URL a fresh retry budget.
|
|
917
|
+
setImgFailed(false);
|
|
918
|
+
retriedUrlRef.current = null;
|
|
893
919
|
|
|
894
920
|
// Initial load — no crossfade. Just update rendered URL.
|
|
895
921
|
if (!oldUrl) {
|
|
@@ -924,6 +950,9 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
924
950
|
setShowNew(false);
|
|
925
951
|
}, []);
|
|
926
952
|
|
|
953
|
+
// Unmount the rainbow loader once its fade-out transition has finished.
|
|
954
|
+
const handleLoaderExited = useCallback(() => setLoaderMounted(false), []);
|
|
955
|
+
|
|
927
956
|
// Early returns now go AFTER every hook above so React sees a stable
|
|
928
957
|
// hook count regardless of artwork / error state.
|
|
929
958
|
if (!hasArtwork) {
|
|
@@ -976,31 +1005,33 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
976
1005
|
style={style}
|
|
977
1006
|
data-hero-image="true"
|
|
978
1007
|
>
|
|
979
|
-
{/* Loading skeleton — painted UNDER the <img> so the image covers it the
|
|
980
|
-
instant it renders, with no state-flush flash. Removed once the first
|
|
981
|
-
image has loaded; later URL changes crossfade over a visible image. */}
|
|
982
|
-
{!firstImageLoaded && (
|
|
983
|
-
<div className="absolute inset-0 bg-muted-foreground/20 animate-pulse" />
|
|
984
|
-
)}
|
|
985
|
-
|
|
986
1008
|
{/* Current image — shows `renderedUrl`, which the crossfade effect only
|
|
987
1009
|
advances after the next image has finished preloading. This prevents
|
|
988
1010
|
the new → old → new flash that happened when displayUrl was bound
|
|
989
1011
|
directly to <img src> (the browser snapped to NEW from the cache
|
|
990
1012
|
before prevUrl could be staged on top, then prevUrl appeared and
|
|
991
1013
|
covered NEW with OLD, then OLD faded back to NEW). */}
|
|
992
|
-
{renderedUrl && (
|
|
1014
|
+
{renderedUrl && !imgFailed && (
|
|
993
1015
|
<img
|
|
994
1016
|
alt={`Product mockup${placement ? ` - ${placement}` : ""}`}
|
|
995
1017
|
crossOrigin="anonymous"
|
|
996
1018
|
className="absolute inset-0 w-full h-full object-cover"
|
|
997
1019
|
draggable={draggable}
|
|
998
|
-
|
|
1020
|
+
// On a retry, append a cache-bust param so the request can't be
|
|
1021
|
+
// served from the poisoned (CORS-less) disk-cache entry. The base
|
|
1022
|
+
// `renderedUrl` is what we hand back to `onUrlGenerated` — never the
|
|
1023
|
+
// busted variant — so caches/state stay keyed on the canonical URL.
|
|
1024
|
+
src={
|
|
1025
|
+
retriedUrlRef.current === renderedUrl
|
|
1026
|
+
? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}_cb=${retryNonce}`
|
|
1027
|
+
: renderedUrl
|
|
1028
|
+
}
|
|
999
1029
|
loading="eager"
|
|
1000
1030
|
// eslint-disable-next-line react/no-unknown-property
|
|
1001
1031
|
fetchPriority="high"
|
|
1002
1032
|
onClick={onClick}
|
|
1003
1033
|
onLoad={() => {
|
|
1034
|
+
setImgFailed(false);
|
|
1004
1035
|
setFirstImageLoaded(true);
|
|
1005
1036
|
if (!onLoadCalledRef.current && onLoad) {
|
|
1006
1037
|
onLoadCalledRef.current = true;
|
|
@@ -1009,6 +1040,20 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1009
1040
|
onUrlGeneratedRef.current?.(renderedUrl);
|
|
1010
1041
|
}}
|
|
1011
1042
|
onError={() => {
|
|
1043
|
+
// First failure for this URL → retry once with a cache-bust before
|
|
1044
|
+
// giving up (recovers a WebKit disk-cache entry cached without CORS
|
|
1045
|
+
// headers). Fires only on failure, so the cached happy path and the
|
|
1046
|
+
// realtime `_t` flow are untouched.
|
|
1047
|
+
if (renderedUrl && retriedUrlRef.current !== renderedUrl) {
|
|
1048
|
+
retriedUrlRef.current = renderedUrl;
|
|
1049
|
+
setRetryNonce((n) => n + 1);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
// Retry also failed → genuine failure. Unmount the broken <img> so
|
|
1053
|
+
// the browser doesn't paint its native broken-image glyph; a clean
|
|
1054
|
+
// fallback renders in its place below (unless a stale crossfade
|
|
1055
|
+
// image is still available to keep).
|
|
1056
|
+
setImgFailed(true);
|
|
1012
1057
|
setFirstImageLoaded(true);
|
|
1013
1058
|
onError?.();
|
|
1014
1059
|
// Dev-only guidance for the most common signed-shop trap: a mockup
|
|
@@ -1044,6 +1089,16 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1044
1089
|
/>
|
|
1045
1090
|
)}
|
|
1046
1091
|
|
|
1092
|
+
{/* Image load failed with no stale image to fall back on. Show a neutral
|
|
1093
|
+
panel instead of the browser's broken-image glyph. `prevUrl` (the
|
|
1094
|
+
crossfade-from layer below) is kept when present — a stale mockup
|
|
1095
|
+
beats a blank one — so we only show this when there's nothing else. */}
|
|
1096
|
+
{imgFailed && !prevUrl && (
|
|
1097
|
+
<div className="absolute inset-0 flex items-center justify-center bg-muted">
|
|
1098
|
+
<p className="text-muted-foreground text-xs">Preview unavailable</p>
|
|
1099
|
+
</div>
|
|
1100
|
+
)}
|
|
1101
|
+
|
|
1047
1102
|
{/* Server render error badge — the realtime renderer rejected the last
|
|
1048
1103
|
render (e.g. asset_not_allowed). The stale image above stays visible
|
|
1049
1104
|
on purpose (stale + clearly marked beats blank); the typed code is
|
|
@@ -1074,6 +1129,19 @@ export const HeroProductImage = memo(function HeroProductImage({
|
|
|
1074
1129
|
/>
|
|
1075
1130
|
)}
|
|
1076
1131
|
|
|
1132
|
+
{/* Rainbow prism loader — the same one /create shows while a mockup
|
|
1133
|
+
generates. Rendered LAST so it sits ON TOP of the <img>: the image
|
|
1134
|
+
loads underneath, then the loader fades OUT to reveal it (a quick
|
|
1135
|
+
~400ms crossfade) instead of the image popping in. First load only;
|
|
1136
|
+
later URL changes use the prevUrl crossfade above. */}
|
|
1137
|
+
{loaderMounted && (
|
|
1138
|
+
<LoadingOverlayPrismCandyInline
|
|
1139
|
+
visible={!firstImageLoaded}
|
|
1140
|
+
variant="light"
|
|
1141
|
+
onExited={handleLoaderExited}
|
|
1142
|
+
/>
|
|
1143
|
+
)}
|
|
1144
|
+
|
|
1077
1145
|
{/* SAFARI FIX: Disabled shimmer overlay - it depends on layers state which causes re-renders */}
|
|
1078
1146
|
{/* {showShimmer && layers.length > 0 && (
|
|
1079
1147
|
<div
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export interface SafeImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
|
6
|
+
/**
|
|
7
|
+
* Rendered in place of the <img> when the image fails to load. Defaults to
|
|
8
|
+
* nothing — the broken image simply disappears, so the container's own
|
|
9
|
+
* background shows instead of the browser's native broken-image glyph.
|
|
10
|
+
*/
|
|
11
|
+
fallback?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An `<img>` that never paints the browser's native broken-image glyph (the
|
|
16
|
+
* "?" box on iOS Safari). On a load failure — a dead/expired URL, a revoked
|
|
17
|
+
* `blob:` from an in-progress design, or a CORS-less disk-cache entry replayed
|
|
18
|
+
* to a crossorigin request — it unmounts and renders `fallback` (or nothing).
|
|
19
|
+
*
|
|
20
|
+
* Use this for any remote/blob image whose URL can go stale. Components with
|
|
21
|
+
* richer recovery (crossfade, retry, skeletons) like `HeroProductImage` keep
|
|
22
|
+
* their own handling; this is the simple guard for plain thumbnails/previews.
|
|
23
|
+
*/
|
|
24
|
+
export const SafeImg = React.forwardRef<HTMLImageElement, SafeImgProps>(
|
|
25
|
+
function SafeImg({ fallback = null, onError, src, ...rest }, ref) {
|
|
26
|
+
const [failed, setFailed] = useState(false);
|
|
27
|
+
// A new src is a fresh attempt — clear any prior failure so it can recover.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setFailed(false);
|
|
30
|
+
}, [src]);
|
|
31
|
+
|
|
32
|
+
if (failed) return <>{fallback}</>;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<img
|
|
36
|
+
{...rest}
|
|
37
|
+
ref={ref}
|
|
38
|
+
src={src}
|
|
39
|
+
onError={(e) => {
|
|
40
|
+
setFailed(true);
|
|
41
|
+
onError?.(e);
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
@@ -51,10 +51,46 @@ import React, {
|
|
|
51
51
|
import type { CarouselImage } from "./types";
|
|
52
52
|
import { EnhancedImageViewer } from "../zoom/EnhancedImageViewer";
|
|
53
53
|
import { HeroProductImage } from "../HeroProductImage";
|
|
54
|
+
import { LoadingOverlayPrismCandyInline } from "../../components/LoadingOverlayPrismCandy";
|
|
54
55
|
import type { ArtworkData } from "@snowcone-app/sdk";
|
|
55
56
|
import { useMockupPriorityOptional } from "../../patterns/MockupPriorityProvider";
|
|
56
57
|
import { useRealtimeOptional } from "../../patterns/RealtimeProvider";
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Static (non-mockup) carousel image with a graceful fallback. A reload or
|
|
61
|
+
* Safari bfcache restore can leave a dead `src` — a revoked `blob:` URL from an
|
|
62
|
+
* in-progress design, or an expired signed mockup URL — and without handling
|
|
63
|
+
* the browser paints its native broken-image icon (the question-mark box).
|
|
64
|
+
* On load failure we swap to a neutral muted panel so the carousel degrades
|
|
65
|
+
* cleanly instead of showing a broken image.
|
|
66
|
+
*/
|
|
67
|
+
function StaticCarouselImage({
|
|
68
|
+
src,
|
|
69
|
+
alt,
|
|
70
|
+
className,
|
|
71
|
+
}: {
|
|
72
|
+
src: string;
|
|
73
|
+
alt: string;
|
|
74
|
+
className?: string;
|
|
75
|
+
}) {
|
|
76
|
+
const [failed, setFailed] = useState(false);
|
|
77
|
+
if (failed || !src) {
|
|
78
|
+
return <div className={`bg-muted ${className ?? ""}`} role="img" aria-label={alt} />;
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
<img
|
|
82
|
+
src={src}
|
|
83
|
+
alt={alt}
|
|
84
|
+
crossOrigin="anonymous"
|
|
85
|
+
className={className}
|
|
86
|
+
loading="lazy"
|
|
87
|
+
decoding="async"
|
|
88
|
+
draggable={false}
|
|
89
|
+
onError={() => setFailed(true)}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
export interface MobileProductCarouselProps {
|
|
59
95
|
/** Array of images to display in the carousel */
|
|
60
96
|
images: CarouselImage[];
|
|
@@ -838,7 +874,9 @@ export const MobileProductCarousel = memo(function MobileProductCarousel({
|
|
|
838
874
|
{!isWithinDecodeWindow ? (
|
|
839
875
|
<div className="w-full h-full bg-muted" />
|
|
840
876
|
) : image.isPlaceholder ? (
|
|
841
|
-
|
|
877
|
+
// Rainbow prism loader (same as the /create route) instead of
|
|
878
|
+
// a flat pulse; fills this relative slide via inset:0.
|
|
879
|
+
<LoadingOverlayPrismCandyInline visible variant="light" />
|
|
842
880
|
) : image.isRealMockup && currentArtwork ? (
|
|
843
881
|
<HeroProductImage
|
|
844
882
|
productId={productId}
|
|
@@ -861,16 +899,12 @@ export const MobileProductCarousel = memo(function MobileProductCarousel({
|
|
|
861
899
|
className="w-full h-full object-cover cursor-pointer pointer-events-none"
|
|
862
900
|
/>
|
|
863
901
|
) : image.isRealMockup && !currentArtwork ? (
|
|
864
|
-
<
|
|
902
|
+
<LoadingOverlayPrismCandyInline visible variant="light" />
|
|
865
903
|
) : (
|
|
866
|
-
<
|
|
904
|
+
<StaticCarouselImage
|
|
867
905
|
src={image.src}
|
|
868
|
-
alt={image.label}
|
|
869
|
-
crossOrigin="anonymous"
|
|
906
|
+
alt={image.label ?? ""}
|
|
870
907
|
className="w-full h-full object-cover cursor-pointer pointer-events-none"
|
|
871
|
-
loading="lazy"
|
|
872
|
-
decoding="async"
|
|
873
|
-
draggable={false}
|
|
874
908
|
/>
|
|
875
909
|
)}
|
|
876
910
|
</div>
|
|
@@ -6,7 +6,7 @@ const MEILISEARCH_HOST =
|
|
|
6
6
|
"https://ms-e5d999b2eaca-15654.sfo.meilisearch.io";
|
|
7
7
|
const MEILISEARCH_API_KEY =
|
|
8
8
|
readEnv("NEXT_PUBLIC_MEILISEARCH_API_KEY") ||
|
|
9
|
-
"
|
|
9
|
+
"c4f053bf342250c472c2ef564628ab675543f46d0c1e606b09f41616a21c8fa7";
|
|
10
10
|
|
|
11
11
|
const { searchClient } = instantMeiliSearch(
|
|
12
12
|
MEILISEARCH_HOST,
|
|
@@ -9,6 +9,7 @@ import React, {
|
|
|
9
9
|
type ComponentType,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { createPortal } from "react-dom";
|
|
12
|
+
import { SafeImg } from "../SafeImg";
|
|
12
13
|
import {
|
|
13
14
|
TransformWrapper as OriginalTransformWrapper,
|
|
14
15
|
TransformComponent as OriginalTransformComponent,
|
|
@@ -332,7 +333,7 @@ export const EnhancedImageViewer = ({
|
|
|
332
333
|
justifyContent: "center",
|
|
333
334
|
}}
|
|
334
335
|
>
|
|
335
|
-
<
|
|
336
|
+
<SafeImg
|
|
336
337
|
ref={imgRef}
|
|
337
338
|
src={imageUrl}
|
|
338
339
|
alt={alt}
|
package/src/index.ts
CHANGED
|
@@ -191,6 +191,7 @@ export type { ArtworkCustomizerProps } from "./composed/ArtworkCustomizer";
|
|
|
191
191
|
// TEMPORARY: Canvas editor mockup for testing
|
|
192
192
|
export * from "./composed/CanvasEditor";
|
|
193
193
|
export * from "./composed/HeroProductImage";
|
|
194
|
+
export * from "./composed/SafeImg";
|
|
194
195
|
|
|
195
196
|
// Re-export types from SDK for convenience
|
|
196
197
|
export type {
|