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

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.
@@ -1,6 +1,22 @@
1
1
  // Type-only: the CSS generator imports the binding-token guard from here, and
2
2
  // a value import of the block module would close that loop at runtime.
3
3
  import type { Block } from '../theme-block';
4
+ import {
5
+ publishesProductRows,
6
+ resolveCollectionRowProduct
7
+ } from '../blocks/item-template-utils';
8
+ import type {
9
+ MoneyCurrencyDisplay,
10
+ MoneyCurrencyPosition,
11
+ MoneyDecimals,
12
+ MoneyStyleOptions
13
+ } from '../blocks/commerce-format';
14
+ import {
15
+ formatListingPrice,
16
+ normalizeListingProduct,
17
+ parseListingLookups,
18
+ type ListingValueLookups
19
+ } from '../blocks/listing-row';
4
20
  import { devWarn } from './dev-warn';
5
21
  import { isMoneyPath } from './field-semantics';
6
22
  import { isVisibleForItem, readRowVisibility } from './row-visibility';
@@ -520,8 +536,58 @@ export const getPriceWithCurrency = (
520
536
  return `${amount} ${currencyLabel}`;
521
537
  };
522
538
 
539
+ /**
540
+ * Which part of the row's price a text bound to a price path prints.
541
+ *
542
+ * `full` is what every published text prints: the list price struck through
543
+ * when there is a discount, then the current price, as one value. A card that
544
+ * wants to colour the struck price on its own — the ask that named this — has
545
+ * to be able to put the two in two texts, so `current` and `retail` print one
546
+ * each and nothing when that figure is absent or zero.
547
+ */
548
+ export type PricePart = 'full' | 'current' | 'retail';
549
+
550
+ const PRICE_PARTS: PricePart[] = ['full', 'current', 'retail'];
551
+
552
+ /**
553
+ * How a row's money is written. `money` is the brand's format as the product
554
+ * grid reads it off its own properties; absent, a bound price prints the way
555
+ * it always has (`getPriceWithCurrency`, "3.499 TL").
556
+ */
557
+ export interface RowMoneyFormat {
558
+ money?: MoneyStyleOptions;
559
+ locale?: string;
560
+ }
561
+
562
+ /** A property as the editor stores it: a scalar or a per-breakpoint object. */
563
+ const readDesktopProperty = (value: unknown): unknown => {
564
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
565
+ return value;
566
+ }
567
+ const record = value as Record<string, unknown>;
568
+ return record.desktop ?? record.mobile;
569
+ };
570
+
571
+ export const readPricePart = (block: Block): PricePart => {
572
+ const raw = String(readDesktopProperty(block.properties?.pricePart) ?? '')
573
+ .trim()
574
+ .toLowerCase();
575
+ return (PRICE_PARTS as string[]).includes(raw) ? (raw as PricePart) : 'full';
576
+ };
577
+
578
+ const formatRowPrice = (
579
+ raw: unknown,
580
+ currency: unknown,
581
+ format?: RowMoneyFormat
582
+ ): string =>
583
+ format?.money
584
+ ? formatListingPrice(raw, String(currency ?? ''), format.locale, format.money)
585
+ : getPriceWithCurrency(raw, currency);
586
+
523
587
  const buildProductPriceHtml = (
524
- productData: Record<string, unknown>
588
+ productData: Record<string, unknown>,
589
+ part: PricePart = 'full',
590
+ format?: RowMoneyFormat
525
591
  ): string => {
526
592
  const activePrice =
527
593
  ((productData as any)?.active_price as
@@ -538,8 +604,19 @@ const buildProductPriceHtml = (
538
604
  const currentNumeric = parsePriceValue(currentRaw);
539
605
  const retailNumeric = parsePriceValue(retailRaw);
540
606
 
541
- const currentText = escapeHtml(getPriceWithCurrency(currentRaw, currency));
542
- const retailText = escapeHtml(getPriceWithCurrency(retailRaw, currency));
607
+ if (part === 'current') {
608
+ return currentNumeric > 0
609
+ ? escapeHtml(formatRowPrice(currentRaw, currency, format))
610
+ : '';
611
+ }
612
+ if (part === 'retail') {
613
+ return retailNumeric > 0
614
+ ? escapeHtml(formatRowPrice(retailRaw, currency, format))
615
+ : '';
616
+ }
617
+
618
+ const currentText = escapeHtml(formatRowPrice(currentRaw, currency, format));
619
+ const retailText = escapeHtml(formatRowPrice(retailRaw, currency, format));
543
620
 
544
621
  if (retailNumeric > 0 && retailNumeric > currentNumeric) {
545
622
  return `<span style="display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;"><span style='color:#6b7280;text-decoration:line-through;font-weight:400;'>${retailText}</span><span style='font-weight:600;'>${currentText}</span></span>`;
@@ -605,7 +682,8 @@ const applyResolvedBinding = (
605
682
  block: Block,
606
683
  resolvedValue: unknown,
607
684
  bindingPath: string,
608
- moneyCurrency?: string
685
+ moneyCurrency?: string,
686
+ rowFormat?: RowMoneyFormat
609
687
  ): Block => {
610
688
  const target = getItemBindingTarget(block);
611
689
  const nextBlock: Block = {
@@ -620,7 +698,11 @@ const applyResolvedBinding = (
620
698
  isPriceBinding(bindingPath) &&
621
699
  isRecord(resolvedValue)
622
700
  ) {
623
- nextBlock.value = buildProductPriceHtml(resolvedValue);
701
+ nextBlock.value = buildProductPriceHtml(
702
+ resolvedValue,
703
+ readPricePart(block),
704
+ rowFormat
705
+ );
624
706
  return nextBlock;
625
707
  }
626
708
 
@@ -1129,7 +1211,9 @@ const replaceBlockValues = (
1129
1211
  * swatch. A visibility rule is worse still: `hidden` is only ever set, never
1130
1212
  * taken back, so one outer row decides every entry at once.
1131
1213
  */
1132
- insideNestedRepeat = false
1214
+ insideNestedRepeat = false,
1215
+ /** The row's money format, from the template this row was built from. */
1216
+ rowFormat?: RowMoneyFormat
1133
1217
  ): Block => {
1134
1218
  const nextBlock: Block = {
1135
1219
  ...blockToReplace,
@@ -1241,13 +1325,20 @@ const replaceBlockValues = (
1241
1325
 
1242
1326
  if (interpolated !== undefined) {
1243
1327
  return {
1244
- ...applyResolvedBinding(nextBlock, interpolated, bindingPath),
1328
+ ...applyResolvedBinding(
1329
+ nextBlock,
1330
+ interpolated,
1331
+ bindingPath,
1332
+ undefined,
1333
+ rowFormat
1334
+ ),
1245
1335
  blocks: nextBlock.blocks?.map((childBlock) =>
1246
1336
  replaceBlockValues(
1247
1337
  childBlock,
1248
1338
  itemData,
1249
1339
  itemIndex,
1250
- childInsideNestedRepeat
1340
+ childInsideNestedRepeat,
1341
+ rowFormat
1251
1342
  )
1252
1343
  )
1253
1344
  };
@@ -1256,29 +1347,46 @@ const replaceBlockValues = (
1256
1347
  const itemBindingPath = stripItemPrefix(bindingPath);
1257
1348
  const resolvedValue = getValueAtPath(itemData, itemBindingPath);
1258
1349
 
1259
- if (resolvedValue !== undefined) {
1260
- if (
1261
- nextBlock.type === 'text' &&
1262
- bindingPath.startsWith('item.') &&
1263
- isPriceBinding(bindingPath) &&
1264
- // A price is read off the whole row (list price, currency, discount),
1265
- // so a row that is a plain value cannot answer one.
1266
- isRecord(itemData)
1267
- ) {
1268
- nextBlock.value = buildProductPriceHtml(itemData);
1269
- } else {
1270
- return {
1271
- ...applyResolvedBinding(nextBlock, resolvedValue, bindingPath),
1272
- blocks: nextBlock.blocks?.map((childBlock) =>
1273
- replaceBlockValues(
1274
- childBlock,
1275
- itemData,
1276
- itemIndex,
1277
- childInsideNestedRepeat
1278
- )
1350
+ // A price is read off the whole row (list price, currency, discount), so
1351
+ // a row that is a plain value cannot answer one — and the row is asked
1352
+ // even when the bound path itself is absent. Omnitron's collection rows
1353
+ // (what the editor previews) carry the money under `active_price`, the
1354
+ // commerce rows the storefront fetches carry it flat; `readProductPricePair`
1355
+ // reads both, but it used to be reached only when `item.price` resolved
1356
+ // on its own, so a card bound to `item.price` previewed without prices.
1357
+ const answersPrice =
1358
+ nextBlock.type === 'text' &&
1359
+ bindingPath.startsWith('item.') &&
1360
+ isPriceBinding(bindingPath) &&
1361
+ isRecord(itemData) &&
1362
+ (resolvedValue !== undefined ||
1363
+ readProductPricePair(itemData).current > 0);
1364
+
1365
+ if (answersPrice) {
1366
+ nextBlock.value = buildProductPriceHtml(
1367
+ itemData,
1368
+ readPricePart(nextBlock),
1369
+ rowFormat
1370
+ );
1371
+ } else if (resolvedValue !== undefined) {
1372
+ return {
1373
+ ...applyResolvedBinding(
1374
+ nextBlock,
1375
+ resolvedValue,
1376
+ bindingPath,
1377
+ undefined,
1378
+ rowFormat
1379
+ ),
1380
+ blocks: nextBlock.blocks?.map((childBlock) =>
1381
+ replaceBlockValues(
1382
+ childBlock,
1383
+ itemData,
1384
+ itemIndex,
1385
+ childInsideNestedRepeat,
1386
+ rowFormat
1279
1387
  )
1280
- };
1281
- }
1388
+ )
1389
+ };
1282
1390
  }
1283
1391
  }
1284
1392
  } else if (isPlaceholderMode && nextBlock.type === 'image') {
@@ -1306,7 +1414,8 @@ const replaceBlockValues = (
1306
1414
  childBlock,
1307
1415
  itemData,
1308
1416
  itemIndex,
1309
- childInsideNestedRepeat
1417
+ childInsideNestedRepeat,
1418
+ rowFormat
1310
1419
  )
1311
1420
  );
1312
1421
  }
@@ -1314,6 +1423,191 @@ const replaceBlockValues = (
1314
1423
  return nextBlock;
1315
1424
  };
1316
1425
 
1426
+ const MONEY_DECIMALS: MoneyDecimals[] = ['always', 'auto', 'never'];
1427
+ const MONEY_DISPLAYS: MoneyCurrencyDisplay[] = [
1428
+ '',
1429
+ 'symbol',
1430
+ 'code',
1431
+ 'custom',
1432
+ 'none'
1433
+ ];
1434
+ const MONEY_POSITIONS: MoneyCurrencyPosition[] = ['', 'before', 'after'];
1435
+
1436
+ const readTemplateText = (template: Block, key: string): string => {
1437
+ const raw = readDesktopProperty(template.properties?.[key]);
1438
+ return typeof raw === 'string' ? raw : '';
1439
+ };
1440
+
1441
+ const pickOption = <T extends string>(raw: string, allowed: T[]): T | undefined =>
1442
+ (allowed as string[]).includes(raw) ? (raw as T) : undefined;
1443
+
1444
+ /**
1445
+ * The money format authored on a repeated row's template — the same four
1446
+ * properties the product grid and the PDP price block carry. Nothing authored
1447
+ * means no `money`, and every price on the row prints as it always has.
1448
+ */
1449
+ export const readRowMoneyFormat = (template: Block): RowMoneyFormat => {
1450
+ const decimals = pickOption(
1451
+ readTemplateText(template, 'decimals').trim(),
1452
+ MONEY_DECIMALS
1453
+ );
1454
+ const currencyDisplay = pickOption(
1455
+ readTemplateText(template, 'currencyDisplay').trim(),
1456
+ MONEY_DISPLAYS
1457
+ );
1458
+ const currencyPosition = pickOption(
1459
+ readTemplateText(template, 'currencyPosition').trim(),
1460
+ MONEY_POSITIONS
1461
+ );
1462
+ const currencyText = readTemplateText(template, 'currencyText').trim();
1463
+
1464
+ const authored =
1465
+ decimals !== undefined ||
1466
+ (currencyDisplay !== undefined && currencyDisplay !== '') ||
1467
+ (currencyPosition !== undefined && currencyPosition !== '') ||
1468
+ currencyText !== '';
1469
+
1470
+ return {
1471
+ locale: bindingLocale,
1472
+ ...(authored
1473
+ ? {
1474
+ money: {
1475
+ ...(decimals ? { decimals } : {}),
1476
+ ...(currencyDisplay ? { currencyDisplay } : {}),
1477
+ ...(currencyText ? { currencyText } : {}),
1478
+ ...(currencyPosition ? { currencyPosition } : {})
1479
+ }
1480
+ }
1481
+ : {})
1482
+ };
1483
+ };
1484
+
1485
+ /** The badge and swatch tables authored on the template, as the grid's. */
1486
+ export const readRowLookups = (
1487
+ template: Block
1488
+ ): ListingValueLookups | undefined =>
1489
+ parseListingLookups(
1490
+ readTemplateText(template, 'badgeAttribute').trim(),
1491
+ readTemplateText(template, 'badgeMap'),
1492
+ readTemplateText(template, 'swatchMap')
1493
+ );
1494
+
1495
+ /**
1496
+ * A collection row published in the listing row's shape as well as its own.
1497
+ *
1498
+ * The editor previews a collection with Omnitron item rows (money under
1499
+ * `active_price`, no `absolute_url`); the storefront fetches commerce rows
1500
+ * (money flat). Neither carries what a card designed against a product-grid
1501
+ * row binds: `url`, `image`, `retailPrice`, `hasDiscount`, `discountPercent`,
1502
+ * `inStock`, `attributes` keyed, `colors[]`, `badge`. The listing normaliser
1503
+ * derives all of those from either shape once the money is flat, so the row is
1504
+ * flattened, normalised, and the normalised fields are added where the row had
1505
+ * none. Nothing the row already carried is replaced — `item.price` still reads
1506
+ * the raw figure the price pair is built from, and every binding a published
1507
+ * collection section has resolves exactly as before.
1508
+ *
1509
+ * Without an authored money format the normaliser would print the list price
1510
+ * in the locale's own form ("₺3.999,00") beside a current price the pair
1511
+ * prints as "3.499 TL"; the list price is therefore written the way the pair
1512
+ * writes it, so the two halves of one card agree.
1513
+ */
1514
+ export const normalizeCollectionRow = (
1515
+ item: Record<string, unknown>,
1516
+ index: number,
1517
+ options: { lookups?: ListingValueLookups; format?: RowMoneyFormat } = {}
1518
+ ): Record<string, unknown> => {
1519
+ if (!isRecord(item) || Object.keys(item).length === 0) return item;
1520
+
1521
+ const activePrice = isRecord(item.active_price) ? item.active_price : null;
1522
+ const flat: Record<string, unknown> = { ...item };
1523
+ if (activePrice) {
1524
+ if (flat.price === undefined) flat.price = activePrice.price;
1525
+ if (flat.retail_price === undefined) {
1526
+ flat.retail_price = activePrice.retail_price;
1527
+ }
1528
+ if (flat.currency_type === undefined && flat.currency === undefined) {
1529
+ flat.currency_type = activePrice.currency_type;
1530
+ }
1531
+ }
1532
+
1533
+ const currency = readProductCurrency(flat);
1534
+ const normalized = normalizeListingProduct(flat, index, {
1535
+ currency: typeof currency === 'string' ? currency : undefined,
1536
+ locale: options.format?.locale,
1537
+ money: options.format?.money,
1538
+ lookups: options.lookups
1539
+ });
1540
+ if (!normalized) return flat;
1541
+
1542
+ // Only fields the row lacks, and only fields that say something: the
1543
+ // normaliser answers a missing figure with '' (a formatted nothing), and
1544
+ // writing that onto the row would turn "no price" into a price of nothing —
1545
+ // the pair reader prints "0 TL" for an empty string it is handed.
1546
+ const row: Record<string, unknown> = { ...flat };
1547
+ for (const [key, value] of Object.entries(normalized)) {
1548
+ if (value !== undefined && value !== '' && row[key] === undefined) {
1549
+ row[key] = value;
1550
+ }
1551
+ }
1552
+
1553
+ // The listing normaliser reads `image` off the page-context row, which has
1554
+ // already been flattened; a raw row keeps its pictures in `productimage_set`,
1555
+ // which the normaliser publishes as `images` — so the first of those is the
1556
+ // picture, the way the page context would have made it.
1557
+ if (
1558
+ (row.image === undefined || row.image === '') &&
1559
+ Array.isArray(normalized.images) &&
1560
+ typeof normalized.images[0] === 'string'
1561
+ ) {
1562
+ row.image = normalized.images[0];
1563
+ }
1564
+
1565
+ if (!options.format?.money) {
1566
+ row.retailPrice =
1567
+ parsePriceValue(flat.retail_price) > 0
1568
+ ? getPriceWithCurrency(flat.retail_price, currency)
1569
+ : '';
1570
+ if (normalized.previousPrice !== undefined) {
1571
+ const previous = isRecord(flat.price_extra_field)
1572
+ ? flat.price_extra_field.previous_price
1573
+ : undefined;
1574
+ row.previousPrice = getPriceWithCurrency(previous, currency);
1575
+ }
1576
+ }
1577
+
1578
+ return row;
1579
+ };
1580
+
1581
+ /**
1582
+ * Expands the repeats INSIDE an already-built row.
1583
+ *
1584
+ * A functional list (the product grid's ItemTemplateList) expands its row
1585
+ * template itself and renders the clones; the section renderer's walk, which
1586
+ * expands a nested repeat after the repeat around it, never sees them. So a
1587
+ * colour-swatch strip inside a grid card was handed its colours
1588
+ * (`iteratorScopedItems`) and then drawn unexpanded. Each clone is walked here
1589
+ * the way the section walks its own: a nested repeat expands against the list
1590
+ * its row handed it, and its children are walked in turn.
1591
+ */
1592
+ export const expandNestedIterators = (
1593
+ block: Block,
1594
+ options: { isDesigner?: boolean } = {}
1595
+ ): Block => {
1596
+ if (!block.blocks || block.blocks.length === 0) return block;
1597
+
1598
+ let changed = false;
1599
+ const blocks = block.blocks.map((child) => {
1600
+ const expanded = child.isIterator
1601
+ ? buildIteratorBlock({ iteratorBlock: child, isDesigner: options.isDesigner })
1602
+ : child;
1603
+ const deep = expandNestedIterators(expanded, options);
1604
+ if (deep !== child) changed = true;
1605
+ return deep;
1606
+ });
1607
+
1608
+ return changed ? { ...block, blocks } : block;
1609
+ };
1610
+
1317
1611
  export const buildIteratorBlock = ({
1318
1612
  iteratorBlock,
1319
1613
  sectionDataSource,
@@ -1363,18 +1657,51 @@ export const buildIteratorBlock = ({
1363
1657
  // its row's list is neither: the row has already answered, and an empty
1364
1658
  // answer means this product has no colours — one empty swatch next to
1365
1659
  // products that do have colours reads as a colour the shopper can pick.
1660
+ //
1661
+ // Except on the canvas. The editor previews a collection with Omnitron's
1662
+ // item rows, which carry no colour variants at all, so a swatch strip in
1663
+ // that card answered "no colours" for every product and the designer had
1664
+ // nothing to arrange or hover — the strip looked broken rather than empty.
1665
+ // The canvas draws the count floor there too; the storefront still draws
1666
+ // nothing for a product without colours.
1366
1667
  const actualCount =
1367
1668
  displayItems.length > 0
1368
1669
  ? displayItems.length
1369
- : scopedItems
1670
+ : scopedItems && !isDesigner
1370
1671
  ? 0
1371
1672
  : Math.max(iteratorCount, 1);
1372
1673
 
1674
+ // Only a repeat that read its rows off a collection publishes them as
1675
+ // products; a caller that brought its own items (a product grid's
1676
+ // ItemTemplateList) opens the scope itself and must not get a second one.
1677
+ // …and not a repeat INSIDE a row: a swatch strip handed its product's
1678
+ // colours draws colours, whatever the section's source publishes.
1679
+ const scopesProducts =
1680
+ providedItems === undefined &&
1681
+ scopedItems === null &&
1682
+ publishesProductRows(sectionDataSource);
1683
+ // The card's own money format and lookup tables, read off the template the
1684
+ // way the product grid reads them off itself — so a card that repeats over
1685
+ // a collection prints "3.499,00 TL" and fills `item.badge` / `item.colors[]`
1686
+ // exactly as the same card does inside a grid.
1687
+ const rowFormat = scopesProducts ? readRowMoneyFormat(template) : undefined;
1688
+ const rowLookups = scopesProducts ? readRowLookups(template) : undefined;
1689
+
1373
1690
  const clonedBlocks = Array.from({ length: actualCount }, (_, index) => {
1374
1691
  // `??`, not `||`: an entry of a list of plain values can be falsy (0, '')
1375
1692
  // and is still the row, while a missing entry is the placeholder padding
1376
1693
  // the count floor asked for.
1377
- const item = displayItems[index] ?? {};
1694
+ const rawItem = displayItems[index] ?? {};
1695
+ // A collection row is a commerce product: it is published in the listing
1696
+ // row's shape too (`item.url`, `item.retailPrice`, `item.hasDiscount`,
1697
+ // `item.colors`…), on top of its own fields, so one card design binds over
1698
+ // a grid row and a collection row alike. See normalizeCollectionRow.
1699
+ const item = scopesProducts
1700
+ ? normalizeCollectionRow(rawItem as Record<string, unknown>, index, {
1701
+ lookups: rowLookups,
1702
+ format: rowFormat
1703
+ })
1704
+ : rawItem;
1378
1705
 
1379
1706
  const templateCopy: Block = {
1380
1707
  ...template,
@@ -1393,10 +1720,19 @@ export const buildIteratorBlock = ({
1393
1720
  : template.properties,
1394
1721
  blocks: template.blocks
1395
1722
  ? template.blocks.map((childBlock) => ({ ...childBlock }))
1396
- : undefined
1723
+ : undefined,
1724
+ // Carried on the clone, read by ThemeBlock, which opens the product
1725
+ // scope around it — see `Block.itemScopeProduct`.
1726
+ ...(scopesProducts
1727
+ ? {
1728
+ itemScopeProduct: resolveCollectionRowProduct(
1729
+ rawItem as Record<string, unknown>
1730
+ )
1731
+ }
1732
+ : {})
1397
1733
  };
1398
1734
 
1399
- return replaceBlockValues(templateCopy, item, index);
1735
+ return replaceBlockValues(templateCopy, item, index, false, rowFormat);
1400
1736
  });
1401
1737
 
1402
1738
  const nextProperties = forgetScopedItems(iteratorBlock.properties);