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

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.
@@ -42,6 +42,13 @@ export interface Section {
42
42
  locked?: boolean;
43
43
  dataSourceId?: string;
44
44
  dataSource?: any;
45
+ /**
46
+ * Sent by the editor, never stored: `true` when publishing now would not
47
+ * change what this section's widget is bound to, so the widget's live rows
48
+ * are the right ones to show. Absent while a pick is unsaved, and from an
49
+ * editor that predates the field. See utils/live-rows.ts.
50
+ */
51
+ dataSourceSaved?: boolean;
45
52
  }
46
53
 
47
54
  interface ThemeSectionProps {
@@ -196,12 +203,12 @@ export default function ThemeSection({
196
203
  ): Record<string, unknown>[] => {
197
204
  if (!effectiveSection.dataSource?.details) return [];
198
205
 
199
- const isEditorMode = isDesigner && window.parent !== window;
200
- const collectionData = isEditorMode
201
- ? effectiveSection.dataSource.details.collection?.products ||
202
- effectiveSection.dataSource.details.collection?.data
203
- : effectiveSection.dataSource.details.collection?.data ||
204
- effectiveSection.dataSource.details.collection?.products;
206
+ // Live rows first in every mode. On the editor canvas the wrapper leaves
207
+ // `data` in place only for a saved pick, so an unsaved pick still shows
208
+ // its snapshot (utils/live-rows.ts).
209
+ const collectionData =
210
+ effectiveSection.dataSource.details.collection?.data ||
211
+ effectiveSection.dataSource.details.collection?.products;
205
212
 
206
213
  if (!collectionData) return [];
207
214
 
@@ -199,19 +199,17 @@ const getItemBindingTarget = (block: Block): string => {
199
199
  };
200
200
 
201
201
  const getCollectionBindingRoot = (
202
- sectionDataSource: any,
203
- isDesigner: boolean
202
+ sectionDataSource: any
204
203
  ): Record<string, unknown> | null => {
205
204
  if (!sectionDataSource?.details?.collection) {
206
205
  return null;
207
206
  }
208
207
 
209
- const isEditorMode =
210
- isDesigner && typeof window !== 'undefined' && window.parent !== window;
208
+ // Live rows first in every mode. On the editor canvas the wrapper leaves
209
+ // `data` in place only for a saved pick, so an unsaved pick still binds to
210
+ // its snapshot (utils/live-rows.ts).
211
211
  const collection = sectionDataSource.details.collection;
212
- const candidates = isEditorMode
213
- ? [collection?.products, collection?.data]
214
- : [collection?.data, collection?.products];
212
+ const candidates = [collection?.data, collection?.products];
215
213
 
216
214
  for (const candidate of candidates) {
217
215
  if (Array.isArray(candidate)) {
@@ -248,7 +246,6 @@ const getStaticBindingRoot = (
248
246
  const getSectionBindingRoot = ({
249
247
  sectionDataSource,
250
248
  pageContext,
251
- isDesigner = false,
252
249
  allowPageContextFallback = false
253
250
  }: BindingContext): Record<string, unknown> | null => {
254
251
  if (
@@ -258,10 +255,7 @@ const getSectionBindingRoot = ({
258
255
  return pageContext;
259
256
  }
260
257
 
261
- const collectionRoot = getCollectionBindingRoot(
262
- sectionDataSource,
263
- isDesigner
264
- );
258
+ const collectionRoot = getCollectionBindingRoot(sectionDataSource);
265
259
  if (collectionRoot) {
266
260
  return collectionRoot;
267
261
  }
@@ -765,13 +759,11 @@ export const resolveBlockBindings = ({
765
759
  block,
766
760
  sectionDataSource,
767
761
  pageContext,
768
- isDesigner = false,
769
762
  currency
770
763
  }: BindingContext & { block: Block }): Block => {
771
764
  const bindingRoot = getSectionBindingRoot({
772
765
  sectionDataSource,
773
766
  pageContext,
774
- isDesigner,
775
767
  // Value bindings resolve against the page context when the section has no
776
768
  // data source. The iterator lookup deliberately does not — see
777
769
  // getSectionBindingRoot.
@@ -852,7 +844,6 @@ const parsePositiveNumber = (value: unknown, fallback: number): number => {
852
844
  const getIteratorItems = ({
853
845
  sectionDataSource,
854
846
  dataPath,
855
- isDesigner = false,
856
847
  pageContext,
857
848
  scopedItems
858
849
  }: BindingContext & {
@@ -869,8 +860,7 @@ const getIteratorItems = ({
869
860
 
870
861
  const bindingRoot = getSectionBindingRoot({
871
862
  sectionDataSource,
872
- pageContext,
873
- isDesigner
863
+ pageContext
874
864
  });
875
865
 
876
866
  if (!bindingRoot) {
@@ -1642,7 +1632,6 @@ export const buildIteratorBlock = ({
1642
1632
  sectionDataSource,
1643
1633
  pageContext,
1644
1634
  dataPath,
1645
- isDesigner,
1646
1635
  scopedItems
1647
1636
  });
1648
1637
 
@@ -0,0 +1,226 @@
1
+ import { buildClientRequestUrl } from '@akinon/next/utils';
2
+
3
+ /**
4
+ * Live rows on the editor canvas (ZERO-5084).
5
+ *
6
+ * A collection-backed section carries two row sets:
7
+ *
8
+ * - `details.collection.products` — the SNAPSHOT the editor captured when the
9
+ * merchant picked the collection. Never refreshed afterwards.
10
+ * - `details.collection.data` — the section widget's LIVE answer, which is
11
+ * what the storefront renders (see load-placeholder-sections.ts).
12
+ *
13
+ * The canvas used to rank the snapshot first, so it drifted from the site as
14
+ * soon as the collection's contents changed. It now ranks the live rows first
15
+ * like the storefront does, and this module decides when the canvas may hold
16
+ * live rows at all: only while the editor reports the section's pick as
17
+ * saved (`dataSourceSaved`). Until an unsaved pick is published, the section's
18
+ * widget is still bound to the previous collection, so its live answer would
19
+ * be the wrong rows — the picked snapshot is shown instead.
20
+ *
21
+ * Client-safe: no server-only imports. The server loader imports
22
+ * `carriesProducts` from here so both sides apply one acceptance rule.
23
+ */
24
+
25
+ /** Rows kept per `<sectionId>|<bindingKey>` for the designer canvas. */
26
+ export type LiveRowsStore = Map<string, unknown>;
27
+
28
+ /**
29
+ * A widget answer counts as live rows only when it actually carries a
30
+ * non-empty `products` array: a section with no data source attached answers
31
+ * without the key at all, and an empty list must never replace the editor's
32
+ * snapshot with nothing to render.
33
+ */
34
+ export const carriesProducts = (widgetData: unknown): boolean =>
35
+ !!widgetData &&
36
+ typeof widgetData === 'object' &&
37
+ Array.isArray((widgetData as { products?: unknown }).products) &&
38
+ (widgetData as { products: unknown[] }).products.length > 0;
39
+
40
+ const readCollection = (
41
+ dataSource: unknown
42
+ ): Record<string, unknown> | null => {
43
+ const collection = (
44
+ dataSource as { details?: { collection?: unknown } } | null | undefined
45
+ )?.details?.collection;
46
+
47
+ return collection && typeof collection === 'object'
48
+ ? (collection as Record<string, unknown>)
49
+ : null;
50
+ };
51
+
52
+ /**
53
+ * What a section's widget is bound to: the two values publishing writes to it
54
+ * (`product_collection` and `product_limit`). `null` for a source that is not
55
+ * a collection, or one with no collection picked yet.
56
+ */
57
+ export const collectionBindingKey = (dataSource: unknown): string | null => {
58
+ const collection = readCollection(dataSource);
59
+ if (!collection) return null;
60
+
61
+ const { pk, productLimit } = collection;
62
+ if (pk === undefined || pk === null || pk === '') return null;
63
+
64
+ return `${String(pk)}|${
65
+ productLimit === undefined || productLimit === null
66
+ ? ''
67
+ : String(productLimit)
68
+ }`;
69
+ };
70
+
71
+ export const liveRowsEntryKey = (sectionId: string, bindingKey: string) =>
72
+ `${sectionId}|${bindingKey}`;
73
+
74
+ export interface LiveRowsRequest {
75
+ path: string;
76
+ /**
77
+ * The section's own widget answers for every section, collection-backed or
78
+ * not, so its answer is only taken when it carries products. An answer from
79
+ * the collection's own widget is taken as it is.
80
+ */
81
+ requireProducts: boolean;
82
+ }
83
+
84
+ /**
85
+ * The endpoints the canvas asks, in the server loader's order
86
+ * (`resolveSectionDataSource` in load-placeholder-sections.ts):
87
+ *
88
+ * 1. `/widgets/<collection.slug>/`, then `/product_collection_widgets/<slug>/`
89
+ * — only when the source carries the collection's slug;
90
+ * 2. `/widgets/<sectionId>/` — the section's own widget.
91
+ *
92
+ * If the two orders drift, the canvas and the site pick different answers for
93
+ * the same section. A parity test pins them together.
94
+ */
95
+ export const liveRowsPaths = (
96
+ sectionId: string,
97
+ collection: { slug?: unknown } | null | undefined
98
+ ): LiveRowsRequest[] => {
99
+ const paths: LiveRowsRequest[] = [];
100
+ const slug = collection?.slug;
101
+
102
+ if (typeof slug === 'string' && slug) {
103
+ paths.push({ path: `/widgets/${slug}/`, requireProducts: false });
104
+ paths.push({
105
+ path: `/product_collection_widgets/${slug}/`,
106
+ requireProducts: false
107
+ });
108
+ }
109
+
110
+ if (sectionId) {
111
+ paths.push({ path: `/widgets/${sectionId}/`, requireProducts: true });
112
+ }
113
+
114
+ return paths;
115
+ };
116
+
117
+ /**
118
+ * Fetches a section's live rows from the browser, walking `liveRowsPaths` in
119
+ * order. Cache-busted, so an answer asked for right after a publish is not a
120
+ * copy from before it. Resolves `null` when no endpoint gives a usable answer;
121
+ * never rejects.
122
+ */
123
+ export const fetchDesignerLiveRows = async (
124
+ sectionId: string,
125
+ collection: { slug?: unknown } | null | undefined
126
+ ): Promise<unknown | null> => {
127
+ for (const { path, requireProducts } of liveRowsPaths(
128
+ sectionId,
129
+ collection
130
+ )) {
131
+ let answer: unknown = null;
132
+
133
+ try {
134
+ const response = await fetch(
135
+ buildClientRequestUrl(path, { cache: false })
136
+ );
137
+ if (response.ok) {
138
+ answer = await response.json();
139
+ }
140
+ } catch {
141
+ answer = null;
142
+ }
143
+
144
+ if (!answer || typeof answer !== 'object') continue;
145
+ if (requireProducts && !carriesProducts(answer)) continue;
146
+
147
+ return answer;
148
+ }
149
+
150
+ return null;
151
+ };
152
+
153
+ /**
154
+ * The live rows the server already wrote into the document, keyed by the
155
+ * binding the server resolved them for. Seeding from these is what lets a
156
+ * saved section keep the exact rows the storefront rendered, with no request.
157
+ */
158
+ export const seedLiveRows = (
159
+ sections:
160
+ ReadonlyArray<{ id?: string; dataSource?: unknown }> | null | undefined
161
+ ): LiveRowsStore => {
162
+ const store: LiveRowsStore = new Map();
163
+
164
+ (sections || []).forEach((section) => {
165
+ const data = readCollection(section?.dataSource)?.data;
166
+ const bindingKey = collectionBindingKey(section?.dataSource);
167
+
168
+ if (section?.id && bindingKey && data) {
169
+ store.set(liveRowsEntryKey(section.id, bindingKey), data);
170
+ }
171
+ });
172
+
173
+ return store;
174
+ };
175
+
176
+ /**
177
+ * The data source the designer canvas renders a section with.
178
+ *
179
+ * `raw` is the source record as the editor (or theme-config) holds it. Its
180
+ * `details.collection.data` is dropped, and live rows are put back only when
181
+ * the editor reports the section's pick as saved AND rows are held for the
182
+ * binding the record names now. A late answer for a binding the section has
183
+ * since left is stored under that binding's key, so it can never be attached
184
+ * to the new one.
185
+ *
186
+ * A record that carries no snapshot keeps the `data` it came with: there is
187
+ * nothing to protect, and those rows are what the canvas showed before and
188
+ * what the site shows.
189
+ */
190
+ export const hangDesignerDataSource = (
191
+ section: { id?: string; dataSourceSaved?: unknown } | null | undefined,
192
+ raw: any,
193
+ liveRows: LiveRowsStore
194
+ ): any => {
195
+ const collection = readCollection(raw);
196
+ if (!collection) return raw;
197
+
198
+ const bindingKey = collectionBindingKey(raw);
199
+ const rows =
200
+ section?.dataSourceSaved === true && section.id && bindingKey
201
+ ? liveRows.get(liveRowsEntryKey(section.id, bindingKey))
202
+ : undefined;
203
+
204
+ if (rows !== undefined) {
205
+ return {
206
+ ...raw,
207
+ details: {
208
+ ...raw.details,
209
+ collection: { ...collection, data: rows }
210
+ }
211
+ };
212
+ }
213
+
214
+ if (!('data' in collection) || !collection.products) return raw;
215
+
216
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
217
+ const { data: _unsavedOrStale, ...snapshotOnly } = collection;
218
+
219
+ return {
220
+ ...raw,
221
+ details: {
222
+ ...raw.details,
223
+ collection: snapshotOnly
224
+ }
225
+ };
226
+ };