@teleporthq/teleport-plugin-next-data-source 0.43.57 → 0.43.60

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 (147) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
  2. package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
  3. package/__tests__/ecommerce-product-discounts.test.ts +196 -0
  4. package/__tests__/ecommerce-product-model.test.ts +179 -0
  5. package/__tests__/infinite-scroll.test.ts +143 -0
  6. package/__tests__/numbered-pagination.test.ts +113 -0
  7. package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
  8. package/__tests__/page-url-sync.test.ts +208 -0
  9. package/__tests__/product-price-sort.ts +313 -0
  10. package/__tests__/search-url-sync.test.ts +7 -3
  11. package/dist/cjs/array-mapper-pagination.d.ts +9 -0
  12. package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
  13. package/dist/cjs/array-mapper-pagination.js.map +1 -1
  14. package/dist/cjs/count-effect.d.ts +42 -0
  15. package/dist/cjs/count-effect.d.ts.map +1 -0
  16. package/dist/cjs/count-effect.js +81 -0
  17. package/dist/cjs/count-effect.js.map +1 -0
  18. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/postgresql.js +2 -1
  20. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  21. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/teleport.js +3 -2
  23. package/dist/cjs/fetchers/teleport.js.map +1 -1
  24. package/dist/cjs/infinite-scroll.d.ts +65 -0
  25. package/dist/cjs/infinite-scroll.d.ts.map +1 -0
  26. package/dist/cjs/infinite-scroll.js +190 -0
  27. package/dist/cjs/infinite-scroll.js.map +1 -0
  28. package/dist/cjs/numbered-pagination.d.ts +42 -0
  29. package/dist/cjs/numbered-pagination.d.ts.map +1 -0
  30. package/dist/cjs/numbered-pagination.js +167 -0
  31. package/dist/cjs/numbered-pagination.js.map +1 -0
  32. package/dist/cjs/page-reset.d.ts +42 -0
  33. package/dist/cjs/page-reset.d.ts.map +1 -0
  34. package/dist/cjs/page-reset.js +116 -0
  35. package/dist/cjs/page-reset.js.map +1 -0
  36. package/dist/cjs/page-url-sync.d.ts +42 -0
  37. package/dist/cjs/page-url-sync.d.ts.map +1 -0
  38. package/dist/cjs/page-url-sync.js +180 -0
  39. package/dist/cjs/page-url-sync.js.map +1 -0
  40. package/dist/cjs/pagination-controls.d.ts +55 -0
  41. package/dist/cjs/pagination-controls.d.ts.map +1 -0
  42. package/dist/cjs/pagination-controls.js +159 -0
  43. package/dist/cjs/pagination-controls.js.map +1 -0
  44. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  45. package/dist/cjs/pagination-plugin.js +506 -160
  46. package/dist/cjs/pagination-plugin.js.map +1 -1
  47. package/dist/cjs/product-price-sort.d.ts +116 -0
  48. package/dist/cjs/product-price-sort.d.ts.map +1 -0
  49. package/dist/cjs/product-price-sort.js +169 -0
  50. package/dist/cjs/product-price-sort.js.map +1 -0
  51. package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
  52. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  53. package/dist/cjs/transformations/ecommerce-product.js +2 -1
  54. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  55. package/dist/cjs/transformations/index.d.ts +2 -1
  56. package/dist/cjs/transformations/index.d.ts.map +1 -1
  57. package/dist/cjs/transformations/index.js +17 -1
  58. package/dist/cjs/transformations/index.js.map +1 -1
  59. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  60. package/dist/cjs/transformations/shared-utils.js +1 -1
  61. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  62. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  63. package/dist/esm/array-mapper-pagination.d.ts +9 -0
  64. package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
  65. package/dist/esm/array-mapper-pagination.js.map +1 -1
  66. package/dist/esm/count-effect.d.ts +42 -0
  67. package/dist/esm/count-effect.d.ts.map +1 -0
  68. package/dist/esm/count-effect.js +53 -0
  69. package/dist/esm/count-effect.js.map +1 -0
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +2 -1
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  74. package/dist/esm/fetchers/teleport.js +3 -2
  75. package/dist/esm/fetchers/teleport.js.map +1 -1
  76. package/dist/esm/infinite-scroll.d.ts +65 -0
  77. package/dist/esm/infinite-scroll.d.ts.map +1 -0
  78. package/dist/esm/infinite-scroll.js +159 -0
  79. package/dist/esm/infinite-scroll.js.map +1 -0
  80. package/dist/esm/numbered-pagination.d.ts +42 -0
  81. package/dist/esm/numbered-pagination.d.ts.map +1 -0
  82. package/dist/esm/numbered-pagination.js +138 -0
  83. package/dist/esm/numbered-pagination.js.map +1 -0
  84. package/dist/esm/page-reset.d.ts +42 -0
  85. package/dist/esm/page-reset.d.ts.map +1 -0
  86. package/dist/esm/page-reset.js +87 -0
  87. package/dist/esm/page-reset.js.map +1 -0
  88. package/dist/esm/page-url-sync.d.ts +42 -0
  89. package/dist/esm/page-url-sync.d.ts.map +1 -0
  90. package/dist/esm/page-url-sync.js +152 -0
  91. package/dist/esm/page-url-sync.js.map +1 -0
  92. package/dist/esm/pagination-controls.d.ts +55 -0
  93. package/dist/esm/pagination-controls.d.ts.map +1 -0
  94. package/dist/esm/pagination-controls.js +126 -0
  95. package/dist/esm/pagination-controls.js.map +1 -0
  96. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  97. package/dist/esm/pagination-plugin.js +507 -161
  98. package/dist/esm/pagination-plugin.js.map +1 -1
  99. package/dist/esm/product-price-sort.d.ts +116 -0
  100. package/dist/esm/product-price-sort.d.ts.map +1 -0
  101. package/dist/esm/product-price-sort.js +160 -0
  102. package/dist/esm/product-price-sort.js.map +1 -0
  103. package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
  104. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  105. package/dist/esm/transformations/ecommerce-product.js +3 -2
  106. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  107. package/dist/esm/transformations/index.d.ts +2 -1
  108. package/dist/esm/transformations/index.d.ts.map +1 -1
  109. package/dist/esm/transformations/index.js +17 -1
  110. package/dist/esm/transformations/index.js.map +1 -1
  111. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  112. package/dist/esm/transformations/shared-utils.js +1 -1
  113. package/dist/esm/transformations/shared-utils.js.map +1 -1
  114. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +5 -5
  116. package/src/array-mapper-pagination.ts +10 -0
  117. package/src/count-effect.ts +137 -0
  118. package/src/fetchers/postgresql.ts +52 -13
  119. package/src/fetchers/teleport.ts +53 -13
  120. package/src/infinite-scroll.ts +401 -0
  121. package/src/numbered-pagination.ts +292 -0
  122. package/src/page-reset.ts +162 -0
  123. package/src/page-url-sync.ts +241 -0
  124. package/src/pagination-controls.ts +176 -0
  125. package/src/pagination-plugin.ts +755 -346
  126. package/src/product-price-sort.ts +204 -0
  127. package/src/transformations/ecommerce-product.ts +141 -10
  128. package/src/transformations/index.ts +19 -1
  129. package/src/transformations/shared-utils.ts +14 -0
  130. package/dist/cjs/array-mapper-registry.d.ts +0 -85
  131. package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
  132. package/dist/cjs/array-mapper-registry.js +0 -296
  133. package/dist/cjs/array-mapper-registry.js.map +0 -1
  134. package/dist/cjs/pagination-with-count.d.ts +0 -6
  135. package/dist/cjs/pagination-with-count.d.ts.map +0 -1
  136. package/dist/cjs/pagination-with-count.js +0 -63
  137. package/dist/cjs/pagination-with-count.js.map +0 -1
  138. package/dist/esm/array-mapper-registry.d.ts +0 -85
  139. package/dist/esm/array-mapper-registry.d.ts.map +0 -1
  140. package/dist/esm/array-mapper-registry.js +0 -292
  141. package/dist/esm/array-mapper-registry.js.map +0 -1
  142. package/dist/esm/pagination-with-count.d.ts +0 -6
  143. package/dist/esm/pagination-with-count.d.ts.map +0 -1
  144. package/dist/esm/pagination-with-count.js +0 -34
  145. package/dist/esm/pagination-with-count.js.map +0 -1
  146. package/src/array-mapper-registry.ts +0 -416
  147. package/src/pagination-with-count.ts +0 -89
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Ordering a products list by the price customers actually pay.
3
+ *
4
+ * A per-product discount is a SCHEDULED VIEW of the price: `teleport_products.
5
+ * price` holds the list price and never moves, while `teleport_products.
6
+ * discounts` holds a JSON array of markdowns with `[startsAt, endsAt)` windows.
7
+ * Every display surface resolves that at read time.
8
+ *
9
+ * Sorting is the one surface that cannot, because it happens in SQL before the
10
+ * rows ever reach JavaScript — and the list is paginated, so re-sorting the
11
+ * fetched page would only reorder 20 of N products. The ordering therefore has
12
+ * to be computed by the database, which is what this expression does: an inline
13
+ * scalar sub-select over the JSON column, evaluated per query against `now()`.
14
+ *
15
+ * Deliberately NOT a stored column. A materialised "effective price" is correct
16
+ * only until a schedule boundary passes with nobody writing that row, and
17
+ * keeping it fresh needs a job no generated store has.
18
+ *
19
+ * ## The expression cannot raise
20
+ *
21
+ * It runs inside `ORDER BY` for the whole products table, so one unparseable
22
+ * row would fail the entire query and empty the page. Every cast is therefore
23
+ * guarded:
24
+ *
25
+ * - the JSON cast only happens for text that already looks like an array;
26
+ * - `value` is cast only when it matches a bare decimal;
27
+ * - the window bounds are compared as TEXT, never cast to a timestamp. The
28
+ * editor writes them with `toISOString()`, whose fixed-width UTC format
29
+ * (`YYYY-MM-DDTHH:MM:SS.sssZ`) sorts lexicographically in the same order it
30
+ * sorts chronologically.
31
+ *
32
+ * The caller adds a second net: a query using this expression falls back to a
33
+ * plain column sort if the database rejects it, so an unforeseen shape degrades
34
+ * to the previous ordering rather than to a broken page.
35
+ */
36
+
37
+ /** The e-commerce products table — the only table this ordering applies to. */
38
+ export const PRODUCTS_TABLE_NAME = 'teleport_products'
39
+
40
+ /** The sort field the storefront's "Price (low to high / high to low)" emits. */
41
+ export const PRODUCT_PRICE_SORT_FIELD = 'price'
42
+
43
+ /**
44
+ * The sort field the storefront's "Discount (low to high / high to low)" emits.
45
+ *
46
+ * Deliberately the name of a REAL column rather than a virtual one like
47
+ * `discount`. The same field travels to sixteen different fetchers and to the
48
+ * editor's own data API, none of which know about this rewrite — and a name
49
+ * that is not a column becomes `ORDER BY discount` and fails the whole query.
50
+ * Using the column the markdowns are stored in means the worst any unaware
51
+ * backend can do is order by the raw JSON: arbitrary, but never an error.
52
+ */
53
+ export const PRODUCT_DISCOUNT_SORT_FIELD = 'discounts'
54
+
55
+ /**
56
+ * The column a rewritten sort falls back to when the expression cannot run —
57
+ * guaranteed to exist on the products table, which `discounts` itself is not on
58
+ * a store provisioned before discounts shipped.
59
+ */
60
+ export const PRODUCT_SORT_FALLBACK_COLUMN = 'price'
61
+
62
+ /**
63
+ * `now()` as the same fixed-width UTC string the editor stores its bounds in,
64
+ * so the window comparison is pure text and can never raise on a bad date.
65
+ */
66
+ const NOW_AS_ISO_TEXT = `to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`
67
+
68
+ /**
69
+ * Only text that already looks like a JSON array is cast; anything else — NULL,
70
+ * empty, or a hand-edited scalar — reads as "no discounts".
71
+ */
72
+ const SAFE_DISCOUNTS_JSONB = `CASE
73
+ WHEN "discounts" IS NULL OR btrim("discounts") NOT LIKE '[%' THEN '[]'::jsonb
74
+ ELSE "discounts"::jsonb
75
+ END`
76
+
77
+ /**
78
+ * The price a product sells for right now, for use in an `ORDER BY` over
79
+ * `teleport_products`. Falls back to the list price when no markdown is live.
80
+ *
81
+ * Mirrors `resolveActiveDiscount` + `computeDiscountedPrice`: the FIRST live
82
+ * entry in array order wins (hence `WITH ORDINALITY` and `ORDER BY ord`),
83
+ * unknown types and non-positive values are ignored, a percentage is capped at
84
+ * 100, and the result is floored at zero and rounded to the cent.
85
+ */
86
+ export const buildProductEffectivePriceSql = (): string =>
87
+ `COALESCE((
88
+ SELECT ROUND(GREATEST(0,
89
+ CASE
90
+ WHEN entry.kind = 'percentage'
91
+ THEN "price"::numeric * (1 - LEAST(entry.amount, 100) / 100)
92
+ ELSE "price"::numeric - entry.amount
93
+ END
94
+ ), 2)
95
+ FROM (
96
+ SELECT
97
+ d.value->>'type' AS kind,
98
+ CASE
99
+ WHEN d.value->>'value' ~ '^[0-9]+(\\.[0-9]+)?$' THEN (d.value->>'value')::numeric
100
+ ELSE 0
101
+ END AS amount,
102
+ d.value->>'startsAt' AS starts_at,
103
+ d.value->>'endsAt' AS ends_at,
104
+ d.ord AS ord
105
+ FROM jsonb_array_elements(${SAFE_DISCOUNTS_JSONB}) WITH ORDINALITY AS d(value, ord)
106
+ ) AS entry
107
+ WHERE entry.amount > 0
108
+ AND entry.kind IN ('percentage', 'fixed')
109
+ AND (entry.starts_at IS NULL OR entry.starts_at <= ${NOW_AS_ISO_TEXT})
110
+ AND (entry.ends_at IS NULL OR entry.ends_at > ${NOW_AS_ISO_TEXT})
111
+ ORDER BY entry.ord
112
+ LIMIT 1
113
+ ), "price"::numeric)`
114
+
115
+ /**
116
+ * How much a product is marked down RIGHT NOW, as a percentage of its list
117
+ * price — the metric "sort by discount" means.
118
+ *
119
+ * A percentage rather than an absolute saving, because it is the only figure
120
+ * comparable across a mixed catalogue: $20 off a $2000 sofa is a worse deal
121
+ * than $5 off a $10 mug, and sorting by the absolute amount would rank them the
122
+ * other way round.
123
+ *
124
+ * Zero when nothing is live, so undiscounted products collect at one end
125
+ * instead of scattering. `price > 0` is the divide-by-zero guard AND the NULL
126
+ * guard — `NULL > 0` is NULL, which is not true, so a priceless row takes the
127
+ * ELSE branch.
128
+ */
129
+ export const buildProductDiscountPercentageSql = (): string =>
130
+ `CASE
131
+ WHEN "price"::numeric > 0
132
+ THEN ROUND(((("price"::numeric - ${buildProductEffectivePriceSql()}) / "price"::numeric) * 100), 4)
133
+ ELSE 0
134
+ END`
135
+
136
+ /** Whether a generated fetcher for `tableName` should order prices by the markdown. */
137
+ export const supportsProductPriceSort = (tableName: string): boolean =>
138
+ tableName === PRODUCTS_TABLE_NAME
139
+
140
+ /**
141
+ * The complete `sortFieldSql` helper a SQL fetcher embeds in its generated
142
+ * route.
143
+ *
144
+ * Returned as one ready-made string, and the SQL is emitted through
145
+ * `JSON.stringify` — a double-quoted JS literal carries no backticks and no
146
+ * `${`, so it can be interpolated into a fetcher's template literal without any
147
+ * nested-escaping hazard. (Emitting it inline is how this first went wrong: a
148
+ * backtick inside an explanatory comment closed the enclosing template.)
149
+ *
150
+ * On a table that is not the products table the helper still exists but is the
151
+ * identity, so a fetcher can embed it unconditionally.
152
+ */
153
+ export const generateSortFieldSqlHelper = (tableName: string): string => {
154
+ const branches = supportsProductPriceSort(tableName)
155
+ ? [
156
+ ` if (field === ${JSON.stringify(PRODUCT_PRICE_SORT_FIELD)}) {`,
157
+ ` return ${JSON.stringify(buildProductEffectivePriceSql())}`,
158
+ ` }`,
159
+ ` if (field === ${JSON.stringify(PRODUCT_DISCOUNT_SORT_FIELD)}) {`,
160
+ ` return ${JSON.stringify(buildProductDiscountPercentageSql())}`,
161
+ ` }`,
162
+ ].join('\n') + '\n'
163
+ : ''
164
+
165
+ return [
166
+ '// Maps a requested sort field to the SQL that orders by it. Only the',
167
+ "// products table's price and discounts are rewritten - into the price",
168
+ '// customers actually pay today, and into how far each product is marked',
169
+ '// down. Every other field is passed through, so this can never change an',
170
+ '// unrelated sort.',
171
+ 'function sortFieldSql(field) {',
172
+ branches + ' return field',
173
+ '}',
174
+ ].join('\n')
175
+ }
176
+
177
+ /**
178
+ * The plain-column ORDER BY used when the rewritten expression cannot run.
179
+ *
180
+ * Both rewritten fields fall back to `price`, never to themselves: `discounts`
181
+ * is the JSON column, and ordering a page by raw JSON is meaningless — while a
182
+ * store provisioned before discounts shipped has no such column at all, so
183
+ * retrying with it would fail exactly like the expression did.
184
+ */
185
+ export const generateSortFallbackFieldHelper = (tableName: string): string => {
186
+ const branches = supportsProductPriceSort(tableName)
187
+ ? ` if (field === ${JSON.stringify(PRODUCT_PRICE_SORT_FIELD)} || field === ${JSON.stringify(
188
+ PRODUCT_DISCOUNT_SORT_FIELD
189
+ )}) {\n return ${JSON.stringify(PRODUCT_SORT_FALLBACK_COLUMN)}\n }\n`
190
+ : ''
191
+
192
+ return ['function sortFallbackField(field) {', branches + ' return field', '}'].join('\n')
193
+ }
194
+
195
+ /**
196
+ * A deterministic tiebreaker appended to every products ORDER BY.
197
+ *
198
+ * Without one, a paginated sort with ties — and "sort by discount" ties every
199
+ * undiscounted product at zero — has no defined order between equal rows, so
200
+ * the same product can appear on two pages or on none. `id` is on the products
201
+ * table from its first migration.
202
+ */
203
+ export const generateSortTiebreakSql = (tableName: string): string =>
204
+ supportsProductPriceSort(tableName) ? ', "id" ASC' : ''
@@ -1,5 +1,5 @@
1
1
  import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
