@salesforce/retail-react-app 8.3.0 → 8.4.0-nightly-20260116000329

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.
@@ -1639,4 +1639,357 @@ describe('Bonus Product Cart Utilities', () => {
1639
1639
  expect(result[0].productId).toBe('bonus-product-1')
1640
1640
  })
1641
1641
  })
1642
+
1643
+ describe('consolidateDuplicateBonusProducts', () => {
1644
+ test('returns empty array for null input', () => {
1645
+ const result = cartUtils.consolidateDuplicateBonusProducts(null)
1646
+ expect(result).toEqual([])
1647
+ })
1648
+
1649
+ test('returns empty array for undefined input', () => {
1650
+ const result = cartUtils.consolidateDuplicateBonusProducts(undefined)
1651
+ expect(result).toEqual([])
1652
+ })
1653
+
1654
+ test('returns empty array for empty array input', () => {
1655
+ const result = cartUtils.consolidateDuplicateBonusProducts([])
1656
+ expect(result).toEqual([])
1657
+ })
1658
+
1659
+ test('returns regular products as-is when no bonus products', () => {
1660
+ const productItems = [
1661
+ {
1662
+ productId: 'regular-product-1',
1663
+ itemId: 'item-1',
1664
+ quantity: 2
1665
+ },
1666
+ {
1667
+ productId: 'regular-product-2',
1668
+ itemId: 'item-2',
1669
+ quantity: 1
1670
+ }
1671
+ ]
1672
+
1673
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1674
+
1675
+ expect(result).toHaveLength(2)
1676
+ expect(result[0].productId).toBe('regular-product-1')
1677
+ expect(result[0].quantity).toBe(2)
1678
+ expect(result[1].productId).toBe('regular-product-2')
1679
+ expect(result[1].quantity).toBe(1)
1680
+ })
1681
+
1682
+ test('consolidates duplicate bonus products by productId', () => {
1683
+ const productItems = [
1684
+ {
1685
+ productId: 'bonus-product-1',
1686
+ itemId: 'bonus-item-1',
1687
+ quantity: 1,
1688
+ bonusProductLineItem: true,
1689
+ bonusDiscountLineItemId: 'bonus-123'
1690
+ },
1691
+ {
1692
+ productId: 'bonus-product-1',
1693
+ itemId: 'bonus-item-2',
1694
+ quantity: 2,
1695
+ bonusProductLineItem: true,
1696
+ bonusDiscountLineItemId: 'bonus-123'
1697
+ },
1698
+ {
1699
+ productId: 'bonus-product-1',
1700
+ itemId: 'bonus-item-3',
1701
+ quantity: 1,
1702
+ bonusProductLineItem: true,
1703
+ bonusDiscountLineItemId: 'bonus-456'
1704
+ }
1705
+ ]
1706
+
1707
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1708
+
1709
+ expect(result).toHaveLength(1)
1710
+ expect(result[0].productId).toBe('bonus-product-1')
1711
+ expect(result[0].quantity).toBe(4) // 1 + 2 + 1
1712
+ expect(result[0].bonusProductLineItem).toBe(true)
1713
+ })
1714
+
1715
+ test('keeps different bonus products separate', () => {
1716
+ const productItems = [
1717
+ {
1718
+ productId: 'bonus-product-1',
1719
+ itemId: 'bonus-item-1',
1720
+ quantity: 2,
1721
+ bonusProductLineItem: true,
1722
+ bonusDiscountLineItemId: 'bonus-123'
1723
+ },
1724
+ {
1725
+ productId: 'bonus-product-2',
1726
+ itemId: 'bonus-item-2',
1727
+ quantity: 1,
1728
+ bonusProductLineItem: true,
1729
+ bonusDiscountLineItemId: 'bonus-456'
1730
+ }
1731
+ ]
1732
+
1733
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1734
+
1735
+ expect(result).toHaveLength(2)
1736
+ expect(result[0].productId).toBe('bonus-product-1')
1737
+ expect(result[0].quantity).toBe(2)
1738
+ expect(result[1].productId).toBe('bonus-product-2')
1739
+ expect(result[1].quantity).toBe(1)
1740
+ })
1741
+
1742
+ test('preserves order: regular products first, then bonus products', () => {
1743
+ const productItems = [
1744
+ {
1745
+ productId: 'bonus-product-1',
1746
+ itemId: 'bonus-item-1',
1747
+ quantity: 1,
1748
+ bonusProductLineItem: true,
1749
+ bonusDiscountLineItemId: 'bonus-123'
1750
+ },
1751
+ {
1752
+ productId: 'regular-product-1',
1753
+ itemId: 'regular-item-1',
1754
+ quantity: 2
1755
+ },
1756
+ {
1757
+ productId: 'bonus-product-2',
1758
+ itemId: 'bonus-item-2',
1759
+ quantity: 1,
1760
+ bonusProductLineItem: true,
1761
+ bonusDiscountLineItemId: 'bonus-456'
1762
+ },
1763
+ {
1764
+ productId: 'regular-product-2',
1765
+ itemId: 'regular-item-2',
1766
+ quantity: 1
1767
+ }
1768
+ ]
1769
+
1770
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1771
+
1772
+ expect(result).toHaveLength(4)
1773
+ // Regular products should come first
1774
+ expect(result[0].productId).toBe('regular-product-1')
1775
+ expect(result[0].bonusProductLineItem).toBeUndefined()
1776
+ expect(result[1].productId).toBe('regular-product-2')
1777
+ expect(result[1].bonusProductLineItem).toBeUndefined()
1778
+ // Bonus products should come after
1779
+ expect(result[2].productId).toBe('bonus-product-1')
1780
+ expect(result[2].bonusProductLineItem).toBe(true)
1781
+ expect(result[3].productId).toBe('bonus-product-2')
1782
+ expect(result[3].bonusProductLineItem).toBe(true)
1783
+ })
1784
+
1785
+ test('consolidates multiple duplicate bonus products and preserves other properties', () => {
1786
+ const productItems = [
1787
+ {
1788
+ productId: 'bonus-product-1',
1789
+ itemId: 'bonus-item-1',
1790
+ quantity: 1,
1791
+ bonusProductLineItem: true,
1792
+ bonusDiscountLineItemId: 'bonus-123'
1793
+ },
1794
+ {
1795
+ productId: 'bonus-product-1',
1796
+ itemId: 'bonus-item-2',
1797
+ quantity: 3,
1798
+ bonusProductLineItem: true,
1799
+ bonusDiscountLineItemId: 'bonus-123'
1800
+ },
1801
+ {
1802
+ productId: 'bonus-product-2',
1803
+ itemId: 'bonus-item-3',
1804
+ quantity: 2,
1805
+ bonusProductLineItem: true,
1806
+ bonusDiscountLineItemId: 'bonus-456'
1807
+ }
1808
+ ]
1809
+
1810
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1811
+
1812
+ expect(result).toHaveLength(2)
1813
+ // First bonus product should be consolidated
1814
+ expect(result[0].productId).toBe('bonus-product-1')
1815
+ expect(result[0].quantity).toBe(4) // 1 + 3
1816
+ expect(result[0].bonusProductLineItem).toBe(true)
1817
+ expect(result[0].bonusDiscountLineItemId).toBe('bonus-123')
1818
+ // Second bonus product should remain separate
1819
+ expect(result[1].productId).toBe('bonus-product-2')
1820
+ expect(result[1].quantity).toBe(2)
1821
+ })
1822
+
1823
+ test('handles mixed regular and bonus products with duplicates', () => {
1824
+ const productItems = [
1825
+ {
1826
+ productId: 'regular-product-1',
1827
+ itemId: 'regular-item-1',
1828
+ quantity: 1
1829
+ },
1830
+ {
1831
+ productId: 'bonus-product-1',
1832
+ itemId: 'bonus-item-1',
1833
+ quantity: 1,
1834
+ bonusProductLineItem: true,
1835
+ bonusDiscountLineItemId: 'bonus-123'
1836
+ },
1837
+ {
1838
+ productId: 'regular-product-2',
1839
+ itemId: 'regular-item-2',
1840
+ quantity: 2
1841
+ },
1842
+ {
1843
+ productId: 'bonus-product-1',
1844
+ itemId: 'bonus-item-2',
1845
+ quantity: 2,
1846
+ bonusProductLineItem: true,
1847
+ bonusDiscountLineItemId: 'bonus-123'
1848
+ },
1849
+ {
1850
+ productId: 'bonus-product-2',
1851
+ itemId: 'bonus-item-3',
1852
+ quantity: 1,
1853
+ bonusProductLineItem: true,
1854
+ bonusDiscountLineItemId: 'bonus-456'
1855
+ }
1856
+ ]
1857
+
1858
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1859
+
1860
+ expect(result).toHaveLength(4)
1861
+ // Regular products first
1862
+ expect(result[0].productId).toBe('regular-product-1')
1863
+ expect(result[0].quantity).toBe(1)
1864
+ expect(result[1].productId).toBe('regular-product-2')
1865
+ expect(result[1].quantity).toBe(2)
1866
+ // Consolidated bonus products
1867
+ expect(result[2].productId).toBe('bonus-product-1')
1868
+ expect(result[2].quantity).toBe(3) // 1 + 2
1869
+ expect(result[3].productId).toBe('bonus-product-2')
1870
+ expect(result[3].quantity).toBe(1)
1871
+ })
1872
+
1873
+ test('handles bonus products with zero quantity', () => {
1874
+ const productItems = [
1875
+ {
1876
+ productId: 'bonus-product-1',
1877
+ itemId: 'bonus-item-1',
1878
+ quantity: 0,
1879
+ bonusProductLineItem: true,
1880
+ bonusDiscountLineItemId: 'bonus-123'
1881
+ },
1882
+ {
1883
+ productId: 'bonus-product-1',
1884
+ itemId: 'bonus-item-2',
1885
+ quantity: 2,
1886
+ bonusProductLineItem: true,
1887
+ bonusDiscountLineItemId: 'bonus-123'
1888
+ }
1889
+ ]
1890
+
1891
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1892
+
1893
+ expect(result).toHaveLength(1)
1894
+ expect(result[0].productId).toBe('bonus-product-1')
1895
+ expect(result[0].quantity).toBe(2) // 0 + 2
1896
+ })
1897
+
1898
+ test('handles bonus products with missing quantity property', () => {
1899
+ const productItems = [
1900
+ {
1901
+ productId: 'bonus-product-1',
1902
+ itemId: 'bonus-item-1',
1903
+ bonusProductLineItem: true,
1904
+ bonusDiscountLineItemId: 'bonus-123'
1905
+ },
1906
+ {
1907
+ productId: 'bonus-product-1',
1908
+ itemId: 'bonus-item-2',
1909
+ quantity: 2,
1910
+ bonusProductLineItem: true,
1911
+ bonusDiscountLineItemId: 'bonus-123'
1912
+ }
1913
+ ]
1914
+
1915
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1916
+
1917
+ expect(result).toHaveLength(1)
1918
+ expect(result[0].productId).toBe('bonus-product-1')
1919
+ expect(result[0].quantity).toBe(2) // undefined treated as 0, then + 2
1920
+ })
1921
+
1922
+ test('consolidates same bonus product from different qualifying products', () => {
1923
+ // Scenario: Same product (bonus-product-1) is a bonus for two different qualifying products
1924
+ // This tests that consolidation happens by productId only, regardless of which
1925
+ // qualifying product triggered the bonus or which bonusDiscountLineItemId it has
1926
+ const productItems = [
1927
+ {
1928
+ productId: 'regular-product-A',
1929
+ itemId: 'regular-item-A',
1930
+ quantity: 1
1931
+ },
1932
+ {
1933
+ productId: 'bonus-product-1',
1934
+ itemId: 'bonus-item-1',
1935
+ quantity: 2,
1936
+ bonusProductLineItem: true,
1937
+ bonusDiscountLineItemId: 'bonus-123' // From regular-product-A
1938
+ },
1939
+ {
1940
+ productId: 'regular-product-B',
1941
+ itemId: 'regular-item-B',
1942
+ quantity: 1
1943
+ },
1944
+ {
1945
+ productId: 'bonus-product-1',
1946
+ itemId: 'bonus-item-2',
1947
+ quantity: 1,
1948
+ bonusProductLineItem: true,
1949
+ bonusDiscountLineItemId: 'bonus-456' // From regular-product-B (different line item)
1950
+ }
1951
+ ]
1952
+
1953
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1954
+
1955
+ expect(result).toHaveLength(3)
1956
+ // Regular products first
1957
+ expect(result[0].productId).toBe('regular-product-A')
1958
+ expect(result[1].productId).toBe('regular-product-B')
1959
+ // Bonus products consolidated - same productId from different sources merged
1960
+ expect(result[2].productId).toBe('bonus-product-1')
1961
+ expect(result[2].quantity).toBe(3) // 2 + 1, consolidated from both qualifying products
1962
+ expect(result[2].bonusProductLineItem).toBe(true)
1963
+ // Note: The bonusDiscountLineItemId will be from the first item found (bonus-123)
1964
+ // This is a side effect of using sampleItem - information about the second source is lost
1965
+ })
1966
+
1967
+ test('itemId is not used in consolidation logic (only for display keys)', () => {
1968
+ // itemId is unique per line item but consolidation only uses productId
1969
+ const productItems = [
1970
+ {
1971
+ productId: 'bonus-product-1',
1972
+ itemId: 'unique-item-id-1',
1973
+ quantity: 1,
1974
+ bonusProductLineItem: true,
1975
+ bonusDiscountLineItemId: 'bonus-123'
1976
+ },
1977
+ {
1978
+ productId: 'bonus-product-1',
1979
+ itemId: 'unique-item-id-2', // Different itemId
1980
+ quantity: 2,
1981
+ bonusProductLineItem: true,
1982
+ bonusDiscountLineItemId: 'bonus-123'
1983
+ }
1984
+ ]
1985
+
1986
+ const result = cartUtils.consolidateDuplicateBonusProducts(productItems)
1987
+
1988
+ expect(result).toHaveLength(1)
1989
+ expect(result[0].productId).toBe('bonus-product-1')
1990
+ expect(result[0].quantity).toBe(3) // Consolidated despite different itemIds
1991
+ // The itemId in result will be from the first item found (unique-item-id-1)
1992
+ // This is preserved for React key generation but not used in consolidation logic
1993
+ })
1994
+ })
1642
1995
  })
