@akinon/pz-theme 2.0.38-rc.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.
- package/CHANGELOG.md +7 -31
- package/package.json +4 -4
- package/src/__tests__/chrome-takeover-gate.test.tsx +252 -0
- package/src/__tests__/registry-parity.test.ts +14 -1
- package/src/__tests__/route-slug.test.ts +116 -0
- package/src/blocks/__tests__/overlay-sweep.test.tsx +592 -0
- package/src/blocks/account-button-block.tsx +133 -0
- package/src/blocks/announcement-bar-block.tsx +407 -0
- package/src/blocks/block-renderer-registry.tsx +26 -0
- package/src/blocks/currency-selector-block.tsx +177 -0
- package/src/blocks/hamburger-menu-block.tsx +461 -0
- package/src/blocks/language-selector-block.tsx +172 -0
- package/src/blocks/mega-menu-block.tsx +712 -0
- package/src/blocks/mini-basket-block.tsx +173 -0
- package/src/blocks/nav-menu-block.tsx +309 -0
- package/src/blocks/newsletter-form-block.tsx +184 -0
- package/src/blocks/payment-badges-block.tsx +393 -0
- package/src/blocks/search-box-block.tsx +277 -0
- package/src/blocks/social-links-block.tsx +391 -0
- package/src/blocks/wishlist-button-block.tsx +115 -0
- package/src/chrome/chrome-fallback.tsx +147 -0
- package/src/chrome/chrome-slot.tsx +68 -0
- package/src/chrome/composition-state.ts +92 -0
- package/src/chrome/page-context.ts +387 -0
- package/src/chrome/placeholder-slugs.ts +86 -0
- package/src/chrome/route-slug.ts +159 -0
- package/src/index.ts +20 -0
|
@@ -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
|
+
};
|