- import { StorefrontTax } from '@teleporthq/teleport-shared'
2
+ import { ProductDiscounts, StorefrontTax } from '@teleporthq/teleport-shared'
3
3
 
4
4
  /**
5
5
  * Everything the product transform needs baked in at export time, beyond the
@@ -14,6 +14,18 @@ export interface EcommerceProductTransformOptions {
14
14
  * Resolved by the caller through `StorefrontTax.resolveStorefrontTaxRate`.
15
15
  */
16
16
  storefrontTaxRate?: number
17
+ /**
18
+ * TRUE when stock levels must never gate purchasability in the storefront:
19
+ * the merchant disabled stock management entirely OR opted into backorders
20
+ * (`stockManagementConfig.allowBackorders`). Resolved by the caller through
21
+ * `buildProductTransformOptions`. Baked into the generated transform as
22
+ * `ALLOW_BACKORDERS`: `outOfStock` then always reads 'false' and every
23
+ * EXISTING variant combination counts as in stock — a combination that does
24
+ * not exist at all stays unavailable (there is nothing to backorder).
25
+ * Mirrors the GUI rule in
26
+ * `apps/gui/app/project-page/features/e-commerce/utils/product-variants.ts`.
27
+ */
28
+ allowBackorders?: boolean
17
29
  }