@@ -9,25 +9,99 @@ import {getSites} from '@salesforce/retail-react-app/app/utils/site-utils'
9
9
  import {urlPartPositions} from '@salesforce/retail-react-app/app/constants'
10
10
 
11
11
  /**
12
- * Construct literal routes based on url config
13
- * with site and locale references (ids and aliases) from each in your application config
12
+ * Build regex patterns for site and locale route parameters.
13
+ * Creates patterns like "siteA|siteB|siteC" from all valid site/locale refs.
14
+ *
15
+ * @param {array} allSites - array of site configurations
16
+ * @returns {object} - { sitePattern, localePattern }
17
+ */
18
+ const buildRoutePatterns = (allSites) => {
19
+ const siteRefs = allSites.flatMap((site) => [site.alias, site.id]).filter(Boolean)
20
+
21
+ const localeRefs = allSites
22
+ .flatMap((site) => site.l10n.supportedLocales)
23
+ .flatMap((locale) => [locale.alias, locale.id])
24
+ .filter(Boolean)
25
+
26
+ // Remove duplicates and join into regex pattern
27
+ const sitePattern = [...new Set(siteRefs)].join('|')
28
+ const localePattern = [...new Set(localeRefs)].join('|')
29
+
30
+ return {sitePattern, localePattern}
31
+ }
32
+
33
+ /**
34
+ * Configure routes using parameterized paths with regex constraints.
35
+ * This approach generates fewer routes by using patterns like:
36
+ * /:site(siteA|siteB)/:locale(en|fr)/path
37
+ *
38
+ * Note: This may match site/locale combinations that aren't valid together
39
+ * (e.g., a locale not supported by a specific site). Runtime validation
40
+ * should be performed after route matching.
14
41
  *
15
42
  * @param {array} routes - array of routes to be reconstructed
16
- * @param {object} urlConfig
17
- * @param {object} options - options if there are any
18
- * @param {array} options.ignoredRoutes - routes that does not need be reconstructed
19
- * @return {array} - list of routes objects that has site and locale refs
43
+ * @param {object} urlConfig - url configuration with site/locale positions
44
+ * @param {array} allSites - array of site configurations
45
+ * @param {array} ignoredRoutes - routes that should not be reconstructed
46
+ * @returns {array} - list of parameterized route objects
20
47
  */
