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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.63-beta.0
3
+ ## 2.0.63
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.63-beta.0
9
- - @akinon/theme-contract@2.0.63-beta.0
7
+ - 72a62c6: ZERO-4823: Implement 404 and 500 pages for theme editor
8
+ - Updated dependencies [72a62c6]
9
+ - @akinon/theme-contract@2.0.63
10
+ - @akinon/next@2.0.63
10
11
 
11
12
  ## 2.0.62
12
13
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.63-beta.0",
4
+ "version": "2.0.63",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.63-beta.0",
11
+ "@akinon/next": "2.0.63",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.63-beta.0",
16
+ "@akinon/theme-contract": "2.0.63",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.63-beta.0",
23
+ "@akinon/next": "2.0.63",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -145,6 +145,23 @@ describe('resolveBodySlug', () => {
145
145
  expect(isPageMountedSlug('checkout-page-body')).toBe(true);
146
146
  });
147
147
 
148
+ it('resolves the 404 screen and keeps it page-mounted', () => {
149
+ expect(resolveBodySlug('/404')).toBe(PLACEHOLDER_SLUGS.notFound);
150
+ expect(resolveBodySlug('/not-found')).toBe(PLACEHOLDER_SLUGS.notFound);
151
+ // The boundary mounts its own slot; a root catch-all must not double it.
152
+ expect(isPageMountedSlug(PLACEHOLDER_SLUGS.notFound)).toBe(true);
153
+ });
154
+
155
+ it('resolves the 500 screen and keeps it page-mounted', () => {
156
+ expect(resolveBodySlug('/500')).toBe(PLACEHOLDER_SLUGS.serverError);
157
+ expect(resolveBodySlug('/server-error')).toBe(
158
+ PLACEHOLDER_SLUGS.serverError
159
+ );
160
+ // The error boundary reads its composition from the layout provider; a
161
+ // root catch-all must not mount this slug a second time.
162
+ expect(isPageMountedSlug(PLACEHOLDER_SLUGS.serverError)).toBe(true);
163
+ });
164
+
148
165
  it('returns null for a path with no placeholder', () => {
149
166
  expect(resolveBodySlug('/kampanya-bilgilendirme')).toBeNull();
150
167
  });
