@akinon/pz-theme 2.0.108-rc.0 → 2.0.108

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,40 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.108-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d45]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.108-rc.0
37
- - @akinon/theme-contract@2.0.108-rc.0
3
+ ## 2.0.108
4
+
5
+ ### Patch Changes
6
+
7
+ - 6ec2ef3: ZERO-5080: Render collection-backed section products server-side instead of the editor's frozen snapshot
8
+ - @akinon/next@2.0.108
9
+ - @akinon/theme-contract@2.0.108
38
10
 
39
11
  ## 2.0.107
40
12
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
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.108-rc.0",
4
+ "version": "2.0.108",
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.108-rc.0",
11
+ "@akinon/next": "2.0.108",
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.108-rc.0",
16
+ "@akinon/theme-contract": "2.0.108",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.108-rc.0",
24
+ "@akinon/next": "2.0.108",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A section bound to a collection has to render its products on the SERVER.
3
+ *
4
+ * It did not: `details.collection.data` was only written when the data source
5
+ * carried an editor-persisted `collection.slug`, and the editor never persists
6
+ * one -- so every brand fell through to `details.collection.products`, the
7
+ * snapshot frozen when the collection was picked. On a force-static page that
8
+ * snapshot is what every shopper, crawler and no-JS visit gets: rows with no
9
+ * `absolute_url` (every card linking to `#`) at whatever price the catalogue
10
+ * showed that day, swapped out visibly once the client refetched.
11
+ *
12
+ * The live rows were already in hand the whole time: the loader fetches each
13
+ * section's own widget for its block values, and for a collection-backed
14
+ * section that answer carries the bound collection's live products.
15
+ */
16
+ jest.mock('server-only', () => ({}), { virtual: true });
17
+
18
+ const getWidgetData = jest.fn();
19
+ const getWidgetSchemaData = jest.fn();
20
+ const getCollectionWidgetData = jest.fn();
21
+ const getPreviewSnapshot = jest.fn(async () => null as unknown);
22
+
23
+ jest.mock('@akinon/next/data/server', () => ({
24
+ getWidgetData: (...args: unknown[]) => getWidgetData(...args),
25
+ getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
26
+ getCollectionWidgetData: (...args: unknown[]) =>
27
+ getCollectionWidgetData(...args),
28
+ getPreviewSnapshot: (...args: unknown[]) =>
29
+ (getPreviewSnapshot as (...a: unknown[]) => unknown)(...args)
30
+ }));
31
+
32
+ jest.mock(
33
+ '@akinon/next/utils/app-fetch',
34
+ () => ({ __esModule: true, default: jest.fn() }),
35
+ { virtual: true }
36
+ );
37
+
38
+ jest.mock(
39
+ '@akinon/next/utils/server-variables',
40
+ () => ({ ServerVariables: { locale: 'tr', currency: 'try' } }),
41
+ { virtual: true }
42
+ );
43
+
44
+ import { loadPlaceholderSections } from '../load-placeholder-sections';
45
+
46
+ const SLOT = 'homepage';
47
+ const SECTION = 'rmd8i';
48
+ const SOURCE_ID = 'ds-1';
49
+
50
+ /** What the backend returns for a section bound to a live collection. */
51
+ const LIVE_ROWS = [
52
+ { pk: 1, name: 'Live one', absolute_url: '/live-one/', price: '199.99' },
53
+ { pk: 2, name: 'Live two', absolute_url: '/live-two/', price: '299.99' }
54
+ ];
55
+
56
+ /** What the editor saved when the designer picked the collection: no links. */
57
+ const SNAPSHOT_ROWS = [
58
+ { pk: 9, name: 'Frozen one', price: '4999.99' },
59
+ { pk: 8, name: 'Frozen two', price: '4999.99' }
60
+ ];
61
+
62
+ const placeholderWidget = () => ({
63
+ slug: SLOT,
64
+ attributes: {
65
+ theme_editor_placeholder: {
66
+ value: JSON.stringify([{ section_slug: SECTION, order: 0 }])
67
+ }
68
+ }
69
+ });
70
+
71
+ const themeConfigWidget = (dataSources: unknown[]) => ({
72
+ slug: 'theme-config',
73
+ attributes: { theme_dataSources: { value: JSON.stringify(dataSources) } }
74
+ });
75
+
76
+ const collectionSource = (collection: Record<string, unknown>) => ({
77
+ id: SOURCE_ID,
78
+ name: 'Picked collection',
79
+ type: 'collection',
80
+ details: { collection }
81
+ });
82
+
83
+ /**
84
+ * Drives the live path: the placeholder lists one section, the section widget
85
+ * answers with `sectionWidget`, and theme-config carries `dataSources`.
86
+ */
87
+ const load = async (options: {
88
+ dataSources: unknown[];
89
+ sectionWidget: unknown;
90
+ /** Answer for the collection's own widget slug, when the source has one. */
91
+ collectionWidget?: unknown;
92
+ dataSourceId?: string | null;
93
+ }) => {
94
+ const { dataSources, sectionWidget, collectionWidget = null } = options;
95
+ const dataSourceId =
96
+ options.dataSourceId === undefined ? SOURCE_ID : options.dataSourceId;
97
+
98
+ getWidgetData.mockImplementation(async ({ slug }: { slug: string }) => {
99
+ if (slug === SLOT) return placeholderWidget();
100
+ if (slug === 'theme-config') return themeConfigWidget(dataSources);
101
+ if (slug === SECTION) return sectionWidget;
102
+ return collectionWidget;
103
+ });
104
+
105
+ getWidgetSchemaData.mockResolvedValue({
106
+ schema: {
107
+ [SECTION]: {
108
+ metadata: {
109
+ name: 'Product slider',
110
+ ...(dataSourceId ? { dataSourceId } : {}),
111
+ blocks: []
112
+ }
113
+ }
114
+ }
115
+ });
116
+
117
+ const loaded = await loadPlaceholderSections(SLOT);
118
+ return loaded?.sections[0];
119
+ };
120
+
121
+ beforeEach(() => {
122
+ getWidgetData.mockReset();
123
+ getWidgetSchemaData.mockReset().mockResolvedValue(null);
124
+ getCollectionWidgetData.mockReset().mockResolvedValue(null);
125
+ getPreviewSnapshot.mockReset().mockResolvedValue(null);
126
+ });
127
+
128
+ describe('collection-backed sections on the server', () => {
129
+ it('renders the live rows the section widget already carries', async () => {
130
+ const section = await load({
131
+ dataSources: [collectionSource({ id: 'c-1', products: SNAPSHOT_ROWS })],
132
+ sectionWidget: { slug: SECTION, attributes: {}, products: LIVE_ROWS }
133
+ });
134
+
135
+ // `data` is what the storefront reads first, and it holds the whole widget
136
+ // answer -- the shape the collection-slug path has always written.
137
+ expect(section?.dataSource.details.collection.data).toEqual({
138
+ slug: SECTION,
139
+ attributes: {},
140
+ products: LIVE_ROWS
141
+ });
142
+ });
143
+
144
+ it('leaves the editor snapshot in place beside the live rows', async () => {
145
+ const section = await load({
146
+ dataSources: [collectionSource({ id: 'c-1', products: SNAPSHOT_ROWS })],
147
+ sectionWidget: { products: LIVE_ROWS }
148
+ });
149
+
150
+ // The designer reads `products` first, so the editor keeps showing the
151
+ // collection exactly as it was picked.
152
+ expect(section?.dataSource.details.collection.products).toEqual(
153
+ SNAPSHOT_ROWS
154
+ );
155
+ });
156
+
157
+ it('does not overwrite a snapshot when the answer carries no products', async () => {
158
+ const section = await load({
159
+ dataSources: [collectionSource({ id: 'c-1', products: SNAPSHOT_ROWS })],
160
+ sectionWidget: { slug: SECTION, attributes: { title: 'Picks' } }
161
+ });
162
+
163
+ expect(section?.dataSource.details.collection.data).toBeUndefined();
164
+ expect(section?.dataSource.details.collection.products).toEqual(
165
+ SNAPSHOT_ROWS
166
+ );
167
+ });
168
+
169
+ it('does not overwrite a snapshot with an empty product list', async () => {
170
+ const section = await load({
171
+ dataSources: [collectionSource({ id: 'c-1', products: SNAPSHOT_ROWS })],
172
+ sectionWidget: { slug: SECTION, products: [] }
173
+ });
174
+
175
+ expect(section?.dataSource.details.collection.data).toBeUndefined();
176
+ expect(section?.dataSource.details.collection.products).toEqual(
177
+ SNAPSHOT_ROWS
178
+ );
179
+ });
180
+
181
+ it('keeps the collection-slug refetch in charge when it fires', async () => {
182
+ const refetched = { products: [{ pk: 3, absolute_url: '/from-slug/' }] };
183
+
184
+ const section = await load({
185
+ dataSources: [
186
+ collectionSource({
187
+ id: 'c-1',
188
+ slug: 'collection-widget',
189
+ products: SNAPSHOT_ROWS
190
+ })
191
+ ],
192
+ sectionWidget: { products: LIVE_ROWS },
193
+ collectionWidget: refetched
194
+ });
195
+
196
+ expect(section?.dataSource.details.collection.data).toEqual(refetched);
197
+ });
198
+
199
+ it('falls back to the section answer when the slug refetch comes back empty', async () => {
200
+ const section = await load({
201
+ dataSources: [
202
+ collectionSource({ id: 'c-1', slug: 'gone', products: SNAPSHOT_ROWS })
203
+ ],
204
+ sectionWidget: { products: LIVE_ROWS },
205
+ collectionWidget: null
206
+ });
207
+
208
+ expect(section?.dataSource.details.collection.data).toEqual({
209
+ products: LIVE_ROWS
210
+ });
211
+ });
212
+
213
+ it('keeps an already-resolved collection rather than replacing it', async () => {
214
+ const resolved = { products: [{ pk: 4, absolute_url: '/already/' }] };
215
+
216
+ const section = await load({
217
+ dataSources: [
218
+ collectionSource({ id: 'c-1', data: resolved, products: SNAPSHOT_ROWS })
219
+ ],
220
+ sectionWidget: { products: LIVE_ROWS }
221
+ });
222
+
223
+ expect(section?.dataSource.details.collection.data).toEqual(resolved);
224
+ });
225
+
226
+ it('leaves a section with no data source untouched', async () => {
227
+ const section = await load({
228
+ dataSources: [],
229
+ sectionWidget: { slug: SECTION, attributes: {}, products: LIVE_ROWS },
230
+ dataSourceId: null
231
+ });
232
+
233
+ expect(section?.dataSourceId).toBeUndefined();
234
+ expect(section?.dataSource).toBeUndefined();
235
+ });
236
+
237
+ it('leaves a non-collection data source untouched', async () => {
238
+ const section = await load({
239
+ dataSources: [
240
+ { id: SOURCE_ID, type: 'static', details: { static: { data: [] } } }
241
+ ],
242
+ sectionWidget: { products: LIVE_ROWS }
243
+ });
244
+
245
+ expect(section?.dataSource.details.collection).toBeUndefined();
246
+ expect(section?.dataSource.details.static).toEqual({ data: [] });
247
+ });
248
+ });
@@ -62,15 +62,44 @@ const getWidgetSchemaData = async <T,>(args: {
62
62
  return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
63
63
  };
64
64
 
65
+ /** Writes a fetched answer into the field the renderers read as "live rows". */
66
+ const withCollectionData = (dataSource: any, collectionData: unknown): any => ({
67
+ ...dataSource,
68
+ details: {
69
+ ...dataSource.details,
70
+ collection: {
71
+ ...dataSource.details.collection,
72
+ data: collectionData
73
+ }
74
+ }
75
+ });
76
+
77
+ /**
78
+ * A widget answer counts as live rows only when it actually carries a
79
+ * non-empty `products` array: a section with no data source attached answers
80
+ * without the key at all, and an empty list must never replace the editor's
81
+ * snapshot with nothing to render.
82
+ */
83
+ const carriesProducts = (widgetData: unknown): boolean =>
84
+ !!widgetData &&
85
+ typeof widgetData === 'object' &&
86
+ Array.isArray((widgetData as { products?: unknown }).products) &&
87
+ (widgetData as { products: unknown[] }).products.length > 0;
88
+
65
89
  /**
66
90
  * Resolves a section's data source reference into the object the renderer
67
91
  * expects, fetching collection data for collection-backed sources. Shared by
68
92
  * the live render path and the preview-snapshot path so the two cannot drift.
93
+ *
94
+ * `sectionWidgetData` is the section's OWN widget answer, which the live path
95
+ * has already fetched for its block values. See the fallback below for why it
96
+ * is handed over rather than fetched again.
69
97
  */
70
98
  async function resolveSectionDataSource(
71
99
  dataSourceId: string | undefined,
72
100
  dataSources: ThemeConfig['dataSources'],
73
- sectionSlug: string
101
+ sectionSlug: string,
102
+ sectionWidgetData?: unknown
74
103
  ): Promise<any | undefined> {
75
104
  if (!dataSourceId) {
76
105
  return undefined;
@@ -90,47 +119,60 @@ async function resolveSectionDataSource(
90
119
  return dataSource;
91
120
  }
92
121
 
93
- // Only fetch collection data if we have a valid widget slug
94
- // (not the section's own slug which contains config, not products)
122
+ // The editor may persist the collection's own widget slug alongside the
123
+ // source. When it does, that widget is the authority and is refetched here.
95
124
  const collectionSlug = dataSource.details.collection?.slug;
96
- if (!collectionSlug) {
97
- // The saved products from the editor (details.collection.products)
98
- // will be used as fallback
99
- return dataSource;
100
- }
101
-
102
- try {
103
- let collectionData = await getWidgetData<{
104
- [key: string]: unknown;
105
- }>({
106
- slug: collectionSlug
107
- });
108
125
 
109
- if (!collectionData) {
110
- collectionData = await getCollectionWidgetData<{
126
+ if (collectionSlug) {
127
+ try {
128
+ let collectionData = await getWidgetData<{
111
129
  [key: string]: unknown;
112
130
  }>({
113
131
  slug: collectionSlug
114
132
  });
115
- }
116
133
 
117
- if (collectionData) {
118
- return {
119
- ...dataSource,
120
- details: {
121
- ...dataSource.details,
122
- collection: {
123
- ...dataSource.details.collection,
124
- data: collectionData
125
- }
126
- }
127
- };
134
+ if (!collectionData) {
135
+ collectionData = await getCollectionWidgetData<{
136
+ [key: string]: unknown;
137
+ }>({
138
+ slug: collectionSlug
139
+ });
140
+ }
141
+
142
+ if (collectionData) {
143
+ return withCollectionData(dataSource, collectionData);
144
+ }
145
+ } catch (error) {
146
+ console.error(
147
+ `Error fetching widget data for section ${sectionSlug}:`,
148
+ error
149
+ );
128
150
  }
129
- } catch (error) {
130
- console.error(
131
- `Error fetching widget data for section ${sectionSlug}:`,
132
- error
133
- );
151
+ }
152
+
153
+ /**
154
+ * No slug, or the refetch came back empty. The section's own widget answer
155
+ * is the second authority: for a collection-backed section the backend
156
+ * returns the bound collection's LIVE rows on it, `absolute_url` and current
157
+ * prices included — and the live path has that answer in hand already.
158
+ *
159
+ * It used to be thrown away, so the server rendered the editor's saved
160
+ * `products` instead: the snapshot frozen when the designer picked the
161
+ * collection. Dead `href="#"` links and yesterday's prices in the served
162
+ * HTML, then a visible swap once the client refetched. Sources saved before
163
+ * the editor began persisting `collection.slug` carry none, so this is the
164
+ * path they take.
165
+ *
166
+ * Only ever fills an ABSENT `data` — a source that already resolved keeps
167
+ * what it resolved to, and the editor reads `products` first either way, so
168
+ * the designer still sees the collection exactly as it was picked.
169
+ */
170
+ if (
171
+ dataSource.details.collection &&
172
+ !dataSource.details.collection.data &&
173
+ carriesProducts(sectionWidgetData)
174
+ ) {
175
+ return withCollectionData(dataSource, sectionWidgetData);
134
176
  }
135
177
 
136
178
  return dataSource;
@@ -366,7 +408,8 @@ export async function loadPlaceholderSections(
366
408
  const dataSourceWithData = await resolveSectionDataSource(
367
409
  dataSourceId,
368
410
  dataSources,
369
- sectionSlug
411
+ sectionSlug,
412
+ sectionData
370
413
  );
371
414
 
372
415
  const section: Section = {