21
- export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
22
- if (!routes.length) return []
23
- if (!config) return routes
48
+ const configureRoutesWithFuzzyMatching = (routes, urlConfig, allSites, ignoredRoutes) => {
49
+ const {sitePattern, localePattern} = buildRoutePatterns(allSites)
50
+ const {locale: localePosition, site: sitePosition} = urlConfig
24
51
 
25
- const {url: urlConfig} = config.app
52
+ const outputRoutes = []
26
53
 
27
- const allSites = getSites()
28
- if (!allSites) return routes
54
+ for (const route of routes) {
55
+ const {path, ...rest} = route
56
+
57
+ if (ignoredRoutes.includes(path)) {
58
+ outputRoutes.push(route)
59
+ continue
60
+ }
61
+
62
+ if (localePosition === urlPartPositions.PATH && sitePosition === urlPartPositions.PATH) {
63
+ // Both site and locale in path
64
+ outputRoutes.push({
65
+ path: `/:site(${sitePattern})/:locale(${localePattern})${path}`,
66
+ ...rest
67
+ })
68
+ } else if (sitePosition === urlPartPositions.PATH) {
69
+ // Site only in path
70
+ outputRoutes.push({
71
+ path: `/:site(${sitePattern})${path}`,
72
+ ...rest
73
+ })
74
+ } else if (localePosition === urlPartPositions.PATH) {
75
+ // Locale only in path
76
+ outputRoutes.push({
77
+ path: `/:locale(${localePattern})${path}`,
78
+ ...rest
79
+ })
80
+ }
81
+
82
+ // Original route as fallback
83
+ outputRoutes.push(route)
84
+ }
85
+
86
+ return outputRoutes
87
+ }
88
+
89
+ /**
90
+ * Configure routes using explicit paths for each site/locale combination.
91
+ * This is the original approach that generates literal routes like:
92
+ * /siteA/en/path, /siteA/fr/path, /siteB/en/path, etc.
93
+ *
94
+ * @param {array} routes - array of routes to be reconstructed
95
+ * @param {object} urlConfig - url configuration with site/locale positions
96
+ * @param {array} allSites - array of site configurations
97
+ * @param {array} ignoredRoutes - routes that should not be reconstructed
98
+ * @returns {array} - list of explicit route objects
99
+ */
100
+ const configureRoutesWithExplicitMatching = (routes, urlConfig, allSites, ignoredRoutes) => {
101
+ const {locale: localePosition, site: sitePosition} = urlConfig
29
102
 
30
103
  let outputRoutes = []
104
+
31
105
  for (let i = 0; i < routes.length; i++) {
32
106
  const {path, ...rest} = routes[i]
33
107
 
@@ -44,7 +118,6 @@ export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
44
118
  localeRefs.push(locale.id)
45
119
  })