18
30
 
19
31
  /**
@@ -69,9 +81,16 @@ export const generateEcommerceProductTransformationCode = (
69
81
  const taxHelperCode = StorefrontTax.generateStorefrontTaxHelperCode(
70
82
  options.storefrontTaxRate ?? 0
71
83
  )
84
+ const allowBackordersLiteral = options.allowBackorders === true ? 'true' : 'false'
72
85
 
73
86
  return `
74
87
  ${taxHelperCode}
88
+ ${ProductDiscounts.generateProductDiscountHelperCode()}
89
+
90
+ // Baked from the merchant's stock settings: TRUE when stock never blocks a
91
+ // purchase (stock management off OR backorders allowed). Regenerating the
92
+ // project refreshes it — do not hand-edit. See EcommerceProductTransformOptions.
93
+ var ALLOW_BACKORDERS = ${allowBackordersLiteral}
75
94
 
76
95
  // Category taxonomy (id -> {name, slug, translations}), baked in at export
77
96
  // time — see flattenCategoriesById in ecommerce-product.ts. Empty when the
@@ -90,6 +109,50 @@ function resolveCategoryName(categoryInfo, currentLang, mainLang) {
90
109
  return categoryInfo.name
91
110
  }
92
111
 
112
+ // The merchant's "Show 3D model by default" toggle, as every backend may hand
113
+ // it back: a real boolean, 1/0, 'true'/'false'/'t'/'f' strings — and NULL /
114
+ // absent / '' as ON, because a product that has a model shows it unless the
115
+ // merchant switched it off. MUST mirror normalizeShowModelFlag in the GUI's
116
+ // features/e-commerce/constants/product-model.ts.
117
+ function normalizeShowModelFlag(raw) {
118
+ if (raw === null || raw === undefined || raw === '') return true
119
+ if (typeof raw === 'boolean') return raw
120
+ if (typeof raw === 'number') return raw !== 0
121
+ if (typeof raw === 'string') {
122
+ var token = raw.trim().toLowerCase()
123
+ return !(token === 'false' || token === 'f' || token === '0' || token === 'no' || token === 'off')
124
+ }
125
+ return true
126
+ }
127
+
128
+ // The product's media gallery in display order, HERO FIRST: the model when it
129
+ // is shown by default, otherwise the images with the model last. Each entry is
130
+ // { kind: 'image' | 'model', src, poster, thumbnail, alt }; 'thumbnail' is what
131
+ // a strip draws and is never empty for an entry that can appear in one. MUST
132
+ // mirror buildProductGalleryMedia in the GUI's
133
+ // features/e-commerce/utils/product-gallery-media.ts.
134
+ function buildProductGalleryMedia(params) {
135
+ var imageItems = []
136
+ for (var gi = 0; gi < params.images.length; gi++) {
137
+ imageItems.push({
138
+ kind: 'image',
139
+ src: params.images[gi],
140
+ poster: '',
141
+ thumbnail: params.images[gi],
142
+ alt: params.imageAlt || params.name,
143
+ })
144
+ }
145
+ if (!params.modelUrl) return imageItems
146
+ var modelItem = {
147
+ kind: 'model',
148
+ src: params.modelUrl,
149
+ poster: params.modelPoster || '',
150
+ thumbnail: params.modelPoster || params.mainImage || params.images[0] || '',
151
+ alt: params.name,
152
+ }
153
+ return params.showModel === 'true' ? [modelItem].concat(imageItems) : imageItems.concat([modelItem])
154
+ }
155
+
93
156
  // ============================================================
94
157
  // E-Commerce Product Transformation
95
158
  // ============================================================
@@ -315,6 +378,36 @@ function buildEcommerceProduct(record, options) {
315
378
  // Step 7: images = full resolved deduplicated array
316
379
  var images = resolvedImages
317
380
 
381
+ // -------------------------------------------------------
382
+ // 3D model (one per product; GUI: constants/product-model.ts)
383
+ // -------------------------------------------------------
384
+ // model_url / model_poster_url hold an asset id OR a raw URL like image_url.
385
+ // A poster the merchant never got (capture failed, plan cap) falls back to
386
+ // the main image so <model-viewer> always has something to paint first.
387
+ var modelUrl = resolveMediaUrl(record.model_url, assetMap)
388
+ var modelPoster = modelUrl ? resolveMediaUrl(record.model_poster_url, assetMap) || mainImage : null
389
+ var hasModel = modelUrl ? 'true' : 'false'
390
+ // STRING gate ('true'/'false') like outOfStock: whether the model replaces the
391
+ // main image on the card / details hero. A product whose only media is the
392
+ // model always shows it. An absent field must read as the IMAGE branch, so the
393
+ // builders gate the viewer on = 'true' and the image on != 'true'.
394
+ var showModel =
395
+ modelUrl && (normalizeShowModelFlag(record.show_model_by_default) || images.length === 0)
396
+ ? 'true'
397
+ : 'false'
398
+ var galleryMedia = buildProductGalleryMedia({
399
+ images: images,
400
+ mainImage: mainImage,
401
+ modelUrl: modelUrl,
402
+ modelPoster: modelPoster,
403
+ showModel: showModel,
404
+ imageAlt: imageAlt,
405
+ name: name,
406
+ })
407
+ // Everything that is not the hero — the details thumbnail strip. When the
408
+ // model is the hero the main image moves in here, so the modal stays reachable.
409
+ var galleryThumbnails = galleryMedia.slice(1)
410
+
318
411
  // Timestamps
319
412
  var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)
320
413
  var createdAt = normalizeTimestamp(rawCreatedAt)
@@ -332,7 +425,9 @@ function buildEcommerceProduct(record, options) {
332
425
  // string), leaving the Add to Cart button visible on out-of-stock
333
426
  // products. NULL/NaN quantity → 'false' (treated as "unlimited
334
427
  // stock", same as the upstream cart-availability rewriter).
335
- var outOfStock = (quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'
428
+ // With ALLOW_BACKORDERS the merchant sells past zero (or does not track
429
+ // stock at all), so nothing is ever presented as out of stock.
430
+ var outOfStock = (!ALLOW_BACKORDERS && quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'
336
431
 
337
432
  // Per-value availability + default-selection flags, derived from the
338
433
  // purchasable combinations, so the storefront picker renders its
@@ -378,24 +473,45 @@ function buildEcommerceProduct(record, options) {
378
473
  // gross, because nothing writes them back.
379
474
  //
380
475
  // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.
381
- var displayPrice = grossMoney(price)
476
+ // The scheduled markdown in force right now, resolved from the raw column on
477
+ // every read so a discount starts and expires on its own. It comes off the NET
478
+ // price BEFORE tax: tax is a percentage of what is actually charged.
479
+ var activeDiscount = __pdResolveActive(record.discounts, Date.now())
480
+ var displayPrice = grossMoney(__pdDiscountedPrice(price, activeDiscount))
481
+ var defaultVariantNetPrice = firstVariant
482
+ ? safeNumber(formatVariantPrice(firstVariant.price, price), 0)
483
+ : 0
382
484
  var defaultVariantDisplayPrice = firstVariant
383
- ? grossMoney(formatVariantPrice(firstVariant.price, price))
485
+ ? grossMoney(__pdDiscountedPrice(defaultVariantNetPrice, activeDiscount))
384
486
  : ''
385
487
  var variantsDisplay = []
386
488
  for (var dv = 0; dv < variants.length; dv++) {
387
489
  var dvRow = variants[dv]
490
+ // A null variant price INHERITS the product price, so discount and gross the
491
+ // RESOLVED value — otherwise an inheriting combination would fall back to
492
+ // the net base price in the picker while an overriding one showed gross.
493
+ var dvNetPrice = dvRow.price == null ? price : dvRow.price
388
494
  variantsDisplay.push({
389
495
  id: dvRow.id,
390
496
  options: dvRow.options,
391
- // A null variant price INHERITS the product price, so gross the RESOLVED
392
- // value — otherwise an inheriting combination would fall back to the net
393
- // base price in the picker while an overriding one showed gross.
394
- price: applyStorefrontTax(dvRow.price == null ? price : dvRow.price),
497
+ price: applyStorefrontTax(__pdDiscountedPrice(dvNetPrice, activeDiscount)),
498
+ // What this combination costs WITHOUT the discount, for the struck-through
499
+ // price beside it. Null when nothing is discounted, so the picker's click
500
+ // resolver can tell "no saving" from "a saving of zero".
501
+ originalPrice: activeDiscount ? applyStorefrontTax(dvNetPrice) : null,
395
502
  quantity: dvRow.quantity,
396
503
  image_url: dvRow.image_url,
397
504
  })
398
505
  }
506
+ // Discount display fields. Strings throughout: rendering conditions compare
507
+ // operands as strings, and a bound text node prints its value verbatim.
508
+ var hasDiscount = activeDiscount ? 'true' : 'false'
509
+ var discountLabel = activeDiscount
510
+ ? __pdDiscountLabel(activeDiscount, currencySymbol, getCurrencyInfo(currency).position)
511
+ : ''
512
+ var originalDisplayPrice = activeDiscount ? grossMoney(price) : ''
513
+ var originalDefaultVariantDisplayPrice =
514
+ activeDiscount && firstVariant ? grossMoney(defaultVariantNetPrice) : ''
399
515
  // Can this product be added to the cart at all, before the shopper touches the
400
516
  // picker? A STRING, same convention as outOfStock/requiresVariantSelection.
401
517
  //
@@ -457,6 +573,12 @@ function buildEcommerceProduct(record, options) {
457
573
  image_url: mainImage,
458
574
  galleryImages: galleryImages,
459
575
  images: images,
576
+ modelUrl: modelUrl,
577
+ modelPoster: modelPoster,
578
+ hasModel: hasModel,
579
+ showModel: showModel,
580
+ galleryMedia: galleryMedia,
581
+ galleryThumbnails: galleryThumbnails,
460
582
  createdAt: createdAt,
461
583
  updatedAt: updatedAt,
462
584
  created: created,
@@ -471,14 +593,23 @@ function buildEcommerceProduct(record, options) {
471
593
  defaultVariantPrice: defaultVariantPrice,
472
594
  displayPrice: displayPrice,
473
595
  defaultVariantDisplayPrice: defaultVariantDisplayPrice,
596
+ hasDiscount: hasDiscount,
597
+ discountLabel: discountLabel,
598
+ originalDisplayPrice: originalDisplayPrice,
599
+ originalDefaultVariantDisplayPrice: originalDefaultVariantDisplayPrice,
474
600
  hasPurchasableVariant: hasPurchasableVariant,
475
601
  }
476
602
  }
477
603
 
478
604
  // --- Variant availability helpers (mirror packages/renderer ecommerce-products) ---
479
- // A variant is in stock when its quantity is null (unlimited) or > 0.
605
+ // A variant is in stock when its quantity is null (unlimited) or > 0. With
606
+ // ALLOW_BACKORDERS every EXISTING combination is purchasable regardless of its
607
+ // quantity — but a missing combination (null) is still not in stock: there is
608
+ // no variant row to backorder.
480
609
  function isVariantInStock(v) {
481
- return !v ? false : v.quantity == null || v.quantity > 0
610
+ if (!v) return false
611
+ if (ALLOW_BACKORDERS) return true
612
+ return v.quantity == null || v.quantity > 0
482
613
  }
483
614
  function variantCoversAllAxes(v, axisKeys) {
484
615
  if (!v || !v.options) return false
@@ -9,19 +9,37 @@ import {
9
9
 
10
10
  export type { EcommerceProductTransformOptions }
11
11
 
12
+ /**
13
+ * Stock never gates purchasability when the merchant disabled stock management
14
+ * entirely or opted into backorders. Mirrors the GUI rule in
15
+ * `apps/gui/app/project-page/features/e-commerce/utils/product-variants.ts`
16
+ * (`isVariantInStock`). A project without e-commerce settings keeps the
17
+ * strict default (stock gates apply) — same behaviour as before the flag.
18
+ */
19
+ const resolveAllowBackorders = (
20
+ settings: GeneratorOptions['ecommerceSettings'] | undefined
21
+ ): boolean => {
22
+ if (!settings) {
23
+ return false
24
+ }
25
+ return !settings.stockManagement || settings.stockManagementConfig?.allowBackorders === true
26
+ }
27
+
12
28
  /**
13
29
  * Collects everything the product transform bakes in from the generator
14
30
  * options, in ONE place so every fetcher-emitting call site stays in step.
15
31
  *
16
32
  * `storefrontTaxRate` is what turns a NET catalogue price into the price the
17
33
  * shopper is quoted; `resolveStorefrontTaxRate` returns 0 (a no-op) for
18
- * tax-inclusive or untaxed stores.
34
+ * tax-inclusive or untaxed stores. `allowBackorders` is the effective
35
+ * "stock never blocks a purchase" flag — see `resolveAllowBackorders`.
19
36
  */
