@teleporthq/teleport-plugin-next-data-source 0.43.67 → 0.43.68

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 (202) hide show
  1. package/__tests__/array-overlap-sql.test.ts +53 -0
  2. package/__tests__/blog-post-page-enrichment.test.ts +214 -0
  3. package/__tests__/browser-row-policy.test.ts +503 -0
  4. package/__tests__/data-source-cache-wrapping.test.ts +18 -2
  5. package/__tests__/ecommerce-product-options.test.ts +206 -0
  6. package/__tests__/ecommerce-product-pages.test.ts +82 -0
  7. package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
  8. package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
  9. package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
  10. package/__tests__/fetchers.test.ts +2 -2
  11. package/__tests__/items-path.test.ts +150 -0
  12. package/__tests__/product-options-storefront.fixture.json +2510 -0
  13. package/__tests__/product-page-url-fixtures.json +25 -0
  14. package/__tests__/product-price-sort.ts +4 -2
  15. package/__tests__/raw-rows-mode.test.ts +111 -0
  16. package/__tests__/rest-api-response-cache.test.ts +434 -0
  17. package/__tests__/table-read-route-protected-tables.test.ts +493 -0
  18. package/dist/cjs/array-overlap-sql.d.ts +21 -0
  19. package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
  20. package/dist/cjs/array-overlap-sql.js +25 -0
  21. package/dist/cjs/array-overlap-sql.js.map +1 -0
  22. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  23. package/dist/cjs/data-source-fetchers.js +10 -4
  24. package/dist/cjs/data-source-fetchers.js.map +1 -1
  25. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/javascript.js +1 -1
  27. package/dist/cjs/fetchers/javascript.js.map +1 -1
  28. package/dist/cjs/fetchers/postgresql.d.ts +1 -1
  29. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  30. package/dist/cjs/fetchers/postgresql.js +8 -4
  31. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  32. package/dist/cjs/fetchers/raw-query.d.ts +8 -1
  33. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/raw-query.js +42 -4
  35. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  36. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/rest-api.js +7 -2
  38. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  39. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/teleport.js +8 -5
  41. package/dist/cjs/fetchers/teleport.js.map +1 -1
  42. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
  43. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  44. package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
  45. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
  46. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
  47. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  48. package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
  49. package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
  50. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  51. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  52. package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
  53. package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
  54. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
  55. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
  56. package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
  57. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
  58. package/dist/cjs/items-path.d.ts +10 -0
  59. package/dist/cjs/items-path.d.ts.map +1 -0
  60. package/dist/cjs/items-path.js +86 -0
  61. package/dist/cjs/items-path.js.map +1 -0
  62. package/dist/cjs/localized-columns.d.ts.map +1 -1
  63. package/dist/cjs/localized-columns.js +13 -0
  64. package/dist/cjs/localized-columns.js.map +1 -1
  65. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  66. package/dist/cjs/pagination-plugin.js +17 -5
  67. package/dist/cjs/pagination-plugin.js.map +1 -1
  68. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
  69. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
  70. package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
  71. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
  72. package/dist/cjs/transformations/blog-comments.d.ts +30 -0
  73. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
  74. package/dist/cjs/transformations/blog-comments.js +34 -0
  75. package/dist/cjs/transformations/blog-comments.js.map +1 -0
  76. package/dist/cjs/transformations/blog-post.d.ts +2 -0
  77. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  78. package/dist/cjs/transformations/blog-post.js +4 -1
  79. package/dist/cjs/transformations/blog-post.js.map +1 -1
  80. package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
  81. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  82. package/dist/cjs/transformations/ecommerce-product.js +28 -1
  83. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  84. package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
  85. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
  86. package/dist/cjs/transformations/heading-anchors.js +29 -0
  87. package/dist/cjs/transformations/heading-anchors.js.map +1 -0
  88. package/dist/cjs/transformations/index.d.ts +47 -4
  89. package/dist/cjs/transformations/index.d.ts.map +1 -1
  90. package/dist/cjs/transformations/index.js +56 -18
  91. package/dist/cjs/transformations/index.js.map +1 -1
  92. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  93. package/dist/cjs/transformations/shared-utils.js +1 -1
  94. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  95. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  96. package/dist/cjs/utils.d.ts.map +1 -1
  97. package/dist/cjs/utils.js +1 -1
  98. package/dist/cjs/utils.js.map +1 -1
  99. package/dist/esm/array-overlap-sql.d.ts +21 -0
  100. package/dist/esm/array-overlap-sql.d.ts.map +1 -0
  101. package/dist/esm/array-overlap-sql.js +21 -0
  102. package/dist/esm/array-overlap-sql.js.map +1 -0
  103. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  104. package/dist/esm/data-source-fetchers.js +11 -5
  105. package/dist/esm/data-source-fetchers.js.map +1 -1
  106. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  107. package/dist/esm/fetchers/javascript.js +1 -1
  108. package/dist/esm/fetchers/javascript.js.map +1 -1
  109. package/dist/esm/fetchers/postgresql.d.ts +1 -1
  110. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  111. package/dist/esm/fetchers/postgresql.js +8 -4
  112. package/dist/esm/fetchers/postgresql.js.map +1 -1
  113. package/dist/esm/fetchers/raw-query.d.ts +8 -1
  114. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  115. package/dist/esm/fetchers/raw-query.js +42 -4
  116. package/dist/esm/fetchers/raw-query.js.map +1 -1
  117. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  118. package/dist/esm/fetchers/rest-api.js +7 -2
  119. package/dist/esm/fetchers/rest-api.js.map +1 -1
  120. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  121. package/dist/esm/fetchers/teleport.js +8 -5
  122. package/dist/esm/fetchers/teleport.js.map +1 -1
  123. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
  124. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  125. package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
  126. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
  127. package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
  128. package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  129. package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
  130. package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
  131. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  132. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  133. package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
  134. package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
  135. package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
  136. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
  137. package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
  138. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
  139. package/dist/esm/items-path.d.ts +10 -0
  140. package/dist/esm/items-path.d.ts.map +1 -0
  141. package/dist/esm/items-path.js +58 -0
  142. package/dist/esm/items-path.js.map +1 -0
  143. package/dist/esm/localized-columns.d.ts.map +1 -1
  144. package/dist/esm/localized-columns.js +13 -0
  145. package/dist/esm/localized-columns.js.map +1 -1
  146. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  147. package/dist/esm/pagination-plugin.js +17 -5
  148. package/dist/esm/pagination-plugin.js.map +1 -1
  149. package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
  150. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
  151. package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
  152. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
  153. package/dist/esm/transformations/blog-comments.d.ts +30 -0
  154. package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
  155. package/dist/esm/transformations/blog-comments.js +30 -0
  156. package/dist/esm/transformations/blog-comments.js.map +1 -0
  157. package/dist/esm/transformations/blog-post.d.ts +2 -0
  158. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  159. package/dist/esm/transformations/blog-post.js +4 -1
  160. package/dist/esm/transformations/blog-post.js.map +1 -1
  161. package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
  162. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  163. package/dist/esm/transformations/ecommerce-product.js +29 -2
  164. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  165. package/dist/esm/transformations/heading-anchors.d.ts +25 -0
  166. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
  167. package/dist/esm/transformations/heading-anchors.js +25 -0
  168. package/dist/esm/transformations/heading-anchors.js.map +1 -0
  169. package/dist/esm/transformations/index.d.ts +47 -4
  170. package/dist/esm/transformations/index.d.ts.map +1 -1
  171. package/dist/esm/transformations/index.js +56 -18
  172. package/dist/esm/transformations/index.js.map +1 -1
  173. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  174. package/dist/esm/transformations/shared-utils.js +1 -1
  175. package/dist/esm/transformations/shared-utils.js.map +1 -1
  176. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  177. package/dist/esm/utils.d.ts.map +1 -1
  178. package/dist/esm/utils.js +1 -1
  179. package/dist/esm/utils.js.map +1 -1
  180. package/package.json +5 -5
  181. package/src/array-overlap-sql.ts +41 -0
  182. package/src/data-source-fetchers.ts +41 -5
  183. package/src/fetchers/javascript.ts +9 -1
  184. package/src/fetchers/postgresql.ts +37 -10
  185. package/src/fetchers/raw-query.ts +60 -5
  186. package/src/fetchers/rest-api.ts +27 -5
  187. package/src/fetchers/teleport.ts +37 -13
  188. package/src/fetchers/utils/browser-row-policy.ts +212 -0
  189. package/src/fetchers/utils/request-sql-guards.ts +25 -0
  190. package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
  191. package/src/fetchers/utils/table-access-guard.ts +39 -0
  192. package/src/items-path.ts +74 -0
  193. package/src/localized-columns.ts +13 -0
  194. package/src/pagination-plugin.ts +29 -7
  195. package/src/transformations/blog-adjacent-posts.ts +74 -0
  196. package/src/transformations/blog-comments.ts +132 -0
  197. package/src/transformations/blog-post.ts +38 -5
  198. package/src/transformations/ecommerce-product.ts +568 -5
  199. package/src/transformations/heading-anchors.ts +85 -0
  200. package/src/transformations/index.ts +165 -22
  201. package/src/transformations/shared-utils.ts +6 -0
  202. package/src/utils.ts +26 -4
