@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
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Per-row visibility.
3
+ *
4
+ * A repeated row renders the same elements for every item — only bound values
5
+ * differ. Real catalogues are not like that: a discount badge exists on three
6
+ * products out of forty, a campaign label on two, a price-history caption only
7
+ * where there was a discount. Without this, a designer either shows an empty
8
+ * badge on every card or cannot have the badge at all.
9
+ *
10
+ * A block inside a row can carry `visibleWhen`, evaluated once per item. The
11
+ * result is written to the clone's existing `hidden` flag, so nothing
12
+ * downstream needs to learn a new concept — and the designer still sees the
13
+ * element on the canvas, because `hidden` only removes it on the storefront.
14
+ *
15
+ * The comparisons started out string-only, which cannot express the rule two
16
+ * brands print on every card: "lowest price in 30 days" when the previous
17
+ * price is above the current one, "advantage price" when it is below while the
18
+ * list price is above, nothing otherwise — one of them only past a 5% gap.
19
+ * That rule needs three things a string equality does not have: an ordering,
20
+ * a second *field* rather than a typed constant, and a percentage. All three
21
+ * are additive — a rule that sets none of them evaluates through exactly the
22
+ * same branches it did before.
23
+ */
24
+
25
+ import { parseAmount } from '../blocks/commerce-format';
26
+
27
+ export type RowVisibilityMode =
28
+ | 'always'
29
+ | 'not-empty'
30
+ | 'empty'
31
+ | 'equals'
32
+ | 'not-equals'
33
+ | 'truthy'
34
+ | 'falsy'
35
+ | 'greater-than'
36
+ | 'greater-or-equal'
37
+ | 'less-than'
38
+ | 'less-or-equal'
39
+ | 'percent-above'
40
+ | 'percent-below';
41
+
42
+ /**
43
+ * Where the second operand comes from. Absent means the literal the designer
44
+ * typed — which is what every rule authored before this existed carries, so
45
+ * an untouched rule keeps comparing exactly what it compared before.
46
+ */
47
+ export type RowVisibilityCompareTo = 'value' | 'field';
48
+
49
+ export interface RowVisibilityRule {
50
+ field?: string;
51
+ mode?: RowVisibilityMode;
52
+ value?: string;
53
+ compareTo?: RowVisibilityCompareTo;
54
+ /** Percent forms only: how far apart the two sides have to be. */
55
+ threshold?: string;
56
+ }
57
+
58
+ const isEmptyValue = (value: unknown): boolean => {
59
+ if (value === undefined || value === null) return true;
60
+ if (typeof value === 'string') return value.trim() === '';
61
+ if (Array.isArray(value)) return value.length === 0;
62
+ if (typeof value === 'object') return Object.keys(value).length === 0;
63
+ return false;
64
+ };
65
+
66
+ /** Same path walk the bindings use, so `item.foo.bar` behaves identically. */
67
+ const readPath = (source: unknown, path: string): unknown => {
68
+ // A bare `item` names the row itself, the same spelling the bindings take —
69
+ // the only way to state a rule about an entry of a list of plain values.
70
+ const rowPath = path === 'item' ? '' : path.replace(/^item\./, '');
71
+ const tokens = rowPath.match(/[^.[\]]+|\[(\d+)\]/g) || [];
72
+ let value: unknown = source;
73
+
74
+ for (const token of tokens) {
75
+ if (value === undefined || value === null) return undefined;
76
+
77
+ if (token.startsWith('[') && token.endsWith(']')) {
78
+ const index = Number.parseInt(token.slice(1, -1), 10);
79
+ value = Array.isArray(value) ? value[index] : undefined;
80
+ continue;
81
+ }
82
+
83
+ value = (value as Record<string, unknown>)?.[token];
84
+ }
85
+
86
+ return value;
87
+ };
88
+
89
+ /** Reads the rule off a block's properties, tolerating the responsive wrapper. */
90
+ export const readRowVisibility = (
91
+ properties: Record<string, unknown> | undefined | null,
92
+ currentBreakpoint = 'desktop'
93
+ ): RowVisibilityRule | null => {
94
+ // `allowNumber` is opt-in per key: the three original keys keep reading
95
+ // strings only, so a number that somehow landed in one of them still
96
+ // resolves to '' exactly as it did before.
97
+ const unwrap = (key: string, allowNumber = false): string => {
98
+ const entry = properties?.[key];
99
+ if (entry === undefined || entry === null) return '';
100
+ if (typeof entry === 'string') return entry;
101
+ if (allowNumber && typeof entry === 'number') return String(entry);
102
+ if (typeof entry === 'object') {
103
+ const responsive = entry as Record<string, unknown>;
104
+ // The same fall-through the stylesheet emits: a tablet with nothing of
105
+ // its own reads the mobile bucket, whose query covers its width.
106
+ const value =
107
+ currentBreakpoint === 'mobile'
108
+ ? responsive.mobile ?? responsive.desktop
109
+ : currentBreakpoint === 'tablet'
110
+ ? responsive.tablet ?? responsive.mobile ?? responsive.desktop
111
+ : responsive.desktop;
112
+ if (typeof value === 'string') return value;
113
+ if (allowNumber && typeof value === 'number') return String(value);
114
+ return '';
115
+ }
116
+ return '';
117
+ };
118
+
119
+ const mode = unwrap('visibleWhenMode') as RowVisibilityMode;
120
+ if (!mode || mode === 'always') return null;
121
+
122
+ const rule: RowVisibilityRule = {
123
+ mode,
124
+ field: unwrap('visibleWhenField'),
125
+ value: unwrap('visibleWhenValue')
126
+ };
127
+
128
+ // Carried only when actually set. A rule authored before these keys existed
129
+ // therefore reads back as the same three-key object it always did.
130
+ if (unwrap('visibleWhenCompareTo') === 'field') rule.compareTo = 'field';
131
+
132
+ const threshold = unwrap('visibleWhenThreshold', true).trim();
133
+ if (threshold) rule.threshold = threshold;
134
+
135
+ return rule;
136
+ };
137
+
138
+ /**
139
+ * The second operand: the literal the designer typed, or another field on the
140
+ * same row. "Previous price is higher than the current price" names two fields
141
+ * and no constant, so a rule that can only hold one path cannot state it.
142
+ */
143
+ const readOperand = (rule: RowVisibilityRule, item: unknown): unknown => {
144
+ if (rule.compareTo !== 'field') return rule.value;
145
+
146
+ const path = (rule.value || '').trim();
147
+ return path ? readPath(item, path) : undefined;
148
+ };
149
+
150
+ /**
151
+ * Ordering and percentage forms. Both sides go through the shared money parser
152
+ * so the fields these rules actually name work: a listing row publishes
153
+ * `price` / `previousPrice` already formatted ("1.299,90 TL"), which `Number()`
154
+ * reads as NaN.
155
+ *
156
+ * A side that is not a number refuses the comparison and hides the element.
157
+ * Coercing instead would turn '' and `false` into 0 and switch rows *on* —
158
+ * a caption claiming a discount nobody has is worse than a missing caption.
159
+ */
160
+ const compareNumbers = (
161
+ mode: RowVisibilityMode,
162
+ left: unknown,
163
+ right: unknown,
164
+ threshold: number
165
+ ): boolean => {
166
+ const a = parseAmount(left);
167
+ const b = parseAmount(right);
168
+
169
+ if (a === null || b === null) return false;
170
+
171
+ switch (mode) {
172
+ case 'greater-than':
173
+ return a > b;
174
+ case 'greater-or-equal':
175
+ return a >= b;
176
+ case 'less-than':
177
+ return a < b;
178
+ case 'less-or-equal':
179
+ return a <= b;
180
+ default:
181
+ break;
182
+ }
183
+
184
+ // Percent forms: "at least N% above/below the other side", measured against
185
+ // that other side. Nothing is a percentage of zero.
186
+ if (b === 0) return false;
187
+
188
+ // The gap signed in the direction the mode names, so `percent-below` reads
189
+ // its own axis rather than a negated one.
190
+ const direction = mode === 'percent-above' ? 1 : -1;
191
+ const gap = ((a - b) / Math.abs(b)) * 100 * direction;
192
+
193
+ // `> 0` is what makes "by any amount" mean a real gap. Without it equality —
194
+ // the full-price row commerce sends as retail_price === price — produces a
195
+ // gap of 0, which clears an empty (0) threshold in *both* directions at once
196
+ // and prints a discount caption on a product that has no discount. A gap of
197
+ // zero is neither above nor below, so both directions must refuse it.
198
+ return gap > 0 && gap >= threshold;
199
+ };
200
+
201
+ /**
202
+ * True when the element should render for this item. An unset or unparseable
203
+ * rule always returns true: a misconfigured condition must never silently
204
+ * empty a page.
205
+ */
206
+ export const isVisibleForItem = (
207
+ rule: RowVisibilityRule | null,
208
+ // `unknown` because a repeat can be drawn over a list of plain values, where
209
+ // the row is a string or a number rather than a record.
210
+ item: unknown
211
+ ): boolean => {
212
+ if (!rule || !rule.mode || rule.mode === 'always') return true;
213
+
214
+ const field = (rule.field || '').trim();
215
+ if (!field) return true;
216
+
217
+ const resolved = readPath(item, field);
218
+
219
+ switch (rule.mode) {
220
+ case 'not-empty':
221
+ return !isEmptyValue(resolved);
222
+ case 'empty':
223
+ return isEmptyValue(resolved);
224
+ case 'truthy':
225
+ return Boolean(resolved) && resolved !== 'false' && resolved !== '0';
226
+ case 'falsy':
227
+ return !resolved || resolved === 'false' || resolved === '0';
228
+ // `readOperand` returns `rule.value` untouched unless the rule opted into
229
+ // a field operand, so the string coercion these two rely on is unchanged.
230
+ case 'equals':
231
+ return String(resolved ?? '') === String(readOperand(rule, item) ?? '');
232
+ case 'not-equals':
233
+ return String(resolved ?? '') !== String(readOperand(rule, item) ?? '');
234
+ case 'greater-than':
235
+ case 'greater-or-equal':
236
+ case 'less-than':
237
+ case 'less-or-equal':
238
+ case 'percent-above':
239
+ case 'percent-below': {
240
+ // No second operand yet is a half-authored rule, not a failing item:
241
+ // treated like any other unusable rule, it shows the element.
242
+ if (!(rule.value || '').trim()) return true;
243
+
244
+ // The threshold belongs to the percent forms alone. The panel stops
245
+ // rendering the box when the mode is switched away from one, but the
246
+ // stored property survives the switch, so an ordering rule can still be
247
+ // carrying a stale one — reading it here would let that stale value
248
+ // decide a comparison that never uses it.
249
+ const isPercent =
250
+ rule.mode === 'percent-above' || rule.mode === 'percent-below';
251
+ const parsedThreshold = isPercent
252
+ ? parseAmount((rule.threshold || '').trim())
253
+ : null;
254
+
255
+ // An unreadable box means "any difference", not "every row". Showing the
256
+ // element instead would hand the percent forms a second door onto the
257
+ // row the `> 0` floor exists to refuse: a full-price product would print
258
+ // a discount caption in both directions at once, which is exactly what a
259
+ // threshold nobody can read must not be able to buy. Rows with a real
260
+ // gap still show, so a mistyped box never empties the page.
261
+ const threshold = parsedThreshold ?? 0;
262
+
263
+ return compareNumbers(
264
+ rule.mode,
265
+ resolved,
266
+ readOperand(rule, item),
267
+ threshold
268
+ );
269
+ }
270
+ default:
271
+ return true;
272
+ }
273
+ };