@deneb-ui/ui 2.0.60 → 2.0.61

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/README.md CHANGED
@@ -100,6 +100,7 @@ export default function Page() {
100
100
  | `ProductGrid` | `EditableProductGrid` | `data-preview-list-path`, `data-preview-item-path` | Catalog grid with quick-view modal hook & filter integration |
101
101
  | `ProductShowcase` | `EditableProductShowcase` | `data-preview-field-path`, `data-preview-list-path`, `color` swatch | Flagship product showcase with color swatches, filter tabs, quick-view & WhatsApp order |
102
102
  | `ProductDetail` | `EditableProductDetail` | `data-preview-page-key`, `data-preview-field-path` | Full single-product view with gallery, specs, & inquiry actions |
103
+ | `PlatformProductDetail` | `PlatformProductDetail` | `useProducts`, live catalog API | Stable static-export product page with URL lookup, retries, and fallback states |
103
104
  | `ProductQuickView` | `ProductQuickView` | `data-preview-field-path` | Instant lightbox modal product inspection with quantity counter |
104
105
  | `CartDrawer` | `EditableCartDrawer` | `data-preview-list-path`, `data-preview-item-path` | Slide-over cart drawer with 1-click unified WhatsApp order compilation |
105
106
  | `FilterSidebar` | `EditableFilterSidebar` | `data-preview-field-path`, `useSiteData` | Faceted catalog filter sidebar (categories, price range, sizes) |
@@ -159,6 +160,7 @@ export default function Page() {
159
160
  | :--- | :--- | :--- |
160
161
  | `SiteDataProvider` / `DenebDataProvider` | `SiteDataProvider` | Real-time backend catalog fetcher & `postMessage` preview state synchronizer |
161
162
  | `useProducts` | `SiteDataProvider` | React hook to access live synchronized products list (`content.products`) |
163
+ | `usePlatformProductDetail` | `PlatformProductDetail` | Resolves `?id=` against live data and the catalog API for a custom detail design |
162
164
  | `useServices` | `SiteDataProvider` | React hook to access live synchronized services list (`content.services`) |
163
165
  | `useSiteCatalog` | `SiteDataProvider` | Returns `{ products, services, project, siteInstance, api }` in one call |
164
166
  | `useSiteApi` | `SiteDataProvider` | Returns official Fivora backend endpoints (`catalogUrl`, `contactUrl`, `analyticsUrl`) |
@@ -170,6 +172,51 @@ export default function Page() {
170
172
  | `useDenebFonts` | `fonts/useDenebFonts` | Hook for checking active font definitions |
171
173
  | `CartProvider` / `useCart`| `cart/useCart` | Global cart state management with persistent storage |
172
174
 
175
+ ## Safe product detail routes for static exports
176
+
177
+ Never link live products to `/products/${product.id}`. A product created after
178
+ the template build has no matching static directory, so that URL can return a
179
+ 404. Export one stable page and pass the product ID in the query string.
180
+
181
+ ```tsx
182
+ // src/app/products/detail/page.tsx
183
+ import { PlatformProductDetail } from '@deneb-ui/ui';
184
+
185
+ export default function ProductDetailPage() {
186
+ return <PlatformProductDetail />;
187
+ }
188
+ ```
189
+
190
+ Build product links with the matching helper:
191
+
192
+ ```tsx
193
+ import { platformProductDetailHref } from '@deneb-ui/ui';
194
+
195
+ <a href={platformProductDetailHref(product.id)}>View product</a>
196
+ ```
197
+
198
+ For a template-specific design, keep the platform data behavior and replace
199
+ only the renderer:
200
+
201
+ ```tsx
202
+ import { PlatformProductDetail } from '@deneb-ui/ui';
203
+
204
+ export default function ProductDetailPage() {
205
+ return (
206
+ <PlatformProductDetail
207
+ renderProduct={(product, { productIndex }) => (
208
+ <MyProductDetail product={product} index={productIndex} />
209
+ )}
210
+ />
211
+ );
212
+ }
213
+ ```
214
+
215
+ `PlatformProductDetail` reads `/products/detail/?id=PRODUCT_ID`, checks the
216
+ reactive `SiteDataProvider` catalog, retries `api.catalogUrl`, and renders safe
217
+ loading, network-error, and not-found states. `usePlatformProductDetail()` is
218
+ also exported for developers who need complete control of the page markup.
219
+
173
220
  ---
174
221
 
175
222
  ## Data Fetching & Backend Integration Hooks
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { MeasurementUnit } from './utils/productOptions';
3
3
  export interface ProductDetailItem {
4
- id?: string;
4
+ id?: string | number;
5
5
  name?: string;
6
6
  title?: string;
7
7
  brand?: string;
@@ -13,7 +13,9 @@ export interface ProductDetailItem {
13
13
  badge?: string;
14
14
  featuredImage?: string;
15
15
  imageUrl?: string;
16
+ image?: string;
16
17
  gallery?: string[];
18
+ images?: string[];
17
19
  addToSelectionLabel?: string;
18
20
  specsTitle?: string;
19
21
  specs?: Array<{
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { type EditableProductDetailProps, type ProductDetailItem } from './EditableProductDetail';
3
+ export type PlatformProductDetailStatus = 'loading' | 'ready' | 'not-found' | 'error';
4
+ export interface UsePlatformProductDetailOptions {
5
+ /** Product ID override. When omitted, the ID is read from `?id=` in the browser URL. */
6
+ productId?: string | number | null;
7
+ /** Query-string key used when `productId` is omitted. Defaults to `id`. */
8
+ queryParam?: string;
9
+ /** Optional product seed used when SiteDataProvider has not supplied a catalog yet. */
10
+ products?: ProductDetailItem[];
11
+ /** Optional live catalog endpoint override. Pass `null` to disable the remote lookup. */
12
+ catalogUrl?: string | null;
13
+ /** Retry delays in milliseconds. The default makes three attempts: immediately, 750ms, and 2s. */
14
+ retryDelays?: readonly number[];
15
+ }
16
+ export interface PlatformProductDetailResult {
17
+ product: ProductDetailItem | null;
18
+ productId: string;
19
+ productIndex: number;
20
+ status: PlatformProductDetailStatus;
21
+ error: Error | null;
22
+ retry: () => void;
23
+ }
24
+ export interface PlatformProductDetailRenderContext extends PlatformProductDetailResult {
25
+ sectionPath: string;
26
+ }
27
+ export interface PlatformProductDetailProps extends Omit<EditableProductDetailProps, 'product'>, UsePlatformProductDetailOptions {
28
+ /** Render a template-specific detail design while the platform handles URL and live-data resolution. */
29
+ renderProduct?: (product: ProductDetailItem, context: PlatformProductDetailRenderContext) => React.ReactNode;
30
+ /** Custom loading content. */
31
+ loadingFallback?: React.ReactNode;
32
+ /** Custom missing-product content. */
33
+ notFoundFallback?: React.ReactNode;
34
+ /** Custom network-error content. */
35
+ errorFallback?: React.ReactNode;
36
+ /** Destination used by the default Back to products action. */
37
+ backHref?: string;
38
+ /** Label used by the default Back to products action. */
39
+ backLabel?: string;
40
+ }
41
+ /**
42
+ * Resolves a product from the stable static route (`/products/detail/?id=...`).
43
+ * It checks the live SiteDataProvider catalog first, then retries the public
44
+ * catalog endpoint so newly-created merchant products work without rebuilding
45
+ * one static route per product.
46
+ */
47
+ export declare function usePlatformProductDetail({ productId: productIdOverride, queryParam, products: fallbackProducts, catalogUrl: catalogUrlOverride, retryDelays, }?: UsePlatformProductDetailOptions): PlatformProductDetailResult;
48
+ /** Build the only product-detail URL that is safe for newly-created products in a static export. */
49
+ export declare function platformProductDetailHref(productId: string | number | null | undefined, route?: string, queryParam?: string): string;
50
+ /**
51
+ * Ready-made live product page for Fivora templates. Use `renderProduct` when
52
+ * the template needs its own design; URL parsing, live lookup, retries, and
53
+ * fallback states remain owned by this component.
54
+ */
55
+ export declare function PlatformProductDetail({ productId, queryParam, products, catalogUrl, retryDelays, renderProduct, loadingFallback, notFoundFallback, errorFallback, backHref, backLabel, sectionPath, ...detailProps }: PlatformProductDetailProps): string | number | bigint | boolean | React.JSX.Element | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined>;
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.usePlatformProductDetail = usePlatformProductDetail;
5
+ exports.platformProductDetailHref = platformProductDetailHref;
6
+ exports.PlatformProductDetail = PlatformProductDetail;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ const react_1 = require("react");
9
+ const EditableProductDetail_1 = require("./EditableProductDetail");
10
+ const SiteDataProvider_1 = require("./SiteDataProvider");
11
+ const utils_1 = require("./utils");
12
+ const DEFAULT_QUERY_PARAM = 'id';
13
+ const DEFAULT_DETAIL_ROUTE = '/products/detail/';
14
+ const DEFAULT_RETRY_DELAYS = [0, 750, 2_000];
15
+ function normalizeId(value) {
16
+ if (typeof value !== 'string' && typeof value !== 'number')
17
+ return '';
18
+ return String(value).trim();
19
+ }
20
+ function readProductId(queryParam) {
21
+ if (typeof window === 'undefined')
22
+ return '';
23
+ return normalizeId(new URL(window.location.href).searchParams.get(queryParam));
24
+ }
25
+ function resolveCatalogUrl(siteData, override) {
26
+ if (override === null)
27
+ return null;
28
+ if (typeof override === 'string')
29
+ return override.trim() || null;
30
+ const configured = siteData.api?.catalogUrl;
31
+ if (typeof configured === 'string' && configured.trim()) {
32
+ return configured.trim();
33
+ }
34
+ const slug = normalizeId(siteData.siteInstance?.slug || siteData.project?.slug || siteData.project?.id);
35
+ if (!slug)
36
+ return null;
37
+ const baseUrl = siteData.api?.baseUrl;
38
+ if (typeof baseUrl === 'string' && baseUrl.trim()) {
39
+ return `${baseUrl.replace(/\/+$/, '')}/site-catalog/${encodeURIComponent(slug)}/live-data`;
40
+ }
41
+ return `/site-catalog/${encodeURIComponent(slug)}/live-data`;
42
+ }
43
+ function readCatalogProducts(payload) {
44
+ if (!(0, SiteDataProvider_1.isRecord)(payload))
45
+ return [];
46
+ if (Array.isArray(payload.products)) {
47
+ return payload.products;
48
+ }
49
+ if ((0, SiteDataProvider_1.isRecord)(payload.content) && Array.isArray(payload.content.products)) {
50
+ return payload.content.products;
51
+ }
52
+ return [];
53
+ }
54
+ function findProduct(products, productId) {
55
+ if (!productId)
56
+ return { product: null, index: -1 };
57
+ const index = products.findIndex((candidate) => normalizeId(candidate?.id) === productId);
58
+ return {
59
+ product: index >= 0 ? products[index] : null,
60
+ index,
61
+ };
62
+ }
63
+ function normalizeProductForDetail(product, siteData) {
64
+ const image = (typeof product.featuredImage === 'string' && product.featuredImage) ||
65
+ (typeof product.imageUrl === 'string' && product.imageUrl) ||
66
+ (typeof product.image === 'string' && product.image) ||
67
+ undefined;
68
+ const images = Array.isArray(product.gallery)
69
+ ? product.gallery
70
+ : Array.isArray(product.images)
71
+ ? product.images
72
+ : undefined;
73
+ const shop = (0, SiteDataProvider_1.isRecord)(siteData.shop) ? siteData.shop : {};
74
+ const merchant = (0, SiteDataProvider_1.isRecord)(siteData.merchant) ? siteData.merchant : {};
75
+ const whatsappNumber = (typeof product.whatsappNumber === 'string' && product.whatsappNumber) ||
76
+ (typeof shop.whatsapp === 'string' && shop.whatsapp) ||
77
+ (typeof shop.whatsappNumber === 'string' && shop.whatsappNumber) ||
78
+ (typeof merchant.whatsapp === 'string' && merchant.whatsapp) ||
79
+ undefined;
80
+ return {
81
+ ...product,
82
+ ...(image && !product.featuredImage ? { featuredImage: image } : {}),
83
+ ...(images && !product.gallery ? { gallery: images } : {}),
84
+ ...(whatsappNumber && !product.whatsappNumber ? { whatsappNumber } : {}),
85
+ };
86
+ }
87
+ /**
88
+ * Resolves a product from the stable static route (`/products/detail/?id=...`).
89
+ * It checks the live SiteDataProvider catalog first, then retries the public
90
+ * catalog endpoint so newly-created merchant products work without rebuilding
91
+ * one static route per product.
92
+ */
93
+ function usePlatformProductDetail({ productId: productIdOverride, queryParam = DEFAULT_QUERY_PARAM, products: fallbackProducts = [], catalogUrl: catalogUrlOverride, retryDelays = DEFAULT_RETRY_DELAYS, } = {}) {
94
+ const siteData = (0, SiteDataProvider_1.useSiteData)();
95
+ const products = (0, SiteDataProvider_1.useProducts)(fallbackProducts);
96
+ const explicitProductId = normalizeId(productIdOverride);
97
+ const [locationState, setLocationState] = (0, react_1.useState)(() => ({
98
+ initialized: Boolean(explicitProductId),
99
+ productId: explicitProductId,
100
+ }));
101
+ const [requestVersion, setRequestVersion] = (0, react_1.useState)(0);
102
+ const [remoteState, setRemoteState] = (0, react_1.useState)({
103
+ key: '',
104
+ product: null,
105
+ index: -1,
106
+ status: 'loading',
107
+ error: null,
108
+ });
109
+ (0, react_1.useEffect)(() => {
110
+ if (explicitProductId) {
111
+ setLocationState({ initialized: true, productId: explicitProductId });
112
+ return;
113
+ }
114
+ const updateFromLocation = () => {
115
+ setLocationState({
116
+ initialized: true,
117
+ productId: readProductId(queryParam),
118
+ });
119
+ };
120
+ updateFromLocation();
121
+ window.addEventListener('popstate', updateFromLocation);
122
+ return () => window.removeEventListener('popstate', updateFromLocation);
123
+ }, [explicitProductId, queryParam]);
124
+ const productId = explicitProductId || locationState.productId;
125
+ const localMatch = (0, react_1.useMemo)(() => findProduct(products, productId), [productId, products]);
126
+ const catalogUrl = (0, react_1.useMemo)(() => resolveCatalogUrl(siteData, catalogUrlOverride), [catalogUrlOverride, siteData]);
127
+ const retryKey = retryDelays.join(',');
128
+ (0, react_1.useEffect)(() => {
129
+ if (!locationState.initialized || !productId || localMatch.product || !catalogUrl) {
130
+ return;
131
+ }
132
+ const controller = new AbortController();
133
+ const requestKey = `${productId}|${catalogUrl}|${requestVersion}`;
134
+ const parsedDelays = retryKey
135
+ .split(',')
136
+ .map((value) => Number(value))
137
+ .filter((value) => Number.isFinite(value));
138
+ const delays = parsedDelays.length > 0 ? parsedDelays : [0];
139
+ let timer = null;
140
+ setRemoteState({
141
+ key: requestKey,
142
+ product: null,
143
+ index: -1,
144
+ status: 'loading',
145
+ error: null,
146
+ });
147
+ const attempt = async (attemptIndex) => {
148
+ const delay = Math.max(0, delays[attemptIndex] ?? 0);
149
+ if (delay > 0) {
150
+ await new Promise((resolve) => {
151
+ timer = setTimeout(resolve, delay);
152
+ });
153
+ }
154
+ if (controller.signal.aborted)
155
+ return;
156
+ try {
157
+ const response = await fetch(catalogUrl, {
158
+ cache: 'no-store',
159
+ headers: { Accept: 'application/json' },
160
+ signal: controller.signal,
161
+ });
162
+ if (!response.ok) {
163
+ throw new Error(`Catalog request failed with status ${response.status}.`);
164
+ }
165
+ const remoteProducts = readCatalogProducts(await response.json());
166
+ const match = findProduct(remoteProducts, productId);
167
+ if (match.product) {
168
+ setRemoteState({
169
+ key: requestKey,
170
+ product: match.product,
171
+ index: match.index,
172
+ status: 'ready',
173
+ error: null,
174
+ });
175
+ return;
176
+ }
177
+ if (attemptIndex + 1 < delays.length) {
178
+ await attempt(attemptIndex + 1);
179
+ return;
180
+ }
181
+ setRemoteState({
182
+ key: requestKey,
183
+ product: null,
184
+ index: -1,
185
+ status: 'not-found',
186
+ error: null,
187
+ });
188
+ }
189
+ catch (cause) {
190
+ if (controller.signal.aborted)
191
+ return;
192
+ if (attemptIndex + 1 < delays.length) {
193
+ await attempt(attemptIndex + 1);
194
+ return;
195
+ }
196
+ setRemoteState({
197
+ key: requestKey,
198
+ product: null,
199
+ index: -1,
200
+ status: 'error',
201
+ error: cause instanceof Error ? cause : new Error('Unable to load the product.'),
202
+ });
203
+ }
204
+ };
205
+ void attempt(0);
206
+ return () => {
207
+ controller.abort();
208
+ if (timer)
209
+ clearTimeout(timer);
210
+ };
211
+ }, [
212
+ catalogUrl,
213
+ localMatch.product,
214
+ locationState.initialized,
215
+ productId,
216
+ requestVersion,
217
+ retryKey,
218
+ ]);
219
+ const retry = (0, react_1.useCallback)(() => setRequestVersion((version) => version + 1), []);
220
+ const requestKey = catalogUrl
221
+ ? `${productId}|${catalogUrl}|${requestVersion}`
222
+ : '';
223
+ if (!locationState.initialized) {
224
+ return { product: null, productId, productIndex: -1, status: 'loading', error: null, retry };
225
+ }
226
+ if (!productId) {
227
+ return { product: null, productId, productIndex: -1, status: 'not-found', error: null, retry };
228
+ }
229
+ if (localMatch.product) {
230
+ return {
231
+ product: normalizeProductForDetail(localMatch.product, siteData),
232
+ productId,
233
+ productIndex: localMatch.index,
234
+ status: 'ready',
235
+ error: null,
236
+ retry,
237
+ };
238
+ }
239
+ if (!catalogUrl) {
240
+ return { product: null, productId, productIndex: -1, status: 'not-found', error: null, retry };
241
+ }
242
+ if (remoteState.key !== requestKey) {
243
+ return { product: null, productId, productIndex: -1, status: 'loading', error: null, retry };
244
+ }
245
+ return {
246
+ product: remoteState.product
247
+ ? normalizeProductForDetail(remoteState.product, siteData)
248
+ : null,
249
+ productId,
250
+ productIndex: remoteState.index,
251
+ status: remoteState.status,
252
+ error: remoteState.error,
253
+ retry,
254
+ };
255
+ }
256
+ /** Build the only product-detail URL that is safe for newly-created products in a static export. */
257
+ function platformProductDetailHref(productId, route = DEFAULT_DETAIL_ROUTE, queryParam = DEFAULT_QUERY_PARAM) {
258
+ const normalizedProductId = normalizeId(productId);
259
+ if (!normalizedProductId)
260
+ return (0, utils_1.withBasePath)('/products/');
261
+ const separator = route.includes('?') ? '&' : '?';
262
+ return (0, utils_1.withBasePath)(`${route}${separator}${encodeURIComponent(queryParam)}=${encodeURIComponent(normalizedProductId)}`);
263
+ }
264
+ /**
265
+ * Ready-made live product page for Fivora templates. Use `renderProduct` when
266
+ * the template needs its own design; URL parsing, live lookup, retries, and
267
+ * fallback states remain owned by this component.
268
+ */
269
+ function PlatformProductDetail({ productId, queryParam = DEFAULT_QUERY_PARAM, products, catalogUrl, retryDelays, renderProduct, loadingFallback, notFoundFallback, errorFallback, backHref = '/products/', backLabel = 'Back to products', sectionPath = 'product', ...detailProps }) {
270
+ const result = usePlatformProductDetail({
271
+ productId,
272
+ queryParam,
273
+ products,
274
+ catalogUrl,
275
+ retryDelays,
276
+ });
277
+ if (result.status === 'loading') {
278
+ return loadingFallback ?? ((0, jsx_runtime_1.jsx)("div", { role: "status", className: "mx-auto flex min-h-[40vh] max-w-7xl items-center justify-center px-4 py-16 text-sm text-slate-400", children: "Loading product\u2026" }));
279
+ }
280
+ if (result.status === 'error') {
281
+ return errorFallback ?? ((0, jsx_runtime_1.jsxs)("div", { className: "mx-auto flex min-h-[40vh] max-w-xl flex-col items-center justify-center gap-4 px-4 py-16 text-center", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold text-slate-100", children: "Unable to load product" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-slate-400", children: "Please check your connection and try again." }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: result.retry, className: "rounded-xl bg-white px-5 py-2.5 text-sm font-bold text-slate-950", children: "Try again" })] }));
282
+ }
283
+ if (result.status === 'not-found' || !result.product) {
284
+ return notFoundFallback ?? ((0, jsx_runtime_1.jsxs)("div", { className: "mx-auto flex min-h-[40vh] max-w-xl flex-col items-center justify-center gap-4 px-4 py-16 text-center", children: [(0, jsx_runtime_1.jsx)("h1", { className: "text-3xl font-bold text-slate-100", children: "Product unavailable" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-slate-400", children: "This product could not be found or is no longer listed." }), (0, jsx_runtime_1.jsx)("a", { href: (0, utils_1.withBasePath)(backHref), className: "rounded-xl bg-white px-5 py-2.5 text-sm font-bold text-slate-950", children: backLabel })] }));
285
+ }
286
+ const context = {
287
+ ...result,
288
+ sectionPath,
289
+ };
290
+ if (renderProduct) {
291
+ return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: renderProduct(result.product, context) });
292
+ }
293
+ return ((0, jsx_runtime_1.jsx)(EditableProductDetail_1.EditableProductDetail, { ...detailProps, product: result.product, sectionPath: sectionPath }));
294
+ }
@@ -195,15 +195,12 @@ function SiteDataProvider({ children, initialSiteData, fallbackSiteData, liveCat
195
195
  : null);
196
196
  if (!endpoint)
197
197
  return;
198
- const controller = new AbortController();
199
- fetch(endpoint, {
200
- signal: controller.signal,
201
- headers: { Accept: "application/json" },
202
- })
203
- .then((res) => (res.ok ? res.json() : null))
204
- .then((live) => {
205
- if (!live || !isRecord(live))
206
- return;
198
+ let stopped = false;
199
+ let activeController = null;
200
+ let retryTimer = null;
201
+ let lastSuccessfulFetchAt = 0;
202
+ const retryDelays = [750, 2_000, 5_000];
203
+ const applyLiveCatalog = (live) => {
207
204
  setSiteData((current) => {
208
205
  const currentContent = isRecord(current.content) ? current.content : {};
209
206
  const currentHome = isRecord(currentContent.home) ? currentContent.home : null;
@@ -251,11 +248,63 @@ function SiteDataProvider({ children, initialSiteData, fallbackSiteData, liveCat
251
248
  rootStyle.setProperty("--brand-accent", live.theme.accentColor);
252
249
  }
253
250
  }
254
- })
255
- .catch(() => {
256
- // Gracefully keep pre-rendered static fallback if live API is unreachable
257
- });
258
- return () => controller.abort();
251
+ };
252
+ const loadLiveCatalog = async (attempt = 0) => {
253
+ if (stopped)
254
+ return;
255
+ activeController?.abort();
256
+ const controller = new AbortController();
257
+ activeController = controller;
258
+ try {
259
+ const response = await fetch(endpoint, {
260
+ signal: controller.signal,
261
+ cache: "no-store",
262
+ headers: { Accept: "application/json" },
263
+ });
264
+ if (!response.ok) {
265
+ throw new Error(`Live catalog request failed with ${response.status}`);
266
+ }
267
+ const live = await response.json();
268
+ if (stopped || !isRecord(live))
269
+ return;
270
+ applyLiveCatalog(live);
271
+ lastSuccessfulFetchAt = Date.now();
272
+ }
273
+ catch (error) {
274
+ if (stopped ||
275
+ (error instanceof DOMException && error.name === "AbortError")) {
276
+ return;
277
+ }
278
+ const retryDelay = retryDelays[attempt];
279
+ if (retryDelay !== undefined) {
280
+ retryTimer = window.setTimeout(() => {
281
+ void loadLiveCatalog(attempt + 1);
282
+ }, retryDelay);
283
+ }
284
+ }
285
+ };
286
+ const refreshIfStale = () => {
287
+ if (document.visibilityState === "visible" &&
288
+ Date.now() - lastSuccessfulFetchAt >= 30_000) {
289
+ if (retryTimer !== null)
290
+ window.clearTimeout(retryTimer);
291
+ retryTimer = null;
292
+ void loadLiveCatalog();
293
+ }
294
+ };
295
+ void loadLiveCatalog();
296
+ window.addEventListener("focus", refreshIfStale);
297
+ window.addEventListener("online", refreshIfStale);
298
+ document.addEventListener("visibilitychange", refreshIfStale);
299
+ return () => {
300
+ stopped = true;
301
+ activeController?.abort();
302
+ if (retryTimer !== null)
303
+ window.clearTimeout(retryTimer);
304
+ window.removeEventListener("focus", refreshIfStale);
305
+ window.removeEventListener("online", refreshIfStale);
306
+ document.removeEventListener("visibilitychange", refreshIfStale);
307
+ };
259
308
  }, [liveCatalogEndpoint, siteSlug, initialSiteData]);
260
309
  (0, react_1.useEffect)(() => {
261
310
  let parentOrigin = resolveParentOrigin();
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export * from './EditableProductCard';
14
14
  export * from './EditableProductGrid';
15
15
  export * from './EditableProductShowcase';
16
16
  export * from './EditableProductDetail';
17
+ export * from './PlatformProductDetail';
17
18
  export * from './EditableCustomerReviews';
18
19
  export * from './EditableGoogleFeedback';
19
20
  export * from './EditableServiceCard';
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ __exportStar(require("./EditableProductCard"), exports);
32
32
  __exportStar(require("./EditableProductGrid"), exports);
33
33
  __exportStar(require("./EditableProductShowcase"), exports);
34
34
  __exportStar(require("./EditableProductDetail"), exports);
35
+ __exportStar(require("./PlatformProductDetail"), exports);
35
36
  __exportStar(require("./EditableCustomerReviews"), exports);
36
37
  __exportStar(require("./EditableGoogleFeedback"), exports);
37
38
  __exportStar(require("./EditableServiceCard"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/ui",
3
- "version": "2.0.60",
3
+ "version": "2.0.61",
4
4
  "description": "Visual-first React component library for editable commerce storefronts. Built for Next.js and Fivora.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -50,7 +50,7 @@
50
50
  ],
51
51
  "license": "MIT",
52
52
  "dependencies": {
53
- "@deneb-ui/core": "^2.0.60"
53
+ "@deneb-ui/core": "^2.0.61"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "react": "^18.0.0 || ^19.0.0",