@@ -1,5 +1,10 @@
1
- import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
- import { ProductDiscounts, ProductRatings, StorefrontTax } from '@teleporthq/teleport-shared'
1
+ import type { UIDLEcommerceCategory, UIDLEcommerceProductPages } from '@teleporthq/teleport-types'
2
+ import {
3
+ ProductDiscounts,
4
+ ProductOptions,
5
+ ProductRatings,
6
+ StorefrontTax,
7
+ } from '@teleporthq/teleport-shared'
3
8
  import { generateCategoryTaxonomyCode } from './category-taxonomy'
4
9
 
5
10
  /**
@@ -27,6 +32,67 @@ export interface EcommerceProductTransformOptions {
27
32
  * `apps/gui/app/project-page/features/e-commerce/utils/product-variants.ts`.
28
33
  */
29
34
  allowBackorders?: boolean
35
+ /**
36
+ * Where the standard product page and every custom product page live
37
+ * (`ecommerceSettings.productPages`). Present only when the store has a
38
+ * custom product page; then every product carries `productPageKey` (the page
39
+ * it opens on, '' = the standard one) and `productPageUrl` (its canonical
40
+ * locale-less address). Absent, the transform is exactly what it was.
41
+ */
42
+ productPages?: UIDLEcommerceProductPages
43
+ }
44
+
45
+ /**
46
+ * The product-page helpers baked into the transform. TWIN of the GUI's
47
+ * `features/e-commerce/utils/product-pages/product-pages-config.ts`
48
+ * (`normalizeProductPageKey` + `buildProductPagePath`) — both pinned by the
49
+ * shared `product-page-url-fixtures.json`. Only routes and attributes are baked:
50
+ * the page names never reach the site.
51
+ */
52
+ const generateProductPagesCode = (productPages: UIDLEcommerceProductPages): string => {
53
+ const standard = productPages.standard
54
+ const baked = {
55
+ standard:
56
+ standard && typeof standard.route === 'string' && typeof standard.attribute === 'string'
57
+ ? { route: standard.route, attribute: standard.attribute }
58
+ : null,
59
+ pages: Object.keys(productPages.pages || {}).reduce(
60
+ (acc: Record<string, { route: string; attribute: string }>, key) => {
61
+ const page = productPages.pages[key]
62
+ if (page && typeof page.route === 'string' && typeof page.attribute === 'string') {
63
+ acc[key] = { route: page.route, attribute: page.attribute }
64
+ }
65
+ return acc
66
+ },
67
+ {}
68
+ ),
69
+ }
70
+ return `
71
+ // Where every product page lives, baked from the store's custom product pages.
72
+ // A product opens on the custom page its product_page column names while that
73
+ // page exists, on the standard product page otherwise. Regenerating the project
74
+ // refreshes it — do not hand-edit.
75
+ var PRODUCT_PAGES = ${JSON.stringify(baked)}
76
+
77
+ function resolveProductPageKey(raw) {
78
+ var key = typeof raw === 'string' ? raw.trim() : ''
79
+ return key !== '' && Object.prototype.hasOwnProperty.call(PRODUCT_PAGES.pages, key) ? key : ''
80
+ }
81
+
82
+ // A product's canonical address: its page's static base, then the value of the
83
+ // column the page's address ends with, encoded. '' when either is missing.
84
+ // values holds the slug in the page's language and the id; any other column
85
+ // the address ends with (a SKU) is read from the row.
86
+ function buildProductPageUrl(key, values, record) {
87
+ var page = key !== '' ? PRODUCT_PAGES.pages[key] : PRODUCT_PAGES.standard
88
+ if (!page || !page.route) return ''
89
+ var value = values[page.attribute]
90
+ if ((value === null || value === undefined) && record) value = record[page.attribute]
91
+ if (value === null || value === undefined || String(value) === '') return ''
92
+ var base = page.route === '/' ? '' : page.route
93
+ return base + '/' + encodeURIComponent(String(value))
94
+ }
95
+ `
30
96
  }
