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

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -31
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -1,5 +1,6 @@
1
1
  import { Block } from '../theme-block';
2
2
  import { Section } from '../theme-section';
3
+ import { BREAKPOINT_FALLBACKS } from './breakpoints';
3
4
  import { devWarn } from './dev-warn';
4
5
 
5
6
  export type VisibilityAuthState = 'all' | 'authenticated' | 'guest';
@@ -9,6 +10,43 @@ export type VisibilityPathMatchType =
9
10
  | 'contains'
10
11
  | 'startsWith';
11
12
 
13
+ /**
14
+ * Operators a data condition can use. `exists` / `notExists` answer for
15
+ * presence, the rest compare — three textual and four numeric, because a
16
+ * catalogue condition is as often "stock is below 5" as "gender is women".
17
+ */
18
+ export type VisibilityDataOperator =
19
+ | 'exists'
20
+ | 'notExists'
21
+ | 'equals'
22
+ | 'notEquals'
23
+ | 'contains'
24
+ | 'gt'
25
+ | 'gte'
26
+ | 'lt'
27
+ | 'lte';
28
+
29
+ export const VISIBILITY_DATA_OPERATORS: VisibilityDataOperator[] = [
30
+ 'exists',
31
+ 'notExists',
32
+ 'equals',
33
+ 'notEquals',
34
+ 'contains',
35
+ 'gt',
36
+ 'gte',
37
+ 'lt',
38
+ 'lte'
39
+ ];
40
+
41
+ /** One condition against the page context, e.g. `product.attributes.gender`. */
42
+ export interface VisibilityDataCondition {
43
+ path: string;
44
+ operator: VisibilityDataOperator;
45
+ value?: string;
46
+ }
47
+
48
+ export type VisibilityDataMatchMode = 'all' | 'any';
49
+
12
50
  export interface VisibilityRules {
13
51
  enabled?: boolean;
14
52
  authState?: VisibilityAuthState;
@@ -19,8 +57,41 @@ export interface VisibilityRules {
19
57
  pathnameValue?: string;
20
58
  queryParamKey?: string;
21
59
  queryParamValue?: string;
60
+ dataConditions?: VisibilityDataCondition[];
61
+ dataMatchMode?: VisibilityDataMatchMode;
22
62
  }
23
63
 
64
+ /**
65
+ * Whether the viewport being rendered is on the rule's allow-list.
66
+ *
67
+ * The list is a set of breakpoint names, and until now there were only two to
68
+ * choose from. Every rule already published that says "mobile only" was written
69
+ * when `mobile` meant *everything below 1024* — so reading such a list
70
+ * literally against a three-way signal would hide a mobile-only banner on every
71
+ * tablet, silently, on compositions nobody touched.
72
+ *
73
+ * So the list falls through the way every other authored value in this system
74
+ * now does: a breakpoint the list does not mention inherits the answer given
75
+ * for the breakpoint it falls back to. A list naming `tablet` is read literally,
76
+ * which is what a rule authored in the current editor does.
77
+ *
78
+ * The one thing this cannot express is "phone but not tablet" — the data has no
79
+ * way to tell a deliberately-unticked tablet from one that could not be ticked
80
+ * when the rule was saved. That is a real limitation, and the alternative is
81
+ * changing what a published rule does, which is worse.
82
+ */
83
+ export const isBreakpointAllowed = (
84
+ allowed: string[],
85
+ breakpoint: string
86
+ ): boolean => {
87
+ if (allowed.includes(breakpoint)) return true;
88
+
89
+ const chain = BREAKPOINT_FALLBACKS[breakpoint];
90
+ if (!chain) return false;
91
+
92
+ return chain.some((next) => allowed.includes(next));
93
+ };
94
+
24
95
  export interface VisibilityRuleContext {
25
96
  authState: 'authenticated' | 'guest' | 'loading';
26
97
  breakpoint: string;
@@ -30,6 +101,18 @@ export interface VisibilityRuleContext {
30
101
  searchParams?: {
31
102
  get: (key: string) => string | null;
32
103
  } | null;
104
+ /**
105
+ * What the page is about — the product, the listing, the category. The server
106
+ * serializes this into the client tree and the walk below is pure, so both
107
+ * renders reach the same answer and the page hydrates into the shape it
108
+ * rendered. Absent (the default) means data conditions have nothing to read.
109
+ */
110
+ pageContext?: Record<string, unknown> | null;
111
+ /**
112
+ * The editor canvas usually sits on a page with no live product, so data
113
+ * conditions are not evaluated there — see evaluateVisibilityRules.
114
+ */
115
+ isDesigner?: boolean;
33
116
  }
34
117
 
35
118
  const normalizeStringArray = (value: unknown): string[] => {
@@ -39,6 +122,243 @@ const normalizeStringArray = (value: unknown): string[] => {
39
122
  .filter(Boolean);
40
123
  };
41
124
 
125
+ /**
126
+ * A row with no path is an unfinished condition, not a condition that fails:
127
+ * it is dropped here. Keeping it and failing closed would let a designer save
128
+ * a half-typed row and blank the block on the storefront while the canvas —
129
+ * which skips data conditions — still shows it. That is the one failure mode
130
+ * nobody can debug from the editor.
131
+ */
132
+ const normalizeDataConditions = (value: unknown): VisibilityDataCondition[] => {
133
+ if (!Array.isArray(value)) return [];
134
+
135
+ return value
136
+ // Annotated so the element type matches the predicate below: `value` is
137
+ // optional on a condition but always present in the object literal, and a
138
+ // type predicate must be assignable to what it narrows.
139
+ .map((entry): VisibilityDataCondition | null => {
140
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
141
+ return null;
142
+ }
143
+
144
+ const candidate = entry as Record<string, unknown>;
145
+ const path = String(candidate.path || '').trim();
146
+ if (!path) return null;
147
+
148
+ const operator = VISIBILITY_DATA_OPERATORS.includes(
149
+ candidate.operator as VisibilityDataOperator
150
+ )
151
+ ? (candidate.operator as VisibilityDataOperator)
152
+ : 'exists';
153
+
154
+ return {
155
+ path,
156
+ operator,
157
+ value: String(candidate.value ?? '')
158
+ };
159
+ })
160
+ .filter((entry): entry is VisibilityDataCondition => entry !== null);
161
+ };
162
+
163
+ // Same notion of "there is nothing here" the per-row rules use: a product with
164
+ // an empty attributes object carries no attribute, so `exists` must say no.
165
+ const isEmptyValue = (value: unknown): boolean => {
166
+ if (value === undefined || value === null) return true;
167
+ if (typeof value === 'string') return value.trim() === '';
168
+ if (Array.isArray(value)) return value.length === 0;
169
+ if (typeof value === 'object') return Object.keys(value).length === 0;
170
+ return false;
171
+ };
172
+
173
+ /**
174
+ * The exact path shapes this file can read, and therefore the only shapes the
175
+ * field picker may offer: a key, `.key`, an index `[0]`, and the bare `[]` the
176
+ * discovery walk emits for "every element of this array".
177
+ *
178
+ * Anything else — `[*]`, a trailing dot, an empty segment — is refused rather
179
+ * than silently mis-tokenised into a shorter path that resolves to the wrong
180
+ * value (or, worse, to a parent that happens to exist).
181
+ */
182
+ const READABLE_PATH = /^[^.[\]]+(?:\.[^.[\]]+|\[\d*\])*$/;
183
+ const PATH_TOKEN = /[^.[\]]+|\[\d*\]/g;
184
+
185
+ /**
186
+ * Segments that would read off the prototype chain rather than off the payload.
187
+ * Merchant data is untrusted, and `product.constructor` answering `exists` on
188
+ * every page is a rule that looks like it works and never does.
189
+ *
190
+ * Naming them is not enough on its own — `toString`, `valueOf` and the rest of
191
+ * Object.prototype answer the same way — so the walk below also reads own
192
+ * properties only. This list stays because a named refusal can warn the
193
+ * designer, where an own-property miss is indistinguishable from a typo.
194
+ */
195
+ const UNREADABLE_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
196
+
197
+ /**
198
+ * Resolve a path to the values it selects: one value for a plain path, one per
199
+ * element for each `[]` it crosses. `null` means the path is not readable at
200
+ * all, which is a different answer from "reads as nothing".
201
+ *
202
+ * Same walk the bindings use, so `product.images[0].url` reads identically.
203
+ */
204
+ const readPageContextPaths = (
205
+ source: unknown,
206
+ path: string
207
+ ): unknown[] | null => {
208
+ if (!READABLE_PATH.test(path)) return null;
209
+
210
+ const tokens = path.match(PATH_TOKEN) || [];
211
+ if (tokens.some(token => UNREADABLE_SEGMENTS.has(token))) return null;
212
+
213
+ let values: unknown[] = [source];
214
+
215
+ for (const token of tokens) {
216
+ const next: unknown[] = [];
217
+
218
+ for (const value of values) {
219
+ if (value === undefined || value === null) continue;
220
+
221
+ if (token === '[]') {
222
+ // Pushed one by one: a listing can carry thousands of products and
223
+ // spreading that into an argument list is what blows the stack.
224
+ if (Array.isArray(value)) {
225
+ for (const element of value) next.push(element);
226
+ }
227
+ continue;
228
+ }
229
+
230
+ if (token.startsWith('[')) {
231
+ const index = Number.parseInt(token.slice(1, -1), 10);
232
+ if (Array.isArray(value)) next.push(value[index]);
233
+ continue;
234
+ }
235
+
236
+ // Own properties only. The page context is JSON the server serialized
237
+ // into the client tree, so everything real about the page is an own key;
238
+ // anything inherited is Object.prototype leaking in. It matters because
239
+ // a prototype member is a function, which every operator reads as
240
+ // "present" — `product.toString exists` would show the block on every
241
+ // page ever rendered. Also keeps SSR and hydration honest: a getter that
242
+ // lives on a class prototype server-side does not survive serialization.
243
+ if (Object.prototype.hasOwnProperty.call(value, token)) {
244
+ next.push((value as Record<string, unknown>)[token]);
245
+ }
246
+ }
247
+
248
+ values = next;
249
+ if (values.length === 0) return values;
250
+ }
251
+
252
+ return values;
253
+ };
254
+
255
+ /** null means "not comparable as text" (object, array, missing). */
256
+ const toScalarString = (value: unknown): string | null => {
257
+ if (typeof value === 'string') return value;
258
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : null;
259
+ if (typeof value === 'boolean') return String(value);
260
+ return null;
261
+ };
262
+
263
+ const toComparableNumber = (value: unknown): number => {
264
+ if (typeof value === 'number') return value;
265
+ if (typeof value === 'string' && value.trim() !== '') return Number(value);
266
+ return Number.NaN;
267
+ };
268
+
269
+ /**
270
+ * A path that resolves to nothing hides the block — every operator except
271
+ * `notExists`, whose whole job is to answer for absence.
272
+ *
273
+ * Failing open is the tempting default and the wrong one: one typo in
274
+ * `product.attributes.gender` would put the women's size guide on every men's
275
+ * PDP and the "no results" copy on a page full of products. Wrong content that
276
+ * looks plausible survives QA; a block that never appears is caught in the
277
+ * first preview. Missing data also genuinely means "this page is not about
278
+ * that thing" — there is no `product` root on a category page — which is
279
+ * exactly when the block should stay away.
280
+ *
281
+ * A path this file cannot read is stricter still: it hides for every operator,
282
+ * `notExists` included, because "unreadable" is not evidence of absence.
283
+ */
284
+ const evaluateDataCondition = (
285
+ condition: VisibilityDataCondition,
286
+ pageContext: Record<string, unknown> | null | undefined
287
+ ): boolean => {
288
+ const resolvedValues = readPageContextPaths(pageContext, condition.path);
289
+
290
+ if (resolvedValues === null) {
291
+ // Not "absent" but "unreadable": no operator can answer it, `notExists`
292
+ // included, so the block stays hidden and the warning names the path.
293
+ devWarn(
294
+ 'visibilityRules',
295
+ `Cannot read the field path "${condition.path}"; the condition never passes (content stays hidden)`
296
+ );
297
+ return false;
298
+ }
299
+
300
+ // A `[]` in the path means "any element matches": one element carrying the
301
+ // value shows the block, and `exists` / `notExists` stay exact duals of each
302
+ // other. A path without `[]` selects a single value, so this is the same
303
+ // answer it has always given.
304
+ const presentValues = resolvedValues.filter(value => !isEmptyValue(value));
305
+ const isPresent = presentValues.length > 0;
306
+
307
+ if (condition.operator === 'exists') return isPresent;
308
+ if (condition.operator === 'notExists') return !isPresent;
309
+
310
+ if (!isPresent) return false;
311
+
312
+ const ruleValue = String(condition.value ?? '').trim();
313
+ if (!ruleValue) return false;
314
+
315
+ return presentValues.some(resolved =>
316
+ compareResolvedValue(resolved, condition.operator, ruleValue)
317
+ );
318
+ };
319
+
320
+ const compareResolvedValue = (
321
+ resolved: unknown,
322
+ operator: VisibilityDataOperator,
323
+ ruleValue: string
324
+ ): boolean => {
325
+ switch (operator) {
326
+ case 'equals':
327
+ case 'notEquals': {
328
+ // Case-insensitive: brands return "Women" while designers type "women",
329
+ // and a fail-closed rule turns that mismatch into an invisible block.
330
+ const scalar = toScalarString(resolved);
331
+ if (scalar === null) return false;
332
+ const matches = scalar.toLowerCase() === ruleValue.toLowerCase();
333
+ return operator === 'equals' ? matches : !matches;
334
+ }
335
+ case 'contains': {
336
+ if (Array.isArray(resolved)) {
337
+ return resolved.some(
338
+ entry => toScalarString(entry)?.toLowerCase() === ruleValue.toLowerCase()
339
+ );
340
+ }
341
+ const scalar = toScalarString(resolved);
342
+ return scalar !== null && scalar.toLowerCase().includes(ruleValue.toLowerCase());
343
+ }
344
+ case 'gt':
345
+ case 'gte':
346
+ case 'lt':
347
+ case 'lte': {
348
+ const left = toComparableNumber(resolved);
349
+ const right = toComparableNumber(ruleValue);
350
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return false;
351
+
352
+ if (operator === 'gt') return left > right;
353
+ if (operator === 'gte') return left >= right;
354
+ if (operator === 'lt') return left < right;
355
+ return left <= right;
356
+ }
357
+ default:
358
+ return false;
359
+ }
360
+ };
361
+
42
362
  export const normalizeVisibilityRules = (value: unknown): VisibilityRules => {
43
363
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
44
364
  // A truthy-but-non-object value (string, number, array) is a malformed
@@ -61,7 +381,9 @@ export const normalizeVisibilityRules = (value: unknown): VisibilityRules => {
61
381
  pathnameMatchType: 'any',
62
382
  pathnameValue: '',
63
383
  queryParamKey: '',
64
- queryParamValue: ''
384
+ queryParamValue: '',
385
+ dataConditions: [],
386
+ dataMatchMode: 'all'
65
387
  };
66
388
  }
67
389
 
@@ -85,7 +407,9 @@ export const normalizeVisibilityRules = (value: unknown): VisibilityRules => {
85
407
  : 'any',
86
408
  pathnameValue: String(rules.pathnameValue || '').trim(),
87
409
  queryParamKey: String(rules.queryParamKey || '').trim(),
88
- queryParamValue: String(rules.queryParamValue || '').trim()
410
+ queryParamValue: String(rules.queryParamValue || '').trim(),
411
+ dataConditions: normalizeDataConditions(rules.dataConditions),
412
+ dataMatchMode: rules.dataMatchMode === 'any' ? 'any' : 'all'
89
413
  };
90
414
  };
91
415
 
@@ -108,7 +432,7 @@ export const evaluateVisibilityRules = (
108
432
  if (
109
433
  rules.allowedBreakpoints &&
110
434
  rules.allowedBreakpoints.length > 0 &&
111
- !rules.allowedBreakpoints.includes(context.breakpoint)
435
+ !isBreakpointAllowed(rules.allowedBreakpoints, context.breakpoint)
112
436
  ) {
113
437
  return false;
114
438
  }
@@ -161,6 +485,25 @@ export const evaluateVisibilityRules = (
161
485
  }
162
486
  }
163
487
 
488
+ // Data conditions are skipped on the canvas. Most editor pages carry no live
489
+ // product, so evaluating them would hide the block the instant the rule is
490
+ // saved — and a designer cannot style what they just made disappear. Visitor
491
+ // conditions above keep applying there, because those the canvas can preview
492
+ // (breakpoint switcher, locale switcher, auth toggle).
493
+ const dataConditions = rules.dataConditions || [];
494
+ if (!context.isDesigner && dataConditions.length > 0) {
495
+ const results = dataConditions.map(condition =>
496
+ evaluateDataCondition(condition, context.pageContext)
497
+ );
498
+
499
+ const isSatisfied =
500
+ rules.dataMatchMode === 'any'
501
+ ? results.some(Boolean)
502
+ : results.every(Boolean);
503
+
504
+ if (!isSatisfied) return false;
505
+ }
506
+
164
507
  return true;
165
508
  };
166
509