@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 +47 -0
- package/dist/EditableProductDetail.d.ts +3 -1
- package/dist/PlatformProductDetail.d.ts +55 -0
- package/dist/PlatformProductDetail.js +294 -0
- package/dist/SiteDataProvider.js +63 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +2 -2
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
|
+
}
|
package/dist/SiteDataProvider.js
CHANGED
|
@@ -195,15 +195,12 @@ function SiteDataProvider({ children, initialSiteData, fallbackSiteData, liveCat
|
|
|
195
195
|
: null);
|
|
196
196
|
if (!endpoint)
|
|
197
197
|
return;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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.
|
|
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.
|
|
53
|
+
"@deneb-ui/core": "^2.0.61"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"react": "^18.0.0 || ^19.0.0",
|