@@ -0,0 +1,43 @@
1
+ import 'server-only';
2
+
3
+ import { loadPlaceholderSections } from '../load-placeholder-sections';
4
+ import { generateThemeCSS } from '../utils';
5
+ import { getSlugCompositionState } from './composition-source';
6
+ import { buildErrorPageContext } from './page-context';
7
+ import { PLACEHOLDER_SLUGS } from './placeholder-slugs';
8
+ import type { ErrorComposition } from './error-composition';
9
+
10
+ /**
11
+ * Prefetches the server-error composition for the layout to hand to the
12
+ * error boundary (see error-composition.tsx for why it must travel early).
13
+ *
14
+ * The cheap request-memoized composition-state read gates the expensive
15
+ * reconstruction: until a designer composes the slug, every page pays one
16
+ * widget read that ChromeSlot/theme-config sharing already amortizes, and
17
+ * nothing more.
18
+ */
19
+ export async function loadErrorComposition(): Promise<ErrorComposition | null> {
20
+ const slug = PLACEHOLDER_SLUGS.serverError;
21
+
22
+ try {
23
+ const state = await getSlugCompositionState(slug);
24
+ if (state.kind !== 'composed') return null;
25
+
26
+ const loaded = await loadPlaceholderSections(slug);
27
+ if (!loaded || loaded.sections.length === 0) return null;
28
+
29
+ return {
30
+ slug,
31
+ placeholderId: loaded.placeholderId,
32
+ sections: loaded.sections,
33
+ themeCSS: generateThemeCSS(loaded.sections),
34
+ themeSettings: loaded.themeSettings,
35
+ pageContext: buildErrorPageContext()
36
+ };
37
+ } catch (error) {
38
+ // A failed prefetch must never take the page down with it — the boundary
39
+ // falls back to the code-owned error UI instead.
40
+ console.warn('Server-error composition prefetch failed:', error);
41
+ return null;
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext } from 'react';
4
+ import type { Section } from '../theme-section';
5
+
6
+ /**
7
+ * The server-error composition, reconstructed BEFORE anything fails.
8
+ *
9
+ * Next.js forces `error.tsx` to be a client component and hands it nothing
10
+ * but `{ error, reset }` — it cannot fetch on the server and it cannot
11
+ * receive props from one. The only channel left is a client context mounted
12
+ * by the layout above the boundary: the layout survives a page failure, so
13
+ * whatever it put here is still readable when the boundary renders. It also
14
+ * means the composition arrives with the page, not during the outage — the
15
+ * one moment the widget API itself may be down.
16
+ */
17
+ export interface ErrorComposition {
18
+ slug: string;
19
+ placeholderId: string;
20
+ sections: Section[];
21
+ /** Pre-generated on the server; the boundary only injects it. */
22
+ themeCSS: string;
23
+ themeSettings: Record<string, unknown> | null;
24
+ pageContext: Record<string, unknown>;
25
+ }
26
+
27
+ const ErrorCompositionContext = createContext<ErrorComposition | null>(null);
28
+
29
+ export const ErrorCompositionProvider = ({
30
+ composition,
31
+ children
32
+ }: {
33
+ composition: ErrorComposition | null;
34
+ children: React.ReactNode;
35
+ }) => (
36
+ <ErrorCompositionContext.Provider value={composition}>
37
+ {children}
38
+ </ErrorCompositionContext.Provider>
39
+ );
40
+
41
+ /** `null` until a designer composes the server-error placeholder. */
42
+ export const useErrorComposition = () => useContext(ErrorCompositionContext);
@@ -534,6 +534,24 @@ export const buildCheckoutPageContext = ({
534
534
  };
535
535
  };
536
536
 
537
+ /** The 404 screen: no commerce entity, just the page identity to bind. */
538
+ export const buildNotFoundPageContext = (path?: string) => ({
539
+ page: {
540
+ type: 'not-found',
541
+ title: 'Page Not Found',
542
+ path: path || '/404'
543
+ }
544
+ });
545
+
546
+ /** The 500 screen: same shape as the 404, published ahead of the failure. */
547
+ export const buildErrorPageContext = (path?: string) => ({
548
+ page: {
549
+ type: 'error',
550
+ title: 'Server Error',
551
+ path: path || '/500'
552
+ }
553
+ });
554
+
537
555
  /** A CMS flat page: no commerce entity, but a title and a slug to bind. */
538
556
  export const buildFlatPageContext = (flatPage?: UnknownRecord | null) => {
539
557
  const source = toRecord(flatPage);
@@ -70,7 +70,8 @@ export const PLACEHOLDER_SLUGS = {
70
70
  sitemap: 'sitemap-page-body',
71
71
  brands: 'brands-page-body',
72
72
  flatPage: 'flat-page-body',
73
- notFound: 'not-found-body'
73
+ notFound: 'not-found-body',
74
+ serverError: 'server-error-body'
74
75
  } as const;
75
76
 
76
77
  export type PlaceholderSlug =
@@ -93,6 +93,12 @@ const DEFAULT_RULES: RouteSlugRule[] = [
93
93
  [/^\/sitemap/, PLACEHOLDER_SLUGS.sitemap],
94
94
  [/^\/(markalar|brands)/, PLACEHOLDER_SLUGS.brands],
95
95
  [/^\/flat-page\//, PLACEHOLDER_SLUGS.flatPage],
96
+ // The not-found boundary renders on ANY unmatched path, so it mounts its
97
+ // own slot; this rule serves the routable /404 page the editor previews.
98
+ [/^\/(404|not-found)(\/|$)/, PLACEHOLDER_SLUGS.notFound],
99
+ // Like the 404 rule: the error boundary renders wherever a page fails, so
100
+ // this rule only serves the routable /500 page the editor previews.
101
+ [/^\/(500|server-error)(\/|$)/, PLACEHOLDER_SLUGS.serverError],
96
102
  [/^\/?$/, PLACEHOLDER_SLUGS.homepage]
97
103
  ];
98
104
 
@@ -136,7 +142,13 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
136
142
  PLACEHOLDER_SLUGS.register,
137
143
  PLACEHOLDER_SLUGS.forgotPassword,
138
144
  PLACEHOLDER_SLUGS.guestLogin,
139
- PLACEHOLDER_SLUGS.flatPage
145
+ PLACEHOLDER_SLUGS.flatPage,
146
+ // Mounted by the not-found boundary itself, which renders on any unmatched
147
+ // path — a route-resolved catch-all would never fire there.
148
+ PLACEHOLDER_SLUGS.notFound,
149
+ // Same shape: the error boundary carries its own composition (prefetched
150
+ // through the layout provider), so no catch-all may mount this slug.
151
+ PLACEHOLDER_SLUGS.serverError
140
152
  ]);
141
153
 
142
154
  /** True when a deeper page/layout already mounts this slug's slot. */
package/src/index.ts CHANGED
@@ -55,9 +55,17 @@ export {
55
55
  buildOrderPageContext,
56
56
  buildCheckoutPageContext,
57
57
  buildFlatPageContext,
58
+ buildNotFoundPageContext,
59
+ buildErrorPageContext,
58
60
  configurePageContextRoutes
59
61
  } from './chrome/page-context';
60
62
 
63
+ export {
64
+ ErrorCompositionProvider,
65
+ useErrorComposition
66
+ } from './chrome/error-composition';
67
+ export type { ErrorComposition } from './chrome/error-composition';
68
+
61
69
  export {
62
70
  PLACEHOLDER_SLUGS,
63
71
  customPageSlug,
@@ -0,0 +1,335 @@
1
+ import 'server-only';
2
+
3
+ import * as serverData from '@akinon/next/data/server';
4
+ import appFetch from '@akinon/next/utils/app-fetch';
5
+ import { ServerVariables } from '@akinon/next/utils/server-variables';
6
+
7
+ import { Section } from './theme-section';
8
+ import { devWarn } from './utils/dev-warn';
9
+ import { validateThemeSectionsInDev } from './schemas/theme-schema';
10
+ import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
11
+
12
+ const { getWidgetData } = serverData;
13
+
14
+ /**
15
+ * v1 cores ship a smaller server data layer: only `getWidgetData` exists,
16
+ * `getWidgetSchemaData` and `getCollectionWidgetData` arrived with v2.
17
+ *
18
+ * The endpoints are identical on both backends — v1 just never wrapped them.
19
+ * Degrading to "no data" would render every published section *without its
20
+ * blocks* (a section's block tree lives in the widget schema, not in the
21
+ * widget), so on v1 we call the REST endpoints directly. This is what keeps
22
+ * composed pages rendering on the brands still running v1.
23
+ */
24
+ type ServerDataFn = (<T>(args: never) => Promise<T | null>) | undefined;
25
+
26
+ const optionalServerFn = (name: string): ServerDataFn =>
27
+ (serverData as unknown as Record<string, ServerDataFn>)[name];
28
+
29
+ const directFetch = async <T,>(url: string): Promise<T | null> => {
30
+ try {
31
+ const result = await appFetch<T>({
32
+ url,
33
+ locale: ServerVariables.locale,
34
+ currency: ServerVariables.currency
35
+ });
36
+ return result ?? null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+
42
+ const getCollectionWidgetData = async <T,>(args: {
43
+ slug: string;
44
+ }): Promise<T | null> => {
45
+ const fn = optionalServerFn('getCollectionWidgetData');
46
+ if (fn) return (await fn<T>(args as never)) ?? null;
47
+ if (!args?.slug) return null;
48
+ return directFetch<T>(`/product_collection_widgets/${args.slug}/`);
49
+ };
50
+
51
+ const getWidgetSchemaData = async <T,>(args: {
52
+ widgetSlug: string;
53
+ }): Promise<T | null> => {
54
+ const fn = optionalServerFn('getWidgetSchemaData');
55
+ if (fn) return (await fn<T>(args as never)) ?? null;
56
+ if (!args?.widgetSlug) return null;
57
+ return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
58
+ };
59
+
60
+ type ThemePlaceholderData = {
61
+ theme_editor_placeholder: Array<{
62
+ section_slug: string;
63
+ order: number;
64
+ }>;
65
+ };
66
+
67
+ export interface LoadedPlaceholder {
68
+ sections: Section[];
69
+ placeholderId: string;
70
+ dataSources: ThemeConfig['dataSources'];
71
+ themeSettings: ThemeConfig['settings'];
72
+ }
73
+
74
+ /**
75
+ * Fetches and reconstructs the composed section tree for a placeholder slug.
76
+ *
77
+ * Shared by `ThemePlaceholder` (render time) and the error-composition
78
+ * prefetch (layout time): the error boundary is a client component that can
79
+ * neither fetch nor receive server props, so its composition has to be
80
+ * reconstructed ahead of the failure — and by the same code path, or the two
81
+ * renders could disagree about what a saved section contains.
82
+ *
83
+ * Returns `null` only when the data layer fails outright; a slug nobody has
84
+ * composed resolves with an empty `sections` array.
85
+ */
86
+ export async function loadPlaceholderSections(
87
+ slug: string
88
+ ): Promise<LoadedPlaceholder | null> {
89
+ try {
90
+ // theme-config is optional for placeholder rendering, and the reader is
91
+ // memoized per request — a page with several placeholders, or a ChromeSlot
92
+ // asking the same question to decide whether it is pinned, reads it once.
93
+ const { dataSources, settings: themeSettings } = await getThemeConfig();
94
+
95
+ let data: { slug?: string; attributes?: { theme_editor_placeholder?: unknown; [key: string]: unknown } } | null = null;
96
+ try {
97
+ data = await getWidgetData<ThemePlaceholderData>({
98
+ slug
99
+ }) as typeof data;
100
+ } catch (placeholderError) {
101
+ console.warn(
102
+ `Theme placeholder widget "${slug}" was not found. Rendering empty placeholder.`,
103
+ placeholderError
104
+ );
105
+ }
106
+
107
+ const sections: Section[] = [];
108
+
109
+ if (data?.attributes?.theme_editor_placeholder) {
110
+ let placeholderData: any = data.attributes.theme_editor_placeholder;
111
+
112
+ if (
113
+ typeof placeholderData === 'object' &&
114
+ placeholderData !== null &&
115
+ 'value' in placeholderData
116
+ ) {
117
+ placeholderData = (
118
+ placeholderData as {
119
+ value: { section_slug: string; order: number }[];
120
+ }
121
+ ).value;
122
+ }
123
+
124
+ if (typeof placeholderData === 'string') {
125
+ try {
126
+ placeholderData = JSON.parse(placeholderData);
127
+ } catch {
128
+ devWarn(
129
+ 'ThemePlaceholder',
130
+ `Placeholder "${slug}" layout JSON is malformed; rendering no sections`
131
+ );
132
+ placeholderData = [];
133
+ }
134
+ }
135
+
136
+ if (!Array.isArray(placeholderData)) {
137
+ placeholderData = [];
138
+ }
139
+
140
+ const sectionPromises = placeholderData.map(async (item: any) => {
141
+ try {
142
+ const sectionSlug = item.value?.section_slug || item.section_slug;
143
+ const sectionOrder = item.value?.order ?? item.order ?? 0;
144
+
145
+ if (!sectionSlug) {
146
+ return null;
147
+ }
148
+
149
+ const [regularSectionData, sectionSchema] = await Promise.all([
150
+ getWidgetData<any>({
151
+ slug: sectionSlug
152
+ }),
153
+ getWidgetSchemaData<any>({
154
+ widgetSlug: sectionSlug
155
+ })
156
+ ]);
157
+
158
+ let sectionData = regularSectionData;
159
+ if (!sectionData) {
160
+ try {
161
+ sectionData = await getCollectionWidgetData<any>({
162
+ slug: sectionSlug
163
+ });
164
+ } catch {
165
+ // Not a collection widget either; sectionData stays null
166
+ devWarn(
167
+ 'ThemePlaceholder',
168
+ `Section "${sectionSlug}" data could not be resolved as a regular or collection widget; rendering from schema only`
169
+ );
170
+ }
171
+ }
172
+
173
+ const sectionSchemaEntry = sectionSchema?.schema?.[sectionSlug];
174
+ const sectionMetadata = sectionSchemaEntry?.metadata || {};
175
+
176
+ const getBlockValue = (blockId: string): unknown => {
177
+ const attrData = sectionData?.attributes?.[blockId];
178
+ if (attrData === undefined || attrData === null) return undefined;
179
+
180
+ if (typeof attrData === 'string') {
181
+ try {
182
+ return JSON.parse(attrData);
183
+ } catch {
184
+ const trimmed = attrData.trim();
185
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
186
+ devWarn(
187
+ 'ThemePlaceholder',
188
+ `Block "${blockId}" value looks like JSON but failed to parse; using the raw string`
189
+ );
190
+ }
191
+ return attrData;
192
+ }
193
+ } else if (typeof attrData === 'object' && 'value' in attrData) {
194
+ return attrData.value;
195
+ }
196
+ return attrData;
197
+ };
198
+
199
+ const reconstructBlock = (blockSchema: any): any => {
200
+ return {
201
+ id: blockSchema.id,
202
+ type: blockSchema.type || 'text',
203
+ label: blockSchema.label || blockSchema.id,
204
+ order: blockSchema.order || 0,
205
+ isIterator: blockSchema.isIterator,
206
+ iteratorDataPath: blockSchema.iteratorDataPath,
207
+ value: getBlockValue(blockSchema.id),
208
+ styles: blockSchema.styles || {},
209
+ properties: blockSchema.properties || {},
210
+ hidden: blockSchema.hidden || false,
211
+ blocks: (blockSchema.blocks || []).map((b: any) =>
212
+ reconstructBlock(b)
213
+ )
214
+ };
215
+ };
216
+
217
+ const blocks: any[] = [];
218
+ if (sectionMetadata.blocks && Array.isArray(sectionMetadata.blocks)) {
219
+ sectionMetadata.blocks.forEach((blockSchema: any) => {
220
+ blocks.push(reconstructBlock(blockSchema));
221
+ });
222
+ }
223
+
224
+ blocks.sort((a, b) => a.order - b.order);
225
+
226
+ // dataSourceId may be stored in schema metadata OR in widget data attributes
227
+ // (theme editor saves it as a widget attribute when user selects a data source)
228
+ const dataSourceId =
229
+ sectionMetadata.dataSourceId ||
230
+ (getBlockValue('dataSourceId') as string | undefined) ||
231
+ (getBlockValue('selectedDataSourceId') as string | undefined);
232
+
233
+ const dataSource = dataSourceId
234
+ ? dataSources.find((ds) => ds.id === dataSourceId)
235
+ : undefined;
236
+
237
+ if (dataSourceId && !dataSource) {
238
+ devWarn(
239
+ 'ThemePlaceholder',
240
+ `Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
241
+ );
242
+ }
243
+
244
+ let dataSourceWithData = dataSource;
245
+ if (dataSource && dataSource.details) {
246
+ // Only fetch collection data if we have a valid widget slug
247
+ // (not the section's own slug which contains config, not products)
248
+ const collectionSlug = dataSource.details.collection?.slug;
249
+ if (collectionSlug) {
250
+ try {
251
+ let collectionData = await getWidgetData<{
252
+ [key: string]: unknown;
253
+ }>({
254
+ slug: collectionSlug
255
+ });
256
+
257
+ if (!collectionData) {
258
+ collectionData = await getCollectionWidgetData<{
259
+ [key: string]: unknown;
260
+ }>({
261
+ slug: collectionSlug
262
+ });
263
+ }
264
+
265
+ if (collectionData) {
266
+ dataSourceWithData = {
267
+ ...dataSource,
268
+ details: {
269
+ ...dataSource.details,
270
+ collection: {
271
+ ...dataSource.details.collection,
272
+ data: collectionData
273
+ }
274
+ }
275
+ };
276
+ }
277
+ } catch (error) {
278
+ console.error(
279
+ `Error fetching widget data for section ${sectionSlug}:`,
280
+ error
281
+ );
282
+ }
283
+ }
284
+ // If no collection slug, the saved products from editor
285
+ // (details.collection.products) will be used as fallback
286
+ }
287
+
288
+ const section: Section = {
289
+ id: sectionSlug,
290
+ type: sectionMetadata.type || 'default',
291
+ name: sectionMetadata.name || sectionSlug,
292
+ label: sectionMetadata.label || sectionSlug,
293
+ properties: sectionMetadata.properties || {},
294
+ styles: sectionMetadata.styles || {},
295
+ blocks,
296
+ order: sectionOrder,
297
+ hidden: sectionMetadata.hidden || false,
298
+ dataSourceId,
299
+ dataSource: dataSourceWithData
300
+ };
301
+
302
+ return section;
303
+ } catch (error) {
304
+ const errorSlug =
305
+ item.value?.section_slug || item.section_slug || 'unknown';
306
+ console.error(
307
+ `Error parsing section metadata for ${errorSlug}:`,
308
+ error
309
+ );
310
+ return null;
311
+ }
312
+ });
313
+
314
+ const resolvedSections = await Promise.all(sectionPromises);
315
+ sections.push(
316
+ ...resolvedSections.filter((s): s is Section => s !== null)
317
+ );
318
+ }
319
+
320
+ // Dev-only, warn-only shape validation of the reconstructed tree. Surfaces
321
+ // structural drift between the editor/backend data and the render engine
322
+ // without changing what renders (no-op in production).
323
+ validateThemeSectionsInDev(sections);
324
+
325
+ return {
326
+ sections,
327
+ placeholderId: data?.slug?.toString() || slug,
328
+ dataSources,
329
+ themeSettings
330
+ };
331
+ } catch (error) {
332
+ console.error(`Error fetching theme placeholder data for ${slug}:`, error);
333
+ return null;
334
+ }
335
+ }
@@ -1,69 +1,8 @@
1
1
  import 'server-only';
2
2
 
3
- import * as serverData from '@akinon/next/data/server';
4
- import appFetch from '@akinon/next/utils/app-fetch';
5
- import { ServerVariables } from '@akinon/next/utils/server-variables';
6
-
7
- const { getWidgetData } = serverData;
8
-
9
- /**
10
- * v1 cores ship a smaller server data layer: only `getWidgetData` exists,
11
- * `getWidgetSchemaData` and `getCollectionWidgetData` arrived with v2.
12
- *
13
- * The endpoints are identical on both backends — v1 just never wrapped them.
14
- * Degrading to "no data" would render every published section *without its
15
- * blocks* (a section's block tree lives in the widget schema, not in the
16
- * widget), so on v1 we call the REST endpoints directly. This is what keeps
17
- * composed pages rendering on the brands still running v1.
18
- */
19
- type ServerDataFn = (<T>(args: never) => Promise<T | null>) | undefined;
20
-
21
- const optionalServerFn = (name: string): ServerDataFn =>
22
- (serverData as unknown as Record<string, ServerDataFn>)[name];
23
-
24
- const directFetch = async <T,>(url: string): Promise<T | null> => {
25
- try {
26
- const result = await appFetch<T>({
27
- url,
28
- locale: ServerVariables.locale,
29
- currency: ServerVariables.currency
30
- });
31
- return result ?? null;
32
- } catch {
33
- return null;
34
- }
35
- };
36
-
37
- const getCollectionWidgetData = async <T,>(args: {
38
- slug: string;
39
- }): Promise<T | null> => {
40
- const fn = optionalServerFn('getCollectionWidgetData');
41
- if (fn) return (await fn<T>(args as never)) ?? null;
42
- if (!args?.slug) return null;
43
- return directFetch<T>(`/product_collection_widgets/${args.slug}/`);
44
- };
45
-
46
- const getWidgetSchemaData = async <T,>(args: {
47
- widgetSlug: string;
48
- }): Promise<T | null> => {
49
- const fn = optionalServerFn('getWidgetSchemaData');
50
- if (fn) return (await fn<T>(args as never)) ?? null;
51
- if (!args?.widgetSlug) return null;
52
- return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
53
- };
54
3
  import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
55
- import { Section } from './theme-section';
4
+ import { loadPlaceholderSections } from './load-placeholder-sections';
56
5
  import { generateThemeCSS } from './utils';
57
- import { devWarn } from './utils/dev-warn';
58
- import { validateThemeSectionsInDev } from './schemas/theme-schema';
59
- import { getThemeConfig } from './chrome/theme-config-source';
60
-
61
- type ThemePlaceholderData = {
62
- theme_editor_placeholder: Array<{
63
- section_slug: string;
64
- order: number;
65
- }>;
66
- };
67
6
 
68
7
  interface ThemePlaceholderProps {
69
8
  slug: string;
@@ -74,260 +13,27 @@ export default async function ThemePlaceholder({
74
13
  slug,
75
14
  pageContext = null
76
15
  }: ThemePlaceholderProps) {
77
- try {
78
- // theme-config is optional for placeholder rendering, and the reader is
79
- // memoized per request — a page with several placeholders, or a ChromeSlot
80
- // asking the same question to decide whether it is pinned, reads it once.
81
- const { dataSources, settings: themeSettings } = await getThemeConfig();
82
-
83
- let data: { slug?: string; attributes?: { theme_editor_placeholder?: unknown; [key: string]: unknown } } | null = null;
84
- try {
85
- data = await getWidgetData<ThemePlaceholderData>({
86
- slug
87
- }) as typeof data;
88
- } catch (placeholderError) {
89
- console.warn(
90
- `Theme placeholder widget "${slug}" was not found. Rendering empty placeholder.`,
91
- placeholderError
92
- );
93
- }
94
-
95
- const sections: Section[] = [];
96
-
97
- if (data?.attributes?.theme_editor_placeholder) {
98
- let placeholderData: any = data.attributes.theme_editor_placeholder;
99
-
100
- if (
101
- typeof placeholderData === 'object' &&
102
- placeholderData !== null &&
103
- 'value' in placeholderData
104
- ) {
105
- placeholderData = (
106
- placeholderData as {
107
- value: { section_slug: string; order: number }[];
108
- }
109
- ).value;
110
- }
111
-
112
- if (typeof placeholderData === 'string') {
113
- try {
114
- placeholderData = JSON.parse(placeholderData);
115
- } catch {
116
- devWarn(
117
- 'ThemePlaceholder',
118
- `Placeholder "${slug}" layout JSON is malformed; rendering no sections`
119
- );
120
- placeholderData = [];
121
- }
122
- }
123
-
124
- if (!Array.isArray(placeholderData)) {
125
- placeholderData = [];
126
- }
127
-
128
- const sectionPromises = placeholderData.map(async (item: any) => {
129
- try {
130
- const sectionSlug = item.value?.section_slug || item.section_slug;
131
- const sectionOrder = item.value?.order ?? item.order ?? 0;
132
-
133
- if (!sectionSlug) {
134
- return null;
135
- }
136
-
137
- const [regularSectionData, sectionSchema] = await Promise.all([
138
- getWidgetData<any>({
139
- slug: sectionSlug
140
- }),
141
- getWidgetSchemaData<any>({
142
- widgetSlug: sectionSlug
143
- })
144
- ]);
16
+ const loaded = await loadPlaceholderSections(slug);
145
17
 
146
- let sectionData = regularSectionData;
147
- if (!sectionData) {
148
- try {
149
- sectionData = await getCollectionWidgetData<any>({
150
- slug: sectionSlug
151
- });
152
- } catch {
153
- // Not a collection widget either; sectionData stays null
154
- devWarn(
155
- 'ThemePlaceholder',
156
- `Section "${sectionSlug}" data could not be resolved as a regular or collection widget; rendering from schema only`
157
- );
158
- }
159
- }
160
-
161
- const sectionSchemaEntry = sectionSchema?.schema?.[sectionSlug];
162
- const sectionMetadata = sectionSchemaEntry?.metadata || {};
163
-
164
- const getBlockValue = (blockId: string): unknown => {
165
- const attrData = sectionData?.attributes?.[blockId];
166
- if (attrData === undefined || attrData === null) return undefined;
167
-
168
- if (typeof attrData === 'string') {
169
- try {
170
- return JSON.parse(attrData);
171
- } catch {
172
- const trimmed = attrData.trim();
173
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
174
- devWarn(
175
- 'ThemePlaceholder',
176
- `Block "${blockId}" value looks like JSON but failed to parse; using the raw string`
177
- );
178
- }
179
- return attrData;
180
- }
181
- } else if (typeof attrData === 'object' && 'value' in attrData) {
182
- return attrData.value;
183
- }
184
- return attrData;
185
- };
186
-
187
- const reconstructBlock = (blockSchema: any): any => {
188
- return {
189
- id: blockSchema.id,
190
- type: blockSchema.type || 'text',
191
- label: blockSchema.label || blockSchema.id,
192
- order: blockSchema.order || 0,
193
- isIterator: blockSchema.isIterator,
194
- iteratorDataPath: blockSchema.iteratorDataPath,
195
- value: getBlockValue(blockSchema.id),
196
- styles: blockSchema.styles || {},
197
- properties: blockSchema.properties || {},
198
- hidden: blockSchema.hidden || false,
199
- blocks: (blockSchema.blocks || []).map((b: any) =>
200
- reconstructBlock(b)
201
- )
202
- };
203
- };
204
-
205
- const blocks: any[] = [];
206
- if (sectionMetadata.blocks && Array.isArray(sectionMetadata.blocks)) {
207
- sectionMetadata.blocks.forEach((blockSchema: any) => {
208
- blocks.push(reconstructBlock(blockSchema));
209
- });
210
- }
211
-
212
- blocks.sort((a, b) => a.order - b.order);
213
-
214
- // dataSourceId may be stored in schema metadata OR in widget data attributes
215
- // (theme editor saves it as a widget attribute when user selects a data source)
216
- const dataSourceId =
217
- sectionMetadata.dataSourceId ||
218
- (getBlockValue('dataSourceId') as string | undefined) ||
219
- (getBlockValue('selectedDataSourceId') as string | undefined);
220
-
221
- const dataSource = dataSourceId
222
- ? dataSources.find((ds) => ds.id === dataSourceId)
223
- : undefined;
224
-
225
- if (dataSourceId && !dataSource) {
226
- devWarn(
227
- 'ThemePlaceholder',
228
- `Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
229
- );
230
- }
231
-
232
- let dataSourceWithData = dataSource;
233
- if (dataSource && dataSource.details) {
234
- // Only fetch collection data if we have a valid widget slug
235
- // (not the section's own slug which contains config, not products)
236
- const collectionSlug = dataSource.details.collection?.slug;
237
- if (collectionSlug) {
238
- try {
239
- let collectionData = await getWidgetData<{
240
- [key: string]: unknown;
241
- }>({
242
- slug: collectionSlug
243
- });
244
-
245
- if (!collectionData) {
246
- collectionData = await getCollectionWidgetData<{
247
- [key: string]: unknown;
248
- }>({
249
- slug: collectionSlug
250
- });
251
- }
252
-
253
- if (collectionData) {
254
- dataSourceWithData = {
255
- ...dataSource,
256
- details: {
257
- ...dataSource.details,
258
- collection: {
259
- ...dataSource.details.collection,
260
- data: collectionData
261
- }
262
- }
263
- };
264
- }
265
- } catch (error) {
266
- console.error(
267
- `Error fetching widget data for section ${sectionSlug}:`,
268
- error
269
- );
270
- }
271
- }
272
- // If no collection slug, the saved products from editor
273
- // (details.collection.products) will be used as fallback
274
- }
275
-
276
- const section: Section = {
277
- id: sectionSlug,
278
- type: sectionMetadata.type || 'default',
279
- name: sectionMetadata.name || sectionSlug,
280
- label: sectionMetadata.label || sectionSlug,
281
- properties: sectionMetadata.properties || {},
282
- styles: sectionMetadata.styles || {},
283
- blocks,
284
- order: sectionOrder,
285
- hidden: sectionMetadata.hidden || false,
286
- dataSourceId,
287
- dataSource: dataSourceWithData
288
- };
289
-
290
- return section;
291
- } catch (error) {
292
- const errorSlug =
293
- item.value?.section_slug || item.section_slug || 'unknown';
294
- console.error(
295
- `Error parsing section metadata for ${errorSlug}:`,
296
- error
297
- );
298
- return null;
299
- }
300
- });
301
-
302
- const resolvedSections = await Promise.all(sectionPromises);
303
- sections.push(
304
- ...resolvedSections.filter((s): s is Section => s !== null)
305
- );
306
- }
307
-
308
- // Dev-only, warn-only shape validation of the reconstructed tree. Surfaces
309
- // structural drift between the editor/backend data and the render engine
310
- // without changing what renders (no-op in production).
311
- validateThemeSectionsInDev(sections);
312
-
313
- const themeCSS = generateThemeCSS(sections);
314
-
315
- return (
316
- <>
317
- {themeCSS && <style dangerouslySetInnerHTML={{ __html: themeCSS }} />}
318
- <ThemePlaceholderWrapper
319
- slug={slug}
320
- initialSections={sections}
321
- initialPlaceholderId={data?.slug?.toString() || slug}
322
- isDesignMode={false}
323
- dataSources={dataSources}
324
- initialThemeSettings={themeSettings}
325
- initialPageContext={pageContext}
326
- />
327
- </>
328
- );
329
- } catch (error) {
330
- console.error(`Error fetching theme placeholder data for ${slug}:`, error);
18
+ if (!loaded) {
331
19
  return null;
332
20
  }
21
+
22
+ const { sections, placeholderId, dataSources, themeSettings } = loaded;
23
+ const themeCSS = generateThemeCSS(sections);
24
+
25
+ return (
26
+ <>
27
+ {themeCSS && <style dangerouslySetInnerHTML={{ __html: themeCSS }} />}
28
+ <ThemePlaceholderWrapper
29
+ slug={slug}
30
+ initialSections={sections}
31
+ initialPlaceholderId={placeholderId}
32
+ isDesignMode={false}
33
+ dataSources={dataSources}
34
+ initialThemeSettings={themeSettings}
35
+ initialPageContext={pageContext}
36
+ />
37
+ </>
38
+ );
333
39
  }