@teleporthq/teleport-plugin-next-data-source 0.43.57 → 0.43.59
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.
- package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
- package/__tests__/ecommerce-product-model.test.ts +179 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +2 -1
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +2 -1
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +17 -1
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
- package/dist/cjs/transformations/shared-utils.js +1 -1
- package/dist/cjs/transformations/shared-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +2 -1
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +2 -1
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +17 -1
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
- package/dist/esm/transformations/shared-utils.js +1 -1
- package/dist/esm/transformations/shared-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/transformations/ecommerce-product.ts +108 -3
- package/src/transformations/index.ts +19 -1
- package/src/transformations/shared-utils.ts +14 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
import { buildProductTransformOptions } from '../src/transformations'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* With backorders allowed (or stock management disabled) the merchant sells
|
|
7
|
+
* past zero, so NOTHING in the storefront may present a product or a variant
|
|
8
|
+
* combination as sold out:
|
|
9
|
+
*
|
|
10
|
+
* - `outOfStock` stays 'false' even at quantity <= 0;
|
|
11
|
+
* - a zero-stock combination is NOT dead, IS default-selectable, and keeps
|
|
12
|
+
* `hasPurchasableVariant: 'true'` / a non-empty `defaultVariantId`;
|
|
13
|
+
* - a combination that does not exist at all is STILL unavailable — there
|
|
14
|
+
* is no variant row to backorder.
|
|
15
|
+
*
|
|
16
|
+
* The flag is baked at export time via
|
|
17
|
+
* `EcommerceProductTransformOptions.allowBackorders`, resolved in ONE place
|
|
18
|
+
* (`buildProductTransformOptions`) as: stock management off OR
|
|
19
|
+
* `stockManagementConfig.allowBackorders` on.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const evalBuildProduct = (
|
|
23
|
+
allowBackorders: boolean
|
|
24
|
+
): ((r: unknown, o?: unknown) => Record<string, unknown>) => {
|
|
25
|
+
const code =
|
|
26
|
+
generateSharedTransformationCode() +
|
|
27
|
+
'\n' +
|
|
28
|
+
generateEcommerceProductTransformationCode({ allowBackorders })
|
|
29
|
+
return new Function(code + '\nreturn buildEcommerceProduct;')() as (
|
|
30
|
+
r: unknown,
|
|
31
|
+
o?: unknown
|
|
32
|
+
) => Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Axis = { key: string; values: Array<Record<string, unknown>> }
|
|
36
|
+
|
|
37
|
+
const SIZE_AXIS = JSON.stringify([
|
|
38
|
+
{
|
|
39
|
+
key: 'size',
|
|
40
|
+
name: 'Size',
|
|
41
|
+
type: 'text',
|
|
42
|
+
values: [
|
|
43
|
+
{ value: 'xs', label: 'XS' },
|
|
44
|
+
{ value: 's', label: 'S' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
const variantProduct = (overrides: Record<string, unknown> = {}) => ({
|
|
50
|
+
id: 'p1',
|
|
51
|
+
name: 'Tee',
|
|
52
|
+
slug: 'tee',
|
|
53
|
+
price: 20,
|
|
54
|
+
currency: 'USD',
|
|
55
|
+
variant_options: SIZE_AXIS,
|
|
56
|
+
...overrides,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const values = (product: Record<string, unknown>): Array<Record<string, unknown>> =>
|
|
60
|
+
(product.variantOptions as Axis[])[0].values
|
|
61
|
+
|
|
62
|
+
describe('ecommerce product transform — allowBackorders', () => {
|
|
63
|
+
const withBackorders = evalBuildProduct(true)
|
|
64
|
+
const withoutBackorders = evalBuildProduct(false)
|
|
65
|
+
|
|
66
|
+
it('a zero-quantity FLAT product is never out of stock with backorders', () => {
|
|
67
|
+
const record = { id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: 0 }
|
|
68
|
+
expect(withBackorders(record).outOfStock).toBe('false')
|
|
69
|
+
expect(withoutBackorders(record).outOfStock).toBe('true')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('a NEGATIVE quantity (already oversold) is still not out of stock', () => {
|
|
73
|
+
const record = { id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: -3 }
|
|
74
|
+
expect(withBackorders(record).outOfStock).toBe('false')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('every combination sold out: values stay alive and purchasable with backorders', () => {
|
|
78
|
+
const options = {
|
|
79
|
+
variantsByProductId: {
|
|
80
|
+
p1: [
|
|
81
|
+
{ id: 'v-xs', options: { size: 'xs' }, quantity: 0 },
|
|
82
|
+
{ id: 'v-s', options: { size: 's' }, quantity: 0 },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
const product = withBackorders(variantProduct(), options)
|
|
87
|
+
expect(values(product).every((v) => v.isDead === 'false')).toBe(true)
|
|
88
|
+
expect(product.hasPurchasableVariant).toBe('true')
|
|
89
|
+
// The first covering combination becomes the default selection again.
|
|
90
|
+
expect(product.defaultVariantId).toBe('v-xs')
|
|
91
|
+
|
|
92
|
+
// The strict build keeps its sold-out answer (regression guard for the
|
|
93
|
+
// flag's default).
|
|
94
|
+
const strict = withoutBackorders(variantProduct(), options)
|
|
95
|
+
expect(strict.hasPurchasableVariant).toBe('false')
|
|
96
|
+
expect(strict.defaultVariantId).toBe('')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('a value with NO combination behind it stays dead even with backorders', () => {
|
|
100
|
+
const product = withBackorders(variantProduct(), {
|
|
101
|
+
variantsByProductId: { p1: [{ id: 'v-xs', options: { size: 'xs' }, quantity: 0 }] },
|
|
102
|
+
})
|
|
103
|
+
expect(values(product)[0].isDead).toBe('false')
|
|
104
|
+
// 's' has no variant row — nothing exists to backorder.
|
|
105
|
+
expect(values(product)[1].isDead).toBe('true')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('looked up and there are NO combinations at all: still not purchasable', () => {
|
|
109
|
+
const product = withBackorders(variantProduct(), { variantsByProductId: {} })
|
|
110
|
+
expect(values(product).every((v) => v.isDead === 'true')).toBe(true)
|
|
111
|
+
expect(product.hasPurchasableVariant).toBe('false')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('omitting the option keeps the strict pre-flag behaviour', () => {
|
|
115
|
+
const code =
|
|
116
|
+
generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
|
|
117
|
+
const build = new Function(code + '\nreturn buildEcommerceProduct;')() as (
|
|
118
|
+
r: unknown
|
|
119
|
+
) => Record<string, unknown>
|
|
120
|
+
expect(
|
|
121
|
+
build({ id: 'f', name: 'Mug', slug: 'mug', price: 9, currency: 'USD', quantity: 0 })
|
|
122
|
+
.outOfStock
|
|
123
|
+
).toBe('true')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('buildProductTransformOptions — allowBackorders resolution', () => {
|
|
128
|
+
const settings = (stockManagement: boolean, allowBackorders?: boolean) =>
|
|
129
|
+
({
|
|
130
|
+
stockManagement,
|
|
131
|
+
stockManagementConfig:
|
|
132
|
+
allowBackorders === undefined ? null : { allowBackorders, lowStockThreshold: 5 },
|
|
133
|
+
} as never)
|
|
134
|
+
|
|
135
|
+
it('stock management ON + backorders ON → true', () => {
|
|
136
|
+
expect(
|
|
137
|
+
buildProductTransformOptions({ ecommerceSettings: settings(true, true) }).allowBackorders
|
|
138
|
+
).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('stock management ON + backorders OFF → false', () => {
|
|
142
|
+
expect(
|
|
143
|
+
buildProductTransformOptions({ ecommerceSettings: settings(true, false) }).allowBackorders
|
|
144
|
+
).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('stock management OFF → true regardless of the config block', () => {
|
|
148
|
+
expect(
|
|
149
|
+
buildProductTransformOptions({ ecommerceSettings: settings(false) }).allowBackorders
|
|
150
|
+
).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('no e-commerce settings at all → false (strict default)', () => {
|
|
154
|
+
expect(buildProductTransformOptions({}).allowBackorders).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
|
|
4
|
+
// A product's optional 3D model. The transform must hand the storefront
|
|
5
|
+
// resolved URLs, STRING gates ('true' / 'false' — the builders compare with
|
|
6
|
+
// `=`/`!=`, so an absent field reads as the IMAGE branch), and the gallery as
|
|
7
|
+
// media entries in display order. MUST stay in sync with the renderer mirror
|
|
8
|
+
// (packages/renderer/src/utils/ecommerce-products.ts) and the GUI's
|
|
9
|
+
// `PRODUCT_PROP_DEFAULT` — see the GUI-side specs.
|
|
10
|
+
|
|
11
|
+
type Product = Record<string, unknown> & {
|
|
12
|
+
galleryMedia: Array<{ kind: string; src: string; poster: string; thumbnail: string; alt: string }>
|
|
13
|
+
galleryThumbnails: Array<{ kind: string; src: string }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const evalBuildProduct = (): ((record: unknown, options?: unknown) => Product) => {
|
|
17
|
+
const code =
|
|
18
|
+
generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
|
|
19
|
+
const fn = new Function(code + '\nreturn buildEcommerceProduct;')
|
|
20
|
+
return fn() as (record: unknown, options?: unknown) => Product
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ASSET_MAP = {
|
|
24
|
+
TQ_model: { remoteSrc: 'https://storage.example.com/p/model.glb' },
|
|
25
|
+
TQ_poster: { remoteSrc: 'https://cdn.example.com/poster.png' },
|
|
26
|
+
TQ_img1: { remoteSrc: 'https://cdn.example.com/img1.jpg' },
|
|
27
|
+
TQ_img2: { remoteSrc: 'https://cdn.example.com/img2.jpg' },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const baseRecord = {
|
|
31
|
+
id: 'p1',
|
|
32
|
+
name: 'Chair',
|
|
33
|
+
slug: 'chair',
|
|
34
|
+
price: '10',
|
|
35
|
+
currency: 'USD',
|
|
36
|
+
image_url: 'TQ_img1',
|
|
37
|
+
gallery_images: JSON.stringify(['TQ_img2']),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('ecommerce product transform — 3D model', () => {
|
|
41
|
+
const buildEcommerceProduct = evalBuildProduct()
|
|
42
|
+
const build = (record: Record<string, unknown>) =>
|
|
43
|
+
buildEcommerceProduct(record, { assetMap: ASSET_MAP })
|
|
44
|
+
|
|
45
|
+
it('emits the image branch for a product without a model', () => {
|
|
46
|
+
const product = build(baseRecord)
|
|
47
|
+
expect(product.modelUrl).toBeNull()
|
|
48
|
+
expect(product.modelPoster).toBeNull()
|
|
49
|
+
expect(product.hasModel).toBe('false')
|
|
50
|
+
expect(product.showModel).toBe('false')
|
|
51
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image'])
|
|
52
|
+
expect(product.galleryThumbnails.map((m) => m.src)).toEqual([ASSET_MAP.TQ_img2.remoteSrc])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('resolves the model and poster asset ids to URLs and shows the model by default', () => {
|
|
56
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', model_poster_url: 'TQ_poster' })
|
|
57
|
+
expect(product.modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
58
|
+
expect(product.modelPoster).toBe(ASSET_MAP.TQ_poster.remoteSrc)
|
|
59
|
+
expect(product.hasModel).toBe('true')
|
|
60
|
+
// NULL / absent flag = ON
|
|
61
|
+
expect(product.showModel).toBe('true')
|
|
62
|
+
// hero first, then the images
|
|
63
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['model', 'image', 'image'])
|
|
64
|
+
expect(product.galleryMedia[0]).toEqual({
|
|
65
|
+
kind: 'model',
|
|
66
|
+
src: ASSET_MAP.TQ_model.remoteSrc,
|
|
67
|
+
poster: ASSET_MAP.TQ_poster.remoteSrc,
|
|
68
|
+
thumbnail: ASSET_MAP.TQ_poster.remoteSrc,
|
|
69
|
+
alt: 'Chair',
|
|
70
|
+
})
|
|
71
|
+
// the main image moves into the strip when the model is the hero
|
|
72
|
+
expect(product.galleryThumbnails.map((m) => m.src)).toEqual([
|
|
73
|
+
ASSET_MAP.TQ_img1.remoteSrc,
|
|
74
|
+
ASSET_MAP.TQ_img2.remoteSrc,
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps the image hero when the merchant switched the model off, with the model last', () => {
|
|
79
|
+
const product = build({
|
|
80
|
+
...baseRecord,
|
|
81
|
+
model_url: 'TQ_model',
|
|
82
|
+
model_poster_url: 'TQ_poster',
|
|
83
|
+
show_model_by_default: false,
|
|
84
|
+
})
|
|
85
|
+
expect(product.hasModel).toBe('true')
|
|
86
|
+
expect(product.showModel).toBe('false')
|
|
87
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image', 'model'])
|
|
88
|
+
expect(product.galleryThumbnails.map((m) => m.kind)).toEqual(['image', 'model'])
|
|
89
|
+
expect(product.galleryThumbnails[1].src).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
[true, 'true'],
|
|
94
|
+
[false, 'false'],
|
|
95
|
+
[1, 'true'],
|
|
96
|
+
[0, 'false'],
|
|
97
|
+
['true', 'true'],
|
|
98
|
+
['false', 'false'],
|
|
99
|
+
['t', 'true'],
|
|
100
|
+
['f', 'false'],
|
|
101
|
+
['1', 'true'],
|
|
102
|
+
['0', 'false'],
|
|
103
|
+
[null, 'true'],
|
|
104
|
+
[undefined, 'true'],
|
|
105
|
+
['', 'true'],
|
|
106
|
+
])('normalizes show_model_by_default = %p to showModel %s', (raw, expected) => {
|
|
107
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', show_model_by_default: raw })
|
|
108
|
+
expect(product.showModel).toBe(expected)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('always shows the model when it is the only media, whatever the flag says', () => {
|
|
112
|
+
const product = build({
|
|
113
|
+
id: 'p2',
|
|
114
|
+
name: 'Lamp',
|
|
115
|
+
slug: 'lamp',
|
|
116
|
+
price: '5',
|
|
117
|
+
currency: 'USD',
|
|
118
|
+
model_url: 'TQ_model',
|
|
119
|
+
show_model_by_default: false,
|
|
120
|
+
})
|
|
121
|
+
expect(product.showModel).toBe('true')
|
|
122
|
+
expect(product.galleryMedia).toHaveLength(1)
|
|
123
|
+
expect(product.galleryThumbnails).toEqual([])
|
|
124
|
+
// nothing to poster with — the viewer paints its transparent poster
|
|
125
|
+
expect(product.modelPoster).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('falls back to the main image as poster and thumbnail when the model has none', () => {
|
|
129
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', show_model_by_default: false })
|
|
130
|
+
expect(product.modelPoster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
131
|
+
const modelEntry = product.galleryMedia.find((m) => m.kind === 'model')
|
|
132
|
+
expect(modelEntry?.poster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
133
|
+
expect(modelEntry?.thumbnail).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('treats an asset id the asset map cannot resolve as no model at all', () => {
|
|
137
|
+
// resolveAssetUrl returns unknown ids verbatim; a bare 'TQ_…' must never
|
|
138
|
+
// reach <model-viewer src>.
|
|
139
|
+
const product = build({ ...baseRecord, model_url: 'TQ_deleted', model_poster_url: 'TQ_gone' })
|
|
140
|
+
expect(product.modelUrl).toBeNull()
|
|
141
|
+
expect(product.hasModel).toBe('false')
|
|
142
|
+
expect(product.showModel).toBe('false')
|
|
143
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('drops a stale poster id but keeps the model', () => {
|
|
147
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', model_poster_url: 'TQ_gone' })
|
|
148
|
+
expect(product.modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
149
|
+
expect(product.modelPoster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('accepts raw URLs in the model columns', () => {
|
|
153
|
+
const product = build({
|
|
154
|
+
...baseRecord,
|
|
155
|
+
model_url: 'https://cdn.example.com/raw.glb',
|
|
156
|
+
model_poster_url: '/local/poster.png',
|
|
157
|
+
})
|
|
158
|
+
expect(product.modelUrl).toBe('https://cdn.example.com/raw.glb')
|
|
159
|
+
expect(product.modelPoster).toBe('/local/poster.png')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('carries the model fields into related products', () => {
|
|
163
|
+
const related = { ...baseRecord, id: 'p9', slug: 'chair-9', model_url: 'TQ_model' }
|
|
164
|
+
const product = buildEcommerceProduct(
|
|
165
|
+
{ ...baseRecord, related_product_ids: JSON.stringify(['p9']) },
|
|
166
|
+
{ assetMap: ASSET_MAP, relatedProductsById: { p9: related } }
|
|
167
|
+
)
|
|
168
|
+
const relatedProducts = product.relatedProducts as Product[]
|
|
169
|
+
expect(relatedProducts).toHaveLength(1)
|
|
170
|
+
expect(relatedProducts[0].showModel).toBe('true')
|
|
171
|
+
expect(relatedProducts[0].modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('uses the image alt for pictures and the product name for the model', () => {
|
|
175
|
+
const product = build({ ...baseRecord, image_alt: 'A chair', model_url: 'TQ_model' })
|
|
176
|
+
expect(product.galleryMedia[0].alt).toBe('Chair')
|
|
177
|
+
expect(product.galleryMedia[1].alt).toBe('A chair')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -12,6 +12,18 @@ export interface EcommerceProductTransformOptions {
|
|
|
12
12
|
* Resolved by the caller through `StorefrontTax.resolveStorefrontTaxRate`.
|
|
13
13
|
*/
|
|
14
14
|
storefrontTaxRate?: number;
|
|
15
|
+
/**
|
|
16
|
+
* TRUE when stock levels must never gate purchasability in the storefront:
|
|
17
|
+
* the merchant disabled stock management entirely OR opted into backorders
|
|
18
|
+
* (`stockManagementConfig.allowBackorders`). Resolved by the caller through
|
|
19
|
+
* `buildProductTransformOptions`. Baked into the generated transform as
|
|
20
|
+
* `ALLOW_BACKORDERS`: `outOfStock` then always reads 'false' and every
|
|
21
|
+
* EXISTING variant combination counts as in stock — a combination that does
|
|
22
|
+
* not exist at all stays unavailable (there is nothing to backorder).
|
|
23
|
+
* Mirrors the GUI rule in
|
|
24
|
+
* `apps/gui/app/project-page/features/e-commerce/utils/product-variants.ts`.
|
|
25
|
+
*/
|
|
26
|
+
allowBackorders?: boolean;
|
|
15
27
|
}
|
|
16
28
|
/**
|
|
17
29
|
* Generates JavaScript code for e-commerce product data transformation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ecommerce-product.d.ts","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAGvE;;;;GAIG;AACH,MAAM,WAAW,gCAAgC;IAC/C,+EAA+E;IAC/E,UAAU,CAAC,EAAE,qBAAqB,EAAE,CAAA;IACpC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"ecommerce-product.d.ts","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAGvE;;;;GAIG;AACH,MAAM,WAAW,gCAAgC;IAC/C,+EAA+E;IAC/E,UAAU,CAAC,EAAE,qBAAqB,EAAE,CAAA;IACpC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;;;;;;OAUG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B;AA2CD;;;;GAIG;AACH,eAAO,MAAM,0CAA0C,aAC5C,gCAAgC,KACxC,MA4yBF,CAAA"}
|
|
@@ -45,7 +45,8 @@ var generateEcommerceProductTransformationCode = function (options) {
|
|
|
45
45
|
if (options === void 0) { options = {}; }
|
|
46
46
|
var categoriesByIdJson = JSON.stringify(flattenCategoriesById(options.categories));
|
|
47
47
|
var taxHelperCode = teleport_shared_1.StorefrontTax.generateStorefrontTaxHelperCode((_a = options.storefrontTaxRate) !== null && _a !== void 0 ? _a : 0);
|
|
48
|
-
return "\n".concat(taxHelperCode, "\n\n// Category taxonomy (id -> {name, slug, translations}), baked in at export\n// time \u2014 see flattenCategoriesById in ecommerce-product.ts. Empty when the\n// store has no category taxonomy, or products.category_ids simply resolves\n// to nothing.\nvar PRODUCT_CATEGORIES_BY_ID = ").concat(categoriesByIdJson, "\n\n// Resolve a baked category's name to currentLang/mainLang from its\n// translations map (same per-request locale as every other i18n field via\n// resolveI18nField), falling back to the main-language name.\nfunction resolveCategoryName(categoryInfo, currentLang, mainLang) {\n if (currentLang && mainLang && currentLang !== mainLang && categoryInfo.translations) {\n var override = categoryInfo.translations[currentLang]\n if (override && override.name) return override.name\n }\n return categoryInfo.name\n}\n\n// ============================================================\n// E-Commerce Product Transformation\n// ============================================================\n\nfunction buildEcommerceProduct(record, options) {\n if (!record || typeof record !== 'object') return record\n options = options || {}\n var currentLang = options.currentLanguage || null\n var mainLang = options.mainLanguage || null\n var assetMap = options.assetMap || {}\n\n var id = record.id !== undefined && record.id !== null ? record.id : null\n\n // i18n-resolved text fields\n var name = resolveI18nField(record, 'name', 'name', currentLang, mainLang) || ''\n var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''\n var description = resolveI18nField(record, 'description', 'description', currentLang, mainLang) || ''\n var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null\n var tagsRaw = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)\n var imageAlt = resolveI18nField(record, 'image_alt', 'imageAlt', currentLang, mainLang) || null\n\n // Computed: plain-text description (HTML stripped)\n var descriptionText = stripHtmlTags(description)\n\n // Numeric fields (with NaN protection)\n var price = safeNumber(record.price, 0)\n var quantity = safeNumber(record.quantity, null)\n\n // Currency\n var currency = (record.currency || 'USD').toUpperCase()\n var rawCurrencySymbol = pickFirst(record.currency_symbol, record.currencySymbol)\n var currencySymbol = rawCurrencySymbol || getCurrencySymbol(currency)\n\n // Status and active (normalize case \u2014 DB / forms may send \"Active\", \"Inactive\", etc.)\n var statusRaw = record.status\n if (statusRaw == null || statusRaw === '') {\n statusRaw = record.active ? 'active' : 'inactive'\n }\n var status =\n typeof statusRaw === 'string'\n ? statusRaw.trim().toLowerCase()\n : String(statusRaw).toLowerCase()\n var active = status === 'active'\n\n // Payment fields\n var paymentType = pickFirst(record.payment_type, record.paymentType) || 'one_time'\n var recurringInterval = pickFirst(record.recurring_interval, record.recurringInterval) || null\n var rawRecurringCount = pickFirst(record.recurring_interval_count, record.recurringIntervalCount)\n var recurringIntervalCount = safeNumber(rawRecurringCount, null)\n\n // Physical product fields\n var sku = record.sku || null\n var weight = safeNumber(record.weight, null)\n var weightUnit = pickFirst(record.weight_unit, record.weightUnit) || null\n var dimensions = record.dimensions || null\n\n // Tags - parse as JSON array (handles JSON strings, arrays, comma-separated)\n var tags = parseJsonArray(tagsRaw)\n\n // Variant AXES (translatable). The i18n-resolved column already carries the\n // display labels for the current language (with the SAME stable keys as the\n // base column); we just parse + normalize. The purchasable combinations live\n // in teleport_product_variants and are fetched by the variant picker widget.\n var variantOptionsRaw = resolveI18nField(record, 'variant_options', 'variantOptions', currentLang, mainLang)\n var variantOptions = normalizeVariantOptions(variantOptionsRaw)\n // Emitted as a STRING ('true'/'false') to match the strict-equality checks the\n // storefront uses (same convention as outOfStock).\n var requiresVariantSelection = variantOptions.length > 0 ? 'true' : 'false'\n\n // Provider fields\n var providerType = pickFirst(record.provider_type, record.providerType) || null\n var providerProductId = pickFirst(record.provider_product_id, record.providerProductId) || null\n var defaultPrice = pickFirst(record.default_price, record.defaultPrice) || null\n\n // Metadata\n var metadata = parseJsonObject(record.metadata)\n\n // Visible custom properties -> [{key,value,kind,color,imageUrl}] for the\n // storefront pills mapper. MUST mirror buildVisibleProductProperties /\n // classifyProductProperty in\n // apps/gui/app/project-page/features/e-commerce/utils/product-property-display.ts\n // (the canvas-preview SSOT) field-for-field: same reserved-key exclusions,\n // same default-VISIBLE (opt-out) rule, same color/image classification. A\n // property is shown unless it is a reserved key, has an empty value, or is\n // explicitly hidden (false) in the _tqPropVisibility map -- so it also\n // renders for products whose form was never opened (no visibility map yet).\n var propVisibility = parseJsonObject(metadata._tqPropVisibility)\n var visibleProperties = []\n Object.keys(metadata).forEach(function (propKey) {\n if (isReservedProductMetadataKey(propKey)) return\n if (propVisibility[propKey] === false) return\n var rawValue = metadata[propKey]\n if (rawValue == null) return\n var propValue = String(rawValue)\n if (propValue.trim() === '') return\n visibleProperties.push(classifyProductProperty(propKey, propValue))\n })\n\n // Assigned category ids -> resolved {id,name,slug} objects for the\n // storefront category pills mapper. The taxonomy itself is not in the DB\n // (categories are authored in the GUI and live only in\n // ecommerceSettings.categories); resolved here against PRODUCT_CATEGORIES_BY_ID,\n // baked in at export time (see flattenCategoriesById). Unknown/stale ids\n // (a deleted category) are silently dropped.\n var categoryIds = parseJsonArray(record.category_ids)\n var categories = []\n for (var ci = 0; ci < categoryIds.length; ci++) {\n var categoryInfo = PRODUCT_CATEGORIES_BY_ID[categoryIds[ci]]\n if (categoryInfo) {\n categories.push({\n id: categoryIds[ci],\n name: resolveCategoryName(categoryInfo, currentLang, mainLang),\n slug: categoryInfo.slug,\n })\n }\n }\n\n // Purchasable variant COMBINATIONS for this product ({id, options map, price,\n // stock, image}). Empty for flat products. Fetched in ONE batched query by\n // transformRecords and passed in via options.variantsByProductId, keyed by\n // product id. The storefront card/details read these to default-select the\n // first in-stock variant, disable dead options, and resolve the chosen id.\n //\n // NULL (not {}) means the lookup could not run \u2014 the table is missing, or the\n // query failed. That is \"combinations UNKNOWN\", which is NOT \"this product has\n // none\": a transient DB failure must never turn every variant product in the\n // catalogue into an unbuyable one. See hasPurchasableVariant below.\n var variantsResolved = options.variantsByProductId != null\n var variantsByProductId = options.variantsByProductId || {}\n var rawVariants = variantsByProductId[id] || []\n var variants = []\n for (var vi = 0; vi < rawVariants.length; vi++) {\n var vrow = rawVariants[vi] || {}\n var vopts = parseJsonObject(vrow.options)\n variants.push({\n id: vrow.id != null ? String(vrow.id) : '',\n options: vopts && typeof vopts === 'object' ? vopts : {},\n price: safeNumber(vrow.price, null),\n quantity: safeNumber(vrow.quantity, null),\n image_url: vrow.image_url ? resolveAssetUrl(vrow.image_url, assetMap) : null,\n })\n }\n\n // The merchant's related-product picks. `relatedProducts` carries the\n // TRANSFORMED rows, not ids, so the details page's related-products rail can\n // map over it and draw a product card per entry; `relatedProductIds` keeps\n // the raw selection for anything that wants it. The rows arrive from ONE\n // batched query (getRelatedProductsMap) that transformRecords runs, keyed by\n // id in options.relatedProductsById.\n //\n // MUST mirror buildRelatedEcommerceProducts in\n // packages/renderer/src/utils/ecommerce-products.ts (the canvas-preview SSOT):\n // the merchant's order, inactive products dropped, self dropped, and the\n // nested build deliberately WITHOUT the map \u2014 two products referencing each\n // other is the normal case (they are each other's accessories) and threading\n // the map through would recurse until the stack ran out.\n var relatedProductIds = parseRelatedIds(record.related_product_ids)\n var relatedProductsById = options.relatedProductsById\n var relatedProducts = []\n if (relatedProductsById && relatedProductIds.length > 0) {\n var relatedNestedOptions = {\n assetMap: assetMap,\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n // Passed through UNCOALESCED so a related product inherits the same\n // known/unknown answer as its parent \u2014 `|| {}` here would tell the nested\n // build \"the lookup ran and found nothing\" and strike out every related\n // card's picker. The batched query includes the related ids for exactly\n // this reason (see getTransformWrapperCode).\n variantsByProductId: options.variantsByProductId,\n }\n for (var rp = 0; rp < relatedProductIds.length; rp++) {\n var relatedId = relatedProductIds[rp]\n if (id != null && relatedId === String(id)) continue\n var relatedRecord = relatedProductsById[relatedId]\n if (!relatedRecord) continue\n // A product taken off sale must not come back through the back door on\n // every product that references it. Missing status reads as active, the\n // same default the main status normalization uses above.\n var relatedStatusRaw = relatedRecord.status\n var relatedStatus =\n relatedStatusRaw == null || relatedStatusRaw === ''\n ? 'active'\n : String(relatedStatusRaw).trim().toLowerCase()\n if (relatedStatus !== 'active') continue\n relatedProducts.push(buildEcommerceProduct(relatedRecord, relatedNestedOptions))\n }\n }\n\n // -------------------------------------------------------\n // Image resolution (complex multi-step process)\n // -------------------------------------------------------\n\n // Step 1: Get raw image values\n var rawImageUrl = pickFirst(record.image_url, record.mainImage, record.imageUrl)\n var rawGalleryImages = pickFirst(record.gallery_images, record.galleryImages)\n var legacyImages = Array.isArray(record.images)\n ? record.images.filter(function(v) { return typeof v === 'string' })\n : []\n\n // Step 2: Parse gallery images from JSON\n var parsedGalleryImages = parseJsonArray(rawGalleryImages)\n\n // Step 3: Build combined raw images array\n var allRawImages = []\n if (rawImageUrl) allRawImages.push(rawImageUrl)\n allRawImages = allRawImages.concat(parsedGalleryImages).concat(legacyImages)\n\n // Step 4: Resolve all asset URLs first, then deduplicate\n var resolvedAll = resolveAssetUrls(allRawImages, assetMap)\n var resolvedImages = deduplicateStrings(resolvedAll)\n\n // Step 5: Determine mainImage\n var mainImage = rawImageUrl\n ? resolveAssetUrl(rawImageUrl, assetMap)\n : (resolvedImages.length > 0 ? resolvedImages[0] : null)\n\n // Step 6: galleryImages = resolvedImages without mainImage\n var galleryImages = mainImage\n ? resolvedImages.filter(function(url) { return url !== mainImage })\n : resolvedImages.slice()\n\n // Step 7: images = full resolved deduplicated array\n var images = resolvedImages\n\n // Timestamps\n var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)\n var createdAt = normalizeTimestamp(rawCreatedAt)\n\n var rawUpdatedAt = pickFirst(record.updated_at, record.updatedAt, record.updated)\n var updatedAt = rawUpdatedAt != null ? normalizeTimestamp(rawUpdatedAt) : createdAt\n\n // Computed: created = Unix seconds\n var created = Math.floor(createdAt / 1000)\n\n // Computed: outOfStock (emitted as a STRING \u2014 'true' / 'false' \u2014 so it\n // matches the strict equality check the AI generates on product\n // cards/details pages: `ecommerceProduct?.outOfStock === 'true'`.\n // A boolean here would silently fail that comparison (boolean !==\n // string), leaving the Add to Cart button visible on out-of-stock\n // products. NULL/NaN quantity \u2192 'false' (treated as \"unlimited\n // stock\", same as the upstream cart-availability rewriter).\n var outOfStock = (quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'\n\n // Per-value availability + default-selection flags, derived from the\n // purchasable combinations, so the storefront picker renders its\n // Default / Selected / Unavailable states with NO runtime fetch. The card\n // (N instances) first-paints from these data flags; the details page seeds\n // its selection from the same combinations. axisValueKey (axisKey|valueKey)\n // disambiguates same-named values across axes (Size \"s\" vs Sleeve \"s\").\n // Emitted as STRINGS to match the storefront's string-equality convention.\n var variantAxisKeys = []\n for (var ak = 0; ak < variantOptions.length; ak++) { variantAxisKeys.push(variantOptions[ak].key) }\n var firstVariant = pickFirstAvailableVariant(variants, variantAxisKeys)\n var deadValueKeys = computeDeadVariantKeys(variants, variantOptions, variantsResolved)\n for (var ai = 0; ai < variantOptions.length; ai++) {\n var vax = variantOptions[ai]\n for (var vj = 0; vj < vax.values.length; vj++) {\n var vval = vax.values[vj]\n var composite = vax.key + '|' + vval.value\n vval.axisValueKey = composite\n vval.isDead = deadValueKeys[composite] ? 'true' : 'false'\n vval.isDefault =\n firstVariant && firstVariant.options && firstVariant.options[vax.key] === vval.value\n ? 'true'\n : 'false'\n // Whether THIS value renders as a colour swatch vs a text pill. A colour\n // axis whose value has no resolvable colour degrades to a text pill so it\n // is never an invisible empty circle. Emitted as a string to match the\n // picker's string-equality rendering gate. MUST mirror the renderer\n // enrichment in packages/renderer/src/utils/ecommerce-products.ts.\n vval.showSwatch = vax.type === 'color' && vval.color ? 'true' : 'false'\n }\n }\n // The combination the picker auto-selects on first paint: the first in-stock\n // one covering every axis. Absent means there is nothing the shopper could add\n // without choosing \u2014 which is what gates the buy button below.\n var hasInStockCombination = !!(firstVariant && isVariantInStock(firstVariant))\n var defaultVariantId = hasInStockCombination ? firstVariant.id : ''\n var defaultVariantPrice = firstVariant ? formatVariantPrice(firstVariant.price, price) : ''\n // Gross (tax-inclusive) counterparts of the three price fields the STOREFRONT\n // renders. `price` / `defaultVariantPrice` / `variantsJson` deliberately stay\n // NET: the same transform feeds the merchant's admin panel, and every write\n // path (add-to-cart, checkout, the invoice route) re-reads the raw row and\n // must keep seeing the price the merchant typed. Only these three are safe to\n // gross, because nothing writes them back.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n var displayPrice = grossMoney(price)\n var defaultVariantDisplayPrice = firstVariant\n ? grossMoney(formatVariantPrice(firstVariant.price, price))\n : ''\n var variantsDisplay = []\n for (var dv = 0; dv < variants.length; dv++) {\n var dvRow = variants[dv]\n variantsDisplay.push({\n id: dvRow.id,\n options: dvRow.options,\n // A null variant price INHERITS the product price, so gross the RESOLVED\n // value \u2014 otherwise an inheriting combination would fall back to the net\n // base price in the picker while an overriding one showed gross.\n price: applyStorefrontTax(dvRow.price == null ? price : dvRow.price),\n quantity: dvRow.quantity,\n image_url: dvRow.image_url,\n })\n }\n // Can this product be added to the cart at all, before the shopper touches the\n // picker? A STRING, same convention as outOfStock/requiresVariantSelection.\n //\n // flat product -> 'true' (stock is governed by outOfStock)\n // combinations UNKNOWN -> 'true' (the lookup could not run; a transient DB\n // failure must never make a whole catalogue\n // unbuyable \u2014 same permissive rule as\n // dead-marking)\n // otherwise -> whether an in-stock combination covering every axis\n // exists. 'false' therefore covers BOTH \"every\n // combination is sold out\" AND \"the axes have no\n // combinations at all\" \u2014 to a shopper those are the\n // same thing: there is no size to pick.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n var hasPurchasableVariant =\n (requiresVariantSelection !== 'true' || !variantsResolved || hasInStockCombination)\n ? 'true'\n : 'false'\n // Stringified companions: a data-* attr bound to an ARRAY renders\n // '[object Object]', so the picker's on-mount/click workflows read the\n // combinations + axes from these JSON strings via getAttribute.\n var variantOptionsJson = safeStringifyJson(variantOptions)\n var variantsJson = safeStringifyJson(variants)\n var variantsDisplayJson = safeStringifyJson(variantsDisplay)\n\n return {\n id: id,\n name: name,\n slug: slug,\n description: description,\n descriptionText: descriptionText,\n price: price,\n currency: currency,\n currencySymbol: currencySymbol,\n status: status,\n active: active,\n quantity: quantity,\n paymentType: paymentType,\n recurringInterval: recurringInterval,\n recurringIntervalCount: recurringIntervalCount,\n sku: sku,\n weight: weight,\n weightUnit: weightUnit,\n dimensions: dimensions,\n category: category,\n categories: categories,\n tags: tags,\n relatedProductIds: relatedProductIds,\n relatedProducts: relatedProducts,\n imageAlt: imageAlt,\n providerType: providerType,\n providerProductId: providerProductId,\n default_price: defaultPrice,\n metadata: metadata,\n visibleProperties: visibleProperties,\n mainImage: mainImage,\n imageUrl: mainImage,\n image_url: mainImage,\n galleryImages: galleryImages,\n images: images,\n createdAt: createdAt,\n updatedAt: updatedAt,\n created: created,\n outOfStock: outOfStock,\n variantOptions: variantOptions,\n requiresVariantSelection: requiresVariantSelection,\n variants: variants,\n variantOptionsJson: variantOptionsJson,\n variantsJson: variantsJson,\n variantsDisplayJson: variantsDisplayJson,\n defaultVariantId: defaultVariantId,\n defaultVariantPrice: defaultVariantPrice,\n displayPrice: displayPrice,\n defaultVariantDisplayPrice: defaultVariantDisplayPrice,\n hasPurchasableVariant: hasPurchasableVariant,\n }\n}\n\n// --- Variant availability helpers (mirror packages/renderer ecommerce-products) ---\n// A variant is in stock when its quantity is null (unlimited) or > 0.\nfunction isVariantInStock(v) {\n return !v ? false : v.quantity == null || v.quantity > 0\n}\nfunction variantCoversAllAxes(v, axisKeys) {\n if (!v || !v.options) return false\n for (var i = 0; i < axisKeys.length; i++) {\n if (v.options[axisKeys[i]] == null) return false\n }\n return true\n}\n// First in-stock combination covering every axis; falls back to the first\n// covering combination (even if OOS) so a fully-OOS product still has a\n// visual default; null when no combination covers the axes.\nfunction pickFirstAvailableVariant(variants, axisKeys) {\n if (!Array.isArray(variants) || !axisKeys.length) return null\n for (var i = 0; i < variants.length; i++) {\n if (variantCoversAllAxes(variants[i], axisKeys) && isVariantInStock(variants[i])) return variants[i]\n }\n for (var j = 0; j < variants.length; j++) {\n if (variantCoversAllAxes(variants[j], axisKeys)) return variants[j]\n }\n return null\n}\n// Map of composite \"axisKey|valueKey\" -> true for every value that appears in\n// NO in-stock combination (rendered as the Unavailable state).\n//\n// variantsResolved is the whole subtlety: dead-marking is only meaningful\n// against combinations we actually LOOKED UP. When the lookup could not run,\n// nothing is dead \u2014 a missing table or a failed query must not grey out every\n// picker in the store. When it DID run, an empty list is a fact, not a gap:\n// every value is unbuyable and renders as Unavailable.\nfunction computeDeadVariantKeys(variants, axes, variantsResolved) {\n if (!variantsResolved || !Array.isArray(variants)) return {}\n var alive = {}\n for (var i = 0; i < variants.length; i++) {\n var v = variants[i]\n if (!isVariantInStock(v)) continue\n for (var k in v.options) {\n if (Object.prototype.hasOwnProperty.call(v.options, k)) alive[k + '|' + v.options[k]] = true\n }\n }\n var dead = {}\n for (var a = 0; a < axes.length; a++) {\n var ax = axes[a]\n for (var b = 0; b < ax.values.length; b++) {\n var key = ax.key + '|' + ax.values[b].value\n if (!alive[key]) dead[key] = true\n }\n }\n return dead\n}\n// Variant price with base-price inheritance (null variant price -> product price).\nfunction formatVariantPrice(vprice, basePrice) {\n var p = vprice == null || isNaN(vprice) ? basePrice : vprice\n return String(Number(p).toFixed(2))\n}\nfunction safeStringifyJson(obj) {\n try {\n return JSON.stringify(obj)\n } catch (e) {\n return '[]'\n }\n}\n\n// Parse + normalize the variant_options JSON into render-ready axes. Tolerant of\n// null / malformed / partial data (returns []). Keeps ONLY the fields the pill\n// widget needs; the labels are already language-resolved by the caller.\nfunction normalizeVariantOptions(raw) {\n if (!raw) return []\n var parsed = raw\n if (typeof raw === 'string') {\n try { parsed = JSON.parse(raw) } catch (e) { return [] }\n }\n if (!Array.isArray(parsed)) return []\n var out = []\n for (var i = 0; i < parsed.length; i++) {\n var axis = parsed[i]\n if (!axis || typeof axis !== 'object' || !Array.isArray(axis.values)) continue\n var name = typeof axis.name === 'string' ? axis.name : ''\n var key = typeof axis.key === 'string' && axis.key ? axis.key : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!key) continue\n var type = axis.type === 'color' ? 'color' : 'text'\n var values = []\n for (var j = 0; j < axis.values.length; j++) {\n var v = axis.values[j]\n if (!v || typeof v !== 'object') continue\n var label = typeof v.label === 'string' ? v.label : ''\n var value = typeof v.value === 'string' && v.value ? v.value : label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!value) continue\n var entry = { value: value, label: label }\n if (type === 'color' && typeof v.color === 'string' && v.color) entry.color = v.color\n values.push(entry)\n }\n if (!values.length) continue\n out.push({ key: key, name: name, type: type, values: values })\n }\n return out\n}\n\n// --- Custom-property classification (MUST mirror product-property-display.ts) ---\n\n// Product-attribute keys that can leak into the free-form `metadata` map (e.g.\n// from a payment-provider sync or legacy data) but are NOT user custom\n// properties. Kept in sync with the GUI's RESERVED_PRODUCT_METADATA_KEYS.\nvar RESERVED_PRODUCT_METADATA_KEYS = {\n paymentType: true, recurringInterval: true, recurringIntervalCount: true,\n price: true, currency: true, status: true, sku: true, weightUnit: true,\n dimensions: true, category: true, brand: true, gtin: true, mpn: true,\n condition: true, tags: true,\n}\nfunction isReservedProductMetadataKey(key) {\n return !!RESERVED_PRODUCT_METADATA_KEYS[key] ||\n key.indexOf('mainImage') === 0 ||\n key.indexOf('secondaryImage') === 0 ||\n key.indexOf('_tq') === 0\n}\n\nvar CSS_NAMED_COLORS = {\n aliceblue: 1, antiquewhite: 1, aqua: 1, aquamarine: 1, azure: 1, beige: 1, bisque: 1,\n black: 1, blanchedalmond: 1, blue: 1, blueviolet: 1, brown: 1, burlywood: 1,\n cadetblue: 1, chartreuse: 1, chocolate: 1, coral: 1, cornflowerblue: 1, cornsilk: 1,\n crimson: 1, cyan: 1, darkblue: 1, darkcyan: 1, darkgoldenrod: 1, darkgray: 1,\n darkgrey: 1, darkgreen: 1, darkkhaki: 1, darkmagenta: 1, darkolivegreen: 1,\n darkorange: 1, darkorchid: 1, darkred: 1, darksalmon: 1, darkseagreen: 1,\n darkslateblue: 1, darkslategray: 1, darkslategrey: 1, darkturquoise: 1,\n darkviolet: 1, deeppink: 1, deepskyblue: 1, dimgray: 1, dimgrey: 1, dodgerblue: 1,\n firebrick: 1, floralwhite: 1, forestgreen: 1, fuchsia: 1, gainsboro: 1,\n ghostwhite: 1, gold: 1, goldenrod: 1, gray: 1, grey: 1, green: 1, greenyellow: 1,\n honeydew: 1, hotpink: 1, indianred: 1, indigo: 1, ivory: 1, khaki: 1, lavender: 1,\n lavenderblush: 1, lawngreen: 1, lemonchiffon: 1, lightblue: 1, lightcoral: 1,\n lightcyan: 1, lightgoldenrodyellow: 1, lightgray: 1, lightgrey: 1, lightgreen: 1,\n lightpink: 1, lightsalmon: 1, lightseagreen: 1, lightskyblue: 1, lightslategray: 1,\n lightslategrey: 1, lightsteelblue: 1, lightyellow: 1, lime: 1, limegreen: 1,\n linen: 1, magenta: 1, maroon: 1, mediumaquamarine: 1, mediumblue: 1,\n mediumorchid: 1, mediumpurple: 1, mediumseagreen: 1, mediumslateblue: 1,\n mediumspringgreen: 1, mediumturquoise: 1, mediumvioletred: 1, midnightblue: 1,\n mintcream: 1, mistyrose: 1, moccasin: 1, navajowhite: 1, navy: 1, oldlace: 1,\n olive: 1, olivedrab: 1, orange: 1, orangered: 1, orchid: 1, palegoldenrod: 1,\n palegreen: 1, paleturquoise: 1, palevioletred: 1, papayawhip: 1, peachpuff: 1,\n peru: 1, pink: 1, plum: 1, powderblue: 1, purple: 1, rebeccapurple: 1, red: 1,\n rosybrown: 1, royalblue: 1, saddlebrown: 1, salmon: 1, sandybrown: 1, seagreen: 1,\n seashell: 1, sienna: 1, silver: 1, skyblue: 1, slateblue: 1, slategray: 1,\n slategrey: 1, snow: 1, springgreen: 1, steelblue: 1, tan: 1, teal: 1, thistle: 1,\n tomato: 1, turquoise: 1, violet: 1, wheat: 1, white: 1, whitesmoke: 1, yellow: 1,\n yellowgreen: 1, transparent: 1,\n}\nvar HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\nvar FUNCTIONAL_COLOR_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\((?:[0-9a-z%.,\\s/+-]+)\\)$/i\nvar COLOR_KEY_WORDS = {\n color: 1, colors: 1, colour: 1, colours: 1, shade: 1, shades: 1, hue: 1, hues: 1,\n tint: 1, tints: 1, tone: 1, tones: 1, swatch: 1, swatches: 1,\n}\nfunction splitKeyWords(key) {\n return key.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)\n}\nfunction keyIndicatesColor(key) {\n var normalized = key.toLowerCase()\n if (/colou?r/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!COLOR_KEY_WORDS[word] })\n}\nfunction resolvePropertyColor(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (HEX_COLOR_RE.test(v) || FUNCTIONAL_COLOR_RE.test(v)) return v\n if (CSS_NAMED_COLORS[v.toLowerCase()] && keyIndicatesColor(key)) return v\n return ''\n}\n\nvar DATA_IMAGE_URI_RE = /^data:image\\//i\nvar ABSOLUTE_URL_RE = /^(?:https?:)?\\/\\//i\nvar IMAGE_EXTENSION_RE = /\\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?|heic|heif)$/i\nvar IMAGE_KEY_WORDS = {\n image: 1, images: 1, img: 1, imgs: 1, photo: 1, photos: 1, picture: 1, pictures: 1,\n pic: 1, pics: 1, thumbnail: 1, thumbnails: 1, thumb: 1, thumbs: 1, icon: 1, icons: 1,\n logo: 1, logos: 1, avatar: 1, avatars: 1, banner: 1, banners: 1, cover: 1, covers: 1,\n gallery: 1, screenshot: 1, screenshots: 1, poster: 1, posters: 1, artwork: 1,\n graphic: 1, graphics: 1, illustration: 1, wallpaper: 1, preview: 1, snapshot: 1,\n media: 1, visual: 1, visuals: 1,\n}\nfunction keyIndicatesImage(key) {\n var normalized = key.toLowerCase()\n if (/(?:image|img|photo|picture|thumb|icon|logo|avatar|banner|gallery|screenshot|poster|artwork|graphic|wallpaper)/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!IMAGE_KEY_WORDS[word] })\n}\nfunction stripUrlQueryAndHash(url) {\n return url.split(/[?#]/)[0]\n}\nfunction resolvePropertyImage(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (DATA_IMAGE_URI_RE.test(v)) return v\n var isAbsoluteUrl = ABSOLUTE_URL_RE.test(v)\n var isRootRelative = v.charAt(0) === '/' && !isAbsoluteUrl\n if (!isAbsoluteUrl && !isRootRelative) return ''\n if (IMAGE_EXTENSION_RE.test(stripUrlQueryAndHash(v))) return v\n if (isAbsoluteUrl && keyIndicatesImage(key)) return v\n return ''\n}\n\n// Image wins over colour (a URL can never be a CSS colour, but checking image\n// first keeps the precedence explicit), text is the fallback.\nfunction classifyProductProperty(key, value) {\n var imageUrl = resolvePropertyImage(key, value)\n if (imageUrl) return { key: key, value: value, kind: 'image', color: '', imageUrl: imageUrl }\n var color = resolvePropertyColor(key, value)\n if (color) return { key: key, value: value, kind: 'color', color: color, imageUrl: '' }\n return { key: key, value: value, kind: 'text', color: '', imageUrl: '' }\n}\n\nfunction transformEcommerceProducts(records, options) {\n if (!Array.isArray(records)) return []\n return records.map(function(record) { return buildEcommerceProduct(record, options) })\n}\n\n// Batched fetch of the purchasable variant rows for a set of product ids, keyed\n// by product_id, so buildEcommerceProduct can attach product.variants without a\n// per-product query. Best-effort: a store with no variants (or before the table\n// exists) yields an empty map and flat products keep variants: [].\n// Batched fetch of the product rows a set of products reference as \"related\",\n// keyed by id, so buildEcommerceProduct can attach product.relatedProducts\n// without a query per product.\n//\n// Best-effort by design: a store whose products table predates the column\n// yields no ids at all (the read below is of an absent property, not of a\n// column \u2014 the SQL never names it), the map stays empty, and the details page's\n// rail simply stays hidden behind its is_not_empty gate. Same for a failed\n// query.\nasync function getRelatedProductsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length === 0) return map\n\n var wantedIds = []\n for (var i = 0; i < records.length; i++) {\n var ids = parseRelatedIds(records[i] && records[i].related_product_ids)\n for (var j = 0; j < ids.length; j++) {\n if (wantedIds.indexOf(ids[j]) === -1) wantedIds.push(ids[j])\n }\n }\n if (wantedIds.length === 0) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query('SELECT * FROM teleport_products WHERE id = ANY($1)', [\n wantedIds,\n ])\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.id != null) map[String(row.id)] = row\n }\n }\n } catch (e) {\n // Leaves relatedProducts empty rather than failing the page.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// Combinations for every product on the page, keyed by product id. Returns NULL\n// when the lookup could not run, which the transform reads as \"unknown\" and\n// stays permissive with; an empty map means the query ran and this catalogue\n// simply has no combinations.\nasync function getVariantsMap(getClientFn, productIds) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n 'SELECT id, product_id, options, price, quantity, image_url FROM teleport_product_variants WHERE product_id = ANY($1) ORDER BY position ASC, id ASC',\n [uniqueIds]\n )\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n var pid = row.product_id\n if (!map[pid]) map[pid] = []\n map[pid].push(row)\n }\n }\n } catch (e) {\n // teleport_product_variants may not exist for a non-variant store, or the\n // query may have failed. Either way the combinations are UNKNOWN \u2014 null\n // keeps every picker selectable instead of declaring the catalogue unbuyable.\n map = null\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n");
|
|
48
|
+
var allowBackordersLiteral = options.allowBackorders === true ? 'true' : 'false';
|
|
49
|
+
return "\n".concat(taxHelperCode, "\n\n// Baked from the merchant's stock settings: TRUE when stock never blocks a\n// purchase (stock management off OR backorders allowed). Regenerating the\n// project refreshes it \u2014 do not hand-edit. See EcommerceProductTransformOptions.\nvar ALLOW_BACKORDERS = ").concat(allowBackordersLiteral, "\n\n// Category taxonomy (id -> {name, slug, translations}), baked in at export\n// time \u2014 see flattenCategoriesById in ecommerce-product.ts. Empty when the\n// store has no category taxonomy, or products.category_ids simply resolves\n// to nothing.\nvar PRODUCT_CATEGORIES_BY_ID = ").concat(categoriesByIdJson, "\n\n// Resolve a baked category's name to currentLang/mainLang from its\n// translations map (same per-request locale as every other i18n field via\n// resolveI18nField), falling back to the main-language name.\nfunction resolveCategoryName(categoryInfo, currentLang, mainLang) {\n if (currentLang && mainLang && currentLang !== mainLang && categoryInfo.translations) {\n var override = categoryInfo.translations[currentLang]\n if (override && override.name) return override.name\n }\n return categoryInfo.name\n}\n\n// The merchant's \"Show 3D model by default\" toggle, as every backend may hand\n// it back: a real boolean, 1/0, 'true'/'false'/'t'/'f' strings \u2014 and NULL /\n// absent / '' as ON, because a product that has a model shows it unless the\n// merchant switched it off. MUST mirror normalizeShowModelFlag in the GUI's\n// features/e-commerce/constants/product-model.ts.\nfunction normalizeShowModelFlag(raw) {\n if (raw === null || raw === undefined || raw === '') return true\n if (typeof raw === 'boolean') return raw\n if (typeof raw === 'number') return raw !== 0\n if (typeof raw === 'string') {\n var token = raw.trim().toLowerCase()\n return !(token === 'false' || token === 'f' || token === '0' || token === 'no' || token === 'off')\n }\n return true\n}\n\n// The product's media gallery in display order, HERO FIRST: the model when it\n// is shown by default, otherwise the images with the model last. Each entry is\n// { kind: 'image' | 'model', src, poster, thumbnail, alt }; 'thumbnail' is what\n// a strip draws and is never empty for an entry that can appear in one. MUST\n// mirror buildProductGalleryMedia in the GUI's\n// features/e-commerce/utils/product-gallery-media.ts.\nfunction buildProductGalleryMedia(params) {\n var imageItems = []\n for (var gi = 0; gi < params.images.length; gi++) {\n imageItems.push({\n kind: 'image',\n src: params.images[gi],\n poster: '',\n thumbnail: params.images[gi],\n alt: params.imageAlt || params.name,\n })\n }\n if (!params.modelUrl) return imageItems\n var modelItem = {\n kind: 'model',\n src: params.modelUrl,\n poster: params.modelPoster || '',\n thumbnail: params.modelPoster || params.mainImage || params.images[0] || '',\n alt: params.name,\n }\n return params.showModel === 'true' ? [modelItem].concat(imageItems) : imageItems.concat([modelItem])\n}\n\n// ============================================================\n// E-Commerce Product Transformation\n// ============================================================\n\nfunction buildEcommerceProduct(record, options) {\n if (!record || typeof record !== 'object') return record\n options = options || {}\n var currentLang = options.currentLanguage || null\n var mainLang = options.mainLanguage || null\n var assetMap = options.assetMap || {}\n\n var id = record.id !== undefined && record.id !== null ? record.id : null\n\n // i18n-resolved text fields\n var name = resolveI18nField(record, 'name', 'name', currentLang, mainLang) || ''\n var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''\n var description = resolveI18nField(record, 'description', 'description', currentLang, mainLang) || ''\n var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null\n var tagsRaw = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)\n var imageAlt = resolveI18nField(record, 'image_alt', 'imageAlt', currentLang, mainLang) || null\n\n // Computed: plain-text description (HTML stripped)\n var descriptionText = stripHtmlTags(description)\n\n // Numeric fields (with NaN protection)\n var price = safeNumber(record.price, 0)\n var quantity = safeNumber(record.quantity, null)\n\n // Currency\n var currency = (record.currency || 'USD').toUpperCase()\n var rawCurrencySymbol = pickFirst(record.currency_symbol, record.currencySymbol)\n var currencySymbol = rawCurrencySymbol || getCurrencySymbol(currency)\n\n // Status and active (normalize case \u2014 DB / forms may send \"Active\", \"Inactive\", etc.)\n var statusRaw = record.status\n if (statusRaw == null || statusRaw === '') {\n statusRaw = record.active ? 'active' : 'inactive'\n }\n var status =\n typeof statusRaw === 'string'\n ? statusRaw.trim().toLowerCase()\n : String(statusRaw).toLowerCase()\n var active = status === 'active'\n\n // Payment fields\n var paymentType = pickFirst(record.payment_type, record.paymentType) || 'one_time'\n var recurringInterval = pickFirst(record.recurring_interval, record.recurringInterval) || null\n var rawRecurringCount = pickFirst(record.recurring_interval_count, record.recurringIntervalCount)\n var recurringIntervalCount = safeNumber(rawRecurringCount, null)\n\n // Physical product fields\n var sku = record.sku || null\n var weight = safeNumber(record.weight, null)\n var weightUnit = pickFirst(record.weight_unit, record.weightUnit) || null\n var dimensions = record.dimensions || null\n\n // Tags - parse as JSON array (handles JSON strings, arrays, comma-separated)\n var tags = parseJsonArray(tagsRaw)\n\n // Variant AXES (translatable). The i18n-resolved column already carries the\n // display labels for the current language (with the SAME stable keys as the\n // base column); we just parse + normalize. The purchasable combinations live\n // in teleport_product_variants and are fetched by the variant picker widget.\n var variantOptionsRaw = resolveI18nField(record, 'variant_options', 'variantOptions', currentLang, mainLang)\n var variantOptions = normalizeVariantOptions(variantOptionsRaw)\n // Emitted as a STRING ('true'/'false') to match the strict-equality checks the\n // storefront uses (same convention as outOfStock).\n var requiresVariantSelection = variantOptions.length > 0 ? 'true' : 'false'\n\n // Provider fields\n var providerType = pickFirst(record.provider_type, record.providerType) || null\n var providerProductId = pickFirst(record.provider_product_id, record.providerProductId) || null\n var defaultPrice = pickFirst(record.default_price, record.defaultPrice) || null\n\n // Metadata\n var metadata = parseJsonObject(record.metadata)\n\n // Visible custom properties -> [{key,value,kind,color,imageUrl}] for the\n // storefront pills mapper. MUST mirror buildVisibleProductProperties /\n // classifyProductProperty in\n // apps/gui/app/project-page/features/e-commerce/utils/product-property-display.ts\n // (the canvas-preview SSOT) field-for-field: same reserved-key exclusions,\n // same default-VISIBLE (opt-out) rule, same color/image classification. A\n // property is shown unless it is a reserved key, has an empty value, or is\n // explicitly hidden (false) in the _tqPropVisibility map -- so it also\n // renders for products whose form was never opened (no visibility map yet).\n var propVisibility = parseJsonObject(metadata._tqPropVisibility)\n var visibleProperties = []\n Object.keys(metadata).forEach(function (propKey) {\n if (isReservedProductMetadataKey(propKey)) return\n if (propVisibility[propKey] === false) return\n var rawValue = metadata[propKey]\n if (rawValue == null) return\n var propValue = String(rawValue)\n if (propValue.trim() === '') return\n visibleProperties.push(classifyProductProperty(propKey, propValue))\n })\n\n // Assigned category ids -> resolved {id,name,slug} objects for the\n // storefront category pills mapper. The taxonomy itself is not in the DB\n // (categories are authored in the GUI and live only in\n // ecommerceSettings.categories); resolved here against PRODUCT_CATEGORIES_BY_ID,\n // baked in at export time (see flattenCategoriesById). Unknown/stale ids\n // (a deleted category) are silently dropped.\n var categoryIds = parseJsonArray(record.category_ids)\n var categories = []\n for (var ci = 0; ci < categoryIds.length; ci++) {\n var categoryInfo = PRODUCT_CATEGORIES_BY_ID[categoryIds[ci]]\n if (categoryInfo) {\n categories.push({\n id: categoryIds[ci],\n name: resolveCategoryName(categoryInfo, currentLang, mainLang),\n slug: categoryInfo.slug,\n })\n }\n }\n\n // Purchasable variant COMBINATIONS for this product ({id, options map, price,\n // stock, image}). Empty for flat products. Fetched in ONE batched query by\n // transformRecords and passed in via options.variantsByProductId, keyed by\n // product id. The storefront card/details read these to default-select the\n // first in-stock variant, disable dead options, and resolve the chosen id.\n //\n // NULL (not {}) means the lookup could not run \u2014 the table is missing, or the\n // query failed. That is \"combinations UNKNOWN\", which is NOT \"this product has\n // none\": a transient DB failure must never turn every variant product in the\n // catalogue into an unbuyable one. See hasPurchasableVariant below.\n var variantsResolved = options.variantsByProductId != null\n var variantsByProductId = options.variantsByProductId || {}\n var rawVariants = variantsByProductId[id] || []\n var variants = []\n for (var vi = 0; vi < rawVariants.length; vi++) {\n var vrow = rawVariants[vi] || {}\n var vopts = parseJsonObject(vrow.options)\n variants.push({\n id: vrow.id != null ? String(vrow.id) : '',\n options: vopts && typeof vopts === 'object' ? vopts : {},\n price: safeNumber(vrow.price, null),\n quantity: safeNumber(vrow.quantity, null),\n image_url: vrow.image_url ? resolveAssetUrl(vrow.image_url, assetMap) : null,\n })\n }\n\n // The merchant's related-product picks. `relatedProducts` carries the\n // TRANSFORMED rows, not ids, so the details page's related-products rail can\n // map over it and draw a product card per entry; `relatedProductIds` keeps\n // the raw selection for anything that wants it. The rows arrive from ONE\n // batched query (getRelatedProductsMap) that transformRecords runs, keyed by\n // id in options.relatedProductsById.\n //\n // MUST mirror buildRelatedEcommerceProducts in\n // packages/renderer/src/utils/ecommerce-products.ts (the canvas-preview SSOT):\n // the merchant's order, inactive products dropped, self dropped, and the\n // nested build deliberately WITHOUT the map \u2014 two products referencing each\n // other is the normal case (they are each other's accessories) and threading\n // the map through would recurse until the stack ran out.\n var relatedProductIds = parseRelatedIds(record.related_product_ids)\n var relatedProductsById = options.relatedProductsById\n var relatedProducts = []\n if (relatedProductsById && relatedProductIds.length > 0) {\n var relatedNestedOptions = {\n assetMap: assetMap,\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n // Passed through UNCOALESCED so a related product inherits the same\n // known/unknown answer as its parent \u2014 `|| {}` here would tell the nested\n // build \"the lookup ran and found nothing\" and strike out every related\n // card's picker. The batched query includes the related ids for exactly\n // this reason (see getTransformWrapperCode).\n variantsByProductId: options.variantsByProductId,\n }\n for (var rp = 0; rp < relatedProductIds.length; rp++) {\n var relatedId = relatedProductIds[rp]\n if (id != null && relatedId === String(id)) continue\n var relatedRecord = relatedProductsById[relatedId]\n if (!relatedRecord) continue\n // A product taken off sale must not come back through the back door on\n // every product that references it. Missing status reads as active, the\n // same default the main status normalization uses above.\n var relatedStatusRaw = relatedRecord.status\n var relatedStatus =\n relatedStatusRaw == null || relatedStatusRaw === ''\n ? 'active'\n : String(relatedStatusRaw).trim().toLowerCase()\n if (relatedStatus !== 'active') continue\n relatedProducts.push(buildEcommerceProduct(relatedRecord, relatedNestedOptions))\n }\n }\n\n // -------------------------------------------------------\n // Image resolution (complex multi-step process)\n // -------------------------------------------------------\n\n // Step 1: Get raw image values\n var rawImageUrl = pickFirst(record.image_url, record.mainImage, record.imageUrl)\n var rawGalleryImages = pickFirst(record.gallery_images, record.galleryImages)\n var legacyImages = Array.isArray(record.images)\n ? record.images.filter(function(v) { return typeof v === 'string' })\n : []\n\n // Step 2: Parse gallery images from JSON\n var parsedGalleryImages = parseJsonArray(rawGalleryImages)\n\n // Step 3: Build combined raw images array\n var allRawImages = []\n if (rawImageUrl) allRawImages.push(rawImageUrl)\n allRawImages = allRawImages.concat(parsedGalleryImages).concat(legacyImages)\n\n // Step 4: Resolve all asset URLs first, then deduplicate\n var resolvedAll = resolveAssetUrls(allRawImages, assetMap)\n var resolvedImages = deduplicateStrings(resolvedAll)\n\n // Step 5: Determine mainImage\n var mainImage = rawImageUrl\n ? resolveAssetUrl(rawImageUrl, assetMap)\n : (resolvedImages.length > 0 ? resolvedImages[0] : null)\n\n // Step 6: galleryImages = resolvedImages without mainImage\n var galleryImages = mainImage\n ? resolvedImages.filter(function(url) { return url !== mainImage })\n : resolvedImages.slice()\n\n // Step 7: images = full resolved deduplicated array\n var images = resolvedImages\n\n // -------------------------------------------------------\n // 3D model (one per product; GUI: constants/product-model.ts)\n // -------------------------------------------------------\n // model_url / model_poster_url hold an asset id OR a raw URL like image_url.\n // A poster the merchant never got (capture failed, plan cap) falls back to\n // the main image so <model-viewer> always has something to paint first.\n var modelUrl = resolveMediaUrl(record.model_url, assetMap)\n var modelPoster = modelUrl ? resolveMediaUrl(record.model_poster_url, assetMap) || mainImage : null\n var hasModel = modelUrl ? 'true' : 'false'\n // STRING gate ('true'/'false') like outOfStock: whether the model replaces the\n // main image on the card / details hero. A product whose only media is the\n // model always shows it. An absent field must read as the IMAGE branch, so the\n // builders gate the viewer on = 'true' and the image on != 'true'.\n var showModel =\n modelUrl && (normalizeShowModelFlag(record.show_model_by_default) || images.length === 0)\n ? 'true'\n : 'false'\n var galleryMedia = buildProductGalleryMedia({\n images: images,\n mainImage: mainImage,\n modelUrl: modelUrl,\n modelPoster: modelPoster,\n showModel: showModel,\n imageAlt: imageAlt,\n name: name,\n })\n // Everything that is not the hero \u2014 the details thumbnail strip. When the\n // model is the hero the main image moves in here, so the modal stays reachable.\n var galleryThumbnails = galleryMedia.slice(1)\n\n // Timestamps\n var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)\n var createdAt = normalizeTimestamp(rawCreatedAt)\n\n var rawUpdatedAt = pickFirst(record.updated_at, record.updatedAt, record.updated)\n var updatedAt = rawUpdatedAt != null ? normalizeTimestamp(rawUpdatedAt) : createdAt\n\n // Computed: created = Unix seconds\n var created = Math.floor(createdAt / 1000)\n\n // Computed: outOfStock (emitted as a STRING \u2014 'true' / 'false' \u2014 so it\n // matches the strict equality check the AI generates on product\n // cards/details pages: `ecommerceProduct?.outOfStock === 'true'`.\n // A boolean here would silently fail that comparison (boolean !==\n // string), leaving the Add to Cart button visible on out-of-stock\n // products. NULL/NaN quantity \u2192 'false' (treated as \"unlimited\n // stock\", same as the upstream cart-availability rewriter).\n // With ALLOW_BACKORDERS the merchant sells past zero (or does not track\n // stock at all), so nothing is ever presented as out of stock.\n var outOfStock = (!ALLOW_BACKORDERS && quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'\n\n // Per-value availability + default-selection flags, derived from the\n // purchasable combinations, so the storefront picker renders its\n // Default / Selected / Unavailable states with NO runtime fetch. The card\n // (N instances) first-paints from these data flags; the details page seeds\n // its selection from the same combinations. axisValueKey (axisKey|valueKey)\n // disambiguates same-named values across axes (Size \"s\" vs Sleeve \"s\").\n // Emitted as STRINGS to match the storefront's string-equality convention.\n var variantAxisKeys = []\n for (var ak = 0; ak < variantOptions.length; ak++) { variantAxisKeys.push(variantOptions[ak].key) }\n var firstVariant = pickFirstAvailableVariant(variants, variantAxisKeys)\n var deadValueKeys = computeDeadVariantKeys(variants, variantOptions, variantsResolved)\n for (var ai = 0; ai < variantOptions.length; ai++) {\n var vax = variantOptions[ai]\n for (var vj = 0; vj < vax.values.length; vj++) {\n var vval = vax.values[vj]\n var composite = vax.key + '|' + vval.value\n vval.axisValueKey = composite\n vval.isDead = deadValueKeys[composite] ? 'true' : 'false'\n vval.isDefault =\n firstVariant && firstVariant.options && firstVariant.options[vax.key] === vval.value\n ? 'true'\n : 'false'\n // Whether THIS value renders as a colour swatch vs a text pill. A colour\n // axis whose value has no resolvable colour degrades to a text pill so it\n // is never an invisible empty circle. Emitted as a string to match the\n // picker's string-equality rendering gate. MUST mirror the renderer\n // enrichment in packages/renderer/src/utils/ecommerce-products.ts.\n vval.showSwatch = vax.type === 'color' && vval.color ? 'true' : 'false'\n }\n }\n // The combination the picker auto-selects on first paint: the first in-stock\n // one covering every axis. Absent means there is nothing the shopper could add\n // without choosing \u2014 which is what gates the buy button below.\n var hasInStockCombination = !!(firstVariant && isVariantInStock(firstVariant))\n var defaultVariantId = hasInStockCombination ? firstVariant.id : ''\n var defaultVariantPrice = firstVariant ? formatVariantPrice(firstVariant.price, price) : ''\n // Gross (tax-inclusive) counterparts of the three price fields the STOREFRONT\n // renders. `price` / `defaultVariantPrice` / `variantsJson` deliberately stay\n // NET: the same transform feeds the merchant's admin panel, and every write\n // path (add-to-cart, checkout, the invoice route) re-reads the raw row and\n // must keep seeing the price the merchant typed. Only these three are safe to\n // gross, because nothing writes them back.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n var displayPrice = grossMoney(price)\n var defaultVariantDisplayPrice = firstVariant\n ? grossMoney(formatVariantPrice(firstVariant.price, price))\n : ''\n var variantsDisplay = []\n for (var dv = 0; dv < variants.length; dv++) {\n var dvRow = variants[dv]\n variantsDisplay.push({\n id: dvRow.id,\n options: dvRow.options,\n // A null variant price INHERITS the product price, so gross the RESOLVED\n // value \u2014 otherwise an inheriting combination would fall back to the net\n // base price in the picker while an overriding one showed gross.\n price: applyStorefrontTax(dvRow.price == null ? price : dvRow.price),\n quantity: dvRow.quantity,\n image_url: dvRow.image_url,\n })\n }\n // Can this product be added to the cart at all, before the shopper touches the\n // picker? A STRING, same convention as outOfStock/requiresVariantSelection.\n //\n // flat product -> 'true' (stock is governed by outOfStock)\n // combinations UNKNOWN -> 'true' (the lookup could not run; a transient DB\n // failure must never make a whole catalogue\n // unbuyable \u2014 same permissive rule as\n // dead-marking)\n // otherwise -> whether an in-stock combination covering every axis\n // exists. 'false' therefore covers BOTH \"every\n // combination is sold out\" AND \"the axes have no\n // combinations at all\" \u2014 to a shopper those are the\n // same thing: there is no size to pick.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n var hasPurchasableVariant =\n (requiresVariantSelection !== 'true' || !variantsResolved || hasInStockCombination)\n ? 'true'\n : 'false'\n // Stringified companions: a data-* attr bound to an ARRAY renders\n // '[object Object]', so the picker's on-mount/click workflows read the\n // combinations + axes from these JSON strings via getAttribute.\n var variantOptionsJson = safeStringifyJson(variantOptions)\n var variantsJson = safeStringifyJson(variants)\n var variantsDisplayJson = safeStringifyJson(variantsDisplay)\n\n return {\n id: id,\n name: name,\n slug: slug,\n description: description,\n descriptionText: descriptionText,\n price: price,\n currency: currency,\n currencySymbol: currencySymbol,\n status: status,\n active: active,\n quantity: quantity,\n paymentType: paymentType,\n recurringInterval: recurringInterval,\n recurringIntervalCount: recurringIntervalCount,\n sku: sku,\n weight: weight,\n weightUnit: weightUnit,\n dimensions: dimensions,\n category: category,\n categories: categories,\n tags: tags,\n relatedProductIds: relatedProductIds,\n relatedProducts: relatedProducts,\n imageAlt: imageAlt,\n providerType: providerType,\n providerProductId: providerProductId,\n default_price: defaultPrice,\n metadata: metadata,\n visibleProperties: visibleProperties,\n mainImage: mainImage,\n imageUrl: mainImage,\n image_url: mainImage,\n galleryImages: galleryImages,\n images: images,\n modelUrl: modelUrl,\n modelPoster: modelPoster,\n hasModel: hasModel,\n showModel: showModel,\n galleryMedia: galleryMedia,\n galleryThumbnails: galleryThumbnails,\n createdAt: createdAt,\n updatedAt: updatedAt,\n created: created,\n outOfStock: outOfStock,\n variantOptions: variantOptions,\n requiresVariantSelection: requiresVariantSelection,\n variants: variants,\n variantOptionsJson: variantOptionsJson,\n variantsJson: variantsJson,\n variantsDisplayJson: variantsDisplayJson,\n defaultVariantId: defaultVariantId,\n defaultVariantPrice: defaultVariantPrice,\n displayPrice: displayPrice,\n defaultVariantDisplayPrice: defaultVariantDisplayPrice,\n hasPurchasableVariant: hasPurchasableVariant,\n }\n}\n\n// --- Variant availability helpers (mirror packages/renderer ecommerce-products) ---\n// A variant is in stock when its quantity is null (unlimited) or > 0. With\n// ALLOW_BACKORDERS every EXISTING combination is purchasable regardless of its\n// quantity \u2014 but a missing combination (null) is still not in stock: there is\n// no variant row to backorder.\nfunction isVariantInStock(v) {\n if (!v) return false\n if (ALLOW_BACKORDERS) return true\n return v.quantity == null || v.quantity > 0\n}\nfunction variantCoversAllAxes(v, axisKeys) {\n if (!v || !v.options) return false\n for (var i = 0; i < axisKeys.length; i++) {\n if (v.options[axisKeys[i]] == null) return false\n }\n return true\n}\n// First in-stock combination covering every axis; falls back to the first\n// covering combination (even if OOS) so a fully-OOS product still has a\n// visual default; null when no combination covers the axes.\nfunction pickFirstAvailableVariant(variants, axisKeys) {\n if (!Array.isArray(variants) || !axisKeys.length) return null\n for (var i = 0; i < variants.length; i++) {\n if (variantCoversAllAxes(variants[i], axisKeys) && isVariantInStock(variants[i])) return variants[i]\n }\n for (var j = 0; j < variants.length; j++) {\n if (variantCoversAllAxes(variants[j], axisKeys)) return variants[j]\n }\n return null\n}\n// Map of composite \"axisKey|valueKey\" -> true for every value that appears in\n// NO in-stock combination (rendered as the Unavailable state).\n//\n// variantsResolved is the whole subtlety: dead-marking is only meaningful\n// against combinations we actually LOOKED UP. When the lookup could not run,\n// nothing is dead \u2014 a missing table or a failed query must not grey out every\n// picker in the store. When it DID run, an empty list is a fact, not a gap:\n// every value is unbuyable and renders as Unavailable.\nfunction computeDeadVariantKeys(variants, axes, variantsResolved) {\n if (!variantsResolved || !Array.isArray(variants)) return {}\n var alive = {}\n for (var i = 0; i < variants.length; i++) {\n var v = variants[i]\n if (!isVariantInStock(v)) continue\n for (var k in v.options) {\n if (Object.prototype.hasOwnProperty.call(v.options, k)) alive[k + '|' + v.options[k]] = true\n }\n }\n var dead = {}\n for (var a = 0; a < axes.length; a++) {\n var ax = axes[a]\n for (var b = 0; b < ax.values.length; b++) {\n var key = ax.key + '|' + ax.values[b].value\n if (!alive[key]) dead[key] = true\n }\n }\n return dead\n}\n// Variant price with base-price inheritance (null variant price -> product price).\nfunction formatVariantPrice(vprice, basePrice) {\n var p = vprice == null || isNaN(vprice) ? basePrice : vprice\n return String(Number(p).toFixed(2))\n}\nfunction safeStringifyJson(obj) {\n try {\n return JSON.stringify(obj)\n } catch (e) {\n return '[]'\n }\n}\n\n// Parse + normalize the variant_options JSON into render-ready axes. Tolerant of\n// null / malformed / partial data (returns []). Keeps ONLY the fields the pill\n// widget needs; the labels are already language-resolved by the caller.\nfunction normalizeVariantOptions(raw) {\n if (!raw) return []\n var parsed = raw\n if (typeof raw === 'string') {\n try { parsed = JSON.parse(raw) } catch (e) { return [] }\n }\n if (!Array.isArray(parsed)) return []\n var out = []\n for (var i = 0; i < parsed.length; i++) {\n var axis = parsed[i]\n if (!axis || typeof axis !== 'object' || !Array.isArray(axis.values)) continue\n var name = typeof axis.name === 'string' ? axis.name : ''\n var key = typeof axis.key === 'string' && axis.key ? axis.key : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!key) continue\n var type = axis.type === 'color' ? 'color' : 'text'\n var values = []\n for (var j = 0; j < axis.values.length; j++) {\n var v = axis.values[j]\n if (!v || typeof v !== 'object') continue\n var label = typeof v.label === 'string' ? v.label : ''\n var value = typeof v.value === 'string' && v.value ? v.value : label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!value) continue\n var entry = { value: value, label: label }\n if (type === 'color' && typeof v.color === 'string' && v.color) entry.color = v.color\n values.push(entry)\n }\n if (!values.length) continue\n out.push({ key: key, name: name, type: type, values: values })\n }\n return out\n}\n\n// --- Custom-property classification (MUST mirror product-property-display.ts) ---\n\n// Product-attribute keys that can leak into the free-form `metadata` map (e.g.\n// from a payment-provider sync or legacy data) but are NOT user custom\n// properties. Kept in sync with the GUI's RESERVED_PRODUCT_METADATA_KEYS.\nvar RESERVED_PRODUCT_METADATA_KEYS = {\n paymentType: true, recurringInterval: true, recurringIntervalCount: true,\n price: true, currency: true, status: true, sku: true, weightUnit: true,\n dimensions: true, category: true, brand: true, gtin: true, mpn: true,\n condition: true, tags: true,\n}\nfunction isReservedProductMetadataKey(key) {\n return !!RESERVED_PRODUCT_METADATA_KEYS[key] ||\n key.indexOf('mainImage') === 0 ||\n key.indexOf('secondaryImage') === 0 ||\n key.indexOf('_tq') === 0\n}\n\nvar CSS_NAMED_COLORS = {\n aliceblue: 1, antiquewhite: 1, aqua: 1, aquamarine: 1, azure: 1, beige: 1, bisque: 1,\n black: 1, blanchedalmond: 1, blue: 1, blueviolet: 1, brown: 1, burlywood: 1,\n cadetblue: 1, chartreuse: 1, chocolate: 1, coral: 1, cornflowerblue: 1, cornsilk: 1,\n crimson: 1, cyan: 1, darkblue: 1, darkcyan: 1, darkgoldenrod: 1, darkgray: 1,\n darkgrey: 1, darkgreen: 1, darkkhaki: 1, darkmagenta: 1, darkolivegreen: 1,\n darkorange: 1, darkorchid: 1, darkred: 1, darksalmon: 1, darkseagreen: 1,\n darkslateblue: 1, darkslategray: 1, darkslategrey: 1, darkturquoise: 1,\n darkviolet: 1, deeppink: 1, deepskyblue: 1, dimgray: 1, dimgrey: 1, dodgerblue: 1,\n firebrick: 1, floralwhite: 1, forestgreen: 1, fuchsia: 1, gainsboro: 1,\n ghostwhite: 1, gold: 1, goldenrod: 1, gray: 1, grey: 1, green: 1, greenyellow: 1,\n honeydew: 1, hotpink: 1, indianred: 1, indigo: 1, ivory: 1, khaki: 1, lavender: 1,\n lavenderblush: 1, lawngreen: 1, lemonchiffon: 1, lightblue: 1, lightcoral: 1,\n lightcyan: 1, lightgoldenrodyellow: 1, lightgray: 1, lightgrey: 1, lightgreen: 1,\n lightpink: 1, lightsalmon: 1, lightseagreen: 1, lightskyblue: 1, lightslategray: 1,\n lightslategrey: 1, lightsteelblue: 1, lightyellow: 1, lime: 1, limegreen: 1,\n linen: 1, magenta: 1, maroon: 1, mediumaquamarine: 1, mediumblue: 1,\n mediumorchid: 1, mediumpurple: 1, mediumseagreen: 1, mediumslateblue: 1,\n mediumspringgreen: 1, mediumturquoise: 1, mediumvioletred: 1, midnightblue: 1,\n mintcream: 1, mistyrose: 1, moccasin: 1, navajowhite: 1, navy: 1, oldlace: 1,\n olive: 1, olivedrab: 1, orange: 1, orangered: 1, orchid: 1, palegoldenrod: 1,\n palegreen: 1, paleturquoise: 1, palevioletred: 1, papayawhip: 1, peachpuff: 1,\n peru: 1, pink: 1, plum: 1, powderblue: 1, purple: 1, rebeccapurple: 1, red: 1,\n rosybrown: 1, royalblue: 1, saddlebrown: 1, salmon: 1, sandybrown: 1, seagreen: 1,\n seashell: 1, sienna: 1, silver: 1, skyblue: 1, slateblue: 1, slategray: 1,\n slategrey: 1, snow: 1, springgreen: 1, steelblue: 1, tan: 1, teal: 1, thistle: 1,\n tomato: 1, turquoise: 1, violet: 1, wheat: 1, white: 1, whitesmoke: 1, yellow: 1,\n yellowgreen: 1, transparent: 1,\n}\nvar HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\nvar FUNCTIONAL_COLOR_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\((?:[0-9a-z%.,\\s/+-]+)\\)$/i\nvar COLOR_KEY_WORDS = {\n color: 1, colors: 1, colour: 1, colours: 1, shade: 1, shades: 1, hue: 1, hues: 1,\n tint: 1, tints: 1, tone: 1, tones: 1, swatch: 1, swatches: 1,\n}\nfunction splitKeyWords(key) {\n return key.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)\n}\nfunction keyIndicatesColor(key) {\n var normalized = key.toLowerCase()\n if (/colou?r/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!COLOR_KEY_WORDS[word] })\n}\nfunction resolvePropertyColor(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (HEX_COLOR_RE.test(v) || FUNCTIONAL_COLOR_RE.test(v)) return v\n if (CSS_NAMED_COLORS[v.toLowerCase()] && keyIndicatesColor(key)) return v\n return ''\n}\n\nvar DATA_IMAGE_URI_RE = /^data:image\\//i\nvar ABSOLUTE_URL_RE = /^(?:https?:)?\\/\\//i\nvar IMAGE_EXTENSION_RE = /\\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?|heic|heif)$/i\nvar IMAGE_KEY_WORDS = {\n image: 1, images: 1, img: 1, imgs: 1, photo: 1, photos: 1, picture: 1, pictures: 1,\n pic: 1, pics: 1, thumbnail: 1, thumbnails: 1, thumb: 1, thumbs: 1, icon: 1, icons: 1,\n logo: 1, logos: 1, avatar: 1, avatars: 1, banner: 1, banners: 1, cover: 1, covers: 1,\n gallery: 1, screenshot: 1, screenshots: 1, poster: 1, posters: 1, artwork: 1,\n graphic: 1, graphics: 1, illustration: 1, wallpaper: 1, preview: 1, snapshot: 1,\n media: 1, visual: 1, visuals: 1,\n}\nfunction keyIndicatesImage(key) {\n var normalized = key.toLowerCase()\n if (/(?:image|img|photo|picture|thumb|icon|logo|avatar|banner|gallery|screenshot|poster|artwork|graphic|wallpaper)/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!IMAGE_KEY_WORDS[word] })\n}\nfunction stripUrlQueryAndHash(url) {\n return url.split(/[?#]/)[0]\n}\nfunction resolvePropertyImage(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (DATA_IMAGE_URI_RE.test(v)) return v\n var isAbsoluteUrl = ABSOLUTE_URL_RE.test(v)\n var isRootRelative = v.charAt(0) === '/' && !isAbsoluteUrl\n if (!isAbsoluteUrl && !isRootRelative) return ''\n if (IMAGE_EXTENSION_RE.test(stripUrlQueryAndHash(v))) return v\n if (isAbsoluteUrl && keyIndicatesImage(key)) return v\n return ''\n}\n\n// Image wins over colour (a URL can never be a CSS colour, but checking image\n// first keeps the precedence explicit), text is the fallback.\nfunction classifyProductProperty(key, value) {\n var imageUrl = resolvePropertyImage(key, value)\n if (imageUrl) return { key: key, value: value, kind: 'image', color: '', imageUrl: imageUrl }\n var color = resolvePropertyColor(key, value)\n if (color) return { key: key, value: value, kind: 'color', color: color, imageUrl: '' }\n return { key: key, value: value, kind: 'text', color: '', imageUrl: '' }\n}\n\nfunction transformEcommerceProducts(records, options) {\n if (!Array.isArray(records)) return []\n return records.map(function(record) { return buildEcommerceProduct(record, options) })\n}\n\n// Batched fetch of the purchasable variant rows for a set of product ids, keyed\n// by product_id, so buildEcommerceProduct can attach product.variants without a\n// per-product query. Best-effort: a store with no variants (or before the table\n// exists) yields an empty map and flat products keep variants: [].\n// Batched fetch of the product rows a set of products reference as \"related\",\n// keyed by id, so buildEcommerceProduct can attach product.relatedProducts\n// without a query per product.\n//\n// Best-effort by design: a store whose products table predates the column\n// yields no ids at all (the read below is of an absent property, not of a\n// column \u2014 the SQL never names it), the map stays empty, and the details page's\n// rail simply stays hidden behind its is_not_empty gate. Same for a failed\n// query.\nasync function getRelatedProductsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length === 0) return map\n\n var wantedIds = []\n for (var i = 0; i < records.length; i++) {\n var ids = parseRelatedIds(records[i] && records[i].related_product_ids)\n for (var j = 0; j < ids.length; j++) {\n if (wantedIds.indexOf(ids[j]) === -1) wantedIds.push(ids[j])\n }\n }\n if (wantedIds.length === 0) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query('SELECT * FROM teleport_products WHERE id = ANY($1)', [\n wantedIds,\n ])\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.id != null) map[String(row.id)] = row\n }\n }\n } catch (e) {\n // Leaves relatedProducts empty rather than failing the page.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// Combinations for every product on the page, keyed by product id. Returns NULL\n// when the lookup could not run, which the transform reads as \"unknown\" and\n// stays permissive with; an empty map means the query ran and this catalogue\n// simply has no combinations.\nasync function getVariantsMap(getClientFn, productIds) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n 'SELECT id, product_id, options, price, quantity, image_url FROM teleport_product_variants WHERE product_id = ANY($1) ORDER BY position ASC, id ASC',\n [uniqueIds]\n )\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n var pid = row.product_id\n if (!map[pid]) map[pid] = []\n map[pid].push(row)\n }\n }\n } catch (e) {\n // teleport_product_variants may not exist for a non-variant store, or the\n // query may have failed. Either way the combinations are UNKNOWN \u2014 null\n // keeps every picker selectable instead of declaring the catalogue unbuyable.\n map = null\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n");
|
|
49
50
|
};
|
|
50
51
|
exports.generateEcommerceProductTransformationCode = generateEcommerceProductTransformationCode;
|
|
51
52
|
//# sourceMappingURL=ecommerce-product.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ecommerce-product.js","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":";;;AACA,+DAA2D;
|
|
1
|
+
{"version":3,"file":"ecommerce-product.js","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":";;;AACA,+DAA2D;AA6B3D;;;;;;;;;;;GAWG;AACH,IAAM,qBAAqB,GAAG,UAC5B,UAA+C;IAK/C,IAAM,IAAI,GAGN,EAAE,CAAA;IACN,IAAM,KAAK,GAAG,UAAC,KAA0C;QACvD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE;YACzB,OAAM;SACP;QACD,KAAmB,UAAK,EAAL,eAAK,EAAL,mBAAK,EAAL,IAAK,EAAE;YAArB,IAAM,IAAI,cAAA;YACb,IAAI,IAAI,IAAI,OAAO,IAAI,CAAC,EAAE,KAAK,QAAQ,EAAE;gBACvC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG;oBACd,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE;oBACrB,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,EAAE;oBACrB,YAAY,EAAE,IAAI,CAAC,YAAY;iBAChC,CAAA;gBACD,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;aACrB;SACF;IACH,CAAC,CAAA;IACD,KAAK,CAAC,UAAU,CAAC,CAAA;IACjB,OAAO,IAAI,CAAA;AACb,CAAC,CAAA;AAED;;;;GAIG;AACI,IAAM,0CAA0C,GAAG,UACxD,OAA8C;;IAA9C,wBAAA,EAAA,YAA8C;IAE9C,IAAM,kBAAkB,GAAG,IAAI,CAAC,SAAS,CAAC,qBAAqB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAA;IACpF,IAAM,aAAa,GAAG,+BAAa,CAAC,+BAA+B,CACjE,MAAA,OAAO,CAAC,iBAAiB,mCAAI,CAAC,CAC/B,CAAA;IACD,IAAM,sBAAsB,GAAG,OAAO,CAAC,eAAe,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAA;IAElF,OAAO,YACP,aAAa,yRAKU,sBAAsB,4SAMd,kBAAkB,8jpCAwxBlD,CAAA;AACD,CAAC,CAAA;AA9yBY,QAAA,0CAA0C,8CA8yBtD"}
|
|
@@ -7,7 +7,8 @@ export type { EcommerceProductTransformOptions };
|
|
|
7
7
|
*
|
|
8
8
|
* `storefrontTaxRate` is what turns a NET catalogue price into the price the
|
|
9
9
|
* shopper is quoted; `resolveStorefrontTaxRate` returns 0 (a no-op) for
|
|
10
|
-
* tax-inclusive or untaxed stores.
|
|
10
|
+
* tax-inclusive or untaxed stores. `allowBackorders` is the effective
|
|
11
|
+
* "stock never blocks a purchase" flag — see `resolveAllowBackorders`.
|
|
11
12
|
*/
|
|
12
13
|
export declare const buildProductTransformOptions: (options: Pick<GeneratorOptions, 'ecommerceSettings' | 'invoiceSettings'>) => EcommerceProductTransformOptions;
|
|
13
14
|
export type TransformationType = 'blog-post' | 'ecommerce-product' | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/transformations/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAIlE,OAAO,EAEL,KAAK,gCAAgC,EACtC,MAAM,qBAAqB,CAAA;AAE5B,YAAY,EAAE,gCAAgC,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/transformations/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAIlE,OAAO,EAEL,KAAK,gCAAgC,EACtC,MAAM,qBAAqB,CAAA;AAE5B,YAAY,EAAE,gCAAgC,EAAE,CAAA;AAkBhD;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,YAC9B,KAAK,gBAAgB,EAAE,mBAAmB,GAAG,iBAAiB,CAAC,KACvE,gCAID,CAAA;AAEF,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,mBAAmB,GAAG,IAAI,CAAA;AAezE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,cAAe,MAAM,KAAG,kBAY5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,cACrB,MAAM,YACR,gCAAgC,KACxC,MAgBF,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,cAAe,MAAM,KAAG,MAAM,GAAG,IAcnE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,cAAe,MAAM,KAAG,MAqF3D,CAAA"}
|
|
@@ -5,19 +5,35 @@ var teleport_shared_1 = require("@teleporthq/teleport-shared");
|
|
|
5
5
|
var shared_utils_1 = require("./shared-utils");
|
|
6
6
|
var blog_post_1 = require("./blog-post");
|
|
7
7
|
var ecommerce_product_1 = require("./ecommerce-product");
|
|
8
|
+
/**
|
|
9
|
+
* Stock never gates purchasability when the merchant disabled stock management
|
|
10
|
+
* entirely or opted into backorders. Mirrors the GUI rule in
|
|
11
|
+
* `apps/gui/app/project-page/features/e-commerce/utils/product-variants.ts`
|
|
12
|
+
* (`isVariantInStock`). A project without e-commerce settings keeps the
|
|
13
|
+
* strict default (stock gates apply) — same behaviour as before the flag.
|
|
14
|
+
*/
|
|
15
|
+
var resolveAllowBackorders = function (settings) {
|
|
16
|
+
var _a;
|
|
17
|
+
if (!settings) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return !settings.stockManagement || ((_a = settings.stockManagementConfig) === null || _a === void 0 ? void 0 : _a.allowBackorders) === true;
|
|
21
|
+
};
|
|
8
22
|
/**
|
|
9
23
|
* Collects everything the product transform bakes in from the generator
|
|
10
24
|
* options, in ONE place so every fetcher-emitting call site stays in step.
|
|
11
25
|
*
|
|
12
26
|
* `storefrontTaxRate` is what turns a NET catalogue price into the price the
|
|
13
27
|
* shopper is quoted; `resolveStorefrontTaxRate` returns 0 (a no-op) for
|
|
14
|
-
* tax-inclusive or untaxed stores.
|
|
28
|
+
* tax-inclusive or untaxed stores. `allowBackorders` is the effective
|
|
29
|
+
* "stock never blocks a purchase" flag — see `resolveAllowBackorders`.
|
|
15
30
|
*/
|
|
16
31
|
var buildProductTransformOptions = function (options) {
|
|
17
32
|
var _a;
|
|
18
33
|
return ({
|
|
19
34
|
categories: (_a = options.ecommerceSettings) === null || _a === void 0 ? void 0 : _a.categories,
|
|
20
35
|
storefrontTaxRate: teleport_shared_1.StorefrontTax.resolveStorefrontTaxRate(options.invoiceSettings),
|
|
36
|
+
allowBackorders: resolveAllowBackorders(options.ecommerceSettings),
|
|
21
37
|
});
|
|
22
38
|
};
|
|
23
39
|
exports.buildProductTransformOptions = buildProductTransformOptions;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/transformations/index.ts"],"names":[],"mappings":";;;AACA,+DAA2D;AAC3D,+CAAiE;AACjE,yCAAgE;AAChE,yDAG4B;AAI5B
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/transformations/index.ts"],"names":[],"mappings":";;;AACA,+DAA2D;AAC3D,+CAAiE;AACjE,yCAAgE;AAChE,yDAG4B;AAI5B;;;;;;GAMG;AACH,IAAM,sBAAsB,GAAG,UAC7B,QAA2D;;IAE3D,IAAI,CAAC,QAAQ,EAAE;QACb,OAAO,KAAK,CAAA;KACb;IACD,OAAO,CAAC,QAAQ,CAAC,eAAe,IAAI,CAAA,MAAA,QAAQ,CAAC,qBAAqB,0CAAE,eAAe,MAAK,IAAI,CAAA;AAC9F,CAAC,CAAA;AAED;;;;;;;;GAQG;AACI,IAAM,4BAA4B,GAAG,UAC1C,OAAwE;;IACnC,OAAA,CAAC;QACtC,UAAU,EAAE,MAAA,OAAO,CAAC,iBAAiB,0CAAE,UAAU;QACjD,iBAAiB,EAAE,+BAAa,CAAC,wBAAwB,CAAC,OAAO,CAAC,eAAe,CAAC;QAClF,eAAe,EAAE,sBAAsB,CAAC,OAAO,CAAC,iBAAiB,CAAC;KACnE,CAAC,CAAA;CAAA,CAAA;AANW,QAAA,4BAA4B,gCAMvC;AAIF;;;;GAIG;AACH,IAAM,oBAAoB,GAAG,UAAC,SAAiB;IAC7C,IAAM,KAAK,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAA;IAC5C,IAAM,OAAO,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;IACtC,IAAM,IAAI,GAAG,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IAC5D,kEAAkE;IAClE,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;AACnC,CAAC,CAAA;AAED;;;;;;;;;;;GAWG;AACI,IAAM,wBAAwB,GAAG,UAAC,SAAiB;IACxD,IAAI,CAAC,SAAS,EAAE;QACd,OAAO,IAAI,CAAA;KACZ;IACD,IAAM,IAAI,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAAA;IAC5C,IAAI,IAAI,KAAK,qBAAqB,EAAE;QAClC,OAAO,WAAW,CAAA;KACnB;IACD,IAAI,IAAI,KAAK,mBAAmB,EAAE;QAChC,OAAO,mBAAmB,CAAA;KAC3B;IACD,OAAO,IAAI,CAAA;AACb,CAAC,CAAA;AAZY,QAAA,wBAAwB,4BAYpC;AAED;;;;GAIG;AACI,IAAM,qBAAqB,GAAG,UACnC,SAAiB,EACjB,OAA8C;IAA9C,wBAAA,EAAA,YAA8C;IAE9C,IAAM,IAAI,GAAG,IAAA,gCAAwB,EAAC,SAAS,CAAC,CAAA;IAChD,IAAI,CAAC,IAAI,EAAE;QACT,OAAO,EAAE,CAAA;KACV;IAED,IAAM,MAAM,GAAG,IAAA,+CAAgC,GAAE,CAAA;IAEjD,QAAQ,IAAI,EAAE;QACZ,KAAK,WAAW;YACd,OAAO,MAAM,GAAG,IAAA,8CAAkC,GAAE,CAAA;QACtD,KAAK,mBAAmB;YACtB,OAAO,MAAM,GAAG,IAAA,8DAA0C,EAAC,OAAO,CAAC,CAAA;QACrE;YACE,OAAO,EAAE,CAAA;KACZ;AACH,CAAC,CAAA;AAnBY,QAAA,qBAAqB,yBAmBjC;AAED;;;;;GAKG;AACI,IAAM,sBAAsB,GAAG,UAAC,SAAiB;IACtD,IAAM,IAAI,GAAG,IAAA,gCAAwB,EAAC,SAAS,CAAC,CAAA;IAChD,IAAI,CAAC,IAAI,EAAE;QACT,OAAO,IAAI,CAAA;KACZ;IAED,QAAQ,IAAI,EAAE;QACZ,KAAK,WAAW;YACd,OAAO,wDAAwD,CAAA;QACjE,KAAK,mBAAmB;YACtB,OAAO,wDAAwD,CAAA;QACjE;YACE,OAAO,IAAI,CAAA;KACd;AACH,CAAC,CAAA;AAdY,QAAA,sBAAsB,0BAclC;AAED;;;;GAIG;AACI,IAAM,uBAAuB,GAAG,UAAC,SAAiB;IACvD,IAAM,IAAI,GAAG,IAAA,gCAAwB,EAAC,SAAS,CAAC,CAAA;IAChD,IAAI,CAAC,IAAI,EAAE;QACT,OAAO,EAAE,CAAA;KACV;IAED,IAAM,WAAW,GAAG,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,4BAA4B,CAAA;IAE9F,+EAA+E;IAC/E,+EAA+E;IAC/E,EAAE;IACF,yEAAyE;IACzE,wEAAwE;IACxE,8EAA8E;IAC9E,2EAA2E;IAC3E,wCAAwC;IACxC,EAAE;IACF,8EAA8E;IAC9E,yEAAyE;IACzE,4EAA4E;IAC5E,IAAM,iBAAiB,GACrB,IAAI,KAAK,mBAAmB;QAC1B,CAAC,CAAC,84BAoBJ;QACE,CAAC,CAAC,EAAE,CAAA;IAER,IAAM,aAAa,GACjB,IAAI,KAAK,mBAAmB,CAAC,CAAC,CAAC,4CAA4C,CAAC,CAAC,CAAC,EAAE,CAAA;IAElF,2EAA2E;IAC3E,0EAA0E;IAC1E,6EAA6E;IAC7E,6EAA6E;IAC7E,0EAA0E;IAC1E,2EAA2E;IAC3E,qBAAqB;IACrB,EAAE;IACF,0EAA0E;IAC1E,8EAA8E;IAC9E,iBAAiB;IACjB,IAAM,aAAa,GAAG,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,qBAAqB,CAAA;IACvF,IAAM,YAAY,GAAG,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,uBAAuB,CAAA;IAC1F,IAAM,iBAAiB,GAAG,kBACpB,aAAa,gGAGb,aAAa,sBAAY,YAAY,sIAIzC,CAAA;IAEF,OAAO,6OAOJ,iBAAiB,SAAG,iBAAiB,4OAG0D,aAAa,eAAK,aAAa,eAAK,aAAa,0BAC1I,WAAW,4BAErB,CAAA;AACD,CAAC,CAAA;AArFY,QAAA,uBAAuB,2BAqFnC"}
|