@akinon/pz-theme 2.0.38-beta.0 → 2.0.38

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.
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { getWidgetData } from '@akinon/next/data/server';
3
+ import ThemePlaceholder from '../theme-placeholder';
4
+ import {
5
+ readCompositionState,
6
+ warnAboutUncomposedSlot
7
+ } from './composition-state';
8
+ import { ChromeFallback } from './chrome-fallback';
9
+
10
+ interface ChromeSlotProps {
11
+ /** Placeholder slug the editor composes into ('header', 'product-page-body', …) */
12
+ slug: string;
13
+ /** The brand's existing markup, rendered until a composition is published */
14
+ fallback?: React.ReactNode;
15
+ /**
16
+ * Commerce data the composed blocks read (product, listing, basket…).
17
+ * Build it with the helpers in ./page-context so the field names match
18
+ * what the blocks expect.
19
+ */
20
+ pageContext?: Record<string, unknown> | null;
21
+ }
22
+
23
+ /**
24
+ * Progressive chrome takeover slot. If the theme editor has published a
25
+ * composition for this slug, it fully replaces the brand markup; until
26
+ * then the brand's own chrome renders untouched. Inside the editor the
27
+ * fallback hides so the designer always composes into the placeholder.
28
+ */
29
+ export default async function ChromeSlot({
30
+ slug,
31
+ fallback,
32
+ pageContext = null
33
+ }: ChromeSlotProps) {
34
+ let hasComposition = false;
35
+
36
+ try {
37
+ // The generic is the widget's attributes bag, not the widget wrapper
38
+ const data = await getWidgetData<{
39
+ theme_editor_placeholder?: unknown;
40
+ }>({ slug });
41
+
42
+ const state = readCompositionState(data);
43
+ hasComposition = state.kind === 'composed';
44
+ // Explain a slot that kept the project markup — see composition-state.ts
45
+ // for why "missing" and "empty" must not read as the same thing.
46
+ warnAboutUncomposedSlot(slug, state);
47
+ } catch {
48
+ // No chrome widget published yet — brand fallback stays active
49
+ }
50
+
51
+ if (hasComposition) {
52
+ return (
53
+ <div data-chrome-slot={slug}>
54
+ <ThemePlaceholder slug={slug} pageContext={pageContext} />
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div data-chrome-slot={slug}>
61
+ {fallback ? (
62
+ <ChromeFallback slug={slug}>{fallback}</ChromeFallback>
63
+ ) : null}
64
+ {/* Empty until the designer overrides this area from the editor */}
65
+ <ThemePlaceholder slug={slug} pageContext={pageContext} />
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,92 @@
1
+ import { devWarn } from '../utils/dev-warn';
2
+
3
+ /**
4
+ * Why a chrome slot decided to keep — or withdraw — the brand's own markup.
5
+ *
6
+ * The distinction matters because the data layer swallows a 404 and returns
7
+ * nothing, so "no widget exists for this slug" and "a widget exists with an
8
+ * empty layout" arrive at the renderer as the same `null`. They have opposite
9
+ * causes: the first usually means the editor is saving into a different store
10
+ * than the one this storefront reads, the second means nobody has composed the
11
+ * area yet. A slot that cannot tell them apart cannot explain itself, and the
12
+ * one time it mattered the absence of a warning was read as proof that the
13
+ * fetch had succeeded.
14
+ */
15
+ export type CompositionState =
16
+ | { kind: 'missing' }
17
+ | { kind: 'empty' }
18
+ | { kind: 'malformed' }
19
+ | { kind: 'composed'; sectionCount: number };
20
+
21
+ type WidgetLike = {
22
+ attributes?: { theme_editor_placeholder?: unknown } | null;
23
+ } | null;
24
+
25
+ /**
26
+ * Read a placeholder widget the way the render engine does: the layout may
27
+ * arrive as an array, as a JSON string, or wrapped in `{ value }` depending on
28
+ * which backend and which core version answered.
29
+ */
30
+ export const readCompositionState = (data: WidgetLike): CompositionState => {
31
+ if (!data) return { kind: 'missing' };
32
+
33
+ let layout: unknown = data.attributes?.theme_editor_placeholder;
34
+
35
+ if (
36
+ layout &&
37
+ typeof layout === 'object' &&
38
+ !Array.isArray(layout) &&
39
+ 'value' in (layout as Record<string, unknown>)
40
+ ) {
41
+ layout = (layout as { value: unknown }).value;
42
+ }
43
+
44
+ if (typeof layout === 'string') {
45
+ const trimmed = layout.trim();
46
+ if (!trimmed) return { kind: 'empty' };
47
+ try {
48
+ layout = JSON.parse(trimmed);
49
+ } catch {
50
+ return { kind: 'malformed' };
51
+ }
52
+ }
53
+
54
+ if (layout === undefined || layout === null) return { kind: 'empty' };
55
+ if (!Array.isArray(layout)) return { kind: 'malformed' };
56
+ if (layout.length === 0) return { kind: 'empty' };
57
+
58
+ return { kind: 'composed', sectionCount: layout.length };
59
+ };
60
+
61
+ /**
62
+ * Dev-only explanation of a slot that kept the brand markup. One line per slug
63
+ * per process (see `devWarn`), so a brand with no compositions at all does not
64
+ * flood the console.
65
+ */
66
+ export const warnAboutUncomposedSlot = (
67
+ slug: string,
68
+ state: CompositionState
69
+ ): void => {
70
+ if (state.kind === 'composed') return;
71
+
72
+ if (state.kind === 'missing') {
73
+ devWarn(
74
+ 'ChromeSlot',
75
+ `No widget answered for placeholder "${slug}", so the project's own markup stays. Either nothing has been published for this slug, or it was published to a different store than the one this storefront reads — compare the storefront's commerce backend with the Omnitron the theme editor is connected to.`
76
+ );
77
+ return;
78
+ }
79
+
80
+ if (state.kind === 'malformed') {
81
+ devWarn(
82
+ 'ChromeSlot',
83
+ `Placeholder "${slug}" carries a layout that is neither a list of sections nor parseable JSON, so the project's own markup stays.`
84
+ );
85
+ return;
86
+ }
87
+
88
+ devWarn(
89
+ 'ChromeSlot',
90
+ `Placeholder "${slug}" exists but its layout is empty, so the project's own markup stays. An empty layout is what the editor writes when it first creates the placeholder, and also what a placeholder holding only native sections persists.`
91
+ );
92
+ };
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Page context builders for the composable pages.
3
+ *
4
+ * These used to live in the brand app, which meant every storefront kept
5
+ * its own copy in step with the block contract. They ship with the render
6
+ * engine instead: a brand hands the raw commerce payload to a slot and the
7
+ * blocks read normalised fields from the context.
8
+ *
9
+ * Route defaults are overridable because brands rename these paths.
10
+ */
11
+ export interface PageContextRoutes {
12
+ home?: string;
13
+ basket?: string;
14
+ checkout?: string;
15
+ account?: string;
16
+ }
17
+
18
+ const DEFAULT_ROUTES: Required<PageContextRoutes> = {
19
+ home: '/',
20
+ basket: '/basket',
21
+ checkout: '/orders/checkout',
22
+ account: '/account'
23
+ };
24
+
25
+ let routes: Required<PageContextRoutes> = DEFAULT_ROUTES;
26
+
27
+ /** Lets a brand align the context defaults with its own ROUTES table */
28
+ export const configurePageContextRoutes = (overrides: PageContextRoutes) => {
29
+ routes = { ...DEFAULT_ROUTES, ...overrides };
30
+ };
31
+
32
+ const ROUTES = {
33
+ get HOME() {
34
+ return routes.home;
35
+ },
36
+ get BASKET() {
37
+ return routes.basket;
38
+ },
39
+ get CHECKOUT() {
40
+ return routes.checkout;
41
+ },
42
+ get ACCOUNT() {
43
+ return routes.account;
44
+ }
45
+ };
46
+ import type {
47
+ Basket,
48
+ BreadcrumbResultType,
49
+ GetCategoryResponse,
50
+ Product,
51
+ ProductResult
52
+ } from '@akinon/next/types';
53
+
54
+ type UnknownRecord = Record<string, unknown>;
55
+
56
+ const toRecord = (value: unknown): UnknownRecord =>
57
+ value && typeof value === 'object' ? (value as UnknownRecord) : {};
58
+
59
+ const toStringValue = (value: unknown, fallback = ''): string =>
60
+ typeof value === 'string' ? value : fallback;
61
+
62
+ const humanizeSlug = (value: string) =>
63
+ value
64
+ .split('-')
65
+ .filter(Boolean)
66
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
67
+ .join(' ');
68
+
69
+ const normalizeProductImages = (
70
+ product?: Product | null
71
+ ): Array<Record<string, unknown>> =>
72
+ (product?.productimage_set || []).map((item, index) => ({
73
+ ...item,
74
+ url: item.image,
75
+ alt: `${product?.name || 'Product'} ${index + 1}`
76
+ }));
77
+
78
+ /**
79
+ * The commerce attribute key travels with the row: it is what the editor's
80
+ * includeKeys/excludeKeys match on, and dropping it left those controls dead
81
+ * because the block could only fall back to a positional `attribute-<index>`.
82
+ */
83
+ const normalizeAttributeEntries = (
84
+ attributes: unknown
85
+ ): Array<Record<string, string>> => {
86
+ const record = toRecord(attributes);
87
+
88
+ return Object.entries(record)
89
+ .map(([recordKey, entry]) => {
90
+ const typedEntry = toRecord(entry);
91
+ const label = toStringValue(typedEntry.label);
92
+ const value = toStringValue(typedEntry.value);
93
+
94
+ if (!label && !value) {
95
+ return null;
96
+ }
97
+
98
+ // `attributes_kwargs` is keyed by the attribute key, so the record key is
99
+ // authoritative. An array payload only yields positional indices, and
100
+ // there the entry's own key is the only real one.
101
+ const key = /^\d+$/.test(recordKey)
102
+ ? toStringValue(typedEntry.key, recordKey)
103
+ : recordKey;
104
+
105
+ return { key, label, value };
106
+ })
107
+ .filter(
108
+ (entry): entry is { key: string; label: string; value: string } =>
109
+ entry !== null
110
+ );
111
+ };
112
+
113
+ export const normalizeProductEntry = (
114
+ product?: Product | null
115
+ ): Record<string, unknown> | null => {
116
+ if (!product) {
117
+ return null;
118
+ }
119
+
120
+ const activePrice = toRecord((product as Product & UnknownRecord).active_price);
121
+ const images = normalizeProductImages(product);
122
+
123
+ return {
124
+ ...product,
125
+ title: product.name,
126
+ name: product.name,
127
+ url: product.absolute_url,
128
+ image: product.image || images[0]?.url || '',
129
+ images,
130
+ price: activePrice.price ?? product.price,
131
+ retailPrice: activePrice.retail_price ?? product.retail_price,
132
+ currency: activePrice.currency_type ?? product.currency_type ?? '',
133
+ description: product.description || '',
134
+ attributes: normalizeAttributeEntries(product.attributes_kwargs)
135
+ };
136
+ };
137
+
138
+ /**
139
+ * `breadcrumbData` is fetched next to the product by `getProductData()` and was
140
+ * previously handed only to the brand's own PDP layout, which left the PDP the
141
+ * one page type with no trail on the context. It is published under the same
142
+ * `breadcrumbs` key, in the same raw shape, as `buildListingPageContext()`.
143
+ */
144
+ export const buildProductPageContext = (
145
+ data: ProductResult,
146
+ breadcrumbData?: BreadcrumbResultType[]
147
+ ) => {
148
+ const normalizedProduct = normalizeProductEntry(data.product);
149
+ const normalizedSelectedVariant = normalizeProductEntry(data.selected_variant);
150
+
151
+ return {
152
+ page: {
153
+ type: 'product',
154
+ title: normalizedProduct?.title || 'Product',
155
+ path: normalizedProduct?.url || ''
156
+ },
157
+ // Emitted only when a brand hands the trail over, so a call site that has
158
+ // not been updated publishes exactly the context it publishes today.
159
+ ...(breadcrumbData ? { breadcrumbs: breadcrumbData } : {}),
160
+ product: {
161
+ ...normalizedProduct,
162
+ selectedVariant: normalizedSelectedVariant,
163
+ selected_variant: normalizedSelectedVariant,
164
+ variants: data.variants || []
165
+ }
166
+ };
167
+ };
168
+
169
+ export const buildHomepagePageContext = () => ({
170
+ page: {
171
+ type: 'homepage',
172
+ title: 'Homepage',
173
+ slug: 'homepage',
174
+ path: ROUTES.HOME
175
+ },
176
+ homepage: {
177
+ title: 'Homepage',
178
+ slug: 'homepage',
179
+ path: ROUTES.HOME
180
+ }
181
+ });
182
+
183
+ export const buildCustomPageContext = (slug: string) => {
184
+ const title = humanizeSlug(slug) || 'Custom Page';
185
+ const path = `/pages/${slug}`;
186
+
187
+ return {
188
+ page: {
189
+ type: 'custom-page',
190
+ title,
191
+ slug,
192
+ path
193
+ },
194
+ customPage: {
195
+ title,
196
+ slug,
197
+ path
198
+ }
199
+ };
200
+ };
201
+
202
+ export const buildListingPageContext = ({
203
+ data,
204
+ breadcrumbData,
205
+ pageType,
206
+ path,
207
+ title: titleOverride
208
+ }: {
209
+ data: GetCategoryResponse;
210
+ breadcrumbData?: BreadcrumbResultType[];
211
+ /**
212
+ * `special` covers campaign/collection pages: same listing shape, but the
213
+ * heading comes from the special page rather than from a category.
214
+ */
215
+ pageType: 'category' | 'search' | 'plp' | 'special';
216
+ path?: string;
217
+ title?: string;
218
+ }) => {
219
+ const normalizedProducts = (data.products || [])
220
+ .map(product => normalizeProductEntry(product))
221
+ .filter((product): product is Record<string, unknown> => product !== null);
222
+ const categorySeoTitle = toStringValue(
223
+ data.category?.attributes?.category_seo_title?.value?.text
224
+ );
225
+ const effectivePath = path || data.category?.absolute_url || ROUTES.HOME;
226
+ const totalProducts =
227
+ Number(toRecord(data.pagination).count) || normalizedProducts.length;
228
+ const title =
229
+ titleOverride ||
230
+ (pageType === 'search'
231
+ ? data.search_text
232
+ ? `Search results for ${data.search_text}`
233
+ : 'Search Results'
234
+ : data.category?.name || 'Product Listing');
235
+
236
+ const category = {
237
+ ...(data.category || {}),
238
+ title: data.category?.name || '',
239
+ name: data.category?.name || '',
240
+ url: data.category?.absolute_url || '',
241
+ description: categorySeoTitle,
242
+ seoTitle: categorySeoTitle,
243
+ facets: data.facets || [],
244
+ products: normalizedProducts
245
+ };
246
+
247
+ const listingBase = {
248
+ title,
249
+ path: effectivePath,
250
+ query: data.search_text || '',
251
+ products: normalizedProducts,
252
+ totalProducts,
253
+ resultCount: totalProducts,
254
+ facets: data.facets || [],
255
+ sorters: data.sorters || [],
256
+ pagination: data.pagination || {},
257
+ category
258
+ };
259
+
260
+ return {
261
+ page: {
262
+ type: pageType,
263
+ title,
264
+ path: effectivePath
265
+ },
266
+ category,
267
+ listing: {
268
+ ...listingBase,
269
+ breadcrumbs: breadcrumbData || []
270
+ },
271
+ search: {
272
+ ...listingBase,
273
+ title: data.search_text
274
+ ? `Search results for ${data.search_text}`
275
+ : 'Search Results'
276
+ },
277
+ plp: {
278
+ ...listingBase,
279
+ title: data.category?.name || 'Product Listing'
280
+ },
281
+ breadcrumbs: breadcrumbData || []
282
+ };
283
+ };
284
+
285
+ export const buildBasketPageContext = ({
286
+ basket,
287
+ path
288
+ }: {
289
+ basket?: Basket | null;
290
+ path?: string;
291
+ }) => {
292
+ const normalizedItems = (basket?.basketitem_set || []).map(item => {
293
+ const normalizedProduct = normalizeProductEntry(item.product);
294
+
295
+ return {
296
+ ...item,
297
+ title: normalizedProduct?.title || item.product?.name || '',
298
+ name: normalizedProduct?.name || item.product?.name || '',
299
+ url: normalizedProduct?.url || item.product?.absolute_url || '',
300
+ image:
301
+ item.image ||
302
+ (typeof normalizedProduct?.image === 'string'
303
+ ? normalizedProduct.image
304
+ : ''),
305
+ price: item.price || normalizedProduct?.price || '',
306
+ retailPrice: item.retail_price || normalizedProduct?.retailPrice || '',
307
+ totalAmount: item.total_amount || '',
308
+ unitPrice: item.unit_price || '',
309
+ currency: item.currency_type || normalizedProduct?.currency || '',
310
+ quantity: item.quantity,
311
+ attributes: normalizeAttributeEntries(
312
+ item.attributes_kwargs || item.product?.attributes_kwargs
313
+ ),
314
+ product: normalizedProduct
315
+ };
316
+ });
317
+
318
+ return {
319
+ page: {
320
+ type: 'basket',
321
+ title: 'Basket',
322
+ path: path || ROUTES.BASKET
323
+ },
324
+ basket: {
325
+ id: basket?.pk || null,
326
+ path: path || ROUTES.BASKET,
327
+ checkoutUrl: ROUTES.CHECKOUT,
328
+ continueShoppingUrl: ROUTES.HOME,
329
+ isEmpty: normalizedItems.length === 0,
330
+ itemCount: normalizedItems.length,
331
+ totalQuantity: basket?.total_quantity || 0,
332
+ totalAmount: basket?.total_amount || '',
333
+ totalProductAmount: basket?.total_product_amount || '',
334
+ totalDiscountAmount: basket?.total_discount_amount || '',
335
+ voucherCode: basket?.voucher_code || '',
336
+ discounts: basket?.discounts || [],
337
+ items: normalizedItems
338
+ }
339
+ };
340
+ };
341
+
342
+ export const buildAccountPageContext = ({
343
+ path,
344
+ profileInfo,
345
+ sessionUser
346
+ }: {
347
+ path?: string;
348
+ profileInfo?: UnknownRecord | null;
349
+ sessionUser?: UnknownRecord | null;
350
+ }) => {
351
+ const normalizedPath = path || ROUTES.ACCOUNT;
352
+ const firstName =
353
+ toStringValue(profileInfo?.first_name) ||
354
+ toStringValue(sessionUser?.firstName) ||
355
+ toStringValue(sessionUser?.name).split(' ')[0] ||
356
+ '';
357
+ const lastName =
358
+ toStringValue(profileInfo?.last_name) ||
359
+ toStringValue(sessionUser?.lastName) ||
360
+ toStringValue(sessionUser?.name).split(' ').slice(1).join(' ') ||
361
+ '';
362
+ const email =
363
+ toStringValue(profileInfo?.email) || toStringValue(sessionUser?.email);
364
+ const fullName =
365
+ [firstName, lastName].filter(Boolean).join(' ') ||
366
+ toStringValue(sessionUser?.name);
367
+
368
+ return {
369
+ page: {
370
+ type: 'account',
371
+ title: 'Account',
372
+ path: normalizedPath
373
+ },
374
+ account: {
375
+ path: normalizedPath,
376
+ route: normalizedPath,
377
+ user: {
378
+ firstName,
379
+ lastName,
380
+ fullName,
381
+ email,
382
+ phone: toStringValue(profileInfo?.phone),
383
+ gender: toStringValue(profileInfo?.gender)
384
+ }
385
+ }
386
+ };
387
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The canonical placeholder slug table.
3
+ *
4
+ * Three repos had drifted apart on these names — the template app mounted
5
+ * `product-page-new`, a brand mounted `product-page-body`, and the editor's
6
+ * route map knew `list-page-body-new` — so a designer could publish a
7
+ * composition the storefront never looked for. Slugs are published content
8
+ * keys: once a brand saves against one, renaming it orphans that content.
9
+ * Everything that mounts or resolves a placeholder imports from here.
10
+ */
11
+ export const PLACEHOLDER_SLUGS = {
12
+ // Chrome
13
+ header: 'header',
14
+ footer: 'footer',
15
+
16
+ // Page bodies — these names are already live in published themes, so the
17
+ // legacy "-new" suffixes stay until a migration moves the saved widgets.
18
+ homepage: 'theme-homepage-body',
19
+ productDetail: 'product-page-new',
20
+ productListing: 'list-page-body-new',
21
+ basket: 'basket-page-body',
22
+ auth: 'auth-page-body',
23
+
24
+ // Account: the layout-level slug renders on every account screen, so each
25
+ // subpage also gets its own body slug — otherwise one composition leaks
26
+ // across orders, addresses, profile and the rest.
27
+ accountShell: 'account-page-body',
28
+ accountMenu: 'account-menu',
29
+ accountOrders: 'account-orders-body',
30
+ accountOrderDetail: 'account-order-detail-body',
31
+ accountAddresses: 'account-address-body',
32
+ accountProfile: 'account-profile-body',
33
+ accountChangePassword: 'account-change-password-body',
34
+ accountChangeEmail: 'account-change-email-body',
35
+ accountCoupons: 'account-coupons-body',
36
+ accountFavourites: 'account-favourites-body',
37
+ accountFaq: 'account-faq-content',
38
+ accountNotifications: 'account-notifications-body',
39
+ accountStockAlerts: 'account-stock-alerts-body',
40
+
41
+ // Order flow. Checkout itself stays code-owned — payment, 3DS and the step
42
+ // machine are not composable — so the editor only gets the zones above and
43
+ // below the flow, never the flow.
44
+ checkoutTop: 'checkout-top-body',
45
+ checkoutBottom: 'checkout-bottom-body',
46
+ checkoutSummary: 'checkout-summary-body',
47
+ orderSuccess: 'order-success-body',
48
+ orderTracking: 'order-tracking-body',
49
+
50
+ // Auth
51
+ register: 'register-page-body',
52
+ forgotPassword: 'forgot-password-body',
53
+ guestLogin: 'guest-login-body',
54
+
55
+ // Content
56
+ stores: 'stores-page-body',
57
+ faq: 'faq-page-body',
58
+ sitemap: 'sitemap-page-body',
59
+ brands: 'brands-page-body',
60
+ flatPage: 'flat-page-body',
61
+ notFound: 'not-found-body'
62
+ } as const;
63
+
64
+ export type PlaceholderSlug =
65
+ (typeof PLACEHOLDER_SLUGS)[keyof typeof PLACEHOLDER_SLUGS];
66
+
67
+ /** Custom pages created from the editor resolve to `page-<slug>` */
68
+ export const customPageSlug = (slug: string) => `page-${slug}`;
69
+
70
+ /**
71
+ * Slug for a brand page that has no canonical placeholder — every bespoke
72
+ * content route (about us, campaigns, returns policy…) gets one derived from
73
+ * its path, so a brand makes them all composable with a single slot in its
74
+ * root layout instead of one edit per page.
75
+ */
76
+ export const customPageSlugFromPath = (pathname: string) => {
77
+ const cleaned = (pathname || '/')
78
+ .split('?')[0]
79
+ .split('#')[0]
80
+ .replace(/^\/+|\/+$/g, '')
81
+ .replace(/[^a-zA-Z0-9/-]/g, '')
82
+ .replace(/\/+/g, '-')
83
+ .toLowerCase();
84
+
85
+ return cleaned ? customPageSlug(cleaned) : customPageSlug('home');
86
+ };