@teleporthq/teleport-plugin-next-data-source 0.43.41 → 0.43.44

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 (87) hide show
  1. package/__tests__/ecommerce-product-variant-swatch.test.ts +93 -0
  2. package/__tests__/fetchers.test.ts +11 -0
  3. package/dist/cjs/array-mapper-registry.d.ts +0 -1
  4. package/dist/cjs/array-mapper-registry.d.ts.map +1 -1
  5. package/dist/cjs/array-mapper-registry.js +0 -3
  6. package/dist/cjs/array-mapper-registry.js.map +1 -1
  7. package/dist/cjs/data-source-fetchers.d.ts +3 -3
  8. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  9. package/dist/cjs/data-source-fetchers.js +4 -4
  10. package/dist/cjs/data-source-fetchers.js.map +1 -1
  11. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  12. package/dist/cjs/fetchers/postgresql.js +1 -1
  13. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  14. package/dist/cjs/fetchers/teleport.d.ts +2 -1
  15. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  16. package/dist/cjs/fetchers/teleport.js +3 -3
  17. package/dist/cjs/fetchers/teleport.js.map +1 -1
  18. package/dist/cjs/filter-utils.d.ts.map +1 -1
  19. package/dist/cjs/filter-utils.js +12 -3
  20. package/dist/cjs/filter-utils.js.map +1 -1
  21. package/dist/cjs/index.d.ts.map +1 -1
  22. package/dist/cjs/index.js +8 -7
  23. package/dist/cjs/index.js.map +1 -1
  24. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  25. package/dist/cjs/pagination-plugin.js +12 -19
  26. package/dist/cjs/pagination-plugin.js.map +1 -1
  27. package/dist/cjs/transformations/ecommerce-product.d.ts +2 -1
  28. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  29. package/dist/cjs/transformations/ecommerce-product.js +36 -2
  30. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  31. package/dist/cjs/transformations/index.d.ts +2 -1
  32. package/dist/cjs/transformations/index.d.ts.map +1 -1
  33. package/dist/cjs/transformations/index.js +9 -3
  34. package/dist/cjs/transformations/index.js.map +1 -1
  35. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  36. package/dist/cjs/utils.d.ts +3 -3
  37. package/dist/cjs/utils.d.ts.map +1 -1
  38. package/dist/cjs/utils.js +4 -4
  39. package/dist/cjs/utils.js.map +1 -1
  40. package/dist/esm/array-mapper-registry.d.ts +0 -1
  41. package/dist/esm/array-mapper-registry.d.ts.map +1 -1
  42. package/dist/esm/array-mapper-registry.js +0 -3
  43. package/dist/esm/array-mapper-registry.js.map +1 -1
  44. package/dist/esm/data-source-fetchers.d.ts +3 -3
  45. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  46. package/dist/esm/data-source-fetchers.js +4 -4
  47. package/dist/esm/data-source-fetchers.js.map +1 -1
  48. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  49. package/dist/esm/fetchers/postgresql.js +1 -1
  50. package/dist/esm/fetchers/postgresql.js.map +1 -1
  51. package/dist/esm/fetchers/teleport.d.ts +2 -1
  52. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  53. package/dist/esm/fetchers/teleport.js +3 -3
  54. package/dist/esm/fetchers/teleport.js.map +1 -1
  55. package/dist/esm/filter-utils.d.ts.map +1 -1
  56. package/dist/esm/filter-utils.js +12 -3
  57. package/dist/esm/filter-utils.js.map +1 -1
  58. package/dist/esm/index.d.ts.map +1 -1
  59. package/dist/esm/index.js +8 -7
  60. package/dist/esm/index.js.map +1 -1
  61. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  62. package/dist/esm/pagination-plugin.js +12 -19
  63. package/dist/esm/pagination-plugin.js.map +1 -1
  64. package/dist/esm/transformations/ecommerce-product.d.ts +2 -1
  65. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  66. package/dist/esm/transformations/ecommerce-product.js +36 -2
  67. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  68. package/dist/esm/transformations/index.d.ts +2 -1
  69. package/dist/esm/transformations/index.d.ts.map +1 -1
  70. package/dist/esm/transformations/index.js +9 -3
  71. package/dist/esm/transformations/index.js.map +1 -1
  72. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  73. package/dist/esm/utils.d.ts +3 -3
  74. package/dist/esm/utils.d.ts.map +1 -1
  75. package/dist/esm/utils.js +4 -4
  76. package/dist/esm/utils.js.map +1 -1
  77. package/package.json +5 -5
  78. package/src/array-mapper-registry.ts +0 -5
  79. package/src/data-source-fetchers.ts +10 -5
  80. package/src/fetchers/postgresql.ts +17 -0
  81. package/src/fetchers/teleport.ts +38 -3
  82. package/src/filter-utils.ts +32 -5
  83. package/src/index.ts +9 -4
  84. package/src/pagination-plugin.ts +20 -42
  85. package/src/transformations/ecommerce-product.ts +430 -1
  86. package/src/transformations/index.ts +28 -4
  87. package/src/utils.ts +12 -4
