@snowcone-app/ui 0.4.9 → 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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.10
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`e57a5bc`](https://github.com/snowcone-app/snowcone-monorepo/commit/e57a5bcb3b62ac612e43c47ab2709992ef6db385)]:
8
+ - @snowcone-app/sdk@0.20.3
9
+
3
10
  ## 0.4.9
4
11
 
5
12
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -5929,6 +5929,67 @@ function useMockupBackgroundOptional() {
5929
5929
  return (0, import_react24.useContext)(MockupBackgroundContext);
5930
5930
  }
5931
5931
 
5932
+ // src/patterns/croppedFrameComposite.ts
5933
+ var COMPOSITE_QUALITY = 0.92;
5934
+ function isFullRect(rect) {
5935
+ if (!rect) return true;
5936
+ return rect.x === 0 && rect.y === 0 && rect.w === 1 && rect.h === 1;
5937
+ }
5938
+ function compositeOffset(rect, bgWidth, bgHeight) {
5939
+ return {
5940
+ dx: Math.round(rect.x * bgWidth),
5941
+ dy: Math.round(rect.y * bgHeight)
5942
+ };
5943
+ }
5944
+ function allBackgroundsSeeded(mockupIds, seeded) {
5945
+ return mockupIds.length > 0 && mockupIds.every((id) => seeded.has(id));
5946
+ }
5947
+ function loadImageElement(url) {
5948
+ return new Promise((resolve, reject) => {
5949
+ const img = new Image();
5950
+ img.crossOrigin = "anonymous";
5951
+ img.onload = () => {
5952
+ if (img.decode) {
5953
+ img.decode().then(
5954
+ () => resolve(img),
5955
+ () => resolve(img)
5956
+ );
5957
+ } else {
5958
+ resolve(img);
5959
+ }
5960
+ };
5961
+ img.onerror = () => reject(
5962
+ new Error(`E_OVERLAY_FRAME_LOAD: image failed to load: ${url.slice(0, 160)}`)
5963
+ );
5964
+ img.src = url;
5965
+ });
5966
+ }
5967
+ async function compositeCroppedFrame(args) {
5968
+ const frame = await loadImageElement(args.frameUrl);
5969
+ const width = args.background.naturalWidth;
5970
+ const height = args.background.naturalHeight;
5971
+ if (!width || !height) {
5972
+ throw new Error("E_OVERLAY_BG_EMPTY: background image has no pixels");
5973
+ }
5974
+ const canvas = args.canvas ?? document.createElement("canvas");
5975
+ canvas.width = width;
5976
+ canvas.height = height;
5977
+ const ctx = canvas.getContext("2d");
5978
+ if (!ctx) {
5979
+ throw new Error("E_OVERLAY_NO_2D_CONTEXT: canvas 2d context unavailable");
5980
+ }
5981
+ ctx.drawImage(args.background, 0, 0);
5982
+ const { dx, dy } = compositeOffset(args.rect, width, height);
5983
+ ctx.drawImage(frame, dx, dy);
5984
+ const blob = await new Promise(
5985
+ (resolve) => canvas.toBlob(resolve, "image/webp", COMPOSITE_QUALITY)
5986
+ );
5987
+ if (!blob) {
5988
+ throw new Error("E_OVERLAY_ENCODE_FAILED: canvas.toBlob returned null");
5989
+ }
5990
+ return blob;
5991
+ }
5992
+
5932
5993
  // src/patterns/RealtimeProvider.tsx
5933
5994
  var import_jsx_runtime52 = require("react/jsx-runtime");
5934
5995
  var DEFAULT_ARTBOARD_SIZE = 2e3;
@@ -5944,7 +6005,8 @@ function RealtimeProvider({
5944
6005
  mockupWidth = 1400,
5945
6006
  autoEnable = false,
5946
6007
  shop,
5947
- grantUrl
6008
+ grantUrl,
6009
+ delivery = "cropped"
5948
6010
  }) {
5949
6011
  const productContext = useProductOptional();
5950
6012
  const mockupBg = useMockupBackgroundOptional();
@@ -6048,58 +6110,214 @@ function RealtimeProvider({
6048
6110
  const registeredBlobUrlsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6049
6111
  const pendingPlacementsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6050
6112
  const pendingMockupIdsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6051
- (0, import_react25.useEffect)(() => {
6052
- if (rawMockupResults.length === 0) {
6053
- if (mockupResultsRef.current.length > 0) {
6054
- mockupResultsRef.current = [];
6055
- mockupSubscribersRef.current.forEach((cb) => cb([]));
6113
+ const backgroundsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
6114
+ const croppedActiveRef = (0, import_react25.useRef)(false);
6115
+ const cropCapableRef = (0, import_react25.useRef)(true);
6116
+ const lastConfigBaseRef = (0, import_react25.useRef)(null);
6117
+ const sceneGenRef = (0, import_react25.useRef)(0);
6118
+ const processedFrameRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
6119
+ const compositeQueueRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
6120
+ const compositeBusyRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
6121
+ const compositeCanvasRef = (0, import_react25.useRef)(null);
6122
+ const registerBlobUrl = (0, import_react25.useCallback)((blobOrUrl, placementName) => {
6123
+ let url;
6124
+ if (blobOrUrl instanceof Blob) {
6125
+ if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
6126
+ const oldest = registeredBlobUrlsRef.current.values().next().value;
6127
+ if (oldest) {
6128
+ URL.revokeObjectURL(oldest);
6129
+ registeredBlobUrlsRef.current.delete(oldest);
6130
+ mockupUrlCacheRef.current.forEach((value, key) => {
6131
+ if (value.url === oldest) {
6132
+ mockupUrlCacheRef.current.delete(key);
6133
+ }
6134
+ });
6135
+ mockupResultsRef.current = mockupResultsRef.current.filter(
6136
+ (r) => r.imageUrl !== oldest
6137
+ );
6138
+ }
6056
6139
  }
6057
- return;
6140
+ url = URL.createObjectURL(blobOrUrl);
6141
+ registeredBlobUrlsRef.current.add(url);
6142
+ } else {
6143
+ url = blobOrUrl;
6058
6144
  }
6059
- console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}`));
6060
- mockupResultsRef.current = rawMockupResults;
6061
- const now = Date.now();
6062
- rawMockupResults.forEach((result) => {
6063
- if (result.imageUrl) {
6145
+ return url;
6146
+ }, []);
6147
+ const croppedDeliveryEnabled = delivery !== "full-frame";
6148
+ const sendCropConfig = (0, import_react25.useCallback)(
6149
+ (withCrop) => {
6150
+ const base = lastConfigBaseRef.current;
6151
+ if (!base) return;
6152
+ croppedActiveRef.current = withCrop;
6153
+ sendConfigRaw({
6154
+ ...base,
6155
+ placementSettings: { ...placementSettingsOverridesRef.current },
6156
+ ...withCrop ? { overlayCrop: "auto" } : {}
6157
+ });
6158
+ },
6159
+ [sendConfigRaw]
6160
+ );
6161
+ const unflipCrop = (0, import_react25.useCallback)(() => {
6162
+ if (!croppedActiveRef.current) return;
6163
+ sendCropConfig(false);
6164
+ }, [sendCropConfig]);
6165
+ const maybeFlipCrop = (0, import_react25.useCallback)(() => {
6166
+ if (!croppedDeliveryEnabled || !cropCapableRef.current) return;
6167
+ if (croppedActiveRef.current) return;
6168
+ const base = lastConfigBaseRef.current;
6169
+ if (!base) return;
6170
+ const ids = base.mockupIds ?? [];
6171
+ const seeded = new Set(backgroundsRef.current.keys());
6172
+ if (allBackgroundsSeeded(ids, seeded)) {
6173
+ console.log(`[RTP] Cropped delivery ON \u2014 all ${ids.length} mockup background(s) seeded`);
6174
+ sendCropConfig(true);
6175
+ } else {
6176
+ const missing = ids.filter((id) => !seeded.has(id));
6177
+ console.log(`[RTP] Cropped delivery waiting \u2014 ${missing.length}/${ids.length} mockup(s) unseeded: [${missing.slice(0, 8).join(", ")}]`);
6178
+ }
6179
+ }, [croppedDeliveryEnabled, sendCropConfig]);
6180
+ const invalidateBackgrounds = (0, import_react25.useCallback)(() => {
6181
+ sceneGenRef.current += 1;
6182
+ backgroundsRef.current.clear();
6183
+ processedFrameRef.current.clear();
6184
+ compositeQueueRef.current.clear();
6185
+ croppedActiveRef.current = false;
6186
+ }, []);
6187
+ const deliverResults = (0, import_react25.useCallback)(
6188
+ (results) => {
6189
+ if (results.length === 0) return;
6190
+ const now = Date.now();
6191
+ const merged = [...mockupResultsRef.current];
6192
+ results.forEach((result) => {
6193
+ const idx = merged.findIndex((r) => r.mockupId === result.mockupId);
6194
+ if (idx >= 0) merged[idx] = result;
6195
+ else merged.push(result);
6196
+ });
6197
+ mockupResultsRef.current = merged;
6198
+ const clearedPlacements = /* @__PURE__ */ new Set();
6199
+ results.forEach((result) => {
6064
6200
  const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
6065
6201
  mockupUrlCacheRef.current.set(result.mockupId, {
6066
6202
  url: result.imageUrl,
6067
- isBlob: false,
6068
- // Server URLs are not blob URLs
6203
+ isBlob: result.imageUrl.startsWith("blob:"),
6069
6204
  timestamp: now
6070
6205
  });
6071
6206
  pendingMockupIdsRef.current.delete(result.mockupId);
6072
6207
  if (wasPending) {
6073
6208
  console.log(`[RTP] Cleared pending for ${result.mockupId} \u2014 url cached`);
6074
6209
  }
6210
+ if (result.placement) clearedPlacements.add(result.placement);
6211
+ });
6212
+ if (clearedPlacements.size > 0) {
6213
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
6075
6214
  } else {
6076
- console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
6215
+ pendingPlacementsRef.current.clear();
6077
6216
  }
6078
- });
6079
- const clearedPlacements = /* @__PURE__ */ new Set();
6080
- rawMockupResults.forEach((result) => {
6081
- if (result.placement) clearedPlacements.add(result.placement);
6082
- });
6083
- if (clearedPlacements.size > 0) {
6084
- clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
6085
- } else {
6086
- pendingPlacementsRef.current.clear();
6217
+ mockupSubscribersRef.current.forEach((cb) => cb(results));
6218
+ results.forEach((result) => {
6219
+ const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
6220
+ if (subscribers && subscribers.size > 0) {
6221
+ console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
6222
+ subscribers.forEach((cb) => cb(result));
6223
+ } else {
6224
+ console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
6225
+ }
6226
+ });
6227
+ if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
6228
+ hasBlobInFlightRef.current = false;
6229
+ pipelineSettledSubscribersRef.current.forEach((cb) => cb());
6230
+ }
6231
+ },
6232
+ [hasPendingBlobs]
6233
+ );
6234
+ const runComposite = (0, import_react25.useCallback)(
6235
+ async (result) => {
6236
+ const gen = sceneGenRef.current;
6237
+ const bg = backgroundsRef.current.get(result.mockupId);
6238
+ if (!bg) {
6239
+ console.error(
6240
+ `[RealtimeProvider] E_OVERLAY_NO_BACKGROUND: cropped frame for ${result.mockupId} has no frozen background \u2014 falling back to full-frame and re-rendering it.`
6241
+ );
6242
+ unflipCrop();
6243
+ updateMockupIdsRaw([result.mockupId]);
6244
+ return;
6245
+ }
6246
+ try {
6247
+ if (!bg.imgPromise) bg.imgPromise = loadImageElement(bg.url);
6248
+ const background = await bg.imgPromise;
6249
+ if (!compositeCanvasRef.current && typeof document !== "undefined") {
6250
+ compositeCanvasRef.current = document.createElement("canvas");
6251
+ }
6252
+ const blob = await compositeCroppedFrame({
6253
+ background,
6254
+ frameUrl: result.imageUrl,
6255
+ rect: result.cropRect,
6256
+ canvas: compositeCanvasRef.current ?? void 0
6257
+ });
6258
+ if (gen !== sceneGenRef.current) return;
6259
+ deliverResults([{ ...result, imageUrl: registerBlobUrl(blob) }]);
6260
+ } catch (err) {
6261
+ console.error(
6262
+ "[RealtimeProvider] E_OVERLAY_COMPOSITE_FAILED: cropped-frame composite failed \u2014 full-frame delivery for the rest of this session.",
6263
+ err
6264
+ );
6265
+ cropCapableRef.current = false;
6266
+ unflipCrop();
6267
+ updateMockupIdsRaw([result.mockupId]);
6268
+ }
6269
+ },
6270
+ [deliverResults, registerBlobUrl, unflipCrop, updateMockupIdsRaw]
6271
+ );
6272
+ const pumpCompositeQueue = (0, import_react25.useCallback)(
6273
+ (mockupId) => {
6274
+ if (compositeBusyRef.current.has(mockupId)) return;
6275
+ const job = compositeQueueRef.current.get(mockupId);
6276
+ if (!job) return;
6277
+ compositeQueueRef.current.delete(mockupId);
6278
+ compositeBusyRef.current.add(mockupId);
6279
+ void runComposite(job).finally(() => {
6280
+ compositeBusyRef.current.delete(mockupId);
6281
+ pumpCompositeQueue(mockupId);
6282
+ });
6283
+ },
6284
+ [runComposite]
6285
+ );
6286
+ (0, import_react25.useEffect)(() => {
6287
+ if (rawMockupResults.length === 0) {
6288
+ if (mockupResultsRef.current.length > 0) {
6289
+ mockupResultsRef.current = [];
6290
+ mockupSubscribersRef.current.forEach((cb) => cb([]));
6291
+ }
6292
+ return;
6087
6293
  }
6088
- mockupSubscribersRef.current.forEach((cb) => cb(rawMockupResults));
6294
+ console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map((r) => `${r.mockupId}${r.imageUrl ? " \u2713url" : " \u2717NO-URL"}${r.cropRect ? " [crop]" : ""}`));
6295
+ const displayable = [];
6089
6296
  rawMockupResults.forEach((result) => {
6090
- const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
6091
- if (subscribers && subscribers.size > 0) {
6092
- console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
6093
- subscribers.forEach((cb) => cb(result));
6094
- } else {
6095
- console.log(`[RTP] No subscribers for ${result.mockupId} \u2014 result received but nobody listening`);
6297
+ if (!result.imageUrl) {
6298
+ console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl \u2014 not caching, pending NOT cleared`);
6299
+ return;
6300
+ }
6301
+ const firstSight = processedFrameRef.current.get(result.mockupId) !== result.imageUrl;
6302
+ const sessionLive = configSentRef.current;
6303
+ if (isFullRect(result.cropRect)) {
6304
+ if (firstSight && sessionLive) {
6305
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
6306
+ backgroundsRef.current.set(result.mockupId, { url: result.imageUrl });
6307
+ }
6308
+ displayable.push(result);
6309
+ } else if (firstSight && sessionLive) {
6310
+ processedFrameRef.current.set(result.mockupId, result.imageUrl);
6311
+ compositeQueueRef.current.set(result.mockupId, result);
6312
+ pumpCompositeQueue(result.mockupId);
6313
+ } else if (pendingMockupIdsRef.current.has(result.mockupId)) {
6314
+ const cached = mockupUrlCacheRef.current.get(result.mockupId);
6315
+ if (cached) displayable.push({ ...result, imageUrl: cached.url });
6096
6316
  }
6097
6317
  });
6098
- if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
6099
- hasBlobInFlightRef.current = false;
6100
- pipelineSettledSubscribersRef.current.forEach((cb) => cb());
6101
- }
6102
- }, [rawMockupResults]);
6318
+ deliverResults(displayable);
6319
+ maybeFlipCrop();
6320
+ }, [rawMockupResults, deliverResults, maybeFlipCrop, pumpCompositeQueue]);
6103
6321
  const sendCanvasBlob = (0, import_react25.useCallback)(
6104
6322
  (placement, blob, mockupCount, baseThrottleMs) => {
6105
6323
  blobQueuedGenRef.current = pipelineGenRef.current;
@@ -6160,9 +6378,11 @@ function RealtimeProvider({
6160
6378
  lastSentBgRef.current = void 0;
6161
6379
  lastSentSpreadCropsRef.current = "null";
6162
6380
  lastSentColorsRef.current = {};
6381
+ invalidateBackgrounds();
6382
+ lastConfigBaseRef.current = null;
6163
6383
  };
6164
6384
  }
6165
- }, [isEnabled, connect, disconnect, wsUrl]);
6385
+ }, [isEnabled, connect, disconnect, wsUrl, invalidateBackgrounds]);
6166
6386
  (0, import_react25.useEffect)(() => {
6167
6387
  if (rawMockupResults.length > 0 && isPendingMockupsRef.current) {
6168
6388
  isPendingMockupsRef.current = false;
@@ -6186,6 +6406,9 @@ function RealtimeProvider({
6186
6406
  if (!isFirstSend && !variantChanged && !bgChanged && !spreadCropsChanged) {
6187
6407
  return;
6188
6408
  }
6409
+ if (variantChanged || bgChanged) {
6410
+ invalidateBackgrounds();
6411
+ }
6189
6412
  const mockupIds = [];
6190
6413
  if (product.mockups && Array.isArray(product.mockups)) {
6191
6414
  product.mockups.forEach((mockup) => {
@@ -6220,7 +6443,10 @@ function RealtimeProvider({
6220
6443
  pendingBlobSubscribersRef.current.forEach((cb) => cb(p.label));
6221
6444
  });
6222
6445
  }
6223
- sendConfigRaw(configPayload);
6446
+ lastConfigBaseRef.current = configPayload;
6447
+ sendConfigRaw(
6448
+ croppedActiveRef.current ? { ...configPayload, overlayCrop: "auto" } : configPayload
6449
+ );
6224
6450
  configSentRef.current = true;
6225
6451
  lastSentVariantIdRef.current = variantId;
6226
6452
  lastSentBgRef.current = mockupBg;
@@ -6242,6 +6468,7 @@ function RealtimeProvider({
6242
6468
  productContext?.selection,
6243
6469
  sendConfigRaw,
6244
6470
  sendColorBlobTracked,
6471
+ invalidateBackgrounds,
6245
6472
  mockupWidth,
6246
6473
  mockupBg,
6247
6474
  spreadCropsVersion
@@ -6271,6 +6498,15 @@ function RealtimeProvider({
6271
6498
  productContext?.selection,
6272
6499
  sendColorBlobTracked
6273
6500
  ]);
6501
+ const updateMockupIdsGuarded = (0, import_react25.useCallback)(
6502
+ (mockupIds) => {
6503
+ if (croppedActiveRef.current && mockupIds.some((id) => !backgroundsRef.current.has(id))) {
6504
+ unflipCrop();
6505
+ }
6506
+ return updateMockupIdsRaw(mockupIds);
6507
+ },
6508
+ [updateMockupIdsRaw, unflipCrop]
6509
+ );
6274
6510
  const updatePlacementSettingsWrapped = (0, import_react25.useCallback)(
6275
6511
  (settings) => {
6276
6512
  placementSettingsOverridesRef.current = settings;
@@ -6470,28 +6706,6 @@ function RealtimeProvider({
6470
6706
  const isMockupLoading = (0, import_react25.useCallback)((mockupId) => {
6471
6707
  return pendingMockupIdsRef.current.has(mockupId);
6472
6708
  }, []);
6473
- const registerBlobUrl = (0, import_react25.useCallback)((blobOrUrl, placementName) => {
6474
- let url;
6475
- if (blobOrUrl instanceof Blob) {
6476
- if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
6477
- const oldest = registeredBlobUrlsRef.current.values().next().value;
6478
- if (oldest) {
6479
- URL.revokeObjectURL(oldest);
6480
- registeredBlobUrlsRef.current.delete(oldest);
6481
- mockupUrlCacheRef.current.forEach((value, key) => {
6482
- if (value.url === oldest) {
6483
- mockupUrlCacheRef.current.delete(key);
6484
- }
6485
- });
6486
- }
6487
- }
6488
- url = URL.createObjectURL(blobOrUrl);
6489
- registeredBlobUrlsRef.current.add(url);
6490
- } else {
6491
- url = blobOrUrl;
6492
- }
6493
- return url;
6494
- }, []);
6495
6709
  const subscribeMockupResultsRef = (0, import_react25.useRef)(subscribeMockupResults);
6496
6710
  const subscribeMockupResultByIdRef = (0, import_react25.useRef)(subscribeMockupResultById);
6497
6711
  const subscribePendingBlobRef = (0, import_react25.useRef)(subscribePendingBlob);
@@ -6572,7 +6786,7 @@ function RealtimeProvider({
6572
6786
  disableRealtime,
6573
6787
  sendCanvasBlob,
6574
6788
  sendCanvasState,
6575
- updateMockupIds: updateMockupIdsRaw,
6789
+ updateMockupIds: updateMockupIdsGuarded,
6576
6790
  updatePlacementSettings: updatePlacementSettingsWrapped,
6577
6791
  setSpreadCrops,
6578
6792
  getMockupResultsImmediate: () => mockupResultsRef.current,
@@ -6609,7 +6823,7 @@ function RealtimeProvider({
6609
6823
  disableRealtime,
6610
6824
  sendCanvasBlob,
6611
6825
  sendCanvasState,
6612
- updateMockupIdsRaw,
6826
+ updateMockupIdsGuarded,
6613
6827
  updatePlacementSettingsWrapped,
6614
6828
  setSpreadCrops,
6615
6829
  stableSubscribeMockupResults,
@@ -10086,7 +10300,10 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10086
10300
  // Quick crossfade
10087
10301
  shimmerDelay = 200,
10088
10302
  shimmerFadeDuration = 500,
10089
- darkShimmerFadeInDuration = 300
10303
+ darkShimmerFadeInDuration = 300,
10304
+ emptyText = "No artwork",
10305
+ errorText = "Error loading",
10306
+ unavailableText = "Preview unavailable"
10090
10307
  }) {
10091
10308
  const context = useProductOptional();
10092
10309
  const designContext = useDesignOptional();
@@ -10531,7 +10748,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10531
10748
  )
10532
10749
  }
10533
10750
  ),
10534
- /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: "No artwork" })
10751
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: emptyText })
10535
10752
  ] })
10536
10753
  }
10537
10754
  );
@@ -10542,7 +10759,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10542
10759
  {
10543
10760
  className: `bg-red-50 flex items-center justify-center ${className || ""}`,
10544
10761
  style,
10545
- children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "text-red-600 text-center p-4", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: "Error loading" }) })
10762
+ children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "text-red-600 text-center p-4", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs", children: errorText }) })
10546
10763
  }
