@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.
- package/CHANGELOG.md +8 -31
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- 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
|
+
};
|