@teleporthq/teleport-plugin-next-data-source 0.43.55 → 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__/array-mapper-cache-wiring.test.ts +425 -0
- package/__tests__/data-source-cache-wrapping.test.ts +152 -0
- package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
- package/__tests__/ecommerce-product-model.test.ts +179 -0
- package/__tests__/ecommerce-product-storefront-tax.test.ts +118 -0
- package/dist/cjs/cache/ast.d.ts +71 -0
- package/dist/cjs/cache/ast.d.ts.map +1 -0
- package/dist/cjs/cache/ast.js +229 -0
- package/dist/cjs/cache/ast.js.map +1 -0
- package/dist/cjs/cache/config.d.ts +22 -0
- package/dist/cjs/cache/config.d.ts.map +1 -0
- package/dist/cjs/cache/config.js +67 -0
- package/dist/cjs/cache/config.js.map +1 -0
- package/dist/cjs/cache/types.d.ts +31 -0
- package/dist/cjs/cache/types.d.ts.map +1 -0
- package/dist/cjs/cache/types.js +3 -0
- package/dist/cjs/cache/types.js.map +1 -0
- package/dist/cjs/data-source-fetchers.d.ts +5 -3
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +51 -8
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts +2 -2
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +3 -2
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +14 -9
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/loading-state.d.ts.map +1 -1
- package/dist/cjs/loading-state.js +74 -2
- package/dist/cjs/loading-state.js.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +204 -64
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts +27 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +8 -3
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +14 -2
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +37 -3
- 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/cjs/utils.d.ts +4 -3
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +6 -4
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/cache/ast.d.ts +71 -0
- package/dist/esm/cache/ast.d.ts.map +1 -0
- package/dist/esm/cache/ast.js +196 -0
- package/dist/esm/cache/ast.js.map +1 -0
- package/dist/esm/cache/config.d.ts +22 -0
- package/dist/esm/cache/config.d.ts.map +1 -0
- package/dist/esm/cache/config.js +62 -0
- package/dist/esm/cache/config.js.map +1 -0
- package/dist/esm/cache/types.d.ts +31 -0
- package/dist/esm/cache/types.d.ts.map +1 -0
- package/dist/esm/cache/types.js +2 -0
- package/dist/esm/cache/types.js.map +1 -0
- package/dist/esm/data-source-fetchers.d.ts +5 -3
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +51 -8
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts +2 -2
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +3 -2
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +12 -8
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/loading-state.d.ts.map +1 -1
- package/dist/esm/loading-state.js +74 -2
- package/dist/esm/loading-state.js.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +205 -65
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/transformations/ecommerce-product.d.ts +27 -1
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +8 -3
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +14 -2
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +36 -3
- 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/dist/esm/utils.d.ts +4 -3
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +6 -4
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/cache/ast.ts +265 -0
- package/src/cache/config.ts +79 -0
- package/src/cache/types.ts +31 -0
- package/src/data-source-fetchers.ts +66 -12
- package/src/fetchers/teleport.ts +3 -3
- package/src/index.ts +10 -4
- package/src/loading-state.ts +65 -2
- package/src/pagination-plugin.ts +315 -116
- package/src/transformations/ecommerce-product.ts +161 -5
- package/src/transformations/index.ts +43 -4
- package/src/transformations/shared-utils.ts +14 -0
- package/src/utils.ts +10 -5
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
import { buildProductTransformOptions } from '../src/transformations'
|
|
4
|
+
import type { UIDLInvoiceSettings } from '@teleporthq/teleport-types'
|
|
5
|
+
|
|
6
|
+
// The storefront quotes tax-inclusive prices when the merchant picked
|
|
7
|
+
// "Added on top", but `teleport_products.price` stays NET — it is what the
|
|
8
|
+
// merchant typed, what the admin panel edits, and what the invoice route
|
|
9
|
+
// re-derives VAT from. The transform therefore emits SEPARATE display fields
|
|
10
|
+
// and must never move `price` itself.
|
|
11
|
+
|
|
12
|
+
const evalBuildProduct = (
|
|
13
|
+
storefrontTaxRate: number
|
|
14
|
+
): ((record: unknown) => Record<string, unknown>) => {
|
|
15
|
+
const code =
|
|
16
|
+
generateSharedTransformationCode() +
|
|
17
|
+
'\n' +
|
|
18
|
+
generateEcommerceProductTransformationCode({ storefrontTaxRate })
|
|
19
|
+
const fn = new Function(code + '\nreturn buildEcommerceProduct;')
|
|
20
|
+
return fn() as (record: unknown) => Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FLAT_PRODUCT = { id: 'p1', name: 'P', slug: 'p', price: '19.99', currency: 'USD' }
|
|
24
|
+
|
|
25
|
+
const invoiceSettings = (over: Partial<UIDLInvoiceSettings>): UIDLInvoiceSettings =>
|
|
26
|
+
({ defaultTaxRate: 0, taxIncludedInPrice: false, ...over } as UIDLInvoiceSettings)
|
|
27
|
+
|
|
28
|
+
describe('ecommerce product transform — storefront tax display fields', () => {
|
|
29
|
+
it('leaves `price` NET and adds a gross `displayPrice`', () => {
|
|
30
|
+
const product = evalBuildProduct(19)(FLAT_PRODUCT)
|
|
31
|
+
expect(product.price).toBe(19.99)
|
|
32
|
+
expect(product.displayPrice).toBe('23.79')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('is a formatting-only no-op at rate 0 — the number is untouched', () => {
|
|
36
|
+
const product = evalBuildProduct(0)(FLAT_PRODUCT)
|
|
37
|
+
expect(product.price).toBe(19.99)
|
|
38
|
+
expect(product.displayPrice).toBe('19.99')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('always emits two decimals, so a trailing-zero price never renders as "19.9"', () => {
|
|
42
|
+
const product = evalBuildProduct(0)({ ...FLAT_PRODUCT, price: '19.90' })
|
|
43
|
+
expect(product.displayPrice).toBe('19.90')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('rounds per UNIT, so unit x quantity equals the line total the cart prints', () => {
|
|
47
|
+
const product = evalBuildProduct(19)(FLAT_PRODUCT)
|
|
48
|
+
expect(Number(product.displayPrice) * 3).toBeCloseTo(71.37, 5)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('grosses the default variant price and the picker JSON, leaving their net twins alone', () => {
|
|
52
|
+
const record = {
|
|
53
|
+
...FLAT_PRODUCT,
|
|
54
|
+
variant_options: JSON.stringify([
|
|
55
|
+
{ key: 'size', name: 'Size', type: 'text', values: [{ value: 's', label: 'S' }] },
|
|
56
|
+
]),
|
|
57
|
+
}
|
|
58
|
+
const withVariants = new Function(
|
|
59
|
+
generateSharedTransformationCode() +
|
|
60
|
+
'\n' +
|
|
61
|
+
generateEcommerceProductTransformationCode({ storefrontTaxRate: 19 }) +
|
|
62
|
+
'\nreturn buildEcommerceProduct;'
|
|
63
|
+
)() as (r: unknown, o: unknown) => Record<string, unknown>
|
|
64
|
+
|
|
65
|
+
const product = withVariants(record, {
|
|
66
|
+
variantsByProductId: {
|
|
67
|
+
// `price: null` INHERITS the product price — the gross copy has to
|
|
68
|
+
// resolve the inheritance before taxing, or an inheriting combination
|
|
69
|
+
// would show the net base price beside an overriding gross one.
|
|
70
|
+
p1: [
|
|
71
|
+
{ id: 'v1', options: { size: 's' }, price: null, quantity: 5 },
|
|
72
|
+
{ id: 'v2', options: { size: 'm' }, price: '30.00', quantity: 5 },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(product.defaultVariantPrice).toBe('19.99')
|
|
78
|
+
expect(product.defaultVariantDisplayPrice).toBe('23.79')
|
|
79
|
+
|
|
80
|
+
const net = JSON.parse(String(product.variantsJson))
|
|
81
|
+
const gross = JSON.parse(String(product.variantsDisplayJson))
|
|
82
|
+
expect(net[0].price).toBeNull()
|
|
83
|
+
expect(net[1].price).toBe(30)
|
|
84
|
+
expect(gross[0].price).toBe(23.79)
|
|
85
|
+
expect(gross[1].price).toBe(35.7)
|
|
86
|
+
// Everything except the price rides through untouched — the picker resolves
|
|
87
|
+
// the selected combination by id and gates on quantity.
|
|
88
|
+
expect(gross[0].id).toBe('v1')
|
|
89
|
+
expect(gross[0].options).toEqual({ size: 's' })
|
|
90
|
+
expect(gross[0].quantity).toBe(5)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('tolerates a missing / unparseable price', () => {
|
|
94
|
+
const build = evalBuildProduct(19)
|
|
95
|
+
expect(build({ id: 'x', name: 'X', slug: 'x' }).displayPrice).toBe('0.00')
|
|
96
|
+
expect(build({ id: 'x', name: 'X', slug: 'x', price: 'abc' }).displayPrice).toBe('0.00')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('buildProductTransformOptions', () => {
|
|
101
|
+
it('resolves the rate from the invoice settings and carries the taxonomy through', () => {
|
|
102
|
+
const options = buildProductTransformOptions({
|
|
103
|
+
ecommerceSettings: { categories: [{ id: 'c1', name: 'C', slug: 'c' }] } as never,
|
|
104
|
+
invoiceSettings: invoiceSettings({ defaultTaxRate: 21 }),
|
|
105
|
+
})
|
|
106
|
+
expect(options.storefrontTaxRate).toBe(21)
|
|
107
|
+
expect(options.categories).toHaveLength(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('is 0 for tax-inclusive pricing and for a store with no invoice settings at all', () => {
|
|
111
|
+
expect(
|
|
112
|
+
buildProductTransformOptions({
|
|
113
|
+
invoiceSettings: invoiceSettings({ defaultTaxRate: 19, taxIncludedInPrice: true }),
|
|
114
|
+
}).storefrontTaxRate
|
|
115
|
+
).toBe(0)
|
|
116
|
+
expect(buildProductTransformOptions({}).storefrontTaxRate).toBe(0)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as types from '@babel/types';
|
|
2
|
+
import { UIDLDependency } from '@teleporthq/teleport-types';
|
|
3
|
+
import { ResolvedDataSourceCache } from './types';
|
|
4
|
+
/** Identifiers the emitted page imports from `utils/tq-cache/client`. */
|
|
5
|
+
export declare const CACHE_CLIENT_IMPORTS: string[];
|
|
6
|
+
export declare const cachedValueVarFor: (index: number) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Registers the client runtime's named imports on the page.
|
|
9
|
+
*
|
|
10
|
+
* Each identifier is its own dependency entry because that is how the
|
|
11
|
+
* import-statements plugin keys them; they share one path, so they collapse
|
|
12
|
+
* into a single `import { … } from '…/utils/tq-cache/client'`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const registerCacheClientImports: (dependencies: Record<string, UIDLDependency>, folderPath: string[] | undefined) => void;
|
|
15
|
+
/**
|
|
16
|
+
* The terminal `.then` of a cached fetch: record the version the server
|
|
17
|
+
* answered with (which purges the scope if it moved) and store the rows.
|
|
18
|
+
*
|
|
19
|
+
* `tqCacheSet` returns the value it was given, so the promise still resolves to
|
|
20
|
+
* exactly what the uncached chain resolved to — `response?.data`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const buildCacheStoreThen: (cache: ResolvedDataSourceCache) => types.ArrowFunctionExpression;
|
|
23
|
+
/** Wraps a network chain in the peek preamble, producing the cached body. */
|
|
24
|
+
export declare const buildCachedFetchBody: (networkChain: types.Expression, cache: ResolvedDataSourceCache) => types.BlockStatement;
|
|
25
|
+
/**
|
|
26
|
+
* Finds the `return <fetch(...)…>` statement inside a cached `fetchData` body.
|
|
27
|
+
*
|
|
28
|
+
* Shared with `loading-state` so both modules agree on what "the network chain"
|
|
29
|
+
* means: the body also contains an early `return Promise.resolve(hit)`, and
|
|
30
|
+
* attaching in-flight bookkeeping to THAT would flip the loading flag on every
|
|
31
|
+
* cache hit — the exact flash this feature exists to remove.
|
|
32
|
+
*/
|
|
33
|
+
export declare const findCachedFetchNetworkChain: (body: types.BlockStatement) => {
|
|
34
|
+
statement: types.ReturnStatement;
|
|
35
|
+
index: number;
|
|
36
|
+
} | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Hoists a cached provider's `params` into a component const and adds the
|
|
39
|
+
* synchronous cache peek beside it.
|
|
40
|
+
*
|
|
41
|
+
* The peek is what removes the loading flash on a page or search change: those
|
|
42
|
+
* change the provider's `key`, so it REMOUNTS with `data: undefined` and paints
|
|
43
|
+
* `renderLoading()` before any promise — even an already-resolved one — can
|
|
44
|
+
* resume. Feeding a hit into `initialData` makes the provider skip its first
|
|
45
|
+
* fetch entirely and paint the cached rows on the very first frame.
|
|
46
|
+
*
|
|
47
|
+
* `sticky` is set because this value is handed to a mounted provider: once it
|
|
48
|
+
* has been, TTL expiry must not flip it back to `undefined`, or the list would
|
|
49
|
+
* blank out without refetching (its `params` never changed).
|
|
50
|
+
*
|
|
51
|
+
* Reference identity comes from the runtime's own map, not from this `useMemo`
|
|
52
|
+
* — the memo only keeps the peek from re-running on unrelated renders.
|
|
53
|
+
*/
|
|
54
|
+
export declare const buildCachedParamsDeclarations: (params: {
|
|
55
|
+
index: number;
|
|
56
|
+
paramsMemo: types.Expression;
|
|
57
|
+
cache: ResolvedDataSourceCache;
|
|
58
|
+
}) => {
|
|
59
|
+
declarations: types.Statement[];
|
|
60
|
+
paramsIdentifier: string;
|
|
61
|
+
cachedIdentifier: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* `useEffect(() => { tqMarkHydrated(); tqCacheRevalidate([...]) }, [])`
|
|
65
|
+
*
|
|
66
|
+
* One effect per page, covering every scope on it in a single request. Flipping
|
|
67
|
+
* the hydration latch here rather than at import time is what keeps the first
|
|
68
|
+
* client render byte-identical to the server render.
|
|
69
|
+
*/
|
|
70
|
+
export declare const buildCacheHydrationEffect: (scopes: string[]) => types.Statement;
|
|
71
|
+
//# sourceMappingURL=ast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ast.d.ts","sourceRoot":"","sources":["../../../src/cache/ast.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAA;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAA;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAA;AAEjD,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,UAOhC,CAAA;AAMD,eAAO,MAAM,iBAAiB,UAAW,MAAM,KAAG,MAA8B,CAAA;AAEhF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,iBACvB,OAAO,MAAM,EAAE,cAAc,CAAC,cAChC,MAAM,EAAE,GAAG,SAAS,KAC/B,IAaF,CAAA;AAyCD;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,UACvB,uBAAuB,KAC7B,MAAM,uBAkCR,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,oBAAoB,iBACjB,MAAM,UAAU,SACvB,uBAAuB,KAC7B,MAAM,cAIL,CAAA;AAEJ;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,SAChC,MAAM,cAAc,KACzB;IAAE,SAAS,EAAE,MAAM,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,SAcxD,CAAA;AAkBD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,6BAA6B,WAAY;IACpD,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,UAAU,CAAA;IAC5B,KAAK,EAAE,uBAAuB,CAAA;CAC/B;kBACe,MAAM,SAAS,EAAE;sBACb,MAAM;sBACN,MAAM;CAkCzB,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,WAAY,MAAM,EAAE,KAAG,MAAM,SAgBhE,CAAA"}
|