@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/ui",
3
- "version": "0.4.8",
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.1"
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-bust retry. A reload / Safari bfcache restore can serve a
908
- // mockup from WebKit's disk cache as a CORS-less entry (cached by an earlier
909
- // non-crossorigin request), which then fails the crossorigin <img> here and
910
- // paints a broken image. On the FIRST load failure for a URL we retry once
911
- // with a `_cb` query param — a fresh URL can't hit the poisoned cache entry.
912
- // This fires ONLY on failure, so the cached happy path (and realtime, which
913
- // already cache-busts via `_t`) is untouched; only a genuinely-broken image
914
- // pays the re-fetch. A second failure falls through to the clean fallback.
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">No artwork</p>
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">Error loading</p>
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
- // On a retry, append a cache-bust param so the request can't be
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 with a cache-bust before
1054
- // giving up (recovers a WebKit disk-cache entry cached without CORS
1055
- // headers). Fires only on failure, so the cached happy path and the
1056
- // realtime `_t` flow are untouched.
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
- setRetryNonce((n) => n + 1);
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">Preview unavailable</p>
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
  })}