@ecomiq/storefront 2.0.22 → 2.1.3

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/src/server.ts CHANGED
@@ -58,8 +58,8 @@ import {
58
58
  type RemoveCartItemParams,
59
59
  type UpdateCartItemQuantityParams,
60
60
  type UploadPaymentReceiptParams,
61
+ CartTokenSchema,
61
62
  EntityIdSchema,
62
- OptionalEntityIdSchema,
63
63
  } from '@ecomiq/core'
64
64
  import { z } from 'zod'
65
65
  import { getStorefrontClient } from './client'
@@ -71,7 +71,10 @@ import {
71
71
  type AddProductToCartInput,
72
72
  } from './cart-session'
73
73
  import { submitProductReview } from './review-submission'
74
- import { loadActiveStorefrontHome } from './storefront-runtime'
74
+ import {
75
+ loadActiveStorefrontHome,
76
+ loadActiveStorefrontPage,
77
+ } from './storefront-runtime'
75
78
 
76
79
  const getSettingsFn = createServerFn({ method: 'GET' }).handler(() =>
77
80
  getStorefrontClient().getSettings(),
@@ -81,6 +84,10 @@ const getActiveStorefrontHomeFn = createServerFn({ method: 'GET' }).handler(
81
84
  () => loadActiveStorefrontHome(),
82
85
  )
83
86
 
87
+ const getActiveStorefrontPageFn = createServerFn({ method: 'GET' })
88
+ .validator(z.object({ pathname: z.string().min(1).max(2_048) }))
89
+ .handler(({ data }) => loadActiveStorefrontPage(data.pathname))
90
+
84
91
  const getStoreIdFn = createServerFn({ method: 'GET' }).handler(() =>
85
92
  getStorefrontClient().getStoreId(),
86
93
  )
@@ -140,9 +147,9 @@ const removeCartItemFn = createServerFn({ method: 'POST' })
140
147
  .handler(({ data }) => submitCartItemRemoval(getStorefrontClient(), data))
141
148
 
142
149
  const AddProductToCartServerSchema = z.object({
143
- cartId: OptionalEntityIdSchema,
150
+ cartToken: CartTokenSchema.optional(),
144
151
  currencyCode: z.string().length(3),
145
- variantId: z.string().regex(/^[1-9]\d*$/),
152
+ variantId: EntityIdSchema,
146
153
  quantity: z.number().int().positive(),
147
154
  })
148
155
 
@@ -179,7 +186,7 @@ const checkoutCartFn = createServerFn({ method: 'POST' })
179
186
  .handler(({ data }) => getStorefrontClient().checkoutCart(data))
180
187
 
181
188
  const CartPaymentReceiptServerSchema = z.object({
182
- cartId: EntityIdSchema,
189
+ cartToken: CartTokenSchema,
183
190
  fileBase64: z.string().min(1),
184
191
  fileName: z.string().min(1),
185
192
  mimeType: z.string().min(1).optional(),
@@ -191,7 +198,7 @@ const uploadPaymentReceiptFn = createServerFn({ method: 'POST' })
191
198
  const binary = Uint8Array.from(atob(data.fileBase64), (c) => c.charCodeAt(0))
192
199
  const file = new Blob([binary], { type: data.mimeType ?? 'application/octet-stream' })
193
200
  return getStorefrontClient().uploadPaymentReceipt({
194
- cartId: data.cartId,
201
+ cartToken: data.cartToken,
195
202
  file,
196
203
  fileName: data.fileName,
197
204
  })
@@ -228,6 +235,8 @@ const getDistrictsFn = createServerFn({ method: 'GET' })
228
235
  export const serverApi = {
229
236
  getSettings: () => getSettingsFn(),
230
237
  getActiveStorefrontHome: () => getActiveStorefrontHomeFn(),
238
+ getActiveStorefrontPage: (pathname: string) =>
239
+ getActiveStorefrontPageFn({ data: { pathname } }),
231
240
  getStoreId: () => getStoreIdFn(),
232
241
  getCollections: () => getCollectionsFn(),
233
242
  getCollectionBySlug: (slug: string) => getCollectionBySlugFn({ data: { slug } }),
@@ -271,7 +280,7 @@ export const serverApi = {
271
280
  for (const byte of bytes) binary += String.fromCharCode(byte)
272
281
  return uploadPaymentReceiptFn({
273
282
  data: {
274
- cartId: params.cartId,
283
+ cartToken: params.cartToken,
275
284
  fileBase64: btoa(binary),
276
285
  fileName:
277
286
  params.fileName ??
@@ -0,0 +1,188 @@
1
+ import type {
2
+ Collection,
3
+ Collections,
4
+ ProductsParamsInput,
5
+ ProductsResponse,
6
+ ProductSummary,
7
+ } from '@ecomiq/core'
8
+ import {
9
+ type CollectionGridStorefrontSection,
10
+ type ProductGridStorefrontSection,
11
+ type StorefrontDefinition,
12
+ } from '@ecomiq/storefront-definition'
13
+ import { storefrontPageFor } from './renderer'
14
+ import type { DefinitionData } from './renderer'
15
+
16
+ const MAX_UNIQUE_PRODUCT_SOURCES = 12
17
+ const MANUAL_PRODUCT_PAGE_SIZE = 100
18
+ const MAX_MANUAL_PRODUCT_SCAN_PAGES = 5
19
+
20
+ export interface StorefrontRuntimeCatalogClient {
21
+ getProducts(params?: ProductsParamsInput): Promise<ProductsResponse>
22
+ getCollections(): Promise<Collections>
23
+ }
24
+
25
+ export interface ResolvedStorefrontDefinitionData
26
+ extends DefinitionData {
27
+ productsBySectionId: Record<string, ProductSummary[]>
28
+ collectionsBySectionId: Record<string, Collection[]>
29
+ }
30
+
31
+ export function emptyRendererData(): ResolvedStorefrontDefinitionData {
32
+ return {
33
+ productsBySectionId: {},
34
+ collectionsBySectionId: {},
35
+ }
36
+ }
37
+
38
+ function productSourceKey(
39
+ section: ProductGridStorefrontSection,
40
+ ): string | null {
41
+ if (section.source.kind === 'manual') return null
42
+ if (section.source.kind === 'collection') {
43
+ return `collection:${section.source.slug}`
44
+ }
45
+ return 'all'
46
+ }
47
+
48
+ async function loadProductSource(
49
+ section: ProductGridStorefrontSection,
50
+ client: StorefrontRuntimeCatalogClient,
51
+ ): Promise<ProductSummary[]> {
52
+ const params: ProductsParamsInput = {
53
+ page: 1,
54
+ pageSize: section.limit,
55
+ sort: 'newest',
56
+ }
57
+ if (section.source.kind === 'collection') {
58
+ params.collection = section.source.slug
59
+ }
60
+ const response = await client.getProducts(params)
61
+ return response.products.slice(0, section.limit)
62
+ }
63
+
64
+ async function scanManualProducts(
65
+ productIds: ReadonlySet<string>,
66
+ client: StorefrontRuntimeCatalogClient,
67
+ ): Promise<Map<string, ProductSummary>> {
68
+ const found = new Map<string, ProductSummary>()
69
+
70
+ for (let page = 1; page <= MAX_MANUAL_PRODUCT_SCAN_PAGES; page += 1) {
71
+ const response = await client.getProducts({
72
+ page,
73
+ pageSize: MANUAL_PRODUCT_PAGE_SIZE,
74
+ sort: 'newest',
75
+ })
76
+ for (const product of response.products) {
77
+ if (productIds.has(product.id)) found.set(product.id, product)
78
+ }
79
+
80
+ if (
81
+ found.size === productIds.size ||
82
+ page >= response.totalPages ||
83
+ response.products.length === 0
84
+ ) {
85
+ break
86
+ }
87
+ }
88
+
89
+ return found
90
+ }
91
+
92
+ export async function resolveStorefrontDefinitionData(
93
+ definition: StorefrontDefinition,
94
+ client: StorefrontRuntimeCatalogClient,
95
+ pageId?: string,
96
+ ): Promise<ResolvedStorefrontDefinitionData> {
97
+ const data = emptyRendererData()
98
+ const page = storefrontPageFor(definition, pageId)
99
+ const sections = page?.sections ?? []
100
+ const productSections = sections.filter(
101
+ (section): section is ProductGridStorefrontSection =>
102
+ section.enabled && section.type === 'product-grid',
103
+ )
104
+ const collectionSections = sections.filter(
105
+ (section): section is CollectionGridStorefrontSection =>
106
+ section.enabled && section.type === 'collection-grid',
107
+ )
108
+
109
+ const sourceSections = new Map<string, ProductGridStorefrontSection>()
110
+ for (const section of productSections) {
111
+ const sourceKey = productSourceKey(section)
112
+ if (!sourceKey) continue
113
+ const current = sourceSections.get(sourceKey)
114
+ if (current) {
115
+ if (section.limit > current.limit) sourceSections.set(sourceKey, section)
116
+ continue
117
+ }
118
+ if (sourceSections.size >= MAX_UNIQUE_PRODUCT_SOURCES) continue
119
+ sourceSections.set(sourceKey, section)
120
+ }
121
+
122
+ const sourceRequests = new Map<string, Promise<ProductSummary[]>>()
123
+ for (const [sourceKey, section] of sourceSections) {
124
+ sourceRequests.set(
125
+ sourceKey,
126
+ loadProductSource(section, client).catch(() => []),
127
+ )
128
+ }
129
+
130
+ const manualIds = new Set(
131
+ productSections.flatMap((section) =>
132
+ section.source.kind === 'manual' ? section.source.productIds : [],
133
+ ),
134
+ )
135
+ const manualProducts =
136
+ manualIds.size > 0
137
+ ? scanManualProducts(manualIds, client).catch(
138
+ () => new Map<string, ProductSummary>(),
139
+ )
140
+ : Promise.resolve(new Map<string, ProductSummary>())
141
+
142
+ await Promise.all(
143
+ productSections.map(async (section) => {
144
+ if (section.source.kind === 'manual') {
145
+ const productsById = await manualProducts
146
+ data.productsBySectionId[section.id] = section.source.productIds
147
+ .map((productId) => productsById.get(productId))
148
+ .filter(
149
+ (product): product is ProductSummary => product !== undefined,
150
+ )
151
+ .slice(0, section.limit)
152
+ return
153
+ }
154
+
155
+ const sourceKey = productSourceKey(section)
156
+ const products = sourceKey
157
+ ? await (sourceRequests.get(sourceKey) ?? Promise.resolve([]))
158
+ : []
159
+ data.productsBySectionId[section.id] = products.slice(0, section.limit)
160
+ }),
161
+ )
162
+
163
+ if (collectionSections.length > 0) {
164
+ const collections = await client.getCollections().catch(() => [])
165
+ const collectionsBySlug = new Map(
166
+ collections.map((collection) => [collection.slug, collection]),
167
+ )
168
+
169
+ for (const section of collectionSections) {
170
+ if (section.source.kind === 'manual') {
171
+ data.collectionsBySectionId[section.id] = section.source.slugs
172
+ .map((slug) => collectionsBySlug.get(slug))
173
+ .filter(
174
+ (collection): collection is Collection =>
175
+ collection !== undefined,
176
+ )
177
+ .slice(0, section.limit)
178
+ continue
179
+ }
180
+ data.collectionsBySectionId[section.id] = collections.slice(
181
+ 0,
182
+ section.limit,
183
+ )
184
+ }
185
+ }
186
+
187
+ return data
188
+ }