Setting the file. One moment.
Seed Storefront · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.826
Position 826 of 828
Type JavaScript
Size 35 KB
Lines 679 storefront/seed/ seed-storefront.mjs
JavaScript · 679 lines · 35 KB
// "options"?: [{ "name", "type"?: "text"|"color",
15 // "choices": ["S","M"] | [{ "name", "colorCode"?, "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"?, "altText"? }] }],
16 // "variantPrices"?: { "<choice name>": price },
17 // "ribbon"?, "modifiers"?: [{ "name", "type"?: "choices"|"text", "mandatory"?,
18 // "choices"?: ["Gift wrap"], "maxChars"?, "minChars"? }],
19 // "infoSections"?: [{ "title", "description" }],
20 // "preorder"?: { "message"?, "limit"? },
21 // "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"?, "altText"?,
22 // "digitalFileUrl"? | "digitalFilePath"?, "digitalFileName"? }],
23 // "categories"?: { "<category name>": ["<product name>", ...] },
24 // "categoryDetails"?: { "<category name>": { "description"?, "imageUrl"? | "imagePath"? | "imagePrompt"? } } }
25 //
26 // Seeding is ADDITIVE — this script never deletes or overwrites existing content.
27 // If a call fails with an unexpected shape, read the live API reference (every call below
28 // carries a docs: line with its reference page) — never guess.
29 import { setSiteCurrency } from "../../shared/seed/site.mjs" ;
30 import { basename } from "node:path" ;
31 import { readFileSync } from "node:fs" ;
32 import { resolveItemImages, resolveItemImagesDetailed } from "../../shared/seed/images.mjs" ;
33 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
34 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
35 import { isMain } from "../../shared/seed/main.mjs" ;
36 import { buildInfoSections, buildModifiers, buildOptions, expandVariants, mkDesc, preorderInfo, validateProducts } from "./seed-storefront-plan.mjs" ;
37 // Importers of this module keep the plan validation where it was.
38 export { validateProducts };
39
40 const API = "https://www.wixapis.com" ;
41 const STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e" ;
42
43 // ---- auth: siteId from wix.config.json, token minted by the Wix CLI ----------------------------
44
45 export function makeCtx ({ cwd = process. cwd () } = {}) {
46 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
47 // a seed there unless --allow-parent is passed after the user confirmed).
48 const siteId = seedSiteId ({ cwd, argv: process.argv });
49 // The CLI returns a byte-identical token within a run — mint once, reuse.
50 const token = wixToken (siteId, cwd);
51 return { token, siteId };
52 }
53
54 // ---- transport ----------------------------------------------------------------------------------
55
56 async function req ( ctx , path , { method = "POST" , body } = {}) {
57 // Retry while the catalog is still provisioning: right after a fresh Stores install the V3
58 // WRITE path becomes usable later than the read path, so the first bulk-create can 428 even
59 // after the read probe clears. Wait it out (~80s budget); other errors throw immediately.
60 // ⚠️ An errored bulk create (seen live with a bare 429 {}) may still have APPLIED
61 // server-side — creation is idempotent by name in setupStore for exactly that reason.
62 for ( let attempt = 0 ; ; attempt ++ ) {
63 const res = await fetch ( API + path, {
64 method,
65 headers: {
66 Authorization: `Bearer ${ ctx . token }` ,
67 "wix-site-id" : ctx.siteId,
68 "Content-Type" : "application/json" ,
69 },
70 body: body ? JSON . stringify (body) : undefined ,
71 });
72 const json = await res. json (). catch (() => ({}));
73 if (res.ok) return json;
74 if ( isProvisioning (res.status, json) && attempt < 40 ) {
75 await sleep ( 2000 );
76 continue ;
77 }
78 throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
79 }
80 }
81
82 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
83
84 // A freshly installed catalog signals "not writable yet" with a 428 under (at least) two codes.
85 const PROVISIONING_CODES = new Set ([ "CATALOG_V1_SITE_CALLING_CATALOG_V3_API" , "CATALOG_V3_SITE_PROVISIONING" ]);
86
87 function isProvisioning ( status , json ) {
88 if ( PROVISIONING_CODES . has (json?.details?.applicationError?.code)) return true ;
89 return status === 428 && /provision/ i . test (json?.message || "" );
90 }
91
92 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
93 async function waitForCatalogV3 ( ctx , { attempts = 40 , delayMs = 2000 } = {}) {
94 for ( let i = 0 ; i < attempts; i ++ ) {
95 const res = await fetch ( `${ API }/stores/v3/products/query` , {
96 method: "POST" ,
97 headers: { Authorization: `Bearer ${ ctx . token }` , "wix-site-id" : ctx.siteId, "Content-Type" : "application/json" },
98 body: JSON . stringify ({ query: { paging: { limit: 1 } } }),
99 });
100 if (res.ok) return ;
101 const json = await res. json (). catch (() => ({}));
102 if ( ! isProvisioning (res.status, json)) return ;
103 await sleep (delayMs);
104 }
105 }
106
107 // ---- operations ---------------------------------------------------------------------------------
108
109 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
110 export async function installStoresApp ( ctx ) {
111 try {
112 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
113 tenant: { tenantType: "SITE" , id: ctx.siteId },
114 appInstance: { appDefId: STORES_APP_ID , enabled: true },
115 } });
116 } catch {
117 // already installed is fine — the readiness wait below still confirms the V3 catalog is live
118 }
119 await waitForCatalogV3 (ctx);
120 }
121
122 // Existing products by exact name (for idempotent reruns). `name` is NOT filterable on the
123 // V3 query — fetch a page and match client-side (seed catalogs are small). Empty map on any
124 // failure — falling back to create-everything is the additive behavior we had before.
125 /** Every product in the catalog (id, name, slug, revision), cursor-paged. Throws on a failed read. */
126 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
127 export async function readAllProducts ( ctx ) {
128 const out = [];
129 let cursor;
130 do {
131 const r = await req (ctx, "/stores/v3/products/query" , { body: { query: { cursorPaging: { limit: 100 , ... (cursor ? { cursor } : {}) } } } });
132 for ( const p of r.products ?? []) out. push ({ id: p.id, name: p.name, slug: p.slug, revision: p.revision });
133 cursor = r.pagingMetadata?.cursors?.next || undefined ;
134 } while (cursor);
135 return out;
136 }
137
138 export async function queryProductsByNames ( ctx , names , all ) {
139 const out = new Map ();
140 if ( ! names. length ) return out;
141 try {
142 const wanted = new Set (names);
143 for ( const p of all ?? ( await readAllProducts (ctx))) {
144 if (wanted. has (p.name) && ! out. has (p.name)) out. set (p.name, { id: p.id, slug: p.slug, revision: p.revision });
145 }
146 } catch (e) {
147 console. error ( `product name pre-check failed (creating everything): ${ String ( e . message ). slice ( 0 , 120 ) }` );
148 }
149 return out;
150 }
151
152 // A digital variant is SELLABLE only with BOTH a file and stock: without the file the cart rejects
153 // it as ITEM_NOT_FOUND_IN_CATALOG, without stock as "exceeds available inventory" — and either way
154 // the product reads back visible and in the catalog, so nothing surfaces until a buyer tries to buy.
155 // A digitalFile* field is the only way into DIGITAL here, which makes the file-less product
156 // unbuildable. The bytes are PUT, not imported by url: an uploaded file is READY at once, while an
157 // imported one stays PENDING and the cart rejects the product until it settles.
158 // docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/generate-file-upload-url.md
159 const FILE_MIME = { pdf: "application/pdf" , zip: "application/zip" , epub: "application/epub+zip" ,
160 mp3: "audio/mpeg" , wav: "audio/wav" , mp4: "video/mp4" , png: "image/png" , jpg: "image/jpeg" };
161
162 async function uploadDigitalFile ( ctx , { digitalFileUrl , digitalFilePath , digitalFileName }) {
163 const src = digitalFilePath ?? digitalFileUrl;
164 const fileName = digitalFileName || decodeURIComponent ( basename ( new URL (src, "file:" ).pathname));
165 const mimeType = FILE_MIME [fileName. split ( "." ). pop (). toLowerCase ()];
166 if ( ! mimeType) throw new Error ( `digitalFileName needs one of these extensions (${ Object . keys ( FILE_MIME ). join ( ", " ) }): ${ fileName }` );
167 const bytes = digitalFilePath
168 ? readFileSync (digitalFilePath)
169 : await fetch (digitalFileUrl). then (( r ) => {
170 // Don't invent a file and don't ship an unbuyable DIGITAL product: seed it PHYSICAL
171 // with stock (drop digitalFileUrl/digitalFilePath, set inStock or a quantity) and
172 // tell the user the download still needs a real file.
173 if ( ! r.ok) throw new Error ( `digitalFileUrl ${ digitalFileUrl } -> ${ r . status }. No fetchable file: re-seed this product as PHYSICAL with stock and tell the user it needs a real file before it can be sold as a download.` );
174 return r. arrayBuffer ();
175 });
176 const { uploadUrl } = await req (ctx, "/site-media/v1/files/generate-upload-url" , { body: { mimeType, fileName } });
177 const res = await fetch (uploadUrl, { method: "PUT" , headers: { "Content-Type" : mimeType }, body: bytes });
178 const json = await res. json (). catch (() => ({}));
179 const id = (json.file || json)?.id;
180 if ( ! res.ok || ! id) throw new Error ( `digital file upload failed (${ res . status }): ${ JSON . stringify ( json ). slice ( 0 , 200 ) }` );
181 return id;
182 }
183
184 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/bulk-create-products-with-inventory.md
185 export async function bulkCreateProducts ( ctx , products ) {
186 const fileIds = await Promise . all (products. map (( p ) =>
187 p.digitalFileUrl || p.digitalFilePath ? uploadDigitalFile (ctx, p) : null ));
188 const body = {
189 returnEntity: true ,
190 products: products. map (( p , i ) => ({
191 name: p.name,
192 // DIGITAL drops physicalProperties and can't be POS-visible (DIGITAL_PRODUCT_CANNOT_BE_VISIBLE_IN_POS).
193 ... (fileIds[i]
194 ? { productType: "DIGITAL" }
195 : { productType: "PHYSICAL" , physicalProperties: {}, visibleInPos: true }),
196 visible: true ,
197 description: mkDesc (p.description, i),
198 options: buildOptions (p.options),
199 // ribbons, modifiers and info sections are created inline by name, like options
200 ... (p.ribbon ? { ribbon: { name: p.ribbon } } : {}),
201 ... (p.modifiers?. length ? { modifiers: buildModifiers (p.modifiers) } : {}),
202 ... (p.infoSections?. length ? { infoSections: buildInfoSections (p.infoSections, i) } : {}),
203 variantsInfo: { variants: expandVariants (p.options, p, fileIds[i]) },
204 })),
205 };
206 const r = await req (ctx, "/stores/v3/bulk/products-with-inventory/create" , { body });
207 // NB: results nest under productResults.results[].item — NOT a top-level `results`.
208 // The bulk returns 200 even on PARTIAL failure, so never map results by array position:
209 // pair each result to its input via itemMetadata.originalIndex and drop the ones that
210 // didn't persist. Positional mapping shifts every id after a failure onto the wrong
211 // product — which then mislabels categories and attaches images to the wrong items.
212 const created = [];
213 const failures = [];
214 for ( const x of r.productResults?.results ?? []) {
215 const i = x.itemMetadata?.originalIndex;
216 const src = typeof i === "number" ? products[i] : undefined ;
217 if ( ! x.itemMetadata?.success || ! x.item?.id) {
218 failures. push ({
219 name: src?.name,
220 error: x.itemMetadata?.error?.description ?? x.itemMetadata?.error?.code ?? "unknown" ,
221 });
222 continue ;
223 }
224 created. push ({
225 id: x.item.id, slug: x.item.slug, revision: x.item.revision, name: src?.name,
226 variantId: x.item.variantsInfo?.variants?.[ 0 ]?.id,
227 hasOptions: (src?.options?. length ?? 0 ) > 0 ,
228 isDigital: !! fileIds[i],
229 quantity: src?.quantity ?? 0 ,
230 inStock: src?.inStock,
231 preorder: src?.preorder,
232 });
233 }
234 await stockOptionlessProducts (ctx, created);
235 return {
236 created: created. map (( p ) => ({ id: p.id, slug: p.slug, revision: p.revision, name: p.name })),
237 failures,
238 };
239 }
240
241 // The bulk create stocks a variant via its choices; an OPTION-LESS product's single default
242 // variant is NOT stocked by it and lands OUT_OF_STOCK — stock those explicitly.
243 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
244 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/inventory-items-v3/bulk-create-inventory-items.md
245 async function stockOptionlessProducts ( ctx , created ) {
246 const need = created. filter (( p ) => ! p.hasOptions && ! p.isDigital && p.id); // digital variants ship inStock from the create
247 if ( ! need. length ) return ;
248 const missing = need. filter (( p ) => ! p.variantId). map (( p ) => p.id);
249 if (missing. length ) {
250 const q = await req (ctx, "/stores/v3/products/query" , { body: { query: { filter: { id: { $in: missing } }, paging: { limit: missing. length } } } });
251 const vById = new Map ((q.products ?? []). map (( p ) => [p.id, p.variantsInfo?.variants?.[ 0 ]?.id]));
252 need. forEach (( p ) => { if ( ! p.variantId) p.variantId = vById. get (p.id); });
253 }
254 // inStock:true == untracked stock (always buyable, no count). Only send a quantity when the
255 // product actually tracks one, or Wix rejects the pair; pre-order rides on the counted item.
256 const inventoryItems = need
257 . filter (( p ) => p.variantId)
258 . map (( p ) => ({
259 productId: p.id,
260 variantId: p.variantId,
261 ... (p.inStock === true ? { inStock: true } : { quantity: p.quantity, ... (p.preorder ? { preorderInfo: preorderInfo (p.preorder) } : {}) }),
262 }));
263 if (inventoryItems. length ) {
264 await req (ctx, "/stores/v3/bulk/inventory-items/create" , { body: { inventoryItems } });
265 }
266 }
267
268 // Existing categories by name (for idempotent reruns) — a re-run of the seed must reuse
269 // "Donuts", not create a second one. Empty map on any failure (falls back to create).
270 export async function queryCategoriesByNames ( ctx , names ) {
271 const out = new Map ();
272 if ( ! names. length ) return out;
273 try {
274 const r = await req (ctx, "/categories/v1/categories/query" , {
275 body: { treeReference: { appNamespace: "@wix/stores" , treeKey: null }, query: { cursorPaging: { limit: 100 } } },
276 });
277 const wanted = new Set (names);
278 for ( const c of r.categories ?? []) if (wanted. has (c.name) && ! out. has (c.name)) out. set (c.name, c.id);
279 } catch (e) {
280 console. error ( `category name pre-check failed (creating everything): ${ String ( e . message ). slice ( 0 , 120 ) }` );
281 }
282 return out;
283 }
284
285 // Categories share the @wix/stores tree revision — concurrent creates 409, so: sequential.
286 // Idempotent by name: a name that already exists is reused, never duplicated (its description
287 // and image are left as they are). `details[name]` = { description?, imageUrl? } — the image is a
288 // Wix-hosted URL (resolveItemImages) passed at create time; the API re-hosts a full URL itself.
289 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/create-category.md
290 export async function createCategories ( ctx , names , details = {}) {
291 const existing = await queryCategoriesByNames (ctx, names);
292 const out = [];
293 for ( const name of names) {
294 if (existing. has (name)) {
295 out. push ({ id: existing. get (name), name });
296 continue ;
297 }
298 const d = details[name] ?? {};
299 const r = await req (ctx, "/categories/v1/categories" , {
300 body: {
301 category: {
302 name,
303 visible: true ,
304 ... (d.description ? { description: String (d.description). slice ( 0 , 600 ) } : {}),
305 ... (d.imageUrl ? { image: { url: d.imageUrl } } : {}), // an Image OBJECT — a bare URL string is rejected
306 },
307 treeReference: { appNamespace: "@wix/stores" , treeKey: null },
308 },
309 });
310 out. push ({ id: r.category?.id, name });
311 }
312 return out;
313 }
314
315
316 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/bulk-add-items-to-category.md
317 export async function addProductsToCategories ( ctx , mapping ) {
318 for ( const [ categoryId , productIds ] of Object. entries (mapping)) {
319 await req (ctx, `/categories/v1/bulk/categories/${ categoryId }/add-items` , {
320 body: {
321 items: productIds. map (( catalogItemId ) => ({ catalogItemId, appId: STORES_APP_ID })),
322 treeReference: { appNamespace: "@wix/stores" , treeKey: null },
323 },
324 });
325 }
326 }
327
328 // Bulk image attach in ONE call. items: [{ id, url, mediaId?, altText }] — no revision to pass: the
329 // current revision is read right before the update, so attach any number of times, any pass.
330 // An item with a mediaId (a file already in this site's Media Manager) is attached by that id and
331 // reused as it is; a bare url is re-hosted by Wix server-side, a new file each time. The media can
332 // take a little while to appear on read-back
333 // (propagation) — normal, not a failure.
334 // The bulk update returns 200 on PARTIAL failure, like the bulk create: each item's outcome is
335 // in results[].itemMetadata (success, error, originalIndex). Seen live (runs 85 and 86,
336 // 2026-09-26): one product of three came back with no media while the call succeeded — most
337 // likely its revision moved between the read and the update (variant stocking runs just
338 // before). So: pair results to inputs, retry the misses once with fresh revisions, and report
339 // what actually persisted. Returns { attached: [id], failures: [{ id, error }] }.
340 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/bulk-update-products.md
341 // Writing `media.itemsInfo.items` REPLACES the gallery and CLEARS every choice's link into it (seen
342 // live: a re-sent gallery came back with the links empty; a gallery that drops a linked item is
343 // refused outright, "Missing media files. Products must include media files linked to choices").
344 // So the attach merges: the items already there are re-sent by id, only a url not yet in the
345 // gallery (by file name) is added, a product with nothing new is left untouched, and a product
346 // whose choices were linked (by this seed or by the owner in the dashboard) gets those links put
347 // back right after.
348 const sameFile = ( a , b ) => a && b && basename (a) === basename (b);
349 // A file attached by id keeps its own display name as the item's filename, so it is found by id.
350 const galleryHas = ( items , url , mediaId ) => items. some (( it ) => (mediaId && it.id === mediaId) || sameFile (it.image?.filename, url) || sameFile (it.image?.url, url));
351
352 export async function attachProductImages ( ctx , items ) {
353 if ( ! items?. length ) return { attached: [], failures: [] };
354 const send = async ( batch ) => {
355 const ids = batch. map (( it ) => it.id);
356 const q = await req (ctx, "/stores/v3/products/query" , {
357 body: { query: { filter: { id: { $in: ids } }, paging: { limit: ids. length } }, fields: [ "MEDIA_ITEMS_INFO" , "PRODUCT_CHOICES_MEDIA_REFERENCES" , "VARIANT_OPTION_CHOICE_NAMES" ] },
358 });
359 const byId = new Map ((q.products ?? []). map (( p ) => [p.id, p]));
360 const ok = new Set ();
361 const failed = [];
362 const toSend = [];
363 for ( const it of batch) {
364 const p = byId. get (it.id);
365 const current = p?.media?.itemsInfo?.items ?? [];
366 const wanted = it.images ?? [{ url: it.url, altText: it.altText }];
367 const fresh = wanted. filter (({ url , mediaId }) => ! galleryHas (current, url, mediaId));
368 if ( ! fresh. length ) { ok. add (it.id); continue ; } // every image is already there
369 // A file already in the Media Manager goes in by its id: set by url, Stores imports a copy.
370 toSend. push ({ it, p, items: [ ... current. map (({ id }) => ({ id })), ... fresh. map (({ url , mediaId , altText }) => (mediaId ? { id: mediaId, altText } : { url, altText }))] });
371 }
372 if (toSend. length ) {
373 const r = await req (ctx, "/stores/v3/bulk/products/update" , {
374 body: { products: toSend. map (({ it , p , items }) => ({ product: { id: it.id, revision: p?.revision, media: { itemsInfo: { items } } } })) },
375 });
376 for ( const x of r.results ?? []) {
377 const src = toSend[x.itemMetadata?.originalIndex];
378 if ( ! src) continue ;
379 if (x.itemMetadata?.success) ok. add (src.it.id);
380 else failed. push ({ id: src.it.id, error: x.itemMetadata?.error?.description ?? x.itemMetadata?.error?.code ?? "unknown" });
381 }
382 // the gallery write cleared the choice links: put back the ones the product had
383 for ( const { it , p } of toSend) {
384 if ( ! ok. has (it.id)) continue ;
385 const links = choiceLinksOf (p);
386 if (links. length ) await linkChoiceImages (ctx, it.id, links). catch (( e ) => failed. push ({ id: it.id, error: `relink: ${ e ?. message ?? e }` }));
387 }
388 }
389 // an input with no result at all did not persist either
390 for ( const it of batch) if ( ! ok. has (it.id) && ! failed. some (( f ) => f.id === it.id)) failed. push ({ id: it.id, error: "no result for item" });
391 return { ok, failed };
392 };
393 const first = await send (items);
394 const attached = new Set (first.ok);
395 let failures = first.failed;
396 if (failures. length ) {
397 const retry = await send (items. filter (( it ) => failures. some (( f ) => f.id === it.id)));
398 for ( const id of retry.ok) attached. add (id);
399 failures = retry.failed;
400 }
401 return { attached: [ ... attached], failures };
402 }
403
404 // The choice → gallery-item links a product carries (read with PRODUCT_CHOICES_MEDIA_REFERENCES).
405 function choiceLinksOf ( p ) {
406 const out = [];
407 for ( const o of p?.options ?? []) {
408 for ( const c of o.choicesSettings?.choices ?? []) {
409 const mediaId = c.media?.items?.[ 0 ]?.mediaId;
410 if (mediaId) out. push ({ optionName: o.name, choiceName: c.name, mediaId });
411 }
412 }
413 return out;
414 }
415
416 // Per-choice images (the dashboard's "image per colour"): a choice points at an item of the
417 // product's OWN gallery, so the image is attached to the gallery first (pass 2) and linked here by
418 // the gallery item's id — for a file imported by url that is a different id from the uploaded
419 // file's and the file's 400s; a file attached by its Media Manager id is the item of that id. Links are a
420 // product update, which the API only accepts with the variants re-sent beside the options, so the
421 // current variants (id, choices, price) ride along unchanged. Wix derives a variant's own media
422 // from its choice at creation only; the storefront reads the choice's image, so a link made here
423 // shows up on the product page the same as one made in the dashboard.
424 // docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/update-product.md
425 // `links`: [{ optionName, choiceName, url }] (the file attached in pass 2) or [{ …, mediaId }] (a
426 // gallery item id, used to put the owner's links back after a gallery write).
427 export async function linkChoiceImages ( ctx , productId , links ) {
428 const read = () => req (ctx, `/stores/v3/products/${ productId }?fields=MEDIA_ITEMS_INFO&fields=VARIANT_OPTION_CHOICE_NAMES` , { method: "GET" });
429 let { product : p } = await read ();
430 const galleryIdFor = ( link ) => {
431 const items = p?.media?.itemsInfo?.items ?? [];
432 if (link.mediaId) return items. some (( it ) => it.id === link.mediaId) ? link.mediaId : null ;
433 const hit = items. find (( it ) => sameFile (it.image?.filename, link.url) || sameFile (it.image?.url, link.url));
434 return hit?.id ?? null ;
435 };
436 // the gallery can lag the attach (propagation, seen past 6 s live): poll for up to half a minute
437 for ( let i = 0 ; i < 5 && links. some (( l ) => ! galleryIdFor (l)); i ++ ) {
438 await new Promise (( r ) => setTimeout (r, 6000 ));
439 ({ product: p } = await read ());
440 }
441 const missing = [];
442 const options = (p?.options ?? []). map (( o ) => ({
443 id: o.id,
444 name: o.name,
445 optionRenderType: o.optionRenderType,
446 choicesSettings: {
447 choices: (o.choicesSettings?.choices ?? []). map (( c ) => {
448 const link = links. find (( l ) => l.optionName === o.name && l.choiceName === c.name);
449 const mediaId = link ? galleryIdFor (link) : null ;
450 if (link && ! mediaId) missing. push ( `${ o . name } / ${ c . name }` );
451 return {
452 choiceId: c.choiceId,
453 name: c.name,
454 choiceType: c.choiceType,
455 ... (c.colorCode ? { colorCode: c.colorCode } : {}),
456 ... (mediaId ? { media: { items: [{ mediaId }] } } : {}),
457 };
458 }),
459 },
460 }));
461 const physical = p?.productType !== "DIGITAL" ;
462 const variants = (p?.variantsInfo?.variants ?? []). map (( v ) => ({
463 id: v.id,
464 choices: v.choices,
465 price: {
466 actualPrice: { amount: v.price?.actualPrice?.amount },
467 ... (v.price?.compareAtPrice?.amount ? { compareAtPrice: { amount: v.price.compareAtPrice.amount } } : {}),
468 },
469 visible: v.visible !== false ,
470 ... (physical ? { physicalProperties: {} } : {}),
471 }));
472 if (missing. length < links. length ) {
473 await req (ctx, `/stores/v3/products/${ productId }` , {
474 method: "PATCH" ,
475 body: { product: { id: productId, revision: p.revision, options, variantsInfo: { variants } } },
476 });
477 }
478 return { linked: links. length - missing. length , missing };
479 }
480
481 /**
482 * ONE-CALL seed: install → currency → create products → categories → attach images, ids
483 * threaded in memory. This is the default path — call it once instead of the individual
484 * functions.
485 */
486 export async function setupStore ( ctx , { products = [], categories = {}, categoryDetails = {}, currency } = {}) {
487 validateProducts (products);
488 await installStoresApp (ctx);
489 // Before any product exists: a product's price is stored in the site currency at create time,
490 // so switching afterwards leaves the catalog priced in the old one.
491 if (currency) await setSiteCurrency (ctx, currency);
492
493 // What the catalog held BEFORE this seed and the plan does not name: on a fresh install that is
494 // Wix's sample catalog ("Baseball Cap", "Ceramic Flower Vase", a dozen of them), which the live shop
495 // lists next to the owner's products. Reported (`preexisting`), never touched: this seed deletes
496 // nothing on a site, ever. The owner removes what they do not want in the dashboard; the closing
497 // message tells them it is there and where.
498 const planNames = new Set (products. map (( p ) => p.name));
499 let all = [];
500 try { all = await readAllProducts (ctx); } catch (e) { console. error ( `catalog read failed (skipping the pre-existing check): ${ String ( e . message ). slice ( 0 , 120 ) }` ); }
501 let preexisting = all. filter (( p ) => ! planNames. has (p.name));
502
503 // Idempotent by name: an errored bulk create (429/5xx) may still have applied server-side,
504 // and SKILL.md tells the agent to re-run a failed seed — creating only the names that don't
505 // exist yet makes that rerun safe instead of a duplicator.
506 const existing = await queryProductsByNames (ctx, products. map (( p ) => p.name), all. length ? all : undefined );
507 const toCreate = products. filter (( p ) => ! existing. has (p.name));
508 const { created , failures } = toCreate. length
509 ? await bulkCreateProducts (ctx, toCreate)
510 : { created: [], failures: [] };
511 const createdByName = new Map (created. map (( p ) => [p.name, p]));
512 const withNames = products. map (( p ) => {
513 const hit = createdByName. get (p.name) ?? existing. get (p.name);
514 return { ... (hit ?? {}), name: p.name };
515 });
516 const idByName = new Map (withNames. map (( p ) => [p.name, p.id]));
517
518 // Category images resolve before the categories exist (the create call takes the image URL);
519 // a failed image leaves the category text-only, like a product.
520 const names = Object. keys (categories);
521 const details = {};
522 if (names. length ) {
523 const catFiles = await resolveItemImages (ctx, names. map (( n ) => ({
524 url: categoryDetails[n]?.imageUrl,
525 path: categoryDetails[n]?.imagePath,
526 prompt: categoryDetails[n]?.imagePrompt,
527 displayName: `${ n . toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ) }.png` ,
528 })));
529 names. forEach (( n , i ) => {
530 details[n] = { description: categoryDetails[n]?.description, imageUrl: catFiles[i]?.url };
531 });
532 }
533 const cats = names. length ? await createCategories (ctx, names, details) : [];
534 if (cats. length ) {
535 const mapping = {};
536 for ( const c of cats) {
537 const ids = (categories[c.name] || []). map (( n ) => idByName. get (n)). filter (Boolean);
538 if (ids. length ) mapping[c.id] = ids;
539 }
540 if (Object. keys (mapping). length ) await addProductsToCategories (ctx, mapping);
541 }
542
543 // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
544 // bulk-attach. Failures leave the product text-only; the seed's exit never depends on images.
545 // A choice's image (`options[].choices[].imageUrl|imagePath|imagePrompt`) resolves in the same
546 // wave and joins the product's gallery; pass 3 links it to its choice.
547 const choiceSpecs = products. map (( pl , i ) =>
548 (pl.options ?? []). flatMap (( o ) =>
549 (o.choices ?? [])
550 . filter (( c ) => typeof c === "object" && c && (c.imageMediaId || c.imageUrl || c.imagePath || c.imagePrompt))
551 . map (( c ) => ({
552 optionName: o.name,
553 choiceName: c.name,
554 productName: pl.name,
555 altText: c.altText ?? `${ pl . name } — ${ c . name }` ,
556 spec: { mediaId: c.imageMediaId, url: c.imageUrl, path: c.imagePath, prompt: c.imagePrompt, displayName: `${ withNames [ i ]. slug || "product"}-${ String ( c . name ). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ) }.png` },
557 })),
558 ));
559 const flatChoiceSpecs = choiceSpecs. flat ();
560 const resolved = await resolveItemImagesDetailed (ctx, [
561 ... withNames. map (( p , i ) => ({
562 mediaId: products[i]?.imageMediaId,
563 url: products[i]?.imageUrl,
564 path: products[i]?.imagePath,
565 prompt: products[i]?.imagePrompt,
566 displayName: `${ p . slug || "product"}.png` ,
567 })),
568 ... flatChoiceSpecs. map (( c ) => c.spec),
569 ]);
570 const files = resolved. map (( r ) => r.file);
571 const choiceFiles = files. slice (withNames. length );
572 const choiceResolved = resolved. slice (withNames. length );
573 let cursor = 0 ;
574 const choiceLinks = choiceSpecs. map (( specs ) =>
575 specs. map (( c ) => ({ ... c, file: choiceFiles[cursor ++ ] })). filter (( c ) => c.file));
576 // `p.id` guards this: a product that failed to create has no id, and bulk-updating an
577 // undefined id would 400 the whole batch and cost every other product its image.
578 const imageItems = withNames
579 . map (( p , i ) => {
580 if ( ! p.id) return null ;
581 const images = [
582 ... (files[i] ? [{ url: files[i].url, mediaId: files[i].existing ? files[i].id : undefined , altText: products[i]?.altText ?? p.slug }] : []),
583 ... choiceLinks[i]. map (( c ) => ({ url: c.file.url, mediaId: c.file.existing ? c.file.id : undefined , altText: c.altText })),
584 ];
585 return images. length ? { id: p.id, images } : null ;
586 })
587 . filter (Boolean);
588 // imagesAttached counts the attaches the API CONFIRMED (per-item results), not the ones sent;
589 // imageFailures names the products left text-only and why. Neither blocks the seed: a re-run of
590 // the same plan reuses the products and attaches again.
591 let imagesAttached = 0 ;
592 const imageFailures = [];
593 // A product whose image never resolved (a bad path, an unsupported file type, a refused
594 // prompt, a timeout) is not in imageItems at all, so the attach step below never sees it:
595 // it is reported here with the resolver's reason, or it ships text-only in silence.
596 withNames. forEach (( p , i ) => {
597 const src = products[i] ?? {};
598 if ((src.imageMediaId || src.imageUrl || src.imagePath || src.imagePrompt) && ! files[i]) {
599 imageFailures. push ({ name: p.name, error: resolved[i]?.error ?? "image did not resolve" });
600 }
601 });
602 const nameOf = ( id ) => withNames. find (( p ) => p.id === id)?.name;
603 try {
604 if (imageItems. length ) {
605 const r = await attachProductImages (ctx, imageItems);
606 imagesAttached = r.attached. length ;
607 for ( const f of r.failures) imageFailures. push ({ name: nameOf (f.id), error: f.error });
608 }
609 } catch (e) {
610 for ( const it of imageItems) imageFailures. push ({ name: nameOf (it.id), error: e?.message ?? String (e) });
611 }
612
613 // Pass 3 — link each choice's image to its choice, by the gallery item's id.
614 let choiceImagesLinked = 0 ;
615 const choiceImageFailures = [];
616 flatChoiceSpecs. forEach (( c , j ) => {
617 if ( ! choiceFiles[j]) choiceImageFailures. push ({ name: c.productName, choice: c.choiceName, error: choiceResolved[j]?.error ?? "image did not resolve" });
618 });
619 for ( const [ i , p ] of withNames. entries ()) {
620 const links = choiceLinks[i];
621 if ( ! links. length || ! p.id || ! imagesAttached) continue ;
622 try {
623 // a file attached by its Media Manager id is the gallery item of that id; an imported one is found by name
624 const r = await linkChoiceImages (ctx, p.id, links. map (( c ) => ({ optionName: c.optionName, choiceName: c.choiceName, ... (c.file.existing ? { mediaId: c.file.id } : { url: c.file.url }) })));
625 choiceImagesLinked += r.linked;
626 for ( const m of r.missing) choiceImageFailures. push ({ name: p.name, choice: m, error: "image not in the product gallery" });
627 } catch (e) {
628 choiceImageFailures. push ({ name: p.name, error: e?.message ?? String (e) });
629 }
630 }
631
632 // The pre-existing list is read again now: on a fresh site the Stores app was installed moments
633 // before the first read, and Wix creates its sample catalog asynchronously after the install, so
634 // that read often came back empty and the closing message said nothing about a dozen samples the
635 // live shop lists. The early read still serves the idempotency check above; this one serves the report.
636 try {
637 const after = await readAllProducts (ctx);
638 preexisting = after. filter (( p ) => ! planNames. has (p.name));
639 } catch (e) {
640 console. error ( `catalog re-read failed (reporting the pre-existing list from the first read): ${ String ( e . message ). slice ( 0 , 120 ) }` );
641 }
642
643 // failures is part of the result, not an exception: a partial seed still leaves a usable
644 // store, and the agent needs the names to report rather than silently shipping a short
645 // catalog. Re-run the seed to retry them — existing names are skipped, not duplicated.
646 return {
647 products: withNames,
648 categories: cats,
649 imagesAttached,
650 imageFailures,
651 choiceImagesLinked,
652 choiceImageFailures,
653 failures,
654 // Products the plan did not name (Wix's install samples on a fresh site, or the owner's own on an
655 // existing one): the closing message names what the shop lists and where the owner removes it.
656 preexisting: preexisting. map (( p ) => ({ id: p.id, name: p.name, slug: p.slug })),
657 // the Stores app's slug + products list (registered dashboard route); `store/products` is no route
658 dashboardProductsUrl: `https://manage.wix.com/dashboard/${ ctx . siteId }/wix-stores/products` ,
659 };
660 }
661
662 // ---- CLI entry ----------------------------------------------------------------------------------
663
664 const invokedDirectly = isMain ( import . meta .url);
665 if (invokedDirectly) {
666 const planPath = process.argv[ 2 ];
667 if ( ! planPath) {
668 console. error ( "usage: node seed-storefront.mjs <plan.json> (run from the project root)" );
669 process. exit ( 1 );
670 }
671 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
672 const ctx = makeCtx ();
673 setupStore (ctx, plan)
674 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
675 . catch (( e ) => {
676 console. error (e.message);
677 process. exit ( 1 );
678 });
679 }