@snowcone-app/ui 0.4.0 → 0.4.1

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.0",
3
+ "version": "0.4.1",
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.0"
106
+ "@snowcone-app/sdk": "0.17.1"
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
- // pulsing skeleton up until the first image has painted.
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
- src={renderedUrl}
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
- <div className="w-full h-full bg-muted-foreground/20 animate-pulse" />
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
- <div className="w-full h-full bg-muted animate-pulse" />
902
+ <LoadingOverlayPrismCandyInline visible variant="light" />
865
903
  ) : (
866
- <img
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
- "eee819b849798ad9091228c486ec05d0931e5292";
9
+ "4a11f6599e39af365f6289dab46b77a30ef78ba1ac078f7d74e152c099b3b63c";
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
- <img
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 {