@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.
Files changed (110) hide show
  1. package/__tests__/array-mapper-cache-wiring.test.ts +425 -0
  2. package/__tests__/data-source-cache-wrapping.test.ts +152 -0
  3. package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
  4. package/__tests__/ecommerce-product-model.test.ts +179 -0
  5. package/__tests__/ecommerce-product-storefront-tax.test.ts +118 -0
  6. package/dist/cjs/cache/ast.d.ts +71 -0
  7. package/dist/cjs/cache/ast.d.ts.map +1 -0
  8. package/dist/cjs/cache/ast.js +229 -0
  9. package/dist/cjs/cache/ast.js.map +1 -0
  10. package/dist/cjs/cache/config.d.ts +22 -0
  11. package/dist/cjs/cache/config.d.ts.map +1 -0
  12. package/dist/cjs/cache/config.js +67 -0
  13. package/dist/cjs/cache/config.js.map +1 -0
  14. package/dist/cjs/cache/types.d.ts +31 -0
  15. package/dist/cjs/cache/types.d.ts.map +1 -0
  16. package/dist/cjs/cache/types.js +3 -0
  17. package/dist/cjs/cache/types.js.map +1 -0
  18. package/dist/cjs/data-source-fetchers.d.ts +5 -3
  19. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  20. package/dist/cjs/data-source-fetchers.js +51 -8
  21. package/dist/cjs/data-source-fetchers.js.map +1 -1
  22. package/dist/cjs/fetchers/teleport.d.ts +2 -2
  23. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  24. package/dist/cjs/fetchers/teleport.js +3 -2
  25. package/dist/cjs/fetchers/teleport.js.map +1 -1
  26. package/dist/cjs/index.d.ts +2 -0
  27. package/dist/cjs/index.d.ts.map +1 -1
  28. package/dist/cjs/index.js +14 -9
  29. package/dist/cjs/index.js.map +1 -1
  30. package/dist/cjs/loading-state.d.ts.map +1 -1
  31. package/dist/cjs/loading-state.js +74 -2
  32. package/dist/cjs/loading-state.js.map +1 -1
  33. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  34. package/dist/cjs/pagination-plugin.js +204 -64
  35. package/dist/cjs/pagination-plugin.js.map +1 -1
  36. package/dist/cjs/transformations/ecommerce-product.d.ts +27 -1
  37. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  38. package/dist/cjs/transformations/ecommerce-product.js +8 -3
  39. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  40. package/dist/cjs/transformations/index.d.ts +14 -2
  41. package/dist/cjs/transformations/index.d.ts.map +1 -1
  42. package/dist/cjs/transformations/index.js +37 -3
  43. package/dist/cjs/transformations/index.js.map +1 -1
  44. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  45. package/dist/cjs/transformations/shared-utils.js +1 -1
  46. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  47. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  48. package/dist/cjs/utils.d.ts +4 -3
  49. package/dist/cjs/utils.d.ts.map +1 -1
  50. package/dist/cjs/utils.js +6 -4
  51. package/dist/cjs/utils.js.map +1 -1
  52. package/dist/esm/cache/ast.d.ts +71 -0
  53. package/dist/esm/cache/ast.d.ts.map +1 -0
  54. package/dist/esm/cache/ast.js +196 -0
  55. package/dist/esm/cache/ast.js.map +1 -0
  56. package/dist/esm/cache/config.d.ts +22 -0
  57. package/dist/esm/cache/config.d.ts.map +1 -0
  58. package/dist/esm/cache/config.js +62 -0
  59. package/dist/esm/cache/config.js.map +1 -0
  60. package/dist/esm/cache/types.d.ts +31 -0
  61. package/dist/esm/cache/types.d.ts.map +1 -0
  62. package/dist/esm/cache/types.js +2 -0
  63. package/dist/esm/cache/types.js.map +1 -0
  64. package/dist/esm/data-source-fetchers.d.ts +5 -3
  65. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  66. package/dist/esm/data-source-fetchers.js +51 -8
  67. package/dist/esm/data-source-fetchers.js.map +1 -1
  68. package/dist/esm/fetchers/teleport.d.ts +2 -2
  69. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  70. package/dist/esm/fetchers/teleport.js +3 -2
  71. package/dist/esm/fetchers/teleport.js.map +1 -1
  72. package/dist/esm/index.d.ts +2 -0
  73. package/dist/esm/index.d.ts.map +1 -1
  74. package/dist/esm/index.js +12 -8
  75. package/dist/esm/index.js.map +1 -1
  76. package/dist/esm/loading-state.d.ts.map +1 -1
  77. package/dist/esm/loading-state.js +74 -2
  78. package/dist/esm/loading-state.js.map +1 -1
  79. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  80. package/dist/esm/pagination-plugin.js +205 -65
  81. package/dist/esm/pagination-plugin.js.map +1 -1
  82. package/dist/esm/transformations/ecommerce-product.d.ts +27 -1
  83. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  84. package/dist/esm/transformations/ecommerce-product.js +8 -3
  85. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  86. package/dist/esm/transformations/index.d.ts +14 -2
  87. package/dist/esm/transformations/index.d.ts.map +1 -1
  88. package/dist/esm/transformations/index.js +36 -3
  89. package/dist/esm/transformations/index.js.map +1 -1
  90. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  91. package/dist/esm/transformations/shared-utils.js +1 -1
  92. package/dist/esm/transformations/shared-utils.js.map +1 -1
  93. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  94. package/dist/esm/utils.d.ts +4 -3
  95. package/dist/esm/utils.d.ts.map +1 -1
  96. package/dist/esm/utils.js +6 -4
  97. package/dist/esm/utils.js.map +1 -1
  98. package/package.json +5 -5
  99. package/src/cache/ast.ts +265 -0
  100. package/src/cache/config.ts +79 -0
  101. package/src/cache/types.ts +31 -0
  102. package/src/data-source-fetchers.ts +66 -12
  103. package/src/fetchers/teleport.ts +3 -3
  104. package/src/index.ts +10 -4
  105. package/src/loading-state.ts +65 -2
  106. package/src/pagination-plugin.ts +315 -116
  107. package/src/transformations/ecommerce-product.ts +161 -5
  108. package/src/transformations/index.ts +43 -4
  109. package/src/transformations/shared-utils.ts +14 -0
  110. 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"}