31
97
 
32
98
  /**
@@ -45,13 +111,14 @@ export const generateEcommerceProductTransformationCode = (
45
111
  return `
46
112
  ${taxHelperCode}
47
113
  ${ProductDiscounts.generateProductDiscountHelperCode()}
114
+ ${ProductOptions.generateProductOptionsHelperCode()}
48
115
  ${ProductRatings.generateProductRatingHelperCode()}
49
116
 
50
117
  // Baked from the merchant's stock settings: TRUE when stock never blocks a
51
118
  // purchase (stock management off OR backorders allowed). Regenerating the
52
119
  // project refreshes it — do not hand-edit. See EcommerceProductTransformOptions.
53
120
  var ALLOW_BACKORDERS = ${allowBackordersLiteral}
54
-
121
+ ${options.productPages ? generateProductPagesCode(options.productPages) : ''}
55
122
  ${generateCategoryTaxonomyCode('PRODUCT_CATEGORIES_BY_ID', options.categories)}
56
123
  // The merchant's "Show 3D model by default" toggle, as every backend may hand
57
124
  // it back: a real boolean, 1/0, 'true'/'false'/'t'/'f' strings — and NULL /
@@ -69,6 +136,57 @@ function normalizeShowModelFlag(raw) {
69
136
  return true
70
137
  }
71
138
 
139
+ // A BOOLEAN column as every backend may hand it back; NULL / absent reads as
140
+ // off. MUST mirror parseBoolean in the GUI's features/e-commerce/utils/parse-boolean.ts.
141
+ function readBooleanFlag(raw) {
142
+ if (typeof raw === 'boolean') return raw
143
+ if (typeof raw === 'number') return raw !== 0
144
+ if (typeof raw === 'string') {
145
+ var token = raw.trim().toLowerCase()
146
+ return token === 'true' || token === 't' || token === '1' || token === 'yes'
147
+ }
148
+ return false
149
+ }
150
+
151
+ // "month", "3 months" — the period a recurring price is billed per. The
152
+ // interval is read however the driver spelled it (anything unknown is a
153
+ // month) and the count is a positive whole number (anything else is 1). MUST
154
+ // mirror formatBillingPeriod in the GUI's features/e-commerce/constants/subscriptions.ts.
155
+ var BILLING_INTERVALS = ['day', 'week', 'month', 'year']
156
+ function formatBillingPeriodLabel(interval, intervalCount) {
157
+ var unit = String(interval == null ? '' : interval).trim().toLowerCase()
158
+ if (BILLING_INTERVALS.indexOf(unit) === -1) unit = 'month'
159
+ var count = normalizeBillingIntervalCountValue(intervalCount)
160
+ return count === 1 ? unit : count + ' ' + unit + 's'
161
+ }
162
+
163
+ // A positive whole number of intervals; anything else is 1. MUST mirror
164
+ // normalizeBillingIntervalCount in the GUI's features/e-commerce/constants/subscriptions.ts.
165
+ function normalizeBillingIntervalCountValue(intervalCount) {
166
+ var count = Math.floor(Number(intervalCount))
167
+ return isFinite(count) && count >= 1 ? count : 1
168
+ }
169
+
170
+ // A positive whole number read off an INTEGER column, or 0 for NULL, zero, a
171
+ // negative or anything unreadable. MUST mirror resolvePositiveCount in the
172
+ // GUI's features/e-commerce/utils/digital-products/digital-product.ts (which
173
+ // answers null where this answers 0).
174
+ function resolvePositiveCountValue(raw) {
175
+ if (raw === null || raw === undefined || raw === '') return 0
176
+ var parsed = Math.floor(Number(raw))
177
+ return isFinite(parsed) && parsed > 0 ? parsed : 0
178
+ }
179
+
180
+ // "Billed every month", "Billed every 3 months · 14-day free trial" — the
181
+ // purchase-details line of a recurring product. MUST mirror
182
+ // formatBillingSummary in the GUI's
183
+ // features/e-commerce/utils/subscriptions/billing-summary.ts.
184
+ function formatBillingSummaryLabel(interval, intervalCount, trialDays) {
185
+ var summary = 'Billed every ' + formatBillingPeriodLabel(interval, intervalCount)
186
+ var trial = resolvePositiveCountValue(trialDays)
187
+ return trial === 0 ? summary : summary + ' · ' + trial + '-day free trial'
188
+ }
189
+
72
190
  // The product's media gallery in display order, HERO FIRST: the model when it
73
191
  // is shown by default, otherwise the images with the model last. Each entry is
74
192
  // { kind: 'image' | 'model', src, poster, thumbnail, alt }; 'thumbnail' is what
@@ -112,7 +230,12 @@ function buildEcommerceProduct(record, options) {
112
230
 
113
231
  // i18n-resolved text fields
114
232
  var name = resolveI18nField(record, 'name', 'name', currentLang, mainLang) || ''
115
- var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''
233
+ var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''${
234
+ options.productPages
235
+ ? `
236
+ var productPageKey = resolveProductPageKey(pickFirst(record.product_page, record.productPage))`
237
+ : ''
238
+ }
116
239
  var description = resolveI18nField(record, 'description', 'description', currentLang, mainLang) || ''
117
240
  var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null
118
241
  var tagsRaw = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)
@@ -145,7 +268,32 @@ function buildEcommerceProduct(record, options) {
145
268
  var paymentType = pickFirst(record.payment_type, record.paymentType) || 'one_time'
146
269
  var recurringInterval = pickFirst(record.recurring_interval, record.recurringInterval) || null
147
270
  var rawRecurringCount = pickFirst(record.recurring_interval_count, record.recurringIntervalCount)
148
- var recurringIntervalCount = safeNumber(rawRecurringCount, null)
271
+ var isRecurring = String(paymentType).trim().toLowerCase() === 'recurring'
272
+ // A recurring product's count is a whole number of intervals (anything
273
+ // unreadable is 1); a one-time product keeps the column as stored.
274
+ var recurringIntervalCount = isRecurring
275
+ ? normalizeBillingIntervalCountValue(rawRecurringCount)
276
+ : safeNumber(rawRecurringCount, null)
277
+ // What a recurring price is followed by ("month", "3 months"; '' for a
278
+ // one-time product), the purchase-details sentence ("Billed every 3 months ·
279
+ // 14-day free trial"; '' for a one-time product), the free trial in days (0
280
+ // for none), and whether the product is delivered as a download or issued
281
+ // as a gift card. Strings where a rendering condition reads them, like
282
+ // hasDiscount: a bound text node prints the period verbatim and a rendering
283
+ // condition compares the flags as strings. MUST mirror
284
+ // packages/renderer/src/utils/ecommerce-products.ts, whose period comes from
285
+ // formatBillingPeriod in constants/subscriptions.ts.
286
+ var billingPeriod = isRecurring
287
+ ? formatBillingPeriodLabel(recurringInterval, recurringIntervalCount)
288
+ : ''
289
+ var trialDays = resolvePositiveCountValue(pickFirst(record.trial_days, record.trialDays))
290
+ var billingSummary = isRecurring
291
+ ? formatBillingSummaryLabel(recurringInterval, recurringIntervalCount, trialDays)
292
+ : ''
293
+ var isDigital = readBooleanFlag(pickFirst(record.is_digital, record.isDigital)) ? 'true' : 'false'
294
+ var isGiftCard = readBooleanFlag(pickFirst(record.is_gift_card, record.isGiftCard))
295
+ ? 'true'
296
+ : 'false'
149
297
 
150
298
  // Physical product fields
151
299
  var sku = record.sku || null
@@ -503,6 +651,23 @@ function buildEcommerceProduct(record, options) {
503
651
  var variantsJson = safeStringifyJson(variants)
504
652
  var variantsDisplayJson = safeStringifyJson(variantsDisplay)
505
653
 
654
+ // Product options (configurator, configured price, card quick-add gate): the
655
+ // three flags always, the detail only for the details page's single-record
656
+ // fetch (options.includeOptionDetail). See buildProductOptionFields below.
657
+ var optionFields = buildProductOptionFields(record, {
658
+ productId: id != null ? String(id) : '',
659
+ currentLanguage: currentLang,
660
+ mainLanguage: mainLang,
661
+ includeDetail: options.includeOptionDetail === true,
662
+ basePrice: price,
663
+ activeDiscount: activeDiscount,
664
+ variants: variants,
665
+ defaultVariant: firstVariant,
666
+ symbol: currencySymbol,
667
+ position: getCurrencyInfo(currency).position,
668
+ assetMap: assetMap,
669
+ })
670
+
506
671
  return {
507
672
  id: id,
508
673
  name: name,
@@ -518,6 +683,11 @@ function buildEcommerceProduct(record, options) {
518
683
  paymentType: paymentType,
519
684
  recurringInterval: recurringInterval,
520
685
  recurringIntervalCount: recurringIntervalCount,
686
+ billingPeriod: billingPeriod,
687
+ trialDays: trialDays,
688
+ billingSummary: billingSummary,
689
+ isDigital: isDigital,
690
+ isGiftCard: isGiftCard,
521
691
  sku: sku,
522
692
  weight: weight,
523
693
  weightUnit: weightUnit,
@@ -579,6 +749,27 @@ function buildEcommerceProduct(record, options) {
579
749
  // THIS FILE — it is one template literal, and a backtick in a comment ends
580
750
  // the string.
581
751
  reviews: reviews,
752
+ // Product options — see buildProductOptionFields.
753
+ hasProductOptions: optionFields.hasProductOptions,
754
+ optionsRequireInput: optionFields.optionsRequireInput,
755
+ optionsQuickAdd: optionFields.optionsQuickAdd,
756
+ optionGroups: optionFields.optionGroups,
757
+ optionGroupsJson: optionFields.optionGroupsJson,
758
+ optionsPricingJson: optionFields.optionsPricingJson,
759
+ optionsDefaultDisplayPrice: optionFields.optionsDefaultDisplayPrice,
760
+ optionsDefaultOriginalDisplayPrice: optionFields.optionsDefaultOriginalDisplayPrice,
761
+ optionsDefaultSummary: optionFields.optionsDefaultSummary,
762
+ optionsFreshKey: optionFields.optionsFreshKey,
763
+ optionsValidKey: optionFields.optionsValidKey,
764
+ optionsOriginalKey: optionFields.optionsOriginalKey,
765
+ optionsSummaryKey: optionFields.optionsSummaryKey,
766
+ optionsFilesKey: optionFields.optionsFilesKey,${
767
+ options.productPages
768
+ ? `
769
+ productPageKey: productPageKey,
770
+ productPageUrl: buildProductPageUrl(productPageKey, { slug: slug, id: id }, record),`
771
+ : ''
772
+ }
582
773
  }
583
774
  }
584
775
 
@@ -653,6 +844,378 @@ function safeStringifyJson(obj) {
653
844
  }
654
845
  }
655
846
 
847
+ // --- Product options -------------------------------------------------------
848
+ // MUST mirror buildStorefrontProductOptionFields in the GUI's
849
+ // features/e-commerce/utils/product-options/product-options-storefront.ts
850
+ // (the canvas copy): both run over __tests__/product-options-storefront.fixture.json.
851
+ //
852
+ // Always emitted: hasProductOptions, optionsRequireInput, optionsQuickAdd.
853
+ // Everything else is details-page only, so a grid of cards never ships every
854
+ // product's option tree. Structure and prices come from the main option_groups;
855
+ // the request language's clone only overlays strings, so a stale translation
856
+ // can never misprice. Flags are 'true'/'false' STRINGS, prices gross 2-decimal
857
+ // strings, and every state key is scoped by the product id.
858
+
859
+ // The configurator's state-key prefixes. MUST mirror OPTION_KEY_PREFIX in the
860
+ // GUI's features/e-commerce/utils/product-options/product-options-uidl.ts.
861
+ var OPTION_STATE_KEY_PREFIX = {
862
+ selected: 'sel:',
863
+ hidden: 'hide:',
864
+ shown: 'show:',
865
+ missing: 'miss:',
866
+ invalid: 'inv:',
867
+ rejected: 'rej:',
868
+ failed: 'fail:',
869
+ uploaded: 'up:',
870
+ uploading: 'load:',
871
+ fresh: 'fresh:',
872
+ valid: 'valid:',
873
+ original: 'orig:',
874
+ summary: 'sum:',
875
+ files: 'files:',
876
+ }
877
+
878
+ // A product without options, and the detail-free shape a listing gets.
879
+ function emptyProductOptionFields() {
880
+ return {
881
+ hasProductOptions: 'false',
882
+ optionsRequireInput: 'false',
883
+ optionsQuickAdd: 'true',
884
+ optionGroups: [],
885
+ optionGroupsJson: '[]',
886
+ optionsPricingJson: '{}',
887
+ optionsDefaultDisplayPrice: '',
888
+ optionsDefaultOriginalDisplayPrice: '',
889
+ optionsDefaultSummary: '',
890
+ optionsFreshKey: '',
891
+ optionsValidKey: '',
892
+ optionsOriginalKey: '',
893
+ optionsSummaryKey: '',
894
+ optionsFilesKey: '',
895
+ }
896
+ }
897
+
898
+ function buildProductOptionFields(record, ctx) {
899
+ var groups = __poParseGroups(pickFirst(record.option_groups, record.optionGroups))
900
+ if (groups.length === 0) return emptyProductOptionFields()
901
+ // Gift cards and subscriptions may carry options, never priced ones.
902
+ var pricesDisabled =
903
+ __poFlag(pickFirst(record.is_gift_card, record.isGiftCard)) ||
904
+ __poText(pickFirst(record.payment_type, record.paymentType)).trim().toLowerCase() === 'recurring'
905
+ var defaultNet = ctx.defaultVariant
906
+ ? optionChargedNet(ctx.defaultVariant, ctx)
907
+ : __pdDiscountedPrice(ctx.basePrice, ctx.activeDiscount)
908
+ var fields = emptyProductOptionFields()
909
+ fields.hasProductOptions = 'true'
910
+ fields.optionsRequireInput = __poRequiresInput(groups) ? 'true' : 'false'
911
+ fields.optionsQuickAdd = __poQuickAdd(groups, defaultNet, pricesDisabled) ? 'true' : 'false'
912
+ if (!ctx.includeDetail) return fields
913
+
914
+ var localized = __poLocalize({ main: groups, clone: readOptionGroupsClone(record, ctx) })
915
+ var initial = __poResolve(defaultNet, localized, __poDefaultEntries(localized), pricesDisabled)
916
+ var view = {
917
+ productId: ctx.productId,
918
+ freshKey: OPTION_STATE_KEY_PREFIX.fresh + ctx.productId,
919
+ // The default answers: which price variant is in force before the shopper
920
+ // touches anything.
921
+ selection: __poDefaultSelection(localized),
922
+ visibleKeys: initial.visibleKeys,
923
+ pricesDisabled: pricesDisabled,
924
+ symbol: ctx.symbol,
925
+ position: ctx.position,
926
+ assetMap: ctx.assetMap,
927
+ }
928
+ var defaultOriginalNet = ctx.defaultVariant
929
+ ? optionListNet(ctx.defaultVariant, ctx)
930
+ : __pdDiscountedPrice(ctx.basePrice, null)
931
+ var optionGroups = []
932
+ for (var gi = 0; gi < localized.length; gi++) {
933
+ optionGroups.push(buildOptionGroupView(localized[gi], view))
934
+ }
935
+ fields.optionGroups = optionGroups
936
+ fields.optionGroupsJson = __poSerialize(withResolvedOptionValueImages(localized, ctx.assetMap)) || '[]'
937
+ fields.optionsPricingJson = JSON.stringify(buildOptionPricing(ctx, pricesDisabled))
938
+ fields.optionsDefaultDisplayPrice = optionDisplayMoney(defaultNet + initial.delta)
939
+ fields.optionsDefaultOriginalDisplayPrice = ctx.activeDiscount
940
+ ? optionDisplayMoney(defaultOriginalNet + initial.delta)
941
+ : ''
942
+ fields.optionsDefaultSummary = initial.label
943
+ fields.optionsFreshKey = view.freshKey
944
+ fields.optionsValidKey = OPTION_STATE_KEY_PREFIX.valid + ctx.productId
945
+ fields.optionsOriginalKey = OPTION_STATE_KEY_PREFIX.original + ctx.productId
946
+ fields.optionsSummaryKey = OPTION_STATE_KEY_PREFIX.summary + ctx.productId
947
+ fields.optionsFilesKey = OPTION_STATE_KEY_PREFIX.files + ctx.productId
948
+ return fields
949
+ }
950
+
951
+ function buildOptionGroupView(group, view) {
952
+ var type = group.type
953
+ var isChoice = type === 'choice'
954
+ var isMulti = type === 'multi-choice'
955
+ var isToggle = type === 'toggle'
956
+ var listsValues = isChoice || isMulti
957
+ var isSwatches = listsValues && group.display === 'swatches'
958
+ var isCards = listsValues && group.display === 'cards'
959
+ var isSelectable = listsValues || isToggle
960
+ var scope = view.productId + '|' + group.key
961
+ var priceVariants =
962
+ __poPricedTypes.indexOf(type) !== -1
963
+ ? buildOptionPriceVariants(group, group.key, null, type === 'number' ? group.unit : undefined, view)
964
+ : []
965
+ var acceptKind = type === 'upload' ? group.accept || 'any' : ''
966
+ var values = []
967
+ if (listsValues) {
968
+ var groupValues = group.values || []
969
+ for (var vi = 0; vi < groupValues.length; vi++) {
970
+ values.push(buildOptionValueView(group, groupValues[vi], isSwatches, isCards, view))
971
+ }
972
+ } else if (isToggle) {
973
+ values.push(buildOptionToggleValue(group, priceVariants, view))
974
+ }
975
+ var input = describeOptionInput(group)
976
+ return {
977
+ id: scope,
978
+ key: group.key,
979
+ label: group.label,
980
+ helpText: group.helpText || '',
981
+ placeholder: group.placeholder || '',
982
+ unit: group.unit || '',
983
+ required: group.required === true ? 'true' : 'false',
984
+ isChoice: isChoice ? 'true' : 'false',
985
+ isMulti: isMulti ? 'true' : 'false',
986
+ isText: type === 'text' ? 'true' : 'false',
987
+ isLongText: type === 'long-text' ? 'true' : 'false',
988
+ isNumber: type === 'number' ? 'true' : 'false',
989
+ isToggle: isToggle ? 'true' : 'false',
990
+ isUpload: type === 'upload' ? 'true' : 'false',
991
+ isButtons: isSelectable && !isSwatches && !isCards ? 'true' : 'false',
992
+ isSwatches: isSwatches ? 'true' : 'false',
993
+ isCards: isCards ? 'true' : 'false',
994
+ isSelectable: isSelectable ? 'true' : 'false',
995
+ isInput: type === 'text' || type === 'number' ? 'true' : 'false',
996
+ initiallyVisible: view.visibleKeys.indexOf(group.key) !== -1 ? 'true' : 'false',
997
+ cellRole: isChoice ? 'radio' : isMulti ? 'checkbox' : isToggle ? 'switch' : '',
998
+ rowRole: isChoice ? 'radiogroup' : 'group',
999
+ // aria-required is allowed on a radiogroup only: a group row gets null, which
1000
+ // renders no attribute (never undefined, which getStaticProps cannot serialize).
1001
+ rowRequired: isChoice ? (group.required === true ? 'true' : 'false') : null,
1002
+ idleTabIndex: isChoice ? '-1' : '0',
1003
+ inputType: input.inputType,
1004
+ inputMode: input.inputMode,
1005
+ maxLengthText: input.maxLengthText,
1006
+ minText: input.minText,
1007
+ maxText: input.maxText,
1008
+ stepText: input.stepText,
1009
+ defaultValueText: input.defaultValueText,
1010
+ rangeHint: input.rangeHint,
1011
+ acceptKind: acceptKind,
1012
+ acceptMime: acceptKind ? __poUploadMime[acceptKind].join(',') : '',
1013
+ priceVariants: priceVariants,
1014
+ headerPriceVariants: isSelectable ? [] : priceVariants,
1015
+ hiddenKey: OPTION_STATE_KEY_PREFIX.hidden + scope,
1016
+ shownKey: OPTION_STATE_KEY_PREFIX.shown + scope,
1017
+ missingKey: OPTION_STATE_KEY_PREFIX.missing + scope,
1018
+ invalidKey: OPTION_STATE_KEY_PREFIX.invalid + scope,
1019
+ rejectedKey: OPTION_STATE_KEY_PREFIX.rejected + scope,
1020
+ failedKey: OPTION_STATE_KEY_PREFIX.failed + scope,
1021
+ uploadedKey: OPTION_STATE_KEY_PREFIX.uploaded + scope,
1022
+ // Product-level: one upload at a time blocks every upload button.
1023
+ uploadingKey: OPTION_STATE_KEY_PREFIX.uploading + view.productId,
1024
+ freshKey: view.freshKey,
1025
+ values: values,
1026
+ }
1027
+ }
1028
+
1029
+ // The groups as the page's resolver reads them (data-option-groups), with each
1030
+ // value's picture as a URL the browser can load: the shopper's choices hand it
1031
+ // on to anything on the page that shows the chosen value's picture. A picture
1032
+ // that resolves to nothing is dropped, never shipped as a bare asset id. TWIN
1033
+ // of withResolvedValueImages in the GUI's product-options-storefront.ts.
1034
+ function withResolvedOptionValueImages(groups, assetMap) {
1035
+ var out = []
1036
+ for (var gi = 0; gi < groups.length; gi++) {
1037
+ var group = groups[gi]
1038
+ if (!Array.isArray(group.values)) {
1039
+ out.push(group)
1040
+ continue
1041
+ }
1042
+ var values = []
1043
+ for (var vi = 0; vi < group.values.length; vi++) {
1044
+ var value = group.values[vi]
1045
+ if (!value.imageUrl) {
1046
+ values.push(value)
1047
+ continue
1048
+ }
1049
+ var valueCopy = {}
1050
+ for (var vk in value) {
1051
+ if (Object.prototype.hasOwnProperty.call(value, vk) && vk !== 'imageUrl') valueCopy[vk] = value[vk]
1052
+ }
1053
+ var resolved = resolveMediaUrl(value.imageUrl, assetMap) || ''
1054
+ if (resolved) valueCopy.imageUrl = resolved
1055
+ values.push(valueCopy)
1056
+ }
1057
+ var groupCopy = {}
1058
+ for (var gk in group) {
1059
+ if (Object.prototype.hasOwnProperty.call(group, gk)) groupCopy[gk] = group[gk]
1060
+ }
1061
+ groupCopy.values = values
1062
+ out.push(groupCopy)
1063
+ }
1064
+ return out
1065
+ }
1066
+
1067
+ function buildOptionValueView(group, value, isSwatches, isCards, view) {
1068
+ var scope = view.productId + '|' + group.key + '|' + value.key
1069
+ var color = value.color || ''
1070
+ // An asset id that resolves to nothing is never shipped as a bare id.
1071
+ var imageUrl = value.imageUrl ? resolveMediaUrl(value.imageUrl, view.assetMap) || '' : ''
1072
+ return {
1073
+ id: scope,
1074
+ key: value.key,
1075
+ label: value.label,
1076
+ description: value.description || '',
1077
+ color: color,
1078
+ imageUrl: imageUrl,
1079
+ isDefault: value['default'] === true ? 'true' : 'false',
1080
+ showSwatch: isSwatches && color !== '' ? 'true' : 'false',
1081
+ showImage: isCards && imageUrl !== '' ? 'true' : 'false',
1082
+ selectedKey: OPTION_STATE_KEY_PREFIX.selected + scope,
1083
+ freshKey: view.freshKey,
1084
+ priceVariants: buildOptionPriceVariants(value, group.key, value.key, undefined, view),
1085
+ }
1086
+ }
1087
+
1088
+ // A toggle is one switch cell: its label is the group's, its price the group's price.
1089
+ function buildOptionToggleValue(group, priceVariants, view) {
1090
+ var scope = view.productId + '|' + group.key + '|on'
1091
+ return {
1092
+ id: scope,
1093
+ key: 'on',
1094
+ label: group.label,
1095
+ description: '',
1096
+ color: '',
1097
+ imageUrl: '',
1098
+ isDefault: 'false',
1099
+ showSwatch: 'false',
1100
+ showImage: 'false',
1101
+ selectedKey: OPTION_STATE_KEY_PREFIX.selected + scope,
1102
+ freshKey: view.freshKey,
1103
+ priceVariants: priceVariants,
1104
+ }
1105
+ }
1106
+
1107
+ // One entry per priceWhen override (in order), then the default price ('d').
1108
+ // The configurator shows the entry whose activeKey the resolver wrote, or the
1109
+ // isInitial one before the shopper touched anything.
1110
+ function buildOptionPriceVariants(target, groupKey, valueKey, unit, view) {
1111
+ var initial = __poEffectiveVariant(target, view.selection, view.visibleKeys)
1112
+ var overrides = Array.isArray(target.priceWhen) ? target.priceWhen : []
1113
+ var variants = []
1114
+ for (var oi = 0; oi < overrides.length; oi++) {
1115
+ variants.push(buildOptionPriceVariant(overrides[oi].price, oi, initial, groupKey, valueKey, unit, view))
1116
+ }
1117
+ variants.push(buildOptionPriceVariant(target.price, 'd', initial, groupKey, valueKey, unit, view))
1118
+ return variants
1119
+ }
1120
+
1121
+ function buildOptionPriceVariant(rule, variant, initial, groupKey, valueKey, unit, view) {
1122
+ return {
1123
+ activeKey: __poPriceVariantKey(view.productId, groupKey, valueKey, variant),
1124
+ label: view.pricesDisabled
1125
+ ? ''
1126
+ : __poPriceLabel(rule, view.symbol, view.position, STOREFRONT_TAX_RATE, unit),
1127
+ isInitial: variant === initial ? 'true' : 'false',
1128
+ // Repeated on every variant: the badge gates inside this list's mapper can
1129
+ // only read the list's own item.
1130
+ freshKey: view.freshKey,
1131
+ }
1132
+ }
1133
+
1134
+ function describeOptionInput(group) {
1135
+ var input = {
1136
+ inputType: '',
1137
+ inputMode: '',
1138
+ maxLengthText: '',
1139
+ minText: '',
1140
+ maxText: '',
1141
+ stepText: '',
1142
+ defaultValueText: '',
1143
+ rangeHint: '',
1144
+ }
1145
+ if (group.type === 'text' || group.type === 'long-text') {
1146
+ var limit = group.type === 'text' ? __poLimits.text : __poLimits.longText
1147
+ input.inputType = 'text'
1148
+ input.inputMode = 'text'
1149
+ input.maxLengthText = String(typeof group.maxLength === 'number' ? Math.min(limit, group.maxLength) : limit)
1150
+ } else if (group.type === 'number') {
1151
+ input.inputType = 'number'
1152
+ input.inputMode = 'decimal'
1153
+ input.minText = typeof group.min === 'number' ? String(group.min) : ''
1154
+ input.maxText = typeof group.max === 'number' ? String(group.max) : ''
1155
+ input.stepText = typeof group.step === 'number' ? String(group.step) : 'any'
1156
+ input.defaultValueText = typeof group.defaultValue === 'number' ? String(group.defaultValue) : ''
1157
+ input.rangeHint = describeOptionRange(input.minText, input.maxText, group.unit || '')
1158
+ }
1159
+ return input
1160
+ }
1161
+
1162
+ // "10 – 200 cm", "≥ 10 cm", "≤ 200 cm"; '' without bounds.
1163
+ function describeOptionRange(minText, maxText, unit) {
1164
+ var range = ''
1165
+ if (minText && maxText) range = minText + ' – ' + maxText
1166
+ else if (minText) range = '≥ ' + minText
1167
+ else if (maxText) range = '≤ ' + maxText
1168
+ return range && unit ? range + ' ' + unit : range
1169
+ }
1170
+
1171
+ // What the configurator's resolver needs to price the combination the shopper
1172
+ // picks in the variant picker: every combination, charged and list NET.
1173
+ function buildOptionPricing(ctx, pricesDisabled) {
1174
+ var discounted = !!ctx.activeDiscount
1175
+ var variants = []
1176
+ for (var pi = 0; pi < ctx.variants.length; pi++) {
1177
+ var variant = ctx.variants[pi]
1178
+ variants.push({
1179
+ id: variant.id,
1180
+ options: variant.options,
1181
+ net: optionChargedNet(variant, ctx),
1182
+ originalNet: discounted ? optionListNet(variant, ctx) : null,
1183
+ })
1184
+ }
1185
+ return {
1186
+ productId: ctx.productId,
1187
+ netBase: __pdDiscountedPrice(ctx.basePrice, ctx.activeDiscount),
1188
+ originalNetBase: discounted ? __pdDiscountedPrice(ctx.basePrice, null) : null,
1189
+ taxRate: STOREFRONT_TAX_RATE,
1190
+ symbol: ctx.symbol,
1191
+ position: ctx.position,
1192
+ pricesDisabled: pricesDisabled,
1193
+ defaultCombinationId: ctx.defaultVariant ? ctx.defaultVariant.id : '',
1194
+ variants: variants,
1195
+ }
1196
+ }
1197
+
1198
+ // A combination's own price, else the product's (a null price inherits).
1199
+ function optionVariantListPrice(variant, basePrice) {
1200
+ return typeof variant.price === 'number' && isFinite(variant.price) ? variant.price : basePrice
1201
+ }
1202
+ function optionChargedNet(variant, ctx) {
1203
+ return __pdDiscountedPrice(optionVariantListPrice(variant, ctx.basePrice), ctx.activeDiscount)
1204
+ }
1205
+ function optionListNet(variant, ctx) {
1206
+ return __pdDiscountedPrice(optionVariantListPrice(variant, ctx.basePrice), null)
1207
+ }
1208
+
1209
+ function readOptionGroupsClone(record, ctx) {
1210
+ if (!ctx.currentLanguage || !ctx.mainLanguage || ctx.currentLanguage === ctx.mainLanguage) return null
1211
+ return record[ctx.currentLanguage + '_option_groups']
1212
+ }
1213
+
1214
+ // A unit price as the shopper reads it: NET rounded to the cent, then gross.
1215
+ function optionDisplayMoney(net) {
1216
+ return applyStorefrontTax(__poRound2(net)).toFixed(2)
1217
+ }
1218
+
656
1219
  // Parse + normalize the variant_options JSON into render-ready axes. Tolerant of
657
1220
  // null / malformed / partial data (returns []). Keeps ONLY the fields the pill
658
1221
  // widget needs; the labels are already language-resolved by the caller.