@akinon/pz-theme 2.0.82-beta.0 → 2.0.82

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.
@@ -0,0 +1,375 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import NextLink from 'next/link';
5
+
6
+ import { partAttrs } from '../utils';
7
+ import { useOverlay } from '../hooks/use-overlay';
8
+ import FavoriteButtonBlock from '../blocks/favorite-button-block';
9
+ import {
10
+ ThemeItemScopeProvider,
11
+ pickProductField,
12
+ type ThemeProduct
13
+ } from '../blocks/product-block-utils';
14
+ import {
15
+ getPriceWithCurrency,
16
+ readProductCurrency,
17
+ readProductPricePair
18
+ } from '../utils/iterator-utils';
19
+
20
+ /**
21
+ * The panel a product slider opens instead of leaving for the product page.
22
+ *
23
+ * Some brands' designs answer a tap on a card with a look at the product
24
+ * rather than a navigation; others do not want it at all. So this is drawn
25
+ * only when the section says `show-quick-view`, and it shows exactly what
26
+ * such a design shows: the photo, the name, the price, a way to favourite it
27
+ * and a way to go to the product page after all.
28
+ *
29
+ * It renders the row the shopper clicked and asks commerce for nothing. Every
30
+ * field — photo, name, price, address — comes off that one row, so the panel
31
+ * always describes exactly the product whose card was tapped.
32
+ *
33
+ * DO NOT ADD A BY-PK FETCH HERE. It was tried and reverted, and the reason is
34
+ * not performance. A collection data source is snapshotted from OMNITRON, so
35
+ * `row.pk` is an Omnitron product id; the storefront's `/product/<pk>` is a
36
+ * different id space. Asking it for `row.pk` returns a real, correct, and
37
+ * COMPLETELY DIFFERENT product — measured on a live store: row pk 48
38
+ * (sku 8684464001616, a bed sheet) answered with pk 48, a glass serving plate.
39
+ * The panel then linked one product under another's photo. A `sku` full-text
40
+ * search can bridge the two spaces, but its index excludes out-of-stock and
41
+ * non-listable products — precisely what a flash-deals strip runs into.
42
+ *
43
+ * So a row with no `absolute_url` gets no link, and that is the honest state:
44
+ * the same row leaves the card itself unlinked, because the card's
45
+ * `item.absolute_url` binding has nothing to bind either.
46
+ *
47
+ * Rendering the storefront's OWN widget answer instead — which does carry
48
+ * commerce-space products with `absolute_url`, and which
49
+ * `load-placeholder-sections.ts` already has in hand — was built and then
50
+ * deliberately reverted. It linked every card, but it also made the published
51
+ * strip show DIFFERENT PRODUCTS than the canvas the merchandiser laid out,
52
+ * because the two catalogues disagree about the collection's members. A
53
+ * merchandiser who cannot trust the canvas has lost more than a link. Until
54
+ * the two catalogues agree, what the editor shows is what the site shows.
55
+ *
56
+ * The surface is hand-rolled rather than taken from `overlays/overlay-surface`
57
+ * on purpose. Those primitives stamp their own `data-part="overlay"` and
58
+ * `data-part="modal"` and overwrite a caller's markers, so the eight
59
+ * `quick-view-*` part groups the editor offers would style nothing. What is
60
+ * NOT hand-rolled is the page contract — focus, the overlay stack, the scroll
61
+ * lock, Escape and `aria-modal` all stay with `useOverlay`, which exists
62
+ * because three blocks previously grew their own incompatible copies.
63
+ *
64
+ * Parts: quick-view-backdrop, quick-view-panel, quick-view-close,
65
+ * quick-view-media, quick-view-name, quick-view-price, quick-view-link,
66
+ * quick-view-favorite.
67
+ */
68
+
69
+ interface SliderQuickViewProps {
70
+ /** The clicked row, already resolved to a product (pk guaranteed). */
71
+ row: ThemeProduct | null;
72
+ open: boolean;
73
+ /**
74
+ * Draw in the flow instead of over the page. The canvas preview: a fixed
75
+ * overlay there would cover the workspace the author is trying to style,
76
+ * and every dismissal route is inert anyway while `isDesigner` is set.
77
+ */
78
+ inline?: boolean;
79
+ onClose: () => void;
80
+ openerRef?: { readonly current: HTMLElement | null };
81
+ /**
82
+ * Where focus lands when the card that opened the panel is gone by the time
83
+ * it closes — the carousel has moved on, or the data source refreshed. It
84
+ * has to be an element that OUTLIVES the panel, so it is the section band
85
+ * rather than anything rendered here.
86
+ */
87
+ fallbackRef?: { readonly current: HTMLElement | null };
88
+ isDesigner?: boolean;
89
+ currentBreakpoint?: string;
90
+ linkLabel: string;
91
+ closeLabel: string;
92
+ showFavorite: boolean;
93
+ }
94
+
95
+ /**
96
+ * The row's photo. Commerce leaves `image` unset on collection payloads and
97
+ * carries the shot in `productimage_set`, which is why the card's own binding
98
+ * is `item.productimage_set[0].image`; both are read here so a row of either
99
+ * shape draws something.
100
+ */
101
+ const readRowImage = (row: ThemeProduct | null): string => {
102
+ const direct = pickProductField<unknown>(row, 'image');
103
+ if (typeof direct === 'string' && direct) return direct;
104
+
105
+ const first = (row?.productimage_set || [])[0];
106
+ const fromSet = first?.image || first?.url;
107
+
108
+ return typeof fromSet === 'string' ? fromSet : '';
109
+ };
110
+
111
+ const SliderQuickView = ({
112
+ row,
113
+ open,
114
+ inline = false,
115
+ onClose,
116
+ openerRef,
117
+ fallbackRef,
118
+ isDesigner = false,
119
+ currentBreakpoint = 'desktop',
120
+ linkLabel,
121
+ closeLabel,
122
+ showFavorite
123
+ }: SliderQuickViewProps) => {
124
+ // Unconditionally, before the early return below: an overlay that mounted
125
+ // and unmounted its own hook would take the stack registration with it.
126
+ const overlay = useOverlay<HTMLDivElement>({
127
+ // An inline panel is not over the page: it takes no focus, claims no
128
+ // modality and freezes nothing.
129
+ open: open && !inline,
130
+ onClose,
131
+ isDesigner,
132
+ modal: true,
133
+ returnFocusRef: openerRef,
134
+ fallbackFocusRef: fallbackRef
135
+ });
136
+
137
+ if (!open || !row) return null;
138
+
139
+ const name = String(pickProductField<unknown>(row, 'name') ?? '');
140
+ // `pickProductField` resolves `url` under this name too, so a row of either
141
+ // shape is read here. Nothing else is consulted — see the note above about
142
+ // why there is no fetch.
143
+ const href = String(pickProductField<unknown>(row, 'absolute_url') ?? '');
144
+ const image = readRowImage(row);
145
+
146
+ const { current: amount } = readProductPricePair(row);
147
+ const price =
148
+ amount > 0 ? getPriceWithCurrency(amount, readProductCurrency(row)) : '';
149
+
150
+ /**
151
+ * On a storefront a piece the row cannot fill is simply not drawn — a link
152
+ * to nowhere and an empty image frame are both worse than their absence.
153
+ *
154
+ * In the canvas the opposite is true: the author turned the preview on
155
+ * precisely to style these, and the sample row the editor falls back to
156
+ * before a data source is attached carries neither a URL nor a photo. So
157
+ * the designer gets the element regardless, inert and clearly a stand-in.
158
+ */
159
+ const showLink = Boolean(href) || isDesigner;
160
+ const showMedia = Boolean(image) || isDesigner;
161
+
162
+ const dismiss = () => {
163
+ if (inline) return;
164
+ onClose();
165
+ };
166
+
167
+ return (
168
+ <div
169
+ data-slider-quick-view=""
170
+ {...partAttrs('quick-view-backdrop', { open: true })}
171
+ onClick={dismiss}
172
+ style={
173
+ inline
174
+ ? {
175
+ position: 'static',
176
+ marginTop: '16px',
177
+ display: 'flex',
178
+ justifyContent: 'center'
179
+ }
180
+ : {
181
+ position: 'fixed',
182
+ inset: 0,
183
+ zIndex: 1000,
184
+ display: 'flex',
185
+ alignItems: 'center',
186
+ justifyContent: 'center',
187
+ padding: '16px',
188
+ background: 'rgba(0, 0, 0, 0.5)'
189
+ }
190
+ }
191
+ >
192
+ <div
193
+ {...partAttrs('quick-view-panel', { open: true })}
194
+ role="dialog"
195
+ aria-label={name || closeLabel}
196
+ tabIndex={-1}
197
+ {...overlay.overlayProps}
198
+ // The backdrop dismisses; a click on the panel itself must not reach
199
+ // that handler.
200
+ onClick={(event) => event.stopPropagation()}
201
+ style={{
202
+ position: 'relative',
203
+ display: 'flex',
204
+ flexDirection: 'column',
205
+ width: '100%',
206
+ maxWidth: '360px',
207
+ maxHeight: inline ? undefined : '85vh',
208
+ overflow: 'auto',
209
+ background: '#ffffff',
210
+ color: '#111827',
211
+ textAlign: 'center'
212
+ }}
213
+ >
214
+ {/*
215
+ * Always drawn, and deliberately not behind a toggle: with the
216
+ * backdrop click and Escape both gone in the inline case, a panel
217
+ * whose close button an author could remove would be a trap.
218
+ */}
219
+ <button
220
+ type="button"
221
+ {...partAttrs('quick-view-close')}
222
+ aria-label={closeLabel}
223
+ onClick={(event) => {
224
+ event.stopPropagation();
225
+ if (!inline) onClose();
226
+ }}
227
+ style={{
228
+ alignSelf: 'flex-end',
229
+ display: 'inline-flex',
230
+ alignItems: 'center',
231
+ gap: '6px',
232
+ padding: '12px 16px',
233
+ border: 'none',
234
+ background: 'transparent',
235
+ color: 'inherit',
236
+ font: 'inherit',
237
+ cursor: 'pointer'
238
+ }}
239
+ >
240
+ <span style={{ textDecoration: 'underline' }}>{closeLabel}</span>
241
+ <svg
242
+ width="14"
243
+ height="14"
244
+ viewBox="0 0 24 24"
245
+ fill="none"
246
+ stroke="currentColor"
247
+ strokeWidth="1.5"
248
+ aria-hidden="true"
249
+ >
250
+ <path d="M6 6l12 12M18 6L6 18" />
251
+ </svg>
252
+ </button>
253
+
254
+ {showMedia &&
255
+ (image ? (
256
+ // eslint-disable-next-line @next/next/no-img-element
257
+ <img
258
+ {...partAttrs('quick-view-media')}
259
+ src={image}
260
+ alt={name}
261
+ style={{
262
+ width: '100%',
263
+ height: 'auto',
264
+ objectFit: 'contain',
265
+ display: 'block'
266
+ }}
267
+ />
268
+ ) : (
269
+ // Canvas stand-in: the same part, so the frame an author styles is
270
+ // the frame the shopper gets.
271
+ <div
272
+ {...partAttrs('quick-view-media', { empty: true })}
273
+ aria-hidden="true"
274
+ style={{
275
+ width: '100%',
276
+ aspectRatio: '3 / 4',
277
+ background: 'rgba(0, 0, 0, 0.06)'
278
+ }}
279
+ />
280
+ ))}
281
+
282
+ {name && (
283
+ <h3
284
+ {...partAttrs('quick-view-name')}
285
+ style={{
286
+ margin: '16px 16px 4px',
287
+ fontSize: '16px',
288
+ fontWeight: 400
289
+ }}
290
+ >
291
+ {name}
292
+ </h3>
293
+ )}
294
+
295
+ {price && (
296
+ <p
297
+ {...partAttrs('quick-view-price')}
298
+ style={{
299
+ margin: '0 16px 16px',
300
+ fontSize: '14px',
301
+ fontWeight: 600
302
+ }}
303
+ >
304
+ {price}
305
+ </p>
306
+ )}
307
+
308
+ <div
309
+ style={{
310
+ display: 'flex',
311
+ alignItems: 'center',
312
+ justifyContent: 'center',
313
+ gap: '16px',
314
+ padding: '16px',
315
+ borderTop: '1px solid rgba(0, 0, 0, 0.1)'
316
+ }}
317
+ >
318
+ {showLink && (
319
+ <NextLink
320
+ {...partAttrs('quick-view-link')}
321
+ // '#' is only ever reached in the canvas, where the click below
322
+ // is cancelled anyway.
323
+ href={href || '#'}
324
+ // A click in the canvas selects; it must not navigate the
325
+ // editor iframe away from the page being authored.
326
+ onClick={(event) => {
327
+ if (isDesigner) event.preventDefault();
328
+ }}
329
+ style={{
330
+ display: 'inline-flex',
331
+ alignItems: 'center',
332
+ justifyContent: 'center',
333
+ minWidth: '160px',
334
+ padding: '12px 24px',
335
+ border: '1px solid currentColor',
336
+ textDecoration: 'none',
337
+ color: 'inherit',
338
+ fontSize: '13px',
339
+ letterSpacing: '0.04em'
340
+ }}
341
+ >
342
+ {linkLabel}
343
+ </NextLink>
344
+ )}
345
+
346
+ {showFavorite && (
347
+ <span {...partAttrs('quick-view-favorite')}>
348
+ {/*
349
+ * The heart acts on the row the shopper clicked, not on the
350
+ * page's product. The scope is what every commerce block
351
+ * already reads through `useThemeProduct()`, so this needs no
352
+ * change in the button itself.
353
+ */}
354
+ <ThemeItemScopeProvider product={row}>
355
+ <FavoriteButtonBlock
356
+ block={{
357
+ id: `quick-view-favorite-${String(row.pk)}`,
358
+ type: 'favorite-button',
359
+ properties: {}
360
+ }}
361
+ placeholderId=""
362
+ sectionId=""
363
+ currentBreakpoint={currentBreakpoint}
364
+ isDesigner={isDesigner}
365
+ />
366
+ </ThemeItemScopeProvider>
367
+ </span>
368
+ )}
369
+ </div>
370
+ </div>
371
+ </div>
372
+ );
373
+ };
374
+
375
+ export default SliderQuickView;
@@ -0,0 +1,215 @@
1
+ import {
2
+ LINE_BREAK_ATTRIBUTE,
3
+ buildLineBreakCss,
4
+ compositionUsesScopedLineBreaks
5
+ } from '../line-breaks';
6
+ import { generateThemeCSS } from '../index';
7
+ import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../breakpoints';
8
+
9
+ const block = (overrides: Record<string, unknown> = {}) =>
10
+ ({
11
+ id: 'b1',
12
+ type: 'text',
13
+ label: 'Text',
14
+ properties: {},
15
+ styles: {},
16
+ order: 0,
17
+ hidden: false,
18
+ ...overrides
19
+ } as any);
20
+
21
+ const section = (blocks: any[], overrides: Record<string, unknown> = {}) =>
22
+ ({
23
+ id: 's1',
24
+ type: 'custom',
25
+ name: 'Custom',
26
+ label: 'Custom',
27
+ properties: {},
28
+ styles: {},
29
+ blocks,
30
+ order: 0,
31
+ hidden: false,
32
+ ...overrides
33
+ } as any);
34
+
35
+ const scoped = (scope: string) =>
36
+ `Şehrin ritmiyle buluşan<br ${LINE_BREAK_ATTRIBUTE}="${scope}"> güneşli yaşam stili`;
37
+
38
+ describe('scoped line breaks', () => {
39
+ describe('the emitted sheet', () => {
40
+ it('hides every scoped break, then shows each scope inside its own band', () => {
41
+ const css = buildLineBreakCss();
42
+
43
+ expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}]{display: none !important;}`);
44
+ expect(css).toContain(
45
+ `@media (min-width: ${DESKTOP_MIN_WIDTH}px){br[${LINE_BREAK_ATTRIBUTE}~="desktop"]{display: inline !important;}}`
46
+ );
47
+ expect(css).toContain(
48
+ `@media (min-width: ${TABLET_MIN_WIDTH}px) and (max-width: ${
49
+ DESKTOP_MIN_WIDTH - 1
50
+ }px){br[${LINE_BREAK_ATTRIBUTE}~="tablet"]{display: inline !important;}}`
51
+ );
52
+ expect(css).toContain(
53
+ `@media (max-width: ${
54
+ TABLET_MIN_WIDTH - 1
55
+ }px){br[${LINE_BREAK_ATTRIBUTE}~="mobile"]{display: inline !important;}}`
56
+ );
57
+ });
58
+
59
+ it('hides before it shows, so a scope reaches the browser as an override', () => {
60
+ const css = buildLineBreakCss();
61
+ const hideAt = css.indexOf(`br[${LINE_BREAK_ATTRIBUTE}]{display: none`);
62
+ const firstShowAt = css.indexOf('display: inline');
63
+
64
+ expect(hideAt).toBeGreaterThanOrEqual(0);
65
+ expect(firstShowAt).toBeGreaterThan(hideAt);
66
+ });
67
+
68
+ it('leaves a plain <br> alone — every rule is attribute-qualified', () => {
69
+ const css = buildLineBreakCss();
70
+ const declarations = css.match(/br[^{]*\{/g) ?? [];
71
+
72
+ expect(declarations.length).toBeGreaterThan(0);
73
+ declarations.forEach((selector) => {
74
+ expect(selector).toContain(`[${LINE_BREAK_ATTRIBUTE}`);
75
+ });
76
+ });
77
+
78
+ it('uses DISJOINT bands: a phone scope never reaches the tablet band', () => {
79
+ // The theme's own MOBILE_MEDIA_QUERY is max-width 1023 and overlaps the
80
+ // tablet band on purpose. A break's scope is an author-written list, not a
81
+ // value on the fall-through chain, so "mobile" here has to stop at 767 —
82
+ // otherwise `mobile` alone is indistinguishable from `mobile tablet` and
83
+ // the tablet-only intent becomes unreachable.
84
+ const css = buildLineBreakCss();
85
+
86
+ expect(css).toContain(`@media (max-width: ${TABLET_MIN_WIDTH - 1}px)`);
87
+ expect(css).not.toContain(
88
+ `@media (max-width: ${DESKTOP_MIN_WIDTH - 1}px){br[`
89
+ );
90
+ });
91
+ });
92
+
93
+ describe('the editor canvas', () => {
94
+ it('resolves to the one band being drawn instead of a media query', () => {
95
+ const css = buildLineBreakCss('mobile');
96
+
97
+ expect(css).toContain(
98
+ `br[${LINE_BREAK_ATTRIBUTE}~="mobile"]{display: inline !important;}`
99
+ );
100
+ expect(css).not.toContain('@media');
101
+ });
102
+
103
+ it('resolves each authorable breakpoint', () => {
104
+ (['desktop', 'tablet', 'mobile'] as const).forEach((bp) => {
105
+ const css = buildLineBreakCss(bp);
106
+ expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}~="${bp}"]`);
107
+ expect(css).not.toContain('@media');
108
+ });
109
+ });
110
+
111
+ it('falls back to the real queries on a breakpoint name it does not know', () => {
112
+ // Silently hiding every scoped break would make the canvas lie about the
113
+ // line count at a width the editor merely named differently.
114
+ const css = buildLineBreakCss('widescreen');
115
+
116
+ expect(css).toContain('@media');
117
+ expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}~="desktop"]`);
118
+ });
119
+ });
120
+
121
+ describe('detection', () => {
122
+ it('finds a scoped break in a locale map', () => {
123
+ const sections = [
124
+ section([block({ value: { tr: scoped('mobile tablet'), en: 'plain' } })])
125
+ ];
126
+
127
+ expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
128
+ });
129
+
130
+ it('finds a scoped break in a bare single-locale string', () => {
131
+ const sections = [section([block({ value: scoped('desktop') })])];
132
+
133
+ expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
134
+ });
135
+
136
+ it('finds one nested inside a group block', () => {
137
+ const sections = [
138
+ section([
139
+ block({
140
+ id: 'group',
141
+ type: 'group',
142
+ blocks: [block({ id: 'inner', value: scoped('mobile') })]
143
+ })
144
+ ])
145
+ ];
146
+
147
+ expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
148
+ });
149
+
150
+ it('finds one authored into a block property rather than its value', () => {
151
+ const sections = [
152
+ section([block({ properties: { content: scoped('tablet') } })])
153
+ ];
154
+
155
+ expect(compositionUsesScopedLineBreaks(sections)).toBe(true);
156
+ });
157
+
158
+ it('says no for a plain <br>', () => {
159
+ const sections = [
160
+ section([block({ value: 'Şehrin ritmiyle buluşan<br> güneşli' })])
161
+ ];
162
+
163
+ expect(compositionUsesScopedLineBreaks(sections)).toBe(false);
164
+ });
165
+
166
+ it('survives a section with no blocks array at all', () => {
167
+ const sections = [section(undefined as any)];
168
+
169
+ expect(() => compositionUsesScopedLineBreaks(sections)).not.toThrow();
170
+ expect(compositionUsesScopedLineBreaks(sections)).toBe(false);
171
+ });
172
+ });
173
+
174
+ describe('generateThemeCSS', () => {
175
+ it('emits the sheet once, ahead of the section rules', () => {
176
+ const sections = [
177
+ section([block({ value: scoped('mobile tablet') })], {
178
+ styles: { 'background-color': { desktop: '#fff' } }
179
+ })
180
+ ];
181
+
182
+ const css = generateThemeCSS(sections);
183
+ const breakAt = css.indexOf(`br[${LINE_BREAK_ATTRIBUTE}]`);
184
+ const sectionAt = css.indexOf('[data-section-id=');
185
+
186
+ expect(breakAt).toBeGreaterThanOrEqual(0);
187
+ expect(sectionAt).toBeGreaterThan(breakAt);
188
+ expect(
189
+ css.match(new RegExp(`br\\[${LINE_BREAK_ATTRIBUTE}\\]\\{`, 'g'))
190
+ ).toHaveLength(1);
191
+ });
192
+
193
+ it('emits NOTHING new for a composition that uses no scoped break', () => {
194
+ const sections = [
195
+ section([block({ value: 'plain<br>text' })], {
196
+ styles: { 'background-color': { desktop: '#fff' } }
197
+ })
198
+ ];
199
+
200
+ const css = generateThemeCSS(sections);
201
+
202
+ expect(css).not.toContain(LINE_BREAK_ATTRIBUTE);
203
+ expect(css).toContain('[data-section-id=');
204
+ });
205
+
206
+ it('passes the canvas breakpoint through', () => {
207
+ const sections = [section([block({ value: scoped('desktop') })])];
208
+
209
+ const css = generateThemeCSS(sections, 'mobile');
210
+
211
+ expect(css).toContain(`br[${LINE_BREAK_ATTRIBUTE}~="mobile"]`);
212
+ expect(css).not.toContain('@media');
213
+ });
214
+ });
215
+ });
@@ -33,6 +33,10 @@ import {
33
33
  splitSectionContentWidth
34
34
  } from './section-content-width';
35
35
  import { hasCustomSectionRenderer } from '../sections/custom-renderer-section-types';
36
+ import {
37
+ buildLineBreakCss,
38
+ compositionUsesScopedLineBreaks
39
+ } from './line-breaks';
36
40
 
37
41
  export * from './publish-window';
38
42
  export * from './part-styles';
@@ -1456,7 +1460,14 @@ export const generateThemeCSS = (
1456
1460
  sections: Section[],
1457
1461
  targetBreakpoint?: string
1458
1462
  ): string => {
1459
- let css = '';
1463
+ // First, and outside every section selector: the rules are global by nature
1464
+ // (a `<br>` scoped to the phone means the same thing wherever it was typed),
1465
+ // and they must lose a specificity tie to anything a section or block emits
1466
+ // for itself, which source order settles. Emitted only when the composition
1467
+ // actually carries a scoped break — see utils/line-breaks.ts.
1468
+ let css = compositionUsesScopedLineBreaks(sections)
1469
+ ? buildLineBreakCss(targetBreakpoint)
1470
+ : '';
1460
1471
 
1461
1472
  sections.forEach((section) => {
1462
1473
  const sectionSelector = `[data-section-id="${escapeCssAttributeValue(
@@ -457,6 +457,52 @@ const normalizeCurrencyLabel = (rawCurrency: unknown): string => {
457
457
  return normalized;
458
458
  };
459
459
 
460
+ const parseProductPrice = (value: unknown): number => {
461
+ const parsed = Number(value);
462
+ return Number.isFinite(parsed) ? parsed : 0;
463
+ };
464
+
465
+ /**
466
+ * What a row costs, from either payload shape.
467
+ *
468
+ * v2 collection items publish prices under `active_price`; PDP-shaped
469
+ * payloads carry them at the top level. Everything that prints a row's price
470
+ * — the slider's own bindings and the quick view panel — has to give the same
471
+ * answer, so the reader lives here beside the formatter rather than once per
472
+ * caller. A missing figure reads as 0, which callers treat as "do not print".
473
+ */
474
+ export const readProductPricePair = (
475
+ productData: Record<string, unknown> | null | undefined
476
+ ): { current: number; retail: number } => {
477
+ const activePrice = (productData as any)?.active_price as
478
+ | Record<string, unknown>
479
+ | undefined;
480
+
481
+ return {
482
+ current: parseProductPrice(
483
+ activePrice?.price ?? (productData as any)?.price
484
+ ),
485
+ retail: parseProductPrice(
486
+ activePrice?.retail_price ?? (productData as any)?.retail_price
487
+ )
488
+ };
489
+ };
490
+
491
+ /** The currency the row prints in, from either payload shape. */
492
+ export const readProductCurrency = (
493
+ productData: Record<string, unknown> | null | undefined
494
+ ): unknown => {
495
+ const activePrice = (productData as any)?.active_price as
496
+ | Record<string, unknown>
497
+ | undefined;
498
+
499
+ return (
500
+ activePrice?.currency_type ??
501
+ (productData as any)?.currency_type ??
502
+ (productData as any)?.currency
503
+ );
504
+ };
505
+
460
506
  export const getPriceWithCurrency = (
461
507
  value: unknown,
462
508
  currency: unknown