Setting the file. One moment.
Catalog · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.823
Position 823 of 828
Type TypeScript
Size 12 KB
Lines 267 storefront/rest/ catalog.ts
TypeScript · 267 lines · 12 KB
11 CATALOG_SORTS,
12 DETAIL_FIELDS,
13 LIST_FIELDS,
14 STORES_TREE,
15 choiceAvailability,
16 facetCustomizationIds,
17 facetSearchBody,
18 pageInfo,
19 readFacetAggregates,
20 resolveVariant,
21 searchQuery,
22 sellingPrice,
23 toCategory,
24 toDetail,
25 toFacetData,
26 toInventory,
27 toSummary,
28 type CatalogSearchOptions,
29 type CatalogSort,
30 type FacetSelection,
31 type Raw,
32 } from "../app/wix/storefront/catalog-core.js" ;
33 import { WIX_STORES_APP_ID } from "../app/wix/storefront/cart-core.js" ;
34 import type { Category, Facet, FacetData, ProductDetail, ProductSummary, VariantInventory } from "../app/wix/storefront/types.js" ;
35
36 export { CATALOG_SORTS, choiceAvailability, resolveVariant, sellingPrice, type CatalogSearchOptions, type CatalogSort, type FacetSelection };
37
38 const notFound = ( e : unknown ) : boolean => e instanceof WixApiError && e.status === 404 ;
39
40 /**
41 * The gallery query: sort, filter, facets, search, and cursor paging, all applied by Wix across the
42 * whole catalog — never on a page already in hand. `total` is issued alongside the first page only;
43 * `hasMore` is the API's own `hasNext`.
44 * POST /stores/v3/products/search { fields, search: { filter, sort, search, cursorPaging } }
45 * POST /stores/v3/products/count { filter }
46 */
47 export async function searchCatalog ( o : CatalogSearchOptions = {}) : Promise <{ products : ProductSummary []; nextCursor : string | null ; hasMore : boolean ; total : number | null }> {
48 const search = searchQuery (o);
49 const [ res , count ] = await Promise . all ([
50 wixRequest < Raw >( "/stores/v3/products/search" , { body: { fields: LIST_FIELDS , search } }),
51 // the count is a nicety — the page is still valid without it
52 ! o.cursor ? wixRequest < Raw >( "/stores/v3/products/count" , { body: { filter: search.filter } }). catch (() => null ) : Promise . resolve ( null ),
53 ]);
54 return {
55 products: ((res?.products ?? []) as Raw []). map (( p ) => toSummary (p, imgSrc, mediaKey)),
56 ... pageInfo (res),
57 total: typeof count?.count === "number" ? count.count : null ,
58 };
59 }
60
61 export async function fetchFacets ( scope : { categoryId ?: string | null } = {}) : Promise < Facet []> {
62 return ( await fetchFacetData (scope)).facets;
63 }
64
65 /**
66 * Facets and price bounds of a scope: ONE aggregations-only search (cursorPaging.limit 0 — no
67 * products, whatever the catalog size), then the named customizations for names, colors, order.
68 * Non-fatal: empty on failure.
69 * POST /stores/v3/products/search { search: { filter, cursorPaging: { limit: 0 }, aggregations } }
70 * POST /stores/v3/customizations/query { query: { filter: { id: { $in }, customizationRenderType: { $ne: "FREE_TEXT" } }, cursorPaging: { limit: 100 } } }
71 */
72 export async function fetchFacetData ({ categoryId } : { categoryId ?: string | null } = {}) : Promise < FacetData > {
73 try {
74 const res = await wixRequest < Raw >( "/stores/v3/products/search" , { body: { search: facetSearchBody (categoryId) } });
75 const agg = readFacetAggregates (res);
76 return toFacetData (agg, await fetchCustomizations ( facetCustomizationIds (agg)));
77 } catch {
78 return { facets: [], priceRange: null };
79 }
80 }
81
82 async function fetchCustomizations ( ids : string []) : Promise < Raw []> {
83 if ( ! ids. length ) return [];
84 const batches : string [][] = [];
85 for ( let i = 0 ; i < ids. length ; i += 100 ) batches. push (ids. slice (i, i + 100 ));
86 const pages = await Promise . all (
87 batches. map (( batch ) =>
88 wixRequest < Raw >( "/stores/v3/customizations/query" , {
89 body: { query: { filter: { id: { $in: batch }, customizationRenderType: { $ne: "FREE_TEXT" } }, cursorPaging: { limit: 100 } } },
90 }),
91 ),
92 );
93 return pages. flatMap (( p ) => (p?.customizations ?? []) as Raw []);
94 }
95
96 export async function fetchProducts ({ limit = 24 } = {}) : Promise < ProductSummary []> {
97 return ( await searchCatalog ({ limit })).products;
98 }
99
100 export async function fetchProductsByCategory ( categoryId : string , { limit = 24 } = {}) : Promise < ProductSummary []> {
101 return ( await searchCatalog ({ limit, categoryId })).products;
102 }
103
104 /**
105 * One complete product for the PDP; null when the slug doesn't resolve — a real 404, never a
106 * fallback to another product. GET /stores/v3/products/slug/{slug}?fields=…&fields=…
107 */
108 export async function fetchProductBySlug ( slug : string ) : Promise < ProductDetail | null > {
109 try {
110 const res = await wixRequest < Raw >( `/stores/v3/products/slug/${ encodeURIComponent ( decodeSlug ( slug )) }` , { method: "GET" , query: { fields: DETAIL_FIELDS } });
111 return res?.product ? toDetail (res.product, imgSrc, mediaKey) : null ;
112 } catch (e) {
113 if ( notFound (e)) return null ;
114 throw e;
115 }
116 }
117
118 function decodeSlug ( slug : string ) : string {
119 try {
120 return decodeURIComponent (slug);
121 } catch {
122 return slug;
123 }
124 }
125
126 /**
127 * The id of Wix's auto-created "All Products" system category — excluded from navigation by id.
128 * null when the read fails (the slug heuristic then stands in). GET /stores/v3/all-products-category
129 */
130 export async function fetchAllProductsCategoryId () : Promise < string | null > {
131 try {
132 const res = await wixRequest < Raw >( "/stores/v3/all-products-category" , { method: "GET" });
133 return res?.categoryId ?? null ;
134 } catch {
135 return null ;
136 }
137 }
138
139 /**
140 * The live VISIBLE category tree for navigation — never a seeded list; every page of it; the
141 * "All Products" system category dropped by id. The API returns only visible categories unless
142 * asked otherwise; the `visible` filter also keeps the query non-empty (an empty filter is
143 * rejected on the visitor path). Non-fatal: [] on failure.
144 * POST /categories/v1/categories/query { treeReference, fields, query: { filter: { visible: true }, cursorPaging: { limit, cursor } } }
145 */
146 export async function fetchCategories () : Promise < Category []> {
147 try {
148 const [ allProductsId , items ] = await Promise . all ([ fetchAllProductsCategoryId (), readCategoryPages ()]);
149 return items
150 . filter (( c ) => c.visible !== false )
151 . map (( c ) => toCategory (c, imgSrc))
152 . filter (( c ) => (allProductsId ? c.id !== allProductsId : c.slug !== "all-products" ));
153 } catch {
154 return [];
155 }
156 }
157
158 async function readCategoryPages () : Promise < Raw []> {
159 const out : Raw [] = [];
160 let cursor : string | null = null ;
161 for ( let i = 0 ; i < 20 ; i ++ ) {
162 const res : Raw = await wixRequest < Raw >( "/categories/v1/categories/query" , {
163 body: {
164 treeReference: STORES_TREE ,
165 fields: [ "DESCRIPTION" ],
166 query: cursor ? { cursorPaging: { limit: 100 , cursor } } : { filter: { visible: true }, cursorPaging: { limit: 100 } },
167 },
168 });
169 out. push ( ... ((res?.categories ?? []) as Raw []));
170 cursor = res?.pagingMetadata?.hasNext ? res?.pagingMetadata?.cursors?.next ?? null : null ;
171 if ( ! cursor) break ;
172 }
173 return out;
174 }
175
176 /**
177 * One category by URL slug (its id feeds searchCatalog; name, description, image, breadcrumbs head
178 * the page). The response is WRAPPED: { category }. null when missing or hidden → a real 404.
179 * GET /categories/v1/categories/slug/{slug}?treeReference.appNamespace=@wix/stores&fields=BREADCRUMBS_INFO&fields=DESCRIPTION
180 */
181 export async function fetchCategoryBySlug ( slug : string ) : Promise < Category | null > {
182 try {
183 const res = await wixRequest < Raw >( `/categories/v1/categories/slug/${ encodeURIComponent ( decodeSlug ( slug )) }` , {
184 method: "GET" ,
185 query: { "treeReference.appNamespace" : "@wix/stores" , fields: [ "BREADCRUMBS_INFO" , "DESCRIPTION" ] },
186 });
187 const raw : Raw | undefined = res?.category;
188 return raw && raw.visible !== false ? toCategory (raw, imgSrc) : null ;
189 } catch (e) {
190 if ( notFound (e)) return null ;
191 throw e;
192 }
193 }
194
195 // The default Stores location — inventory is per location. Read once per session.
196 // POST /stores/v3/locations/query { query: { filter: { defaultLocation: true } } }
197 let defaultLocation : Promise < string | null > | null = null ;
198 export function fetchDefaultLocationId () : Promise < string | null > {
199 defaultLocation ??= wixRequest < Raw >( "/stores/v3/locations/query" , { body: { query: { filter: { defaultLocation: true } } } })
200 . then (( r ) => ((r?.storesLocations ?? []) as Raw [])[ 0 ]?.id ?? null )
201 . catch (() => null );
202 return defaultLocation;
203 }
204
205 /**
206 * Live inventory of a product's variants at the default location, keyed by variantId; {} on
207 * failure. Merge with mergeInventory() from ./catalog-core.
208 * POST /stores/v3/inventory-items/query { query: { filter: { productId, locationId }, cursorPaging: { limit: 100 } } }
209 */
210 export async function fetchInventory ( productId : string ) : Promise < Record < string , VariantInventory >> {
211 try {
212 const locationId = await fetchDefaultLocationId ();
213 const items : Raw [] = [];
214 let cursor : string | null = null ;
215 for ( let i = 0 ; i < 10 ; i ++ ) {
216 const res : Raw = await wixRequest < Raw >( "/stores/v3/inventory-items/query" , {
217 body: { query: cursor ? { cursorPaging: { limit: 100 , cursor } } : { filter: { productId, ... (locationId ? { locationId } : {}) }, cursorPaging: { limit: 100 } } },
218 });
219 items. push ( ... ((res?.inventoryItems ?? []) as Raw []));
220 cursor = res?.pagingMetadata?.hasNext ? res?.pagingMetadata?.cursors?.next ?? null : null ;
221 if ( ! cursor) break ;
222 }
223 return toInventory (items);
224 } catch {
225 return {};
226 }
227 }
228
229 // Back in Stock (eCom). The public paths are the ones the generated SDK resolves for
230 // www.wixapis.com: its "/back-in-stock-service" prefix in front of the service paths, and
231 // GetSettings as a PUT with an empty body — kept exactly, not guessed.
232 const BACK_IN_STOCK = "/back-in-stock-service/v1/back-in-stock-notification-requests" ;
233
234 // Whether the merchant collects "notify me" requests for Stores items. Read once; false on failure.
235 // PUT /back-in-stock-service/v1/back-in-stock-notification-requests/settings {}
236 let backInStockEnabled : Promise < boolean > | null = null ;
237 export function fetchBackInStockEnabled () : Promise < boolean > {
238 backInStockEnabled ??= wixRequest < Raw >( `${ BACK_IN_STOCK }/settings` , { method: "PUT" , body: {} })
239 . then (( r ) => ((r?.settings?.collectionStates ?? []) as Raw []). some (( s ) => s.appId === WIX_STORES_APP_ID && s.collectingRequests === true ))
240 . catch (() => false );
241 return backInStockEnabled;
242 }
243
244 /**
245 * Ask Wix to email the buyer when the variant is back in stock. `price` is the variant's plain
246 * amount (a decimal string). "already-subscribed" when that email already asked for this item.
247 * POST /back-in-stock-service/v1/back-in-stock-notification-requests { request: { catalogReference, email }, itemDetails: { name, price, image } }
248 */
249 export async function requestBackInStock (
250 productId : string ,
251 variantId : string | null ,
252 email : string ,
253 item : { name : string ; price : string ; imageUrl ?: string },
254 ) : Promise < "created" | "already-subscribed" > {
255 try {
256 await wixRequest < Raw >( BACK_IN_STOCK , {
257 body: {
258 request: { catalogReference: { catalogItemId: productId, appId: WIX_STORES_APP_ID , ... (variantId ? { options: { variantId } } : {}) }, email },
259 itemDetails: { name: item.name, price: item.price, ... (item.imageUrl ? { image: item.imageUrl } : {}) },
260 },
261 });
262 return "created" ;
263 } catch (e) {
264 if (e instanceof WixApiError && e.code === "BACK_IN_STOCK_NOTIFICATION_REQUEST_ALREADY_EXISTS" ) return "already-subscribed" ;
265 throw e;
266 }
267 }