10547
10764
  );
10548
10765
  }
@@ -10561,7 +10778,7 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10561
10778
  crossOrigin: "anonymous",
10562
10779
  className: "absolute inset-0 w-full h-full object-cover",
10563
10780
  draggable,
10564
- src: retriedUrlRef.current === renderedUrl ? `${renderedUrl}${renderedUrl.includes("?") ? "&" : "?"}_cb=${retryNonce}` : renderedUrl,
10781
+ src: renderedUrl,
10565
10782
  loading: "eager",
10566
10783
  fetchPriority: "high",
10567
10784
  onClick,
@@ -10577,7 +10794,8 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10577
10794
  onError: () => {
10578
10795
  if (renderedUrl && retriedUrlRef.current !== renderedUrl) {
10579
10796
  retriedUrlRef.current = renderedUrl;
10580
- setRetryNonce((n) => n + 1);
10797
+ void fetch(renderedUrl, { mode: "cors", cache: "reload", credentials: "omit" }).catch(() => {
10798
+ }).then(() => setRetryNonce((n) => n + 1));
10581
10799
  return;
10582
10800
  }
10583
10801
  setImgFailed(true);
@@ -10591,9 +10809,10 @@ var HeroProductImage = (0, import_react46.memo)(function HeroProductImage2({
10591
10809
  );
10592
10810
  }
10593
10811
  }
10594
- }
10812
+ },
10813
+ retryNonce
10595
10814
  ),
