Setting the file. One moment. Seed Store · Wix Vibe Headless · wix/skills · Skills DocsWix Blog
212
async function addProductsToCategories
— line 212
This file
- Number
- 20.121
- Position
- 121 of 126
- Type
- JavaScript
- Size
- 13 KB
- Lines
- 284
references/storefront/seed/seed-store.js
JavaScript·284 lines·13 KB
14// await seed.attachProductImages(ctx, products.map((p,i) => ({ id:p.id, url:imageUrls[i], altText:p.slug })));
15//
16// If any call fails with a shape the caller didn't expect, fall back to the wix-docs skill
17// (search + read the live Wix API reference) — never guess. Source recipe (authoritative):
18// wix-headless/references/inline-recipes/setup-online-store.md.
19
20const API = "https://www.wixapis.com";
21const STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
22
23async function req(ctx, path, { method = "POST", body } = {}) {
24 // Retry the catalog-V1 provisioning race: right after a fresh Stores install the V3 WRITE path
25 // clears CATALOG_V1 a bit later than the V3 read path, so even once waitForCatalogV3 (a read probe)
26 // returns, the first bulk-create can still 428 with CATALOG_V1_SITE_CALLING_CATALOG_V3_API. Wait it
27 // out on that code only (~80s budget); every other error throws on the first try as before.
28 for (let attempt = 0; ; attempt++) {
29 const res = await fetch(API + path, {
30 method,
31 headers: {
32 Authorization: `Bearer ${ctx.token}`,
33 "wix-site-id": ctx.siteId,
34 "Content-Type": "application/json",
35 },
36 body: body ? JSON.stringify(body) : undefined,
37 });
38 const json = await res.json().catch(() => ({}));
39 if (res.ok) return json;
40 if (json?.details?.applicationError?.code === "CATALOG_V1_SITE_CALLING_CATALOG_V3_API" && attempt < 40) {
41 await sleep(2000);
42 continue;
43 }
44 throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
45 }
46}
47
48const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
49
50// A freshly installed Stores catalog transiently reports CATALOG_V1: V3 calls 428 with
51// applicationError.code === "CATALOG_V1_SITE_CALLING_CATALOG_V3_API" until provisioning settles to
52// V3. Poll a cheap V3 read until that code clears (bounded ~80s), so we don't fire the expensive
53// bulk-create repeatedly during the window. This is a cheap pre-gate on the READ path; the WRITE path
54// clears slightly later, so the real guarantee is req()'s retry on the same code — this just minimizes
55// how many times the actual write has to retry.
56async function waitForCatalogV3(ctx, { attempts = 40, delayMs = 2000 } = {}) {
57 for (let i = 0; i < attempts; i++) {
58 const res = await fetch(`${API}/stores/v3/products/query`, {
59 method: "POST",
60 headers: { Authorization: `Bearer ${ctx.token}`, "wix-site-id": ctx.siteId, "Content-Type": "application/json" },
61 body: JSON.stringify({ query: { paging: { limit: 1 } } }),
62 });
63 if (res.ok) return;
64 const json = await res.json().catch(() => ({}));
65 if (json?.details?.applicationError?.code !== "CATALOG_V1_SITE_CALLING_CATALOG_V3_API") return;
66 await sleep(delayMs);
67 }
68}
69
70// plain string -> Wix rich-text description node tree (plainDescription is HTML/nodes, not text)
71function mkDesc(text, i) {
72 return {
73 nodes: [{
74 type: "PARAGRAPH", id: `desc-${i}`,
75 nodes: [{ type: "TEXT", textData: { text: text || "" } }],
76 paragraphData: { textStyle: { textAlignment: "AUTO" } },
77 }],
78 metadata: { version: 1, id: `desc-meta-${i}` },
79 };
80}
81
82// [{name, type?:"text"|"color", choices:["8","9"] | [{name,colorCode}]}] -> Wix options[]
83function buildOptions(options = []) {
84 return options.map((o) => {
85 const color = o.type === "color";
86 return {
87 name: o.name,
88 optionRenderType: color ? "SWATCH_CHOICES" : "TEXT_CHOICES",
89 choicesSettings: {
90 choices: o.choices.map((c) =>
91 color
92 ? { choiceType: "ONE_COLOR", name: c.name, colorCode: c.colorCode }
93 : { choiceType: "CHOICE_TEXT", name: typeof c === "string" ? c : c.name }),
94 },
95 };
96 });
97}
98
99// full Cartesian product of variants, each priced/stocked from the product; visible:true baked in
100function expandVariants(options = [], { price, compareAtPrice, quantity }) {
101 const base = {
102 price: {
103 actualPrice: { amount: String(price) },
104 ...(compareAtPrice ? { compareAtPrice: { amount: String(compareAtPrice) } } : {}),
105 },
106 visible: true,
107 physicalProperties: {},
108 inventoryItem: { quantity: quantity ?? 0, preorderInfo: { enabled: false } },
109 };
110 if (!options.length) return [base];
111 let combos = [[]];
112 for (const o of options) {
113 const rt = o.type === "color" ? "SWATCH_CHOICES" : "TEXT_CHOICES";
114 const names = o.choices.map((c) => (typeof c === "string" ? c : c.name));
115 combos = combos.flatMap((combo) =>
116 names.map((choiceName) => [...combo, { optionChoiceNames: { optionName: o.name, choiceName, renderType: rt } }]));
117 }
118 return combos.map((choices) => ({ ...base, choices }));
119}
120
121// ---- exported operations ----
122
123async function installStoresApp(ctx) {
124 try {
125 await req(ctx, "/apps-installer-service/v1/app-instance/install", { body: {
126 tenant: { tenantType: "SITE", id: ctx.siteId },
127 appInstance: { appDefId: STORES_APP_ID, enabled: true },
128 } });
129 } catch {
130 // already installed is fine — the readiness wait below still confirms the V3 catalog is live
131 }
132 // Do NOT return to the caller until V3 is ready, else the first V3 seed call 428s on CATALOG_V1.
133 await waitForCatalogV3(ctx);
134}
135
136async function listProducts(ctx) {
137 const r = await req(ctx, "/stores/v3/products/query", { body: { query: { paging: { limit: 50 } } } });
138 return (r.products ?? []).map((p) => ({ id: p.id, name: p.name }));
139}
140
141/**
142 * Bulk-create products.
143 * @param products [{ name, description, price, compareAtPrice?, quantity,
144 * options?: [{ name, type?:"text"|"color", choices:["8","9"] | [{name,colorCode}] }] }]
145 * options = ONLY things the buyer selects-and-buys (Size, Color) -> become variants.
146 * Display-only attributes go in name/category/description, NOT options. Default: no options.
147 * visible/physicalProperties/variant-expansion handled here. `quantity` is the stock created.
148 * @returns [{ id, slug, revision }]
149 */
150async function bulkCreateProducts(ctx, products) {
151 const body = {
152 returnEntity: true,
153 products: products.map((p, i) => ({
154 name: p.name,
155 productType: "PHYSICAL",
156 physicalProperties: {},
157 visible: true,
158 visibleInPos: true,
159 description: mkDesc(p.description, i),
160 options: buildOptions(p.options),
161 variantsInfo: { variants: expandVariants(p.options, p) },
162 })),
163 };
164 const r = await req(ctx, "/stores/v3/bulk/products-with-inventory/create", { body });
165 // NB: results nest under productResults.results[].item — NOT a top-level `results`.
166 const created = (r.productResults?.results ?? []).map((x, i) => ({
167 id: x.item?.id, slug: x.item?.slug, revision: x.item?.revision,
168 variantId: x.item?.variantsInfo?.variants?.[0]?.id,
169 hasOptions: (products[i]?.options?.length ?? 0) > 0,
170 quantity: products[i]?.quantity ?? 0,
171 }));
172 await stockOptionlessProducts(ctx, created);
173 return created.map((p) => ({ id: p.id, slug: p.slug, revision: p.revision }));
174}
175
176// products-with-inventory/create stocks a variant via its choices; an OPTION-LESS product has a
177// single choiceless (default) variant that the create does NOT stock — it lands OUT_OF_STOCK. So
178// set stock on those default variants explicitly (bulk/inventory-items/create). Products WITH options
179// are already stocked by the create above, so they're skipped. Backfills the default variantId from a
180// query if the create response didn't return it.
181async function stockOptionlessProducts(ctx, created) {
182 const need = created.filter((p) => !p.hasOptions && p.id);
183 if (!need.length) return;
184 const missing = need.filter((p) => !p.variantId).map((p) => p.id);
185 if (missing.length) {
186 const q = await req(ctx, "/stores/v3/products/query", { body: { query: { filter: { id: { $in: missing } }, paging: { limit: missing.length } } } });
187 const vById = new Map((q.products ?? []).map((p) => [p.id, p.variantsInfo?.variants?.[0]?.id]));
188 need.forEach((p) => { if (!p.variantId) p.variantId = vById.get(p.id); });
189 }
190 const inventoryItems = need
191 .filter((p) => p.variantId)
192 .map((p) => ({ productId: p.id, variantId: p.variantId, quantity: p.quantity }));
193 if (inventoryItems.length) {
194 await req(ctx, "/stores/v3/bulk/inventory-items/create", { body: { inventoryItems } });
195 }
196}
197
198// Categories: no bulk create, and MUST be sequential — they share the @wix/stores tree revision,
199// so concurrent creates 409. Run after products (catalog can lag right after the Stores install).
200async function createCategories(ctx, names) {
201 const out = [];
202 for (const name of names) {
203 const r = await req(ctx, "/categories/v1/categories", {
204 body: { category: { name, visible: true }, treeReference: { appNamespace: "@wix/stores", treeKey: null } },
205 });
206 out.push({ id: r.category?.id, name });
207 }
208 return out;
209}
210
211// mapping: { [categoryId]: [productId, ...] } — also sequential (same shared tree)
212async function addProductsToCategories(ctx, mapping) {
213 for (const [categoryId, productIds] of Object.entries(mapping)) {
214 await req(ctx, `/categories/v1/bulk/categories/${categoryId}/add-items`, {
215 body: {
216 items: productIds.map((catalogItemId) => ({ catalogItemId, appId: STORES_APP_ID })),
217 treeReference: { appNamespace: "@wix/stores", treeKey: null },
218 },
219 });
220 }
221}
222
223// Bulk image attach in ONE call. items: [{ id, url, altText }] — NO revision.
224// An attach bumps the product's revision, so a caller-supplied revision goes stale between passes
225// (INVALID_REVISION); we read each product's CURRENT revision here, right before the update, so the
226// caller never manages a revision token — attach any number of times, in any pass. Wix re-hosts the
227// image from the url server-side; the re-hosted media can take a little while to appear on read-back
228// (propagation) — that's normal, not a failure, so we don't block on it.
229async function attachProductImages(ctx, items) {
230 if (!items?.length) return;
231 const ids = items.map((it) => it.id);
232 const q = await req(ctx, "/stores/v3/products/query", { body: { query: { filter: { id: { $in: ids } }, paging: { limit: ids.length } } } });
233 const revById = new Map((q.products ?? []).map((p) => [p.id, p.revision]));
234 return req(ctx, "/stores/v3/bulk/products/update", {
235 body: {
236 products: items.map((it) => ({
237 product: { id: it.id, revision: revById.get(it.id), media: { itemsInfo: { items: [{ url: it.url, altText: it.altText }] } } },
238 })),
239 },
240 });
241}
242
243/**
244 * ONE-CALL seed: install → create products → categories → attach images, in the correct order,
245 * keeping the created ids in memory (no hand-threading of product ids across exec calls). This is
246 * the DEFAULT path — call it once instead of the individual functions.
247 *
248 * @param plan {{
249 * products: [{ name, description, price, compareAtPrice?, quantity, options?, imageUrl?, altText? }],
250 * categories?: { [categoryName]: string[] }, // map of category name -> product NAMES in it
251 * }}
252 * @returns { products: [{id,slug,revision,name}], categories: [{id,name}], imagesAttached: number }
253 */
254async function setupStore(ctx, { products = [], categories = {} } = {}) {
255 await installStoresApp(ctx); // installs if needed AND waits for the V3 catalog to be ready
256
257 const created = await bulkCreateProducts(ctx, products);
258 const withNames = created.map((p, i) => ({ ...p, name: products[i]?.name }));
259 const idByName = new Map(withNames.map((p) => [p.name, p.id]));
260
261 const names = Object.keys(categories);
262 const cats = names.length ? await createCategories(ctx, names) : [];
263 if (cats.length) {
264 const mapping = {};
265 for (const c of cats) {
266 const ids = (categories[c.name] || []).map((n) => idByName.get(n)).filter(Boolean);
267 if (ids.length) mapping[c.id] = ids;
268 }
269 if (Object.keys(mapping).length) await addProductsToCategories(ctx, mapping);
270 }
271
272 const imageItems = withNames
273 .map((p, i) => ({ id: p.id, url: products[i]?.imageUrl, altText: products[i]?.altText ?? p.slug }))
274 .filter((it) => it.url);
275 if (imageItems.length) await attachProductImages(ctx, imageItems);
276
277 return { products: withNames, categories: cats, imagesAttached: imageItems.length };
278}
279
280module.exports = {
281 setupStore,
282 installStoresApp, listProducts,
283 bulkCreateProducts, createCategories, addProductsToCategories, attachProductImages,
284};