20
37
  export const buildProductTransformOptions = (
21
38
  options: Pick<GeneratorOptions, 'ecommerceSettings' | 'invoiceSettings'>
22
39
  ): EcommerceProductTransformOptions => ({
23
40
  categories: options.ecommerceSettings?.categories,
24
41
  storefrontTaxRate: StorefrontTax.resolveStorefrontTaxRate(options.invoiceSettings),
42
+ allowBackorders: resolveAllowBackorders(options.ecommerceSettings),
25
43
  })
26
44
 
27
45
  export type TransformationType = 'blog-post' | 'ecommerce-product' | null
@@ -234,6 +234,20 @@ function resolveAssetUrl(value, assetMap) {
234
234
  return value
235
235
  }
236
236
 
237
+ // Whether an asset-id resolution produced a real URL. resolveAssetUrl returns
238
+ // an UNKNOWN id verbatim (asset deleted, teleport_assets not mirrored yet) and
239
+ // a bare 'TQ_...' must never reach a <model-viewer src> or a poster.
240
+ function isResolvedMediaUrl(value) {
241
+ return typeof value === 'string' && (isUrl(value) || value.charAt(0) === '/')
242
+ }
243
+
244
+ // resolveAssetUrl for MEDIA that has no safe fallback: a value that did not
245
+ // resolve to a URL is reported as absent (null) instead of passed through.
246
+ function resolveMediaUrl(value, assetMap) {
247
+ var resolved = resolveAssetUrl(value, assetMap)
248
+ return isResolvedMediaUrl(resolved) ? resolved : null
249
+ }
250
+
237
251
  function resolveAssetUrls(values, assetMap) {
238
252
  if (!Array.isArray(values)) return []
239
253
  return values
@@ -1,85 +0,0 @@
1
- /**
2
- * ArrayMapperRegistry - Centralized registry for managing array mappers,
3
- * their associated states, search inputs, and pagination buttons.
4
- *
5
- * This ensures consistent index usage across:
6
- * - Component state declarations
7
- * - DataProvider params
8
- * - getStaticProps fetch calls and prop names
9
- * - Search input bindings
10
- * - Pagination button bindings
11
- */
12
- export type MapperType = 'paginated' | 'pagination-only' | 'search-only' | 'plain';
13
- export interface ArrayMapperRegistryEntry {
14
- registryIndex: number;
15
- type: MapperType;
16
- dataSourceIdentifier: string;
17
- arrayMapperRenderProp: string;
18
- uidlGlobalIndex: number;
19
- paginated: boolean;
20
- searchEnabled: boolean;
21
- perPage: number;
22
- searchDebounce: number;
23
- searchDefaultValue?: string;
24
- queryColumns: string[];
25
- stateVarPrefix: string;
26
- pageStateVar?: string;
27
- setPageStateVar?: string;
28
- maxPagesStateVar?: string;
29
- setMaxPagesStateVar?: string;
30
- combinedStateVar?: string;
31
- setCombinedStateVar?: string;
32
- searchQueryVar?: string;
33
- setSearchQueryVar?: string;
34
- debouncedSearchQueryVar?: string;
35
- setDebouncedSearchQueryVar?: string;
36
- skipDebounceRefVar?: string;
37
- getStaticPropsDataVar: string;
38
- getStaticPropsMaxPagesVar: string;
39
- getStaticPropsPropName: string;
40
- getStaticPropsMaxPagesPropName: string;
41
- searchInputClass?: string;
42
- paginationNodeClass?: string;
43
- prevButtonClass?: string;
44
- nextButtonClass?: string;
45
- dataProviderJSX?: any;
46
- searchInputJSX?: any;
47
- }
48
- export declare class ArrayMapperRegistry {
49
- private entries;
50
- private paginatedCount;
51
- private paginationOnlyCount;
52
- private searchOnlyCount;
53
- private plainCount;
54
- registerFromUIDL(config: {
55
- dataSourceIdentifier: string;
56
- arrayMapperRenderProp: string;
57
- uidlGlobalIndex: number;
58
- paginated: boolean;
59
- searchEnabled: boolean;
60
- perPage?: number;
61
- searchDebounce?: number;
62
- searchDefaultValue?: string;
63
- queryColumns?: string[];
64
- }): ArrayMapperRegistryEntry;
65
- getEntry(registryIndex: number): ArrayMapperRegistryEntry | undefined;
66
- getEntryByUIDLIndex(uidlGlobalIndex: number): ArrayMapperRegistryEntry | undefined;
67
- getEntriesByType(type: MapperType): ArrayMapperRegistryEntry[];
68
- getAllEntries(): ArrayMapperRegistryEntry[];
69
- getPaginatedEntries(): ArrayMapperRegistryEntry[];
70
- getPaginationOnlyEntries(): ArrayMapperRegistryEntry[];
71
- getSearchOnlyEntries(): ArrayMapperRegistryEntry[];
72
- getPlainEntries(): ArrayMapperRegistryEntry[];
73
- updateJSXReferences(registryIndex: number, updates: Partial<Pick<ArrayMapperRegistryEntry, 'dataProviderJSX' | 'searchInputJSX' | 'searchInputClass' | 'paginationNodeClass' | 'prevButtonClass' | 'nextButtonClass'>>): void;
74
- getStateInitializationMaxPagesPropName(entry: ArrayMapperRegistryEntry): string;
75
- toDebugSummary(): string;
76
- private determineType;
77
- private getTypeIndex;
78
- private incrementTypeCount;
79
- private generateStateVarPrefix;
80
- private generateStateVarNames;
81
- private generateGetStaticPropsVarNames;
82
- private capitalize;
83
- }
84
- export declare function createRegistryFromUIDL(uidlNode: any, allResources: any): ArrayMapperRegistry;
85
- //# sourceMappingURL=array-mapper-registry.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"array-mapper-registry.d.ts","sourceRoot":"","sources":["../../src/array-mapper-registry.ts"],"names":[],"mappings":"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"}