10596
- imgFailed && !prevUrl && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "absolute inset-0 flex items-center justify-center bg-muted", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-muted-foreground text-xs", children: "Preview unavailable" }) }),
10815
+ imgFailed && !prevUrl && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { className: "absolute inset-0 flex items-center justify-center bg-muted", children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-muted-foreground text-xs", children: unavailableText }) }),
10597
10816
  realtimeContext?.renderError && /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
10598
10817
  "div",
10599
10818
  {
@@ -12083,7 +12302,10 @@ var MobileProductCarousel = (0, import_react56.memo)(function MobileProductCarou
12083
12302
  onZoomChange,
12084
12303
  stickyHeroContext,
12085
12304
  mockupWidth,
12086
- onMockupUrlGenerated
12305
+ onMockupUrlGenerated,
12306
+ renderImageAction,
12307
+ errorText,
12308
+ unavailableText
12087
12309
  }) {
12088
12310
  const [activeIndex, setActiveIndex] = (0, import_react56.useState)(currentIndex);
12089
12311
  const [touchStart, setTouchStart] = (0, import_react56.useState)(
@@ -12617,34 +12839,52 @@ var MobileProductCarousel = (0, import_react56.memo)(function MobileProductCarou
12617
12839
  children: images.map((image, index) => {
12618
12840
  const DECODE_WINDOW = 1;
12619
12841
  const isWithinDecodeWindow = Math.abs(index - activeIndex) <= DECODE_WINDOW;
12620
- return /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12842
+ return /* @__PURE__ */ (0, import_jsx_runtime74.jsxs)(
12621
12843
  "div",
12622
12844
  {
12623
12845
  className: "w-full h-full flex-shrink-0 relative select-none",
12624
- children: !isWithinDecodeWindow ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12625
- // Rainbow prism loader (same as the /create route) instead of
12626
- // a flat pulse; fills this relative slide via inset:0.
12627
- /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12628
- ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12629
- HeroProductImage,
12630
- {
12631
- productId,
12632
- artwork: currentArtwork,
12633
- placement: image.placement,
12634
- mockupId: image.mockupId,
12635
- width: mockupWidth,
12636
- realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12637
- onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12638
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12639
- }
12640
- ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12641
- StaticCarouselImage,
12642
- {
12643
- src: image.src,
12644
- alt: image.label ?? "",
12645
- className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12646
- }
12647
- )
12846
+ children: [
12847
+ !isWithinDecodeWindow ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)("div", { className: "w-full h-full bg-muted" }) : image.isPlaceholder ? (
12848
+ // Rainbow prism loader (same as the /create route) instead of
12849
+ // a flat pulse; fills this relative slide via inset:0.
12850
+ /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" })
12851
+ ) : image.isRealMockup && currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12852
+ HeroProductImage,
12853
+ {
12854
+ productId,
12855
+ artwork: currentArtwork,
12856
+ placement: image.placement,
12857
+ mockupId: image.mockupId,
12858
+ width: mockupWidth,
12859
+ realtimeUrl: image.mockupId ? realtimeMockupUrls[image.mockupId] ?? generatedUrlsRef.current.get(image.mockupId) ?? void 0 : void 0,
12860
+ onUrlGenerated: image.mockupId ? (url) => handleUrlGenerated(image.mockupId, url) : void 0,
12861
+ errorText,
12862
+ unavailableText,
12863
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12864
+ }
12865
+ ) : image.isRealMockup && !currentArtwork ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(LoadingOverlayPrismCandyInline, { visible: true, variant: "light" }) : /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12866
+ StaticCarouselImage,
12867
+ {
12868
+ src: image.src,
12869
+ alt: image.label ?? "",
12870
+ className: "w-full h-full object-cover cursor-pointer pointer-events-none"
12871
+ }
12872
+ ),
12873
+ isWithinDecodeWindow && (() => {
12874
+ const action = renderImageAction?.(image, index);
12875
+ return action ? /* @__PURE__ */ (0, import_jsx_runtime74.jsx)(
12876
+ "div",
12877
+ {
12878
+ className: "absolute top-3 right-3 z-10",
12879
+ onClick: (e) => e.stopPropagation(),
12880
+ onTouchStart: (e) => e.stopPropagation(),
12881
+ onTouchEnd: (e) => e.stopPropagation(),
12882
+ onMouseDown: (e) => e.stopPropagation(),
12883
+ children: action
12884
+ }
12885
+ ) : null;
12886
+ })()
12887
+ ]
12648
12888
  },
12649
12889
  index
12650
12890
  );