Setting the file. One moment. Seed Store · Wix Vibe Headless · wix/skills · Skills Docs22.10
Post Detail
function inventoryFor
— line 214
This file
- Number
- 22.136
- Position
- 136 of 145
- Type
- JavaScript
- Size
- 27 KB
- Lines
- 540
references/storefront/seed/seed-store.cjs
JavaScript·540 lines·27 KB
// await seed.addProductsToCategories(ctx, { [cats[0].id]: products.map(p => p.id) });
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 documentation skill available in your environment
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";
22const WIX_CONFIG_PATH = "/app/src/rest/wix-config.js";
23let siteId;
24
25function getSiteId() {
26 if (siteId) return siteId;
27 let source;
28 try {
29 source = require("fs").readFileSync(WIX_CONFIG_PATH, "utf8");
30 } catch {
31 throw new Error(`Cannot read ${WIX_CONFIG_PATH}; deploy the Wix config before seeding.`);
32 }
33 // Base44/deploy writes a named export containing a JSON string literal. Do not execute config.
34 const match = source.match(/^export const WIX_METASITE_ID\s*=\s*("(?:[^"\\]|\\.)*")\s*;/m);
35 let value;
36 try { value = match && JSON.parse(match[1]); } catch { /* invalid config */ }
37 if (typeof value !== "string" || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value)) {
38 throw new Error(`Missing or invalid WIX_METASITE_ID in ${WIX_CONFIG_PATH}; deploy the Wix config before seeding.`);
39 }
40 siteId = value;
41 return siteId;
42}
43
44
45async function req(ctx, path, { method = "POST", body, headers = {} } = {}) {
46 // Retry while the catalog is still provisioning: right after a fresh Stores install the V3 WRITE
47 // path becomes usable a bit later than the V3 read path, so even once waitForCatalogV3 (a read
48 // probe) returns, the first bulk-create can still 428. Wait it out (~80s budget); every other
49 // error throws on the first try as before.
50 for (let attempt = 0; ; attempt++) {
51 const res = await fetch(API + path, {
52 method,
53 headers: {
54 Authorization: `Bearer ${ctx.token}`,
55 ...headers,
56 "Content-Type": "application/json",
57 },
58 body: body ? JSON.stringify(body) : undefined,
59 });
60 const json = await res.json().catch(() => ({}));
61 if (res.ok) return json;
62 if (isProvisioning(res.status, json) && attempt < 40) {
63 await sleep(2000);
64 continue;
65 }
66 throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
67 }
68}
69
70const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
71
72// A freshly installed catalog isn't writable yet, and Wix has signalled that with two different
73// 428s: the older CATALOG_V1_SITE_CALLING_CATALOG_V3_API (site still reports V1) and the current
74// CATALOG_V3_SITE_PROVISIONING ("Site is currently being provisioned for CATALOG_V3"). Sites exist
75// on both behaviours, so match either. The message check catches a further rename: on this endpoint
76// a 428 means "not ready, retry", and treating an unknown one as fatal turns a wait into a failed
77// seed — which is exactly how the V3 code slipped through when only the V1 code was matched.
78const PROVISIONING_CODES = new Set(["CATALOG_V1_SITE_CALLING_CATALOG_V3_API", "CATALOG_V3_SITE_PROVISIONING"]);
79
80function isProvisioning(status, json) {
81 if (PROVISIONING_CODES.has(json?.details?.applicationError?.code)) return true;
82 return status === 428 && /provision/i.test(json?.message || "");
83}
84
85// A freshly installed Stores catalog 428s on V3 calls until provisioning settles (see
86// isProvisioning for the codes). Poll a cheap V3 read until that clears (bounded ~80s), so we don't
87// fire the expensive bulk-create repeatedly during the window. This is a pre-gate on the READ path;
88// the WRITE path clears slightly later, so the real guarantee is req()'s retry — this just minimizes
89// how many times the actual write has to retry.
90async function waitForCatalogV3(ctx, { attempts = 40, delayMs = 2000 } = {}) {
91 for (let i = 0; i < attempts; i++) {
92 const res = await fetch(`${API}/stores/v3/products/query`, {
93 method: "POST",
94 headers: { Authorization: `Bearer ${ctx.token}`, "Content-Type": "application/json" },
95 body: JSON.stringify({ query: { paging: { limit: 1 } } }),
96 });
97 if (res.ok) return;
98 const json = await res.json().catch(() => ({}));
99 // Anything that isn't a provisioning signal is a real error — return and let the caller's own
100 // request surface it. Matching only one of the two codes here made this exit the wait on the
101 // other one, handing the caller straight to a write that then 428'd.
102 if (!isProvisioning(res.status, json)) return;
103 await sleep(delayMs);
104 }
105}
106
107// description string -> Wix rich-text node tree.
108//
109// Descriptions arrive as HTML as often as not: asked to describe a product, a model writes
110// <p>…</p><strong>Care:</strong><br/>. The writable field is `description` (Ricos nodes) — the HTML
111// `plainDescription` the storefront renders is read-only, derived from them. So markup dropped into
112// a single TEXT node is stored as literal text, comes back escaped, and the PDP shows the tags to
113// the buyer. Convert the tags a model actually emits; a string with no tags stays one paragraph.
114const HTML_ENTITIES = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'", " ": " " };
115
116function decodeEntities(s) {
117 return s.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (m) => HTML_ENTITIES[m] ?? m);
118}
119
120// One paragraph's inner HTML -> TEXT nodes, carrying bold/italic as Ricos decorations.
121function mkTextNodes(html) {
122 const nodes = [];
123 let bold = 0, italic = 0, last = 0, m;
124 const tag = /<(\/?)(strong|b|em|i)\s*\/?>/gi;
125 const push = (raw) => {
126 const text = decodeEntities(raw.replace(/<[^>]*>/g, ""));
127 if (!text) return;
128 const decorations = [];
129 if (bold > 0) decorations.push({ type: "BOLD" });
130 if (italic > 0) decorations.push({ type: "ITALIC" });
131 nodes.push({ type: "TEXT", textData: { text, decorations } });
132 };
133 while ((m = tag.exec(html)) !== null) {
134 push(html.slice(last, m.index));
135 const step = m[1] ? -1 : 1;
136 if (/^(strong|b)$/i.test(m[2])) bold = Math.max(0, bold + step);
137 else italic = Math.max(0, italic + step);
138 last = tag.lastIndex;
139 }
140 push(html.slice(last));
141 return nodes.length ? nodes : [{ type: "TEXT", textData: { text: "", decorations: [] } }];
142}
143
144function mkDesc(text, i) {
145 const blocks = String(text ?? "").split(/<\/p\s*>|<br\s*\/?>/i).map((b) => b.trim()).filter(Boolean);
146 return {
147 nodes: (blocks.length ? blocks : [""]).map((block, n) => ({
148 type: "PARAGRAPH", id: `desc-${i}-${n}`,
149 nodes: mkTextNodes(block),
150 paragraphData: { textStyle: { textAlignment: "AUTO" } },
151 })),
152 metadata: { version: 1, id: `desc-meta-${i}` },
153 };
154}
155
156// [{name, type?:"text"|"color", choices:["8","9"] | [{name,colorCode}]}] -> Wix options[]
157function buildOptions(options = []) {
158 return options.map((o) => {
159 const color = o.type === "color";
160 return {
161 name: o.name,
162 optionRenderType: color ? "SWATCH_CHOICES" : "TEXT_CHOICES",
163 choicesSettings: {
164 choices: o.choices.map((c) =>
165 color
166 ? { choiceType: "ONE_COLOR", name: c.name, colorCode: c.colorCode }
167 : { choiceType: "CHOICE_TEXT", name: typeof c === "string" ? c : c.name }),
168 },
169 };
170 });
171}
172
173// Validate the whole batch before installation, uploads, or product creation.
174function validateProducts(products) {
175 const seen = new Map();
176 for (const product of products) {
177 if (product.inStock !== undefined && typeof product.inStock !== "boolean") {
178 throw new Error(`Product "${product.name}": inStock must be a boolean. No products were created by this call.`);
179 }
180 if (product.inStock !== undefined && product.quantity !== undefined) {
181 throw new Error(`Product "${product.name}": supply either inStock or quantity, not both. No products were created by this call.`);
182 }
183 if (!product.digitalFileUrl && product.quantity !== undefined &&
184 (!Number.isInteger(product.quantity) || product.quantity < 0 || product.quantity > 99999)) {
185 throw new Error(`Product "${product.name}": quantity must be an integer from 0 to 99999. ` +
186 `For unlimited stock, use inStock: true without quantity. No products were created by this call.`);
187 }
188 for (const option of product.options ?? []) {
189 const choiceNames = new Set();
190 for (const choice of option.choices ?? []) {
191 const name = typeof choice === "string" ? choice : choice.name;
192 const normalized = name.trim().toLowerCase();
193 if (choiceNames.has(normalized)) {
194 throw new Error(`Duplicate choice "${name}" in option "${option.name}" on product "${product.name}". ` +
195 `Each choice name must be unique within an option. No products were created by this call.`);
196 }
197 choiceNames.add(normalized);
198 if (option.type !== "color") continue;
199 const key = JSON.stringify([option.name.trim().toLowerCase(), choice.name.trim().toLowerCase()]);
200 const code = choice.colorCode?.trim().toLowerCase();
201 const previous = seen.get(key);
202 if (previous && previous.code !== code) {
203 throw new Error(`Conflicting color "${choice.name}" in option "${option.name}": ` +
204 `"${previous.product}" uses ${previous.code}, but "${product.name}" uses ${code}. ` +
205 `Use one color code for this name across the batch, or distinct names for different shades. No products were created by this call.`);
206 }
207 seen.set(key, { code, product: product.name });
208 }
209 }
210 }
211}
212
213// Choose one inventory tracking mode; downloads retain their default available stock.
214function inventoryFor(product) {
215 if (product.inStock !== undefined) return { inStock: product.inStock };
216 if (product.digitalFileUrl) return { inStock: true };
217 return { quantity: product.quantity ?? 0 };
218}
219
220// full Cartesian product of variants, each priced/stocked from the product; visible:true baked in
221function expandVariants(options = [], product, digitalFileId) {
222 const { price, compareAtPrice } = product;
223 const inventoryItem = inventoryFor(product);
224 const base = {
225 price: {
226 actualPrice: { amount: String(price) },
227 ...(compareAtPrice ? { compareAtPrice: { amount: String(compareAtPrice) } } : {}),
228 },
229 visible: true,
230 ...(digitalFileId
231 ? { digitalProperties: { digitalFile: { id: digitalFileId } }, inventoryItem }
232 : { physicalProperties: {}, inventoryItem: { ...inventoryItem,
233 ...(inventoryItem.quantity !== undefined ? { preorderInfo: { enabled: false } } : {}) } }),
234 };
235 if (!options.length) return [base];
236 let combos = [[]];
237 for (const o of options) {
238 const rt = o.type === "color" ? "SWATCH_CHOICES" : "TEXT_CHOICES";
239 const names = o.choices.map((c) => (typeof c === "string" ? c : c.name));
240 combos = combos.flatMap((combo) =>
241 names.map((choiceName) => [...combo, { optionChoiceNames: { optionName: o.name, choiceName, renderType: rt } }]));
242 }
243 return combos.map((choices) => ({ ...base, choices }));
244}
245
246// A digital variant is SELLABLE only with BOTH a file and stock: without the file the cart rejects
247// it as ITEM_NOT_FOUND_IN_CATALOG, without stock as "exceeds available inventory" — and either way
248// the product reads back visible and in the catalog, so nothing surfaces until a buyer tries to buy.
249// `digitalFileUrl` is the only way into DIGITAL here, which makes the file-less product unbuildable.
250// The bytes are PUT, not imported by url: an uploaded file is READY at once, while an imported one
251// stays PENDING and the cart rejects the product until it settles.
252const FILE_MIME = { pdf: "application/pdf", zip: "application/zip", epub: "application/epub+zip",
253 mp3: "audio/mpeg", wav: "audio/wav", mp4: "video/mp4", png: "image/png", jpg: "image/jpeg" };
254
255async function uploadDigitalFile(ctx, url, fileName) {
256 const mimeType = FILE_MIME[(fileName.split(".").pop() || "").toLowerCase()];
257 if (!mimeType) throw new Error(`digitalFileName needs one of these extensions (${Object.keys(FILE_MIME).join(", ")}): ${fileName}`);
258 const { uploadUrl } = await req(ctx, "/site-media/v1/files/generate-upload-url", { body: { mimeType, fileName } });
259 const src = await fetch(url);
260 if (!src.ok) throw new Error(`digitalFileUrl ${url} -> ${src.status}. A digital product needs a real, ` +
261 `fetchable file — with none at hand, seed this product as physical with inStock: true and tell the user.`);
262 const res = await fetch(uploadUrl, {
263 method: "PUT", headers: { "Content-Type": mimeType }, body: Buffer.from(await src.arrayBuffer()),
264 });
265 const json = await res.json().catch(() => ({}));
266 const id = (json.file || json)?.id;
267 if (!res.ok || !id) throw new Error(`digital file upload failed (${res.status}): ${JSON.stringify(json).slice(0, 200)}`);
268 return id;
269}
270
271const digitalFileName = (p) =>
272 p.digitalFileName || decodeURIComponent(new URL(p.digitalFileUrl).pathname.split("/").pop() || "");
273
274// ---- exported operations ----
275
276async function installStoresApp(ctx) {
277 const siteId = getSiteId(); // Fail before the install-error catch if config is missing.
278 try {
279 await req(ctx, "/apps-installer-service/v1/app-instance/install", { headers: { "wix-site-id": siteId }, body: {
280 tenant: { tenantType: "SITE", id: siteId },
281 appInstance: { appDefId: STORES_APP_ID, enabled: true },
282 } });
283 } catch {
284 // already installed is fine — the readiness wait below still confirms the V3 catalog is live
285 }
286 // Do NOT return to the caller until V3 is ready, else the first V3 seed call 428s on CATALOG_V1.
287 await waitForCatalogV3(ctx);
288}
289
290async function listProducts(ctx) {
291 const r = await req(ctx, "/stores/v3/products/query", { body: { query: { paging: { limit: 50 } } } });
292 return (r.products ?? []).map((p) => ({ id: p.id, name: p.name }));
293}
294
295/**
296 * Bulk-create products.
297 * @param products [{ name, description, price, compareAtPrice?, quantity?, inStock?,
298 * options?: [{ name, type?:"text"|"color", choices:["8","9"] | [{name,colorCode}] }],
299 * digitalFileUrl?, digitalFileName? }]
300 * digitalFileUrl: makes the product a DIGITAL download — the file is uploaded and the variant
301 * created with both the file and stock (see uploadDigitalFile). `quantity` is ignored.
302 * description: plain text, or simple HTML (`<p>`, `<br/>`, `<strong>`, `<em>`) — converted to
303 * Wix rich text here, so the storefront renders paragraphs and bold rather than tag text.
304 * options = ONLY things the buyer selects-and-buys (Size, Color) -> become variants.
305 * Display-only attributes go in name/category/description, NOT options. Default: no options.
306 * visible/physicalProperties/variant-expansion handled here. `quantity` tracks 0–99999 units (default 0); `inStock` selects availability without a count.
307 * Supply only one. Each variant inherits that stock mode. Downloads default to inStock:true.
308 * @returns [{ id, slug, revision }]
309 */
310async function bulkCreateProducts(ctx, products) {
311 validateProducts(products);
312 const fileIds = await Promise.all(products.map((p) =>
313 p.digitalFileUrl ? uploadDigitalFile(ctx, p.digitalFileUrl, digitalFileName(p)) : null));
314 const body = {
315 returnEntity: true,
316 products: products.map((p, i) => ({
317 name: p.name,
318 // DIGITAL drops physicalProperties and can't be POS-visible (DIGITAL_PRODUCT_CANNOT_BE_VISIBLE_IN_POS).
319 ...(fileIds[i]
320 ? { productType: "DIGITAL" }
321 : { productType: "PHYSICAL", physicalProperties: {}, visibleInPos: true }),
322 visible: true,
323 description: mkDesc(p.description, i),
324 options: buildOptions(p.options),
325 variantsInfo: { variants: expandVariants(p.options, p, fileIds[i]) },
326 })),
327 };
328 const r = await req(ctx, "/stores/v3/bulk/products-with-inventory/create", { body });
329 // NB: results nest under productResults.results[].item — NOT a top-level `results`.
330 const results = r.productResults?.results ?? [];
331 const created = [];
332 const failures = [];
333 const seen = new Set();
334 for (const [position, result] of results.entries()) {
335 const index = result.itemMetadata?.originalIndex ?? position;
336 seen.add(index);
337 if (result.itemMetadata?.success === false || !result.item?.id) {
338 failures.push({ index, name: products[index]?.name, ...result.itemMetadata?.error,
339 message: result.itemMetadata?.error?.description ?? "Create result has no product ID" });
340 continue;
341 }
342 created.push({
343 id: result.item.id, slug: result.item.slug, revision: result.item.revision,
344 variantId: result.item.variantsInfo?.variants?.[0]?.id,
345 hasOptions: (products[index]?.options?.length ?? 0) > 0,
346 isDigital: !!fileIds[index], inventory: inventoryFor(products[index]),
347 index, name: products[index]?.name,
348 });
349 }
350 products.forEach((product, index) => {
351 if (!seen.has(index)) failures.push({ index, name: product.name, message: "Missing bulk-create result; creation status unknown" });
352 });
353 if (failures.length) {
354 const successes = created.map(({ id, name, index }) => ({ id, name, index }));
355 const error = new Error(`Product creation did not fully succeed. ` +
356 `Created: ${JSON.stringify(successes)}. Failures: ${JSON.stringify(failures)}. ` +
357 `Stopped before stock, categories, or images. Do not rerun the full seed: existing products would be duplicated.`);
358 error.createdProducts = successes;
359 error.failures = failures;
360 throw error;
361 }
362 created.sort((a, b) => a.index - b.index);
363 await stockOptionlessProducts(ctx, created);
364 return created.map((p) => ({ id: p.id, slug: p.slug, revision: p.revision }));
365}
366
367// products-with-inventory/create stocks a variant via its choices; an OPTION-LESS product has a
368// single choiceless (default) variant that the create does NOT stock — it lands OUT_OF_STOCK. So
369// set stock on those default variants explicitly (bulk/inventory-items/create). Products WITH options
370// are already stocked by the create above, so they're skipped. Backfills the default variantId from a
371// query if the create response didn't return it.
372async function stockOptionlessProducts(ctx, created) {
373 const need = created.filter((p) => !p.hasOptions && !p.isDigital && p.id); // digital variants ship inStock from the create
374 if (!need.length) return;
375 const missing = need.filter((p) => !p.variantId).map((p) => p.id);
376 if (missing.length) {
377 const q = await req(ctx, "/stores/v3/products/query", { body: { query: { filter: { id: { $in: missing } }, paging: { limit: missing.length } } } });
378 const vById = new Map((q.products ?? []).map((p) => [p.id, p.variantsInfo?.variants?.[0]?.id]));
379 need.forEach((p) => { if (!p.variantId) p.variantId = vById.get(p.id); });
380 }
381 const inventoryItems = need
382 .filter((p) => p.variantId)
383 .map((p) => ({ productId: p.id, variantId: p.variantId, ...p.inventory }));
384 if (inventoryItems.length) {
385 await req(ctx, "/stores/v3/bulk/inventory-items/create", { body: { inventoryItems } });
386 }
387}
388
389// Categories: no bulk create, and MUST be sequential — they share the @wix/stores tree revision,
390// so concurrent creates 409. Run after products (catalog can lag right after the Stores install).
391async function createCategories(ctx, names) {
392 const out = [];
393 for (const name of names) {
394 const r = await req(ctx, "/categories/v1/categories", {
395 body: { category: { name, visible: true }, treeReference: { appNamespace: "@wix/stores", treeKey: null } },
396 });
397 out.push({ id: r.category?.id, name });
398 }
399 return out;
400}
401
402// mapping: { [categoryId]: [productId, ...] } — also sequential (same shared tree)
403async function addProductsToCategories(ctx, mapping) {
404 for (const [categoryId, productIds] of Object.entries(mapping)) {
405 await req(ctx, `/categories/v1/bulk/categories/${categoryId}/add-items`, {
406 body: {
407 items: productIds.map((catalogItemId) => ({ catalogItemId, appId: STORES_APP_ID })),
408 treeReference: { appNamespace: "@wix/stores", treeKey: null },
409 },
410 });
411 }
412}
413
414// Bulk image attach in ONE call. items: [{ id, url, altText }] — NO revision.
415// An attach bumps the product's revision, so a caller-supplied revision goes stale between passes
416// (INVALID_REVISION); we read each product's CURRENT revision here, right before the update, so the
417// caller never manages a revision token — attach any number of times, in any pass. Wix re-hosts the
418// image from the url server-side; the re-hosted media can take a little while to appear on read-back
419// (propagation) — that's normal, not a failure, so we don't block on it.
420// Wix imports the image bytes server-side, so an attach needs an absolute, publicly fetchable url.
421const isFetchableImageUrl = (url) => typeof url === "string" && /^https:\/\//.test(url);
422
423async function attachProductImages(ctx, items) {
424 if (!items?.length) return;
425 if (items.some((it) => !it.id)) throw new Error("Image attachment requires a product ID for every item; no image request was sent.");
426 const unfetchable = items.filter((it) => !isFetchableImageUrl(it.url));
427 if (unfetchable.length) throw new Error(
428 `Image url(s) for [${unfetchable.map((it) => it.altText || it.id).join(", ")}] are not absolute ` +
429 `https:// urls, and Wix copies the image bytes at attach time. Re-call attachProductImages once each ` +
430 `image has its final url. No image request was sent; products are unaffected.`);
431 const ids = items.map((it) => it.id);
432 const q = await req(ctx, "/stores/v3/products/query", { body: { query: { filter: { id: { $in: ids } }, paging: { limit: ids.length } } } });
433 const revById = new Map((q.products ?? []).map((p) => [p.id, p.revision]));
434 return req(ctx, "/stores/v3/bulk/products/update", {
435 body: {
436 products: items.map((it) => ({
437 product: { id: it.id, revision: revById.get(it.id), media: { itemsInfo: { items: [{ url: it.url, altText: it.altText }] } } },
438 })),
439 },
440 });
441}
442
443// Site-wide payment currency; product amounts are not converted when this changes.
444// https://dev.wix.com/docs/api-reference/business-management/site-properties/skills/change-payment-currency-site-properties.md
445async function configureCurrency(ctx, requested) {
446 const result = { requested: requested ?? null, actual: null, status: "unchanged", warnings: [] };
447 if (requested !== undefined) {
448 try {
449 if (typeof requested !== "string" || !/^[A-Z]{3}$/.test(requested))
450 throw new Error("currency must be a three-letter uppercase ISO currency code");
451 await req(ctx, "/site-properties/v4/properties", {
452 method: "PATCH",
453 body: { properties: { paymentCurrency: requested }, fields: { paths: ["paymentCurrency"] } },
454 });
455 result.status = "updated";
456 } catch (error) {
457 result.status = "failed";
458 result.warnings.push(`Currency update failed; seeding continued: ${error.message}`);
459 }
460 }
461 try {
462 const snapshot = await req(ctx, "/site-properties/v4/properties", { method: "GET" });
463 result.actual = snapshot.properties?.paymentCurrency ?? null;
464 if (!result.actual) throw new Error("Site Properties returned no paymentCurrency");
465 if (requested !== undefined && result.actual !== requested) {
466 result.status = "failed";
467 result.warnings.push(`Requested currency ${requested}; site currency is ${result.actual}.`);
468 }
469 } catch (error) {
470 result.status = "failed";
471 result.warnings.push(`Currency verification failed; seeding continued: ${error.message}`);
472 }
473 return result;
474}
475
476/**
477 * ONE-CALL seed: install → create products → categories → attach images, in the correct order,
478 * keeping the created ids in memory (no hand-threading of product ids across exec calls). This is
479 * the DEFAULT path — call it once instead of the individual functions.
480 *
481 * @param plan {{
482 * currency?: string, // requested site payment currency; omitted preserves the current setting
483 * products: [{ name, description, price, compareAtPrice?, quantity?, inStock?, options?, imageUrl?, altText?,
484 * digitalFileUrl?, digitalFileName? }],
485 * categories?: { [categoryName]: string[] }, // map of category name -> product NAMES in it
486 * }}
487 * @returns { products: [{id,slug,revision,name}], categories: [{id,name}], imagesAttached: number,
488 * imagesSkipped?: string[], note?: string, currency: {requested,actual,status,warnings} }
489 */
490async function setupStore(ctx, { products = [], categories = {}, currency } = {}) {
491 validateProducts(products);
492 await installStoresApp(ctx); // installs if needed AND waits for the V3 catalog to be ready
493
494 const currencyResult = await configureCurrency(ctx, currency);
495 const created = await bulkCreateProducts(ctx, products);
496 const withNames = created.map((p, i) => ({ ...p, name: products[i]?.name }));
497 const idByName = new Map(withNames.map((p) => [p.name, p.id]));
498
499 const names = Object.keys(categories);
500 const cats = names.length ? await createCategories(ctx, names) : [];
501 if (cats.length) {
502 const mapping = {};
503 for (const c of cats) {
504 const ids = (categories[c.name] || []).map((n) => idByName.get(n)).filter(Boolean);
505 if (ids.length) mapping[c.id] = ids;
506 }
507 if (Object.keys(mapping).length) await addProductsToCategories(ctx, mapping);
508 }
509
510 const imageItems = withNames
511 .map((p, i) => ({ id: p.id, url: products[i]?.imageUrl, altText: products[i]?.altText ?? p.slug, name: p.name }))
512 .filter((it) => it.url);
513 // A url Wix cannot fetch would fail the attach: seed the product imageless and report it,
514 // so the caller attaches once the final url exists.
515 const readyItems = imageItems.filter((it) => isFetchableImageUrl(it.url));
516 const skipped = imageItems.filter((it) => !isFetchableImageUrl(it.url)).map((it) => it.name);
517 if (readyItems.length) await attachProductImages(ctx, readyItems);
518
519 const withoutImages = withNames.filter((p, i) => !products[i]?.imageUrl).map((p) => p.name);
520 return {
521 products: withNames, categories: cats, imagesAttached: readyItems.length,
522 ...(skipped.length && {
523 imagesSkipped: skipped,
524 note: "these products' image urls were not absolute https:// urls — attach them with " +
525 "attachProductImages once each image has its final url",
526 }),
527 ...(withoutImages.length && !skipped.length && {
528 productsWithoutImages: withoutImages,
529 note: "these products were seeded without an image — once their images have final urls, " +
530 "attach them with attachProductImages",
531 }),
532 currency: currencyResult,
533 };
534}
535
536module.exports = {
537 setupStore,
538 installStoresApp, listProducts,
539 bulkCreateProducts, createCategories, addProductsToCategories, attachProductImages,
540};