@@ -1,10 +1,74 @@
1
+ import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
+
3
+ /**
4
+ * Flattens the nested category tree (`ecommerceSettings.categories`, baked at
5
+ * export time) into an `id -> {name, slug, translations}` lookup map,
6
+ * JSON-embedded into the generated transform below so a product's
7
+ * `category_ids` can be resolved to display names with no runtime DB lookup
8
+ * (there is no DB table for the taxonomy — it lives only in the UIDL).
9
+ * `translations` is carried through unresolved (a per-language `{name,
10
+ * description}` map) so `buildEcommerceProduct` can resolve it against the
11
+ * SAME per-request `currentLanguage`/`mainLanguage` used for every other i18n
12
+ * field — `slug` is never localized (it's the language-neutral join key).
13
+ * Tolerant of a missing/malformed tree.
14
+ */
15
+ const flattenCategoriesById = (
16
+ categories: UIDLEcommerceCategory[] | undefined
17
+ ): Record<
18
+ string,
19
+ { name: string; slug: string; translations?: UIDLEcommerceCategory['translations'] }
20
+ > => {
21
+ const byId: Record<
22
+ string,
23
+ { name: string; slug: string; translations?: UIDLEcommerceCategory['translations'] }
24
+ > = {}
25
+ const visit = (nodes: UIDLEcommerceCategory[] | undefined): void => {
26
+ if (!Array.isArray(nodes)) {
27
+ return
28
+ }
29
+ for (const node of nodes) {
30
+ if (node && typeof node.id === 'string') {
31
+ byId[node.id] = {
32
+ name: node.name || '',
33
+ slug: node.slug || '',
34
+ translations: node.translations,
35
+ }
36
+ visit(node.children)
37
+ }
38
+ }
39
+ }
40
+ visit(categories)
41
+ return byId
42
+ }
43
+
1
44
  /**
2
45
  * Generates JavaScript code for e-commerce product data transformation.
3
46
  * Transforms raw snake_case database records into the camelCase shape
4
47
  * that UIDL components expect.
5
48
  */