46
120
  localeRefs = localeRefs.filter(Boolean)
47
- const {locale: localePosition, site: sitePosition} = urlConfig
48
121
 
49
122
  if (
50
123
  localePosition === urlPartPositions.PATH &&
@@ -100,6 +173,7 @@ export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
100
173
  outputRoutes.push(routes[i])
101
174
  }
102
175
  }
176
+
103
177
  // Remove any duplicate routes
104
178
  outputRoutes = outputRoutes.reduce((res, route) => {
105
179
  if (!res.some(({path}) => path === route.path)) {
@@ -107,5 +181,49 @@ export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
107
181
  }
108
182
  return res
109
183
  }, [])
184
+
185
+ return outputRoutes
186
+ }
187
+
188
+ /**
189
+ * Construct routes based on url config with site and locale references
190
+ * (ids and aliases) from each site in your application config.
191
+ *
192
+ * @param {array} routes - array of routes to be reconstructed
193
+ * @param {object} config - application configuration
194
+ * @param {object} options - options if there are any
195
+ * @param {array} options.ignoredRoutes - routes that should not be reconstructed
196
+ * @param {boolean} options.fuzzyPathMatching - when true, uses parameterized routes with
197
+ * regex constraints (e.g., /:site(a|b)/:locale(en|fr)/path) for fewer, more efficient
198
+ * route configurations. When false (default), generates explicit routes for each
199
+ * site/locale combination. Fuzzy matching may match invalid site/locale combinations
200
+ * that require runtime validation.
201
+ * @returns {array} - list of route objects with site and locale refs
202
+ */
203
+ export const configureRoutes = (
204
+ routes = [],
205
+ config,
206
+ {ignoredRoutes = [], fuzzyPathMatching = false}
207
+ ) => {
208
+ if (!routes.length) return []
209
+ if (!config) return routes
210
+
211
+ let outputRoutes = []
212
+ const {url: urlConfig} = config.app
213
+
214
+ const allSites = getSites()
215
+ if (!allSites) return routes
216
+
217
+ if (fuzzyPathMatching) {
218
+ outputRoutes = configureRoutesWithFuzzyMatching(routes, urlConfig, allSites, ignoredRoutes)
219
+ } else {
220
+ outputRoutes = configureRoutesWithExplicitMatching(
221
+ routes,
222
+ urlConfig,
223
+ allSites,
224
+ ignoredRoutes
225
+ )
226
+ }
227
+
110
228
  return outputRoutes
111
229
  }