6
- export const generateEcommerceProductTransformationCode = (): string => {
49
+ export const generateEcommerceProductTransformationCode = (
50
+ categories?: UIDLEcommerceCategory[]
51
+ ): string => {
52
+ const categoriesByIdJson = JSON.stringify(flattenCategoriesById(categories))
53
+
7
54
  return `
55
+ // Category taxonomy (id -> {name, slug, translations}), baked in at export
56
+ // time — see flattenCategoriesById in ecommerce-product.ts. Empty when the
57
+ // store has no category taxonomy, or products.category_ids simply resolves
58
+ // to nothing.
59
+ var PRODUCT_CATEGORIES_BY_ID = ${categoriesByIdJson}
60
+
61
+ // Resolve a baked category's name to currentLang/mainLang from its
62
+ // translations map (same per-request locale as every other i18n field via
63
+ // resolveI18nField), falling back to the main-language name.
64
+ function resolveCategoryName(categoryInfo, currentLang, mainLang) {
65
+ if (currentLang && mainLang && currentLang !== mainLang && categoryInfo.translations) {
66
+ var override = categoryInfo.translations[currentLang]
67
+ if (override && override.name) return override.name
68
+ }
69
+ return categoryInfo.name
70
+ }
71
+
8
72
  // ============================================================
9
73
  // E-Commerce Product Transformation
10
74
  // ============================================================
@@ -64,6 +128,16 @@ function buildEcommerceProduct(record, options) {
64
128
  // Tags - parse as JSON array (handles JSON strings, arrays, comma-separated)
65
129
  var tags = parseJsonArray(tagsRaw)
66
130
 
131
+ // Variant AXES (translatable). The i18n-resolved column already carries the
132
+ // display labels for the current language (with the SAME stable keys as the
133
+ // base column); we just parse + normalize. The purchasable combinations live
134
+ // in teleport_product_variants and are fetched by the variant picker widget.
135
+ var variantOptionsRaw = resolveI18nField(record, 'variant_options', 'variantOptions', currentLang, mainLang)
136
+ var variantOptions = normalizeVariantOptions(variantOptionsRaw)
137
+ // Emitted as a STRING ('true'/'false') to match the strict-equality checks the
138
+ // storefront uses (same convention as outOfStock).
139
+ var requiresVariantSelection = variantOptions.length > 0 ? 'true' : 'false'
140
+
67
141
  // Provider fields
68
142
  var providerType = pickFirst(record.provider_type, record.providerType) || null
69
143
  var providerProductId = pickFirst(record.provider_product_id, record.providerProductId) || null
@@ -72,6 +146,66 @@ function buildEcommerceProduct(record, options) {
72
146
  // Metadata
73
147
  var metadata = parseJsonObject(record.metadata)
74
148
 
149
+ // Visible custom properties -> [{key,value,kind,color,imageUrl}] for the
150
+ // storefront pills mapper. MUST mirror buildVisibleProductProperties /
151
+ // classifyProductProperty in
152
+ // apps/gui/app/project-page/features/e-commerce/utils/product-property-display.ts
153
+ // (the canvas-preview SSOT) field-for-field: same reserved-key exclusions,
154
+ // same default-VISIBLE (opt-out) rule, same color/image classification. A
155
+ // property is shown unless it is a reserved key, has an empty value, or is
156
+ // explicitly hidden (false) in the _tqPropVisibility map -- so it also
157
+ // renders for products whose form was never opened (no visibility map yet).
158
+ var propVisibility = parseJsonObject(metadata._tqPropVisibility)
159
+ var visibleProperties = []
160
+ Object.keys(metadata).forEach(function (propKey) {
161
+ if (isReservedProductMetadataKey(propKey)) return
162
+ if (propVisibility[propKey] === false) return
163
+ var rawValue = metadata[propKey]
164
+ if (rawValue == null) return
165
+ var propValue = String(rawValue)
166
+ if (propValue.trim() === '') return
167
+ visibleProperties.push(classifyProductProperty(propKey, propValue))
168
+ })
169
+
170
+ // Assigned category ids -> resolved {id,name,slug} objects for the
171
+ // storefront category pills mapper. The taxonomy itself is not in the DB
172
+ // (categories are authored in the GUI and live only in
173
+ // ecommerceSettings.categories); resolved here against PRODUCT_CATEGORIES_BY_ID,
174
+ // baked in at export time (see flattenCategoriesById). Unknown/stale ids
175
+ // (a deleted category) are silently dropped.
176
+ var categoryIds = parseJsonArray(record.category_ids)
177
+ var categories = []
178
+ for (var ci = 0; ci < categoryIds.length; ci++) {
179
+ var categoryInfo = PRODUCT_CATEGORIES_BY_ID[categoryIds[ci]]
180
+ if (categoryInfo) {
181
+ categories.push({
182
+ id: categoryIds[ci],
183
+ name: resolveCategoryName(categoryInfo, currentLang, mainLang),
184
+ slug: categoryInfo.slug,
185
+ })
186
+ }
187
+ }
188
+
189
+ // Purchasable variant COMBINATIONS for this product ({id, options map, price,
190
+ // stock, image}). Empty for flat products. Fetched in ONE batched query by
191
+ // transformRecords and passed in via options.variantsByProductId, keyed by
192
+ // product id. The storefront card/details read these to default-select the
193
+ // first in-stock variant, disable dead options, and resolve the chosen id.
194
+ var variantsByProductId = options.variantsByProductId || {}
195
+ var rawVariants = variantsByProductId[id] || []
196
+ var variants = []
197
+ for (var vi = 0; vi < rawVariants.length; vi++) {
198
+ var vrow = rawVariants[vi] || {}
199
+ var vopts = parseJsonObject(vrow.options)
200
+ variants.push({
201
+ id: vrow.id != null ? String(vrow.id) : '',
202
+ options: vopts && typeof vopts === 'object' ? vopts : {},
203
+ price: safeNumber(vrow.price, null),
204
+ quantity: safeNumber(vrow.quantity, null),
205
+ image_url: vrow.image_url ? resolveAssetUrl(vrow.image_url, assetMap) : null,
206
+ })
207
+ }
208
+
75
209
  // -------------------------------------------------------
76
210
  // Image resolution (complex multi-step process)
77
211
  // -------------------------------------------------------
@@ -127,6 +261,44 @@ function buildEcommerceProduct(record, options) {
127
261
  // stock", same as the upstream cart-availability rewriter).
128
262
  var outOfStock = (quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'
129
263
 
264
+ // Per-value availability + default-selection flags, derived from the
265
+ // purchasable combinations, so the storefront picker renders its
266
+ // Default / Selected / Unavailable states with NO runtime fetch. The card
267
+ // (N instances) first-paints from these data flags; the details page seeds
268
+ // its selection from the same combinations. axisValueKey (axisKey|valueKey)
269
+ // disambiguates same-named values across axes (Size "s" vs Sleeve "s").
270
+ // Emitted as STRINGS to match the storefront's string-equality convention.
271
+ var variantAxisKeys = []
272
+ for (var ak = 0; ak < variantOptions.length; ak++) { variantAxisKeys.push(variantOptions[ak].key) }
273
+ var firstVariant = pickFirstAvailableVariant(variants, variantAxisKeys)
274
+ var deadValueKeys = computeDeadVariantKeys(variants, variantOptions)
275
+ for (var ai = 0; ai < variantOptions.length; ai++) {
276
+ var vax = variantOptions[ai]
277
+ for (var vj = 0; vj < vax.values.length; vj++) {
278
+ var vval = vax.values[vj]
279
+ var composite = vax.key + '|' + vval.value
280
+ vval.axisValueKey = composite
281
+ vval.isDead = deadValueKeys[composite] ? 'true' : 'false'
282
+ vval.isDefault =
283
+ firstVariant && firstVariant.options && firstVariant.options[vax.key] === vval.value
284
+ ? 'true'
285
+ : 'false'
286
+ // Whether THIS value renders as a colour swatch vs a text pill. A colour
287
+ // axis whose value has no resolvable colour degrades to a text pill so it
288
+ // is never an invisible empty circle. Emitted as a string to match the
289
+ // picker's string-equality rendering gate. MUST mirror the renderer
290
+ // enrichment in packages/renderer/src/utils/ecommerce-products.ts.
291
+ vval.showSwatch = vax.type === 'color' && vval.color ? 'true' : 'false'
292
+ }
293
+ }
294
+ var defaultVariantId = firstVariant && isVariantInStock(firstVariant) ? firstVariant.id : ''
295
+ var defaultVariantPrice = firstVariant ? formatVariantPrice(firstVariant.price, price) : ''
296
+ // Stringified companions: a data-* attr bound to an ARRAY renders
297
+ // '[object Object]', so the picker's on-mount/click workflows read the
298
+ // combinations + axes from these JSON strings via getAttribute.
299
+ var variantOptionsJson = safeStringifyJson(variantOptions)
300
+ var variantsJson = safeStringifyJson(variants)
301
+
130
302
  return {
131
303
  id: id,
132
304
  name: name,
@@ -147,12 +319,14 @@ function buildEcommerceProduct(record, options) {
147
319
  weightUnit: weightUnit,
148
320
  dimensions: dimensions,
149
321
  category: category,
322
+ categories: categories,
150
323
  tags: tags,
151
324
  imageAlt: imageAlt,
152
325
  providerType: providerType,
153
326
  providerProductId: providerProductId,
154
327
  default_price: defaultPrice,
155
328
  metadata: metadata,
329
+ visibleProperties: visibleProperties,
156
330
  mainImage: mainImage,
157
331
  imageUrl: mainImage,
158
332
  image_url: mainImage,
@@ -162,12 +336,267 @@ function buildEcommerceProduct(record, options) {
162
336
  updatedAt: updatedAt,
163
337
  created: created,
164
338
  outOfStock: outOfStock,
339
+ variantOptions: variantOptions,
340
+ requiresVariantSelection: requiresVariantSelection,
341
+ variants: variants,
342
+ variantOptionsJson: variantOptionsJson,
343
+ variantsJson: variantsJson,
344
+ defaultVariantId: defaultVariantId,
345
+ defaultVariantPrice: defaultVariantPrice,
165
346
  }
166
347
  }
167
348
 
349
+ // --- Variant availability helpers (mirror packages/renderer ecommerce-products) ---
350
+ // A variant is in stock when its quantity is null (unlimited) or > 0.
351
+ function isVariantInStock(v) {
352
+ return !v ? false : v.quantity == null || v.quantity > 0
353
+ }
354
+ function variantCoversAllAxes(v, axisKeys) {
355
+ if (!v || !v.options) return false
356
+ for (var i = 0; i < axisKeys.length; i++) {
357
+ if (v.options[axisKeys[i]] == null) return false
358
+ }
359
+ return true
360
+ }
361
+ // First in-stock combination covering every axis; falls back to the first
362
+ // covering combination (even if OOS) so a fully-OOS product still has a
363
+ // visual default; null when no combination covers the axes.
364
+ function pickFirstAvailableVariant(variants, axisKeys) {
365
+ if (!Array.isArray(variants) || !axisKeys.length) return null
366
+ for (var i = 0; i < variants.length; i++) {
367
+ if (variantCoversAllAxes(variants[i], axisKeys) && isVariantInStock(variants[i])) return variants[i]
368
+ }
369
+ for (var j = 0; j < variants.length; j++) {
370
+ if (variantCoversAllAxes(variants[j], axisKeys)) return variants[j]
371
+ }
372
+ return null
373
+ }
374
+ // Map of composite "axisKey|valueKey" -> true for every value that appears in
375
+ // NO in-stock combination (rendered as the Unavailable state).
376
+ function computeDeadVariantKeys(variants, axes) {
377
+ // No combinations attached (flat product, zero-variant product, or editor
378
+ // preview) -> nothing is dead; every value stays selectable. Dead-marking is
379
+ // only meaningful relative to combinations that actually exist.
380
+ if (!Array.isArray(variants) || variants.length === 0) return {}
381
+ var alive = {}
382
+ for (var i = 0; i < variants.length; i++) {
383
+ var v = variants[i]
384
+ if (!isVariantInStock(v)) continue
385
+ for (var k in v.options) {
386
+ if (Object.prototype.hasOwnProperty.call(v.options, k)) alive[k + '|' + v.options[k]] = true
387
+ }
388
+ }
389
+ var dead = {}
390
+ for (var a = 0; a < axes.length; a++) {
391
+ var ax = axes[a]
392
+ for (var b = 0; b < ax.values.length; b++) {
393
+ var key = ax.key + '|' + ax.values[b].value
394
+ if (!alive[key]) dead[key] = true
395
+ }
396
+ }
397
+ return dead
398
+ }
399
+ // Variant price with base-price inheritance (null variant price -> product price).
400
+ function formatVariantPrice(vprice, basePrice) {
401
+ var p = vprice == null || isNaN(vprice) ? basePrice : vprice
402
+ return String(Number(p).toFixed(2))
403
+ }
404
+ function safeStringifyJson(obj) {
405
+ try {
406
+ return JSON.stringify(obj)
407
+ } catch (e) {
408
+ return '[]'
409
+ }
410
+ }
411
+
412
+ // Parse + normalize the variant_options JSON into render-ready axes. Tolerant of
413
+ // null / malformed / partial data (returns []). Keeps ONLY the fields the pill
414
+ // widget needs; the labels are already language-resolved by the caller.
415
+ function normalizeVariantOptions(raw) {
416
+ if (!raw) return []
417
+ var parsed = raw
418
+ if (typeof raw === 'string') {
419
+ try { parsed = JSON.parse(raw) } catch (e) { return [] }
420
+ }
421
+ if (!Array.isArray(parsed)) return []
422
+ var out = []
423
+ for (var i = 0; i < parsed.length; i++) {
424
+ var axis = parsed[i]
425
+ if (!axis || typeof axis !== 'object' || !Array.isArray(axis.values)) continue
426
+ var name = typeof axis.name === 'string' ? axis.name : ''
427
+ var key = typeof axis.key === 'string' && axis.key ? axis.key : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
428
+ if (!key) continue
429
+ var type = axis.type === 'color' ? 'color' : 'text'
430
+ var values = []
431
+ for (var j = 0; j < axis.values.length; j++) {
432
+ var v = axis.values[j]
433
+ if (!v || typeof v !== 'object') continue
434
+ var label = typeof v.label === 'string' ? v.label : ''
435
+ var value = typeof v.value === 'string' && v.value ? v.value : label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
436
+ if (!value) continue
437
+ var entry = { value: value, label: label }
438
+ if (type === 'color' && typeof v.color === 'string' && v.color) entry.color = v.color
439
+ values.push(entry)
440
+ }
441
+ if (!values.length) continue
442
+ out.push({ key: key, name: name, type: type, values: values })
443
+ }
444
+ return out
445
+ }
446
+
447
+ // --- Custom-property classification (MUST mirror product-property-display.ts) ---
448
+
449
+ // Product-attribute keys that can leak into the free-form \`metadata\` map (e.g.
450
+ // from a payment-provider sync or legacy data) but are NOT user custom
451
+ // properties. Kept in sync with the GUI's RESERVED_PRODUCT_METADATA_KEYS.
452
+ var RESERVED_PRODUCT_METADATA_KEYS = {
453
+ paymentType: true, recurringInterval: true, recurringIntervalCount: true,
454
+ price: true, currency: true, status: true, sku: true, weightUnit: true,
455
+ dimensions: true, category: true, brand: true, gtin: true, mpn: true,
456
+ condition: true, tags: true,
457
+ }
458
+ function isReservedProductMetadataKey(key) {
459
+ return !!RESERVED_PRODUCT_METADATA_KEYS[key] ||
460
+ key.indexOf('mainImage') === 0 ||
461
+ key.indexOf('secondaryImage') === 0 ||
462
+ key.indexOf('_tq') === 0
463
+ }
464
+
465
+ var CSS_NAMED_COLORS = {
466
+ aliceblue: 1, antiquewhite: 1, aqua: 1, aquamarine: 1, azure: 1, beige: 1, bisque: 1,
467
+ black: 1, blanchedalmond: 1, blue: 1, blueviolet: 1, brown: 1, burlywood: 1,
468
+ cadetblue: 1, chartreuse: 1, chocolate: 1, coral: 1, cornflowerblue: 1, cornsilk: 1,
469
+ crimson: 1, cyan: 1, darkblue: 1, darkcyan: 1, darkgoldenrod: 1, darkgray: 1,
470
+ darkgrey: 1, darkgreen: 1, darkkhaki: 1, darkmagenta: 1, darkolivegreen: 1,
471
+ darkorange: 1, darkorchid: 1, darkred: 1, darksalmon: 1, darkseagreen: 1,
472
+ darkslateblue: 1, darkslategray: 1, darkslategrey: 1, darkturquoise: 1,
473
+ darkviolet: 1, deeppink: 1, deepskyblue: 1, dimgray: 1, dimgrey: 1, dodgerblue: 1,
474
+ firebrick: 1, floralwhite: 1, forestgreen: 1, fuchsia: 1, gainsboro: 1,
475
+ ghostwhite: 1, gold: 1, goldenrod: 1, gray: 1, grey: 1, green: 1, greenyellow: 1,
476
+ honeydew: 1, hotpink: 1, indianred: 1, indigo: 1, ivory: 1, khaki: 1, lavender: 1,
477
+ lavenderblush: 1, lawngreen: 1, lemonchiffon: 1, lightblue: 1, lightcoral: 1,
478
+ lightcyan: 1, lightgoldenrodyellow: 1, lightgray: 1, lightgrey: 1, lightgreen: 1,
479
+ lightpink: 1, lightsalmon: 1, lightseagreen: 1, lightskyblue: 1, lightslategray: 1,
480
+ lightslategrey: 1, lightsteelblue: 1, lightyellow: 1, lime: 1, limegreen: 1,
481
+ linen: 1, magenta: 1, maroon: 1, mediumaquamarine: 1, mediumblue: 1,
482
+ mediumorchid: 1, mediumpurple: 1, mediumseagreen: 1, mediumslateblue: 1,
483
+ mediumspringgreen: 1, mediumturquoise: 1, mediumvioletred: 1, midnightblue: 1,
484
+ mintcream: 1, mistyrose: 1, moccasin: 1, navajowhite: 1, navy: 1, oldlace: 1,
485
+ olive: 1, olivedrab: 1, orange: 1, orangered: 1, orchid: 1, palegoldenrod: 1,
486
+ palegreen: 1, paleturquoise: 1, palevioletred: 1, papayawhip: 1, peachpuff: 1,
487
+ peru: 1, pink: 1, plum: 1, powderblue: 1, purple: 1, rebeccapurple: 1, red: 1,
488
+ rosybrown: 1, royalblue: 1, saddlebrown: 1, salmon: 1, sandybrown: 1, seagreen: 1,
489
+ seashell: 1, sienna: 1, silver: 1, skyblue: 1, slateblue: 1, slategray: 1,
490
+ slategrey: 1, snow: 1, springgreen: 1, steelblue: 1, tan: 1, teal: 1, thistle: 1,
491
+ tomato: 1, turquoise: 1, violet: 1, wheat: 1, white: 1, whitesmoke: 1, yellow: 1,
492
+ yellowgreen: 1, transparent: 1,
493
+ }
494
+ var HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
495
+ var FUNCTIONAL_COLOR_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\((?:[0-9a-z%.,\\s/+-]+)\\)$/i
496
+ var COLOR_KEY_WORDS = {
497
+ color: 1, colors: 1, colour: 1, colours: 1, shade: 1, shades: 1, hue: 1, hues: 1,
498
+ tint: 1, tints: 1, tone: 1, tones: 1, swatch: 1, swatches: 1,
499
+ }
500
+ function splitKeyWords(key) {
501
+ return key.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
502
+ }
503
+ function keyIndicatesColor(key) {
504
+ var normalized = key.toLowerCase()
505
+ if (/colou?r/.test(normalized)) return true
506
+ return splitKeyWords(key).some(function(word) { return !!COLOR_KEY_WORDS[word] })
507
+ }
508
+ function resolvePropertyColor(key, value) {
509
+ var v = value.trim()
510
+ if (!v) return ''
511
+ if (HEX_COLOR_RE.test(v) || FUNCTIONAL_COLOR_RE.test(v)) return v
512
+ if (CSS_NAMED_COLORS[v.toLowerCase()] && keyIndicatesColor(key)) return v
513
+ return ''
514
+ }
515
+
516
+ var DATA_IMAGE_URI_RE = /^data:image\\//i
517
+ var ABSOLUTE_URL_RE = /^(?:https?:)?\\/\\//i
518
+ var IMAGE_EXTENSION_RE = /\\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?|heic|heif)$/i
519
+ var IMAGE_KEY_WORDS = {
520
+ image: 1, images: 1, img: 1, imgs: 1, photo: 1, photos: 1, picture: 1, pictures: 1,
521
+ pic: 1, pics: 1, thumbnail: 1, thumbnails: 1, thumb: 1, thumbs: 1, icon: 1, icons: 1,
522
+ logo: 1, logos: 1, avatar: 1, avatars: 1, banner: 1, banners: 1, cover: 1, covers: 1,
523
+ gallery: 1, screenshot: 1, screenshots: 1, poster: 1, posters: 1, artwork: 1,
524
+ graphic: 1, graphics: 1, illustration: 1, wallpaper: 1, preview: 1, snapshot: 1,
525
+ media: 1, visual: 1, visuals: 1,
526
+ }
527
+ function keyIndicatesImage(key) {
528
+ var normalized = key.toLowerCase()
529
+ if (/(?:image|img|photo|picture|thumb|icon|logo|avatar|banner|gallery|screenshot|poster|artwork|graphic|wallpaper)/.test(normalized)) return true
530
+ return splitKeyWords(key).some(function(word) { return !!IMAGE_KEY_WORDS[word] })
531
+ }
532
+ function stripUrlQueryAndHash(url) {
533
+ return url.split(/[?#]/)[0]
534
+ }
535
+ function resolvePropertyImage(key, value) {
536
+ var v = value.trim()
537
+ if (!v) return ''
538
+ if (DATA_IMAGE_URI_RE.test(v)) return v
539
+ var isAbsoluteUrl = ABSOLUTE_URL_RE.test(v)
540
+ var isRootRelative = v.charAt(0) === '/' && !isAbsoluteUrl
541
+ if (!isAbsoluteUrl && !isRootRelative) return ''
542
+ if (IMAGE_EXTENSION_RE.test(stripUrlQueryAndHash(v))) return v
543
+ if (isAbsoluteUrl && keyIndicatesImage(key)) return v
544
+ return ''
545
+ }
546
+
547
+ // Image wins over colour (a URL can never be a CSS colour, but checking image
548
+ // first keeps the precedence explicit), text is the fallback.
549
+ function classifyProductProperty(key, value) {
550
+ var imageUrl = resolvePropertyImage(key, value)
551
+ if (imageUrl) return { key: key, value: value, kind: 'image', color: '', imageUrl: imageUrl }
552
+ var color = resolvePropertyColor(key, value)
553
+ if (color) return { key: key, value: value, kind: 'color', color: color, imageUrl: '' }
554
+ return { key: key, value: value, kind: 'text', color: '', imageUrl: '' }
555
+ }
556
+
168
557
  function transformEcommerceProducts(records, options) {
169
558
  if (!Array.isArray(records)) return []
170
559
  return records.map(function(record) { return buildEcommerceProduct(record, options) })
171
560
  }
561
+
562
+ // Batched fetch of the purchasable variant rows for a set of product ids, keyed
563
+ // by product_id, so buildEcommerceProduct can attach product.variants without a
564
+ // per-product query. Best-effort: a store with no variants (or before the table
565
+ // exists) yields an empty map and flat products keep variants: [].
566
+ async function getVariantsMap(getClientFn, productIds) {
567
+ var map = {}
568
+ if (!Array.isArray(productIds) || productIds.length === 0) return map
569
+ var uniqueIds = []
570
+ var seenIds = {}
571
+ for (var u = 0; u < productIds.length; u++) {
572
+ var pid0 = productIds[u]
573
+ if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }
574
+ }
575
+ if (uniqueIds.length === 0) return map
576
+ var client
577
+ try {
578
+ client = getClientFn()
579
+ await client.connect()
580
+ var result = await client.query(
581
+ 'SELECT id, product_id, options, price, quantity, image_url FROM teleport_product_variants WHERE product_id = ANY($1) ORDER BY position ASC, id ASC',
582
+ [uniqueIds]
583
+ )
584
+ if (result && result.rows) {
585
+ for (var r = 0; r < result.rows.length; r++) {
586
+ var row = result.rows[r]
587
+ var pid = row.product_id
588
+ if (!map[pid]) map[pid] = []
589
+ map[pid].push(row)
590
+ }
591
+ }
592
+ } catch (e) {
593
+ // teleport_product_variants may not exist for a non-variant store.
594
+ } finally {
595
+ if (client) {
596
+ try { await client.end() } catch (e) { /* ignore */ }
597
+ }
598
+ }
599
+ return map
600
+ }
172
601
  `
173
602
  }
@@ -1,3 +1,4 @@
1
+ import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
1
2
  import { generateSharedTransformationCode } from './shared-utils'
2
3
  import { generateBlogPostTransformationCode } from './blog-post'
3
4
  import { generateEcommerceProductTransformationCode } from './ecommerce-product'
@@ -48,7 +49,10 @@ export const detectTransformationType = (tableName: string): TransformationType
48
49
  * to be included in the generated data source fetcher file.
49
50
  * Returns empty string if no transformation is needed for this table.
50
51
  */
51
- export const getTransformationCode = (tableName: string): string => {
52
+ export const getTransformationCode = (
53
+ tableName: string,
54
+ categories?: UIDLEcommerceCategory[]
55
+ ): string => {
52
56
  const type = detectTransformationType(tableName)
53
57
  if (!type) {
54
58
  return ''
@@ -60,7 +64,7 @@ export const getTransformationCode = (tableName: string): string => {
60
64
  case 'blog-post':
61
65
  return shared + generateBlogPostTransformationCode()
62
66
  case 'ecommerce-product':
63
- return shared + generateEcommerceProductTransformationCode()
67
+ return shared + generateEcommerceProductTransformationCode(categories)
64
68
  default:
65
69
  return ''
66
70
  }
@@ -101,6 +105,26 @@ export const getTransformWrapperCode = (tableName: string): string => {
101
105
 
102
106
  const transformFn = type === 'blog-post' ? 'transformBlogPosts' : 'transformEcommerceProducts'
103
107
 
108
+ // Products additionally get their purchasable variant combinations attached in
109
+ // ONE batched query (keyed by product id). Blog posts have no such enrichment.
110
+ const variantEnrichment =
111
+ type === 'ecommerce-product'
112
+ ? `
113
+ var variantsByProductId = {}
114
+ try {
115
+ var __variantPids = []
116
+ for (var __i = 0; __i < records.length; __i++) {
117
+ if (records[__i] && records[__i].id != null) __variantPids.push(records[__i].id)
118
+ }
119
+ variantsByProductId = await getVariantsMap(getClientFn, __variantPids)
120
+ } catch (e) {
121
+ // Variant enrichment is best-effort; flat products keep variants: [].
122
+ }`
123
+ : ''
124
+
125
+ const variantOption =
126
+ type === 'ecommerce-product' ? ', variantsByProductId: variantsByProductId' : ''
127
+
104
128
  return `
105
129
  async function transformRecords(records, getClientFn, reqQuery) {
106
130
  var assetMap = {}
@@ -108,10 +132,10 @@ async function transformRecords(records, getClientFn, reqQuery) {
108
132
  assetMap = await getAssetMap(getClientFn)
109
133
  } catch (e) {
110
134
  // Asset resolution is best-effort; continue without it
111
- }
135
+ }${variantEnrichment}
112
136
  var currentLanguage = (reqQuery && reqQuery.lang) || null
113
137
  var mainLanguage = (reqQuery && reqQuery.mainLang) || null
114
- var options = { assetMap: assetMap, currentLanguage: currentLanguage, mainLanguage: mainLanguage }
138
+ var options = { assetMap: assetMap, currentLanguage: currentLanguage, mainLanguage: mainLanguage${variantOption} }
115
139
  return ${transformFn}(records, options)
116
140
  }
117
141
  `
package/src/utils.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  UIDLDataSource,
8
8
  DataSourceType,
9
9
  ChunkType,
10
+ UIDLEcommerceCategory,
10
11
  } from '@teleporthq/teleport-types'
11
12
  import * as types from '@babel/types'
12
13
  import { ASTUtils } from '@teleporthq/teleport-plugin-common'
@@ -189,7 +190,8 @@ export const extractDataSourceIntoNextAPIFolder = (
189
190
  node: UIDLDataSourceItemNode | UIDLDataSourceListNode,
190
191
  dataSources: Record<string, UIDLDataSource>,
191
192
  componentChunk: ChunkDefinition,
192
- extractedResources: GeneratorOptions['extractedResources']
193
+ extractedResources: GeneratorOptions['extractedResources'],
194
+ categories?: UIDLEcommerceCategory[]
193
195
  ) => {
194
196
  try {
195
197
  // Validate node content structure
@@ -443,7 +445,7 @@ export default dataSourceModule.handler
443
445
  // Generate fetcher code for API route (exports just the handler)
444
446
  let fetcherCode: string
445
447
  try {
446
- fetcherCode = generateDataSourceFetcherWithCore(dataSource, tableName || '', true)
448
+ fetcherCode = generateDataSourceFetcherWithCore(dataSource, tableName || '', true, categories)
447
449
  } catch (error) {
448
450
  return
449
451
  }
@@ -892,7 +894,8 @@ export const extractDataSourceIntoGetStaticProps = (
892
894
  extractedResources: GeneratorOptions['extractedResources'],
893
895
  dependencies: Record<string, any>,
894
896
  dynamicRouteAttr?: string,
895
- folderPath?: string[]
897
+ folderPath?: string[],
898
+ categories?: UIDLEcommerceCategory[]
896
899
  ): { success: boolean; chunk?: any } => {
897
900
  try {
898
901
  // Validate node content
@@ -1216,7 +1219,12 @@ export const extractDataSourceIntoGetStaticProps = (
1216
1219
  // Generate fetcher code with core function
1217
1220
  let fetcherCode: string
1218
1221
  try {
1219
- fetcherCode = generateDataSourceFetcherWithCore(dataSource, tableName || '')
1222
+ fetcherCode = generateDataSourceFetcherWithCore(
1223
+ dataSource,
1224
+ tableName || '',
1225
+ false,
1226
+ categories
1227
+ )
1220
1228
  } catch (error) {
1221
1229
  // tslint:disable-next-line:no-console
1222
1230
  console.error(`Failed to generate fetcher for ${dataSourceType} (${dataSourceId}):`, error)