← Files WixARCHIVED FILE

skills/wix-headless-templates/storefront/seed/seed-store.mjs

42.9 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// Storefront seed — a BUILD-TIME script, never shipped in the app. Run it from the project
// root (where wix.config.json lives) with a plan file:
//
//   node <SKILL_ROOT>/templates/storefront/seed/seed-store.mjs plan.json
//
// It mints its own site token via the Wix CLI (the token never leaves this process), installs
// the Wix Stores app if needed, waits for the V3 catalog, bulk-creates products (variants
// expanded, descriptions converted to rich text), creates categories (serially — the shared
// tree 409s on concurrent creates), assigns products, and attaches images. Prints a JSON
// result to stdout.
//
// Plan shape (see SEED.md):
//   { "products": [{ "name", "description", "price", "compareAtPrice"?, "quantity",
//                    "options"?: [{ "name", "type"?: "text"|"color",
//                                   "choices": ["S","M"] | [{ "name", "colorCode"?, "imageUrl"? | "imagePath"? | "imagePrompt"?, "altText"? }] }],
//                    "variantPrices"?: { "<choice name>": price },
//                    "ribbon"?, "modifiers"?: [{ "name", "type"?: "choices"|"text", "mandatory"?,
//                                              "choices"?: ["Gift wrap"], "maxChars"?, "minChars"? }],
//                    "infoSections"?: [{ "title", "description" }],
//                    "preorder"?: { "message"?, "limit"? },
//                    "imageUrl"? | "imagePath"? | "imagePrompt"?, "altText"?,
//                    "digitalFileUrl"? | "digitalFilePath"?, "digitalFileName"? }],
//     "categories"?: { "<category name>": ["<product name>", ...] },
//     "categoryDetails"?: { "<category name>": { "description"?, "imageUrl"? | "imagePath"? | "imagePrompt"? } } }
//
// Seeding is ADDITIVE — this script never deletes or overwrites existing content.
// If a call fails with an unexpected shape, read the live API reference (every call below
// carries a docs: line with its reference page) — never guess.
import { setSiteCurrency } from "../../shared/seed/site.mjs";
import { basename } from "node:path";
import { readFileSync } from "node:fs";
import { resolveItemImages, resolveItemImagesDetailed } from "../../shared/seed/images.mjs";
import { seedSiteId } from "../../shared/seed/site-context.mjs";
import { wixToken } from "../../shared/seed/wix-cli.mjs";

const API = "https://www.wixapis.com";
const STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";

// ---- auth: siteId from wix.config.json, token minted by the Wix CLI ----------------------------

export function makeCtx({ cwd = process.cwd() } = {}) {
  // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
  // a seed there unless --allow-parent is passed after the user confirmed).
  const siteId = seedSiteId({ cwd, argv: process.argv });
  // The CLI returns a byte-identical token within a run — mint once, reuse.
  const token = wixToken(siteId, cwd);
  return { token, siteId };
}

// ---- transport ----------------------------------------------------------------------------------

async function req(ctx, path, { method = "POST", body } = {}) {
  // Retry while the catalog is still provisioning: right after a fresh Stores install the V3
  // WRITE path becomes usable later than the read path, so the first bulk-create can 428 even
  // after the read probe clears. Wait it out (~80s budget); other errors throw immediately.
  // ⚠️ An errored bulk create (seen live with a bare 429 {}) may still have APPLIED
  // server-side — creation is idempotent by name in setupStore for exactly that reason.
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(API + path, {
      method,
      headers: {
        Authorization: `Bearer ${ctx.token}`,
        "wix-site-id": ctx.siteId,
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const json = await res.json().catch(() => ({}));
    if (res.ok) return json;
    if (isProvisioning(res.status, json) && attempt < 40) {
      await sleep(2000);
      continue;
    }
    throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 400)}`);
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// A freshly installed catalog signals "not writable yet" with a 428 under (at least) two codes.
const PROVISIONING_CODES = new Set(["CATALOG_V1_SITE_CALLING_CATALOG_V3_API", "CATALOG_V3_SITE_PROVISIONING"]);

function isProvisioning(status, json) {
  if (PROVISIONING_CODES.has(json?.details?.applicationError?.code)) return true;
  return status === 428 && /provision/i.test(json?.message || "");
}

// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
async function waitForCatalogV3(ctx, { attempts = 40, delayMs = 2000 } = {}) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(`${API}/stores/v3/products/query`, {
      method: "POST",
      headers: { Authorization: `Bearer ${ctx.token}`, "wix-site-id": ctx.siteId, "Content-Type": "application/json" },
      body: JSON.stringify({ query: { paging: { limit: 1 } } }),
    });
    if (res.ok) return;
    const json = await res.json().catch(() => ({}));
    if (!isProvisioning(res.status, json)) return;
    await sleep(delayMs);
  }
}

// ---- description string -> Wix rich-text nodes --------------------------------------------------
// Descriptions arrive as HTML as often as not. The writable field is `description` (Ricos
// nodes) — the HTML `plainDescription` the storefront renders is derived from them, so markup
// dropped into a TEXT node comes back escaped and the PDP shows literal tags. Convert the tags
// a model actually emits; a tag-free string stays one paragraph.

const HTML_ENTITIES = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'", "&nbsp;": " " };

function decodeEntities(s) {
  return s.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (m) => HTML_ENTITIES[m] ?? m);
}

function mkTextNodes(html) {
  const nodes = [];
  let bold = 0, italic = 0, last = 0, m;
  const tag = /<(\/?)(strong|b|em|i)\s*\/?>/gi;
  const push = (raw) => {
    const text = decodeEntities(raw.replace(/<[^>]*>/g, ""));
    if (!text) return;
    const decorations = [];
    if (bold > 0) decorations.push({ type: "BOLD" });
    if (italic > 0) decorations.push({ type: "ITALIC" });
    nodes.push({ type: "TEXT", textData: { text, decorations } });
  };
  while ((m = tag.exec(html)) !== null) {
    push(html.slice(last, m.index));
    const step = m[1] ? -1 : 1;
    if (/^(strong|b)$/i.test(m[2])) bold = Math.max(0, bold + step);
    else italic = Math.max(0, italic + step);
    last = tag.lastIndex;
  }
  push(html.slice(last));
  return nodes.length ? nodes : [{ type: "TEXT", textData: { text: "", decorations: [] } }];
}

function mkDesc(text, i) {
  const blocks = String(text ?? "").split(/<\/p\s*>|<br\s*\/?>/i).map((b) => b.trim()).filter(Boolean);
  return {
    nodes: (blocks.length ? blocks : [""]).map((block, n) => ({
      type: "PARAGRAPH", id: `desc-${i}-${n}`,
      nodes: mkTextNodes(block),
      paragraphData: { textStyle: { textAlignment: "AUTO" } },
    })),
    metadata: { version: 1, id: `desc-meta-${i}` },
  };
}

// ---- options / variants -------------------------------------------------------------------------

function buildOptions(options = []) {
  return options.map((o) => {
    const color = o.type === "color";
    return {
      name: o.name,
      optionRenderType: color ? "SWATCH_CHOICES" : "TEXT_CHOICES",
      choicesSettings: {
        choices: o.choices.map((c) =>
          color
            ? { choiceType: "ONE_COLOR", name: c.name, colorCode: c.colorCode }
            : { choiceType: "CHOICE_TEXT", name: typeof c === "string" ? c : c.name }),
      },
    };
  });
}

// Modifiers collect buyer input WITHOUT creating variants (gift wrap, engraving) — defined inline
// like options, each becomes a customization. `type: "text"` → FREE_TEXT with the merchant's
// character limits; anything else → TEXT_CHOICES. `mandatory` defaults to TRUE, which is how the
// storefront reads an omitted flag.
function buildModifiers(modifiers = []) {
  return modifiers.map((m) => {
    const text = m.type === "text";
    return {
      name: m.name,
      mandatory: m.mandatory !== false,
      ...(text
        ? {
            modifierRenderType: "FREE_TEXT",
            freeTextSettings: {
              title: m.title ?? m.name,
              ...(m.maxChars ? { maxCharCount: m.maxChars } : {}),
              ...(m.minChars ? { minCharCount: m.minChars } : {}),
            },
          }
        : {
            modifierRenderType: "TEXT_CHOICES",
            choicesSettings: {
              choices: (m.choices ?? []).map((c) => ({
                choiceType: "CHOICE_TEXT",
                name: typeof c === "string" ? c : c.name,
                ...(typeof c === "object" && c.addedPrice != null ? { addedPrice: String(c.addedPrice) } : {}),
              })),
            },
          }),
    };
  });
}

// Info sections (materials, shipping, care) — inline definitions; the same title on two products
// shares one section (uniqueName is derived from the title).
function buildInfoSections(sections = [], i) {
  return sections.map((s, n) => ({
    uniqueName: String(s.title).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100) || `section-${n}`,
    title: s.title,
    description: mkDesc(s.description, `info-${i}-${n}`),
  }));
}

// Pre-order needs counted stock: `preorderInfo` on the inventory item, enabled with the merchant's
// message and the number of units buyers may pre-order once stock hits zero.
function preorderInfo(preorder) {
  if (!preorder) return { enabled: false };
  return {
    enabled: true,
    ...(preorder.message ? { message: preorder.message } : {}),
    ...(Number.isInteger(preorder.limit) ? { limit: preorder.limit } : {}),
  };
}

// Full Cartesian product, each variant priced/stocked from the product; visible:true baked in.
// `variantPrices` prices a variant by one of its choice names ("Large": 32) — the first choice
// with a price wins; the product's compareAtPrice is kept only when it stays above that price.
function expandVariants(options = [], { price, compareAtPrice, quantity, inStock, preorder, variantPrices = {} }, digitalFileId) {
  const priceOf = (amount) => ({
    actualPrice: { amount: String(amount) },
    ...(compareAtPrice && Number(compareAtPrice) > Number(amount) ? { compareAtPrice: { amount: String(compareAtPrice) } } : {}),
  });
  const base = {
    price: priceOf(price),
    visible: true,
    ...(digitalFileId
      ? { digitalProperties: { digitalFile: { id: digitalFileId } }, inventoryItem: { inStock: true } }
      // inStock:true == untracked stock — always buyable, no count. Otherwise track a quantity.
      : { physicalProperties: {}, inventoryItem: inStock === true
          ? { inStock: true }
          : { quantity: quantity ?? 0, preorderInfo: preorderInfo(preorder) } }),
  };
  if (!options.length) return [base];
  let combos = [[]];
  for (const o of options) {
    const rt = o.type === "color" ? "SWATCH_CHOICES" : "TEXT_CHOICES";
    const names = o.choices.map((c) => (typeof c === "string" ? c : c.name));
    combos = combos.flatMap((combo) =>
      names.map((choiceName) => [...combo, { optionChoiceNames: { optionName: o.name, choiceName, renderType: rt } }]));
  }
  return combos.map((choices) => {
    const override = choices.map((c) => variantPrices[c.optionChoiceNames.choiceName]).find((v) => v != null);
    return { ...base, choices, ...(override != null ? { price: priceOf(override) } : {}) };
  });
}

// ---- operations ---------------------------------------------------------------------------------

// docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
export async function installStoresApp(ctx) {
  try {
    await req(ctx, "/apps-installer-service/v1/app-instance/install", { body: {
      tenant: { tenantType: "SITE", id: ctx.siteId },
      appInstance: { appDefId: STORES_APP_ID, enabled: true },
    } });
  } catch {
    // already installed is fine — the readiness wait below still confirms the V3 catalog is live
  }
  await waitForCatalogV3(ctx);
}

// Existing products by exact name (for idempotent reruns). `name` is NOT filterable on the
// V3 query — fetch a page and match client-side (seed catalogs are small). Empty map on any
// failure — falling back to create-everything is the additive behavior we had before.
/** Every product in the catalog (id, name, slug, revision), cursor-paged. Throws on a failed read. */
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
export async function readAllProducts(ctx) {
  const out = [];
  let cursor;
  do {
    const r = await req(ctx, "/stores/v3/products/query", { body: { query: { cursorPaging: { limit: 100, ...(cursor ? { cursor } : {}) } } } });
    for (const p of r.products ?? []) out.push({ id: p.id, name: p.name, slug: p.slug, revision: p.revision });
    cursor = r.pagingMetadata?.cursors?.next || undefined;
  } while (cursor);
  return out;
}

export async function queryProductsByNames(ctx, names, all) {
  const out = new Map();
  if (!names.length) return out;
  try {
    const wanted = new Set(names);
    for (const p of all ?? (await readAllProducts(ctx))) {
      if (wanted.has(p.name) && !out.has(p.name)) out.set(p.name, { id: p.id, slug: p.slug, revision: p.revision });
    }
  } catch (e) {
    console.error(`product name pre-check failed (creating everything): ${String(e.message).slice(0, 120)}`);
  }
  return out;
}

// A digital variant is SELLABLE only with BOTH a file and stock: without the file the cart rejects
// it as ITEM_NOT_FOUND_IN_CATALOG, without stock as "exceeds available inventory" — and either way
// the product reads back visible and in the catalog, so nothing surfaces until a buyer tries to buy.
// A digitalFile* field is the only way into DIGITAL here, which makes the file-less product
// unbuildable. The bytes are PUT, not imported by url: an uploaded file is READY at once, while an
// imported one stays PENDING and the cart rejects the product until it settles.
// docs: https://dev.wix.com/docs/api-reference/assets/media/media-manager/files/generate-file-upload-url.md
const FILE_MIME = { pdf: "application/pdf", zip: "application/zip", epub: "application/epub+zip",
  mp3: "audio/mpeg", wav: "audio/wav", mp4: "video/mp4", png: "image/png", jpg: "image/jpeg" };

async function uploadDigitalFile(ctx, { digitalFileUrl, digitalFilePath, digitalFileName }) {
  const src = digitalFilePath ?? digitalFileUrl;
  const fileName = digitalFileName || decodeURIComponent(basename(new URL(src, "file:").pathname));
  const mimeType = FILE_MIME[fileName.split(".").pop().toLowerCase()];
  if (!mimeType) throw new Error(`digitalFileName needs one of these extensions (${Object.keys(FILE_MIME).join(", ")}): ${fileName}`);
  const bytes = digitalFilePath
    ? readFileSync(digitalFilePath)
    : await fetch(digitalFileUrl).then((r) => {
        // Don't invent a file and don't ship an unbuyable DIGITAL product: seed it PHYSICAL
        // with stock (drop digitalFileUrl/digitalFilePath, set inStock or a quantity) and
        // tell the user the download still needs a real file.
        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.`);
        return r.arrayBuffer();
      });
  const { uploadUrl } = await req(ctx, "/site-media/v1/files/generate-upload-url", { body: { mimeType, fileName } });
  const res = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": mimeType }, body: bytes });
  const json = await res.json().catch(() => ({}));
  const id = (json.file || json)?.id;
  if (!res.ok || !id) throw new Error(`digital file upload failed (${res.status}): ${JSON.stringify(json).slice(0, 200)}`);
  return id;
}

// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/bulk-create-products-with-inventory.md
export async function bulkCreateProducts(ctx, products) {
  const fileIds = await Promise.all(products.map((p) =>
    p.digitalFileUrl || p.digitalFilePath ? uploadDigitalFile(ctx, p) : null));
  const body = {
    returnEntity: true,
    products: products.map((p, i) => ({
      name: p.name,
      // DIGITAL drops physicalProperties and can't be POS-visible (DIGITAL_PRODUCT_CANNOT_BE_VISIBLE_IN_POS).
      ...(fileIds[i]
        ? { productType: "DIGITAL" }
        : { productType: "PHYSICAL", physicalProperties: {}, visibleInPos: true }),
      visible: true,
      description: mkDesc(p.description, i),
      options: buildOptions(p.options),
      // ribbons, modifiers and info sections are created inline by name, like options
      ...(p.ribbon ? { ribbon: { name: p.ribbon } } : {}),
      ...(p.modifiers?.length ? { modifiers: buildModifiers(p.modifiers) } : {}),
      ...(p.infoSections?.length ? { infoSections: buildInfoSections(p.infoSections, i) } : {}),
      variantsInfo: { variants: expandVariants(p.options, p, fileIds[i]) },
    })),
  };
  const r = await req(ctx, "/stores/v3/bulk/products-with-inventory/create", { body });
  // NB: results nest under productResults.results[].item — NOT a top-level `results`.
  // The bulk returns 200 even on PARTIAL failure, so never map results by array position:
  // pair each result to its input via itemMetadata.originalIndex and drop the ones that
  // didn't persist. Positional mapping shifts every id after a failure onto the wrong
  // product — which then mislabels categories and attaches images to the wrong items.
  const created = [];
  const failures = [];
  for (const x of r.productResults?.results ?? []) {
    const i = x.itemMetadata?.originalIndex;
    const src = typeof i === "number" ? products[i] : undefined;
    if (!x.itemMetadata?.success || !x.item?.id) {
      failures.push({
        name: src?.name,
        error: x.itemMetadata?.error?.description ?? x.itemMetadata?.error?.code ?? "unknown",
      });
      continue;
    }
    created.push({
      id: x.item.id, slug: x.item.slug, revision: x.item.revision, name: src?.name,
      variantId: x.item.variantsInfo?.variants?.[0]?.id,
      hasOptions: (src?.options?.length ?? 0) > 0,
      isDigital: !!fileIds[i],
      quantity: src?.quantity ?? 0,
      inStock: src?.inStock,
      preorder: src?.preorder,
    });
  }
  await stockOptionlessProducts(ctx, created);
  return {
    created: created.map((p) => ({ id: p.id, slug: p.slug, revision: p.revision, name: p.name })),
    failures,
  };
}

// The bulk create stocks a variant via its choices; an OPTION-LESS product's single default
// variant is NOT stocked by it and lands OUT_OF_STOCK — stock those explicitly.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/inventory-items-v3/bulk-create-inventory-items.md
async function stockOptionlessProducts(ctx, created) {
  const need = created.filter((p) => !p.hasOptions && !p.isDigital && p.id); // digital variants ship inStock from the create
  if (!need.length) return;
  const missing = need.filter((p) => !p.variantId).map((p) => p.id);
  if (missing.length) {
    const q = await req(ctx, "/stores/v3/products/query", { body: { query: { filter: { id: { $in: missing } }, paging: { limit: missing.length } } } });
    const vById = new Map((q.products ?? []).map((p) => [p.id, p.variantsInfo?.variants?.[0]?.id]));
    need.forEach((p) => { if (!p.variantId) p.variantId = vById.get(p.id); });
  }
  // inStock:true == untracked stock (always buyable, no count). Only send a quantity when the
  // product actually tracks one, or Wix rejects the pair; pre-order rides on the counted item.
  const inventoryItems = need
    .filter((p) => p.variantId)
    .map((p) => ({
      productId: p.id,
      variantId: p.variantId,
      ...(p.inStock === true ? { inStock: true } : { quantity: p.quantity, ...(p.preorder ? { preorderInfo: preorderInfo(p.preorder) } : {}) }),
    }));
  if (inventoryItems.length) {
    await req(ctx, "/stores/v3/bulk/inventory-items/create", { body: { inventoryItems } });
  }
}

// Existing categories by name (for idempotent reruns) — a re-run of the seed must reuse
// "Donuts", not create a second one. Empty map on any failure (falls back to create).
export async function queryCategoriesByNames(ctx, names) {
  const out = new Map();
  if (!names.length) return out;
  try {
    const r = await req(ctx, "/categories/v1/categories/query", {
      body: { treeReference: { appNamespace: "@wix/stores", treeKey: null }, query: { cursorPaging: { limit: 100 } } },
    });
    const wanted = new Set(names);
    for (const c of r.categories ?? []) if (wanted.has(c.name) && !out.has(c.name)) out.set(c.name, c.id);
  } catch (e) {
    console.error(`category name pre-check failed (creating everything): ${String(e.message).slice(0, 120)}`);
  }
  return out;
}

// Categories share the @wix/stores tree revision — concurrent creates 409, so: sequential.
// Idempotent by name: a name that already exists is reused, never duplicated (its description
// and image are left as they are). `details[name]` = { description?, imageUrl? } — the image is a
// Wix-hosted URL (resolveItemImages) passed at create time; the API re-hosts a full URL itself.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/create-category.md
export async function createCategories(ctx, names, details = {}) {
  const existing = await queryCategoriesByNames(ctx, names);
  const out = [];
  for (const name of names) {
    if (existing.has(name)) {
      out.push({ id: existing.get(name), name });
      continue;
    }
    const d = details[name] ?? {};
    const r = await req(ctx, "/categories/v1/categories", {
      body: {
        category: {
          name,
          visible: true,
          ...(d.description ? { description: String(d.description).slice(0, 600) } : {}),
          ...(d.imageUrl ? { image: { url: d.imageUrl } } : {}), // an Image OBJECT — a bare URL string is rejected
        },
        treeReference: { appNamespace: "@wix/stores", treeKey: null },
      },
    });
    out.push({ id: r.category?.id, name });
  }
  return out;
}


// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/bulk-add-items-to-category.md
export async function addProductsToCategories(ctx, mapping) {
  for (const [categoryId, productIds] of Object.entries(mapping)) {
    await req(ctx, `/categories/v1/bulk/categories/${categoryId}/add-items`, {
      body: {
        items: productIds.map((catalogItemId) => ({ catalogItemId, appId: STORES_APP_ID })),
        treeReference: { appNamespace: "@wix/stores", treeKey: null },
      },
    });
  }
}

// Bulk image attach in ONE call. items: [{ id, url, altText }] — no revision to pass: the
// current revision is read right before the update, so attach any number of times, any pass.
// Wix re-hosts each url server-side; the media can take a little while to appear on read-back
// (propagation) — normal, not a failure.
// The bulk update returns 200 on PARTIAL failure, like the bulk create: each item's outcome is
// in results[].itemMetadata (success, error, originalIndex). Seen live (runs 85 and 86,
// 2026-09-26): one product of three came back with no media while the call succeeded — most
// likely its revision moved between the read and the update (variant stocking runs just
// before). So: pair results to inputs, retry the misses once with fresh revisions, and report
// what actually persisted. Returns { attached: [id], failures: [{ id, error }] }.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/bulk-update-products.md
// Writing `media.itemsInfo.items` REPLACES the gallery and CLEARS every choice's link into it (seen
// live: a re-sent gallery came back with the links empty; a gallery that drops a linked item is
// refused outright, "Missing media files. Products must include media files linked to choices").
// So the attach merges: the items already there are re-sent by id, only a url not yet in the
// gallery (by file name) is added, a product with nothing new is left untouched, and a product
// whose choices were linked (by this seed or by the owner in the dashboard) gets those links put
// back right after.
const sameFile = (a, b) => a && b && basename(a) === basename(b);
const galleryHas = (items, url) => items.some((it) => sameFile(it.image?.filename, url) || sameFile(it.image?.url, url));

export async function attachProductImages(ctx, items) {
  if (!items?.length) return { attached: [], failures: [] };
  const send = async (batch) => {
    const ids = batch.map((it) => it.id);
    const q = await req(ctx, "/stores/v3/products/query", {
      body: { query: { filter: { id: { $in: ids } }, paging: { limit: ids.length } }, fields: ["MEDIA_ITEMS_INFO", "PRODUCT_CHOICES_MEDIA_REFERENCES", "VARIANT_OPTION_CHOICE_NAMES"] },
    });
    const byId = new Map((q.products ?? []).map((p) => [p.id, p]));
    const ok = new Set();
    const failed = [];
    const toSend = [];
    for (const it of batch) {
      const p = byId.get(it.id);
      const current = p?.media?.itemsInfo?.items ?? [];
      const wanted = it.images ?? [{ url: it.url, altText: it.altText }];
      const fresh = wanted.filter(({ url }) => !galleryHas(current, url));
      if (!fresh.length) { ok.add(it.id); continue; } // every image is already there
      toSend.push({ it, p, items: [...current.map(({ id }) => ({ id })), ...fresh.map(({ url, altText }) => ({ url, altText }))] });
    }
    if (toSend.length) {
      const r = await req(ctx, "/stores/v3/bulk/products/update", {
        body: { products: toSend.map(({ it, p, items }) => ({ product: { id: it.id, revision: p?.revision, media: { itemsInfo: { items } } } })) },
      });
      for (const x of r.results ?? []) {
        const src = toSend[x.itemMetadata?.originalIndex];
        if (!src) continue;
        if (x.itemMetadata?.success) ok.add(src.it.id);
        else failed.push({ id: src.it.id, error: x.itemMetadata?.error?.description ?? x.itemMetadata?.error?.code ?? "unknown" });
      }
      // the gallery write cleared the choice links: put back the ones the product had
      for (const { it, p } of toSend) {
        if (!ok.has(it.id)) continue;
        const links = choiceLinksOf(p);
        if (links.length) await linkChoiceImages(ctx, it.id, links).catch((e) => failed.push({ id: it.id, error: `relink: ${e?.message ?? e}` }));
      }
    }
    // an input with no result at all did not persist either
    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" });
    return { ok, failed };
  };
  const first = await send(items);
  const attached = new Set(first.ok);
  let failures = first.failed;
  if (failures.length) {
    const retry = await send(items.filter((it) => failures.some((f) => f.id === it.id)));
    for (const id of retry.ok) attached.add(id);
    failures = retry.failed;
  }
  return { attached: [...attached], failures };
}

// The choice → gallery-item links a product carries (read with PRODUCT_CHOICES_MEDIA_REFERENCES).
function choiceLinksOf(p) {
  const out = [];
  for (const o of p?.options ?? []) {
    for (const c of o.choicesSettings?.choices ?? []) {
      const mediaId = c.media?.items?.[0]?.mediaId;
      if (mediaId) out.push({ optionName: o.name, choiceName: c.name, mediaId });
    }
  }
  return out;
}

// Per-choice images (the dashboard's "image per colour"): a choice points at an item of the
// product's OWN gallery, so the image is attached to the gallery first (pass 2) and linked here by
// the gallery item's id — the id of the uploaded file is a different id and 400s. Links are a
// product update, which the API only accepts with the variants re-sent beside the options, so the
// current variants (id, choices, price) ride along unchanged. Wix derives a variant's own media
// from its choice at creation only; the storefront reads the choice's image, so a link made here
// shows up on the product page the same as one made in the dashboard.
// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/update-product.md
// `links`: [{ optionName, choiceName, url }] (the file attached in pass 2) or [{ …, mediaId }] (a
// gallery item id, used to put the owner's links back after a gallery write).
export async function linkChoiceImages(ctx, productId, links) {
  const read = () => req(ctx, `/stores/v3/products/${productId}?fields=MEDIA_ITEMS_INFO&fields=VARIANT_OPTION_CHOICE_NAMES`, { method: "GET" });
  let { product: p } = await read();
  const galleryIdFor = (link) => {
    const items = p?.media?.itemsInfo?.items ?? [];
    if (link.mediaId) return items.some((it) => it.id === link.mediaId) ? link.mediaId : null;
    const hit = items.find((it) => sameFile(it.image?.filename, link.url) || sameFile(it.image?.url, link.url));
    return hit?.id ?? null;
  };
  // the gallery can lag the attach (propagation, seen past 6 s live): poll for up to half a minute
  for (let i = 0; i < 5 && links.some((l) => !galleryIdFor(l)); i++) {
    await new Promise((r) => setTimeout(r, 6000));
    ({ product: p } = await read());
  }
  const missing = [];
  const options = (p?.options ?? []).map((o) => ({
    id: o.id,
    name: o.name,
    optionRenderType: o.optionRenderType,
    choicesSettings: {
      choices: (o.choicesSettings?.choices ?? []).map((c) => {
        const link = links.find((l) => l.optionName === o.name && l.choiceName === c.name);
        const mediaId = link ? galleryIdFor(link) : null;
        if (link && !mediaId) missing.push(`${o.name} / ${c.name}`);
        return {
          choiceId: c.choiceId,
          name: c.name,
          choiceType: c.choiceType,
          ...(c.colorCode ? { colorCode: c.colorCode } : {}),
          ...(mediaId ? { media: { items: [{ mediaId }] } } : {}),
        };
      }),
    },
  }));
  const physical = p?.productType !== "DIGITAL";
  const variants = (p?.variantsInfo?.variants ?? []).map((v) => ({
    id: v.id,
    choices: v.choices,
    price: {
      actualPrice: { amount: v.price?.actualPrice?.amount },
      ...(v.price?.compareAtPrice?.amount ? { compareAtPrice: { amount: v.price.compareAtPrice.amount } } : {}),
    },
    visible: v.visible !== false,
    ...(physical ? { physicalProperties: {} } : {}),
  }));
  if (missing.length < links.length) {
    await req(ctx, `/stores/v3/products/${productId}`, {
      method: "PATCH",
      body: { product: { id: productId, revision: p.revision, options, variantsInfo: { variants } } },
    });
  }
  return { linked: links.length - missing.length, missing };
}

// Reject plans the API would reject halfway through, while nothing has been created yet —
// a mid-batch 400 leaves a half-seeded store that the agent then has to reason about.
export function validateProducts(products) {
  const problems = [];
  const colorByName = new Map();
  products.forEach((p, i) => {
    const where = p.name ? `"${p.name}"` : `product #${i + 1}`;
    if (!p.name) problems.push(`${where}: name is required`);
    if (p.quantity != null && (!Number.isInteger(p.quantity) || p.quantity < 0)) {
      problems.push(`${where}: quantity must be a non-negative integer (got ${p.quantity}) — omit it and set inStock:true for untracked stock`);
    }
    if (p.preorder && (p.inStock === true || p.digitalFilePath || p.digitalFileUrl)) {
      problems.push(`${where}: preorder needs counted stock (a quantity) on a physical product`);
    }
    if (p.preorder?.limit != null && (!Number.isInteger(p.preorder.limit) || p.preorder.limit < 1)) {
      problems.push(`${where}: preorder.limit must be a positive integer`);
    }
    for (const m of p.modifiers ?? []) {
      if (!m?.name) problems.push(`${where}: every modifier needs a name`);
      else if (m.type !== "text" && !(m.choices?.length > 0)) problems.push(`${where}: modifier "${m.name}" needs choices (or type: "text")`);
    }
    for (const s of p.infoSections ?? []) if (!s?.title) problems.push(`${where}: every info section needs a title`);
    const choiceNames = new Set((p.options ?? []).flatMap((o) => (o.choices ?? []).map((c) => (typeof c === "string" ? c : c?.name))));
    for (const [choice, amount] of Object.entries(p.variantPrices ?? {})) {
      if (!choiceNames.has(choice)) problems.push(`${where}: variantPrices names "${choice}", which is not a choice of its options`);
      if (!(Number(amount) >= 0)) problems.push(`${where}: variantPrices["${choice}"] must be a non-negative number`);
    }
    for (const opt of p.options ?? []) {
      const seen = new Set();
      for (const c of opt.choices ?? []) {
        const key = typeof c === "string" ? c : c?.name;
        if (seen.has(key)) problems.push(`${where}: option "${opt.name}" repeats the choice "${key}"`);
        seen.add(key);
        // Wix keys a color choice by name, so the same name with two codes collides.
        const code = typeof c === "object" ? c?.colorCode : undefined;
        if (code) {
          const prev = colorByName.get(key);
          if (prev && prev !== code) problems.push(`color "${key}" is ${prev} on one product and ${code} on another — pick one`);
          colorByName.set(key, code);
        }
      }
    }
  });
  if (problems.length) throw new Error(`invalid seed plan:\n  - ${problems.join("\n  - ")}`);
}

/**
 * ONE-CALL seed: install → currency → create products → categories → attach images, ids
 * threaded in memory. This is the default path — call it once instead of the individual
 * functions.
 */
export async function setupStore(ctx, { products = [], categories = {}, categoryDetails = {}, currency } = {}) {
  validateProducts(products);
  await installStoresApp(ctx);
  // Before any product exists: a product's price is stored in the site currency at create time,
  // so switching afterwards leaves the catalog priced in the old one.
  if (currency) await setSiteCurrency(ctx, currency);

  // What the catalog held BEFORE this seed and the plan does not name: on a fresh install that is
  // Wix's sample catalog ("Baseball Cap", "Ceramic Flower Vase", a dozen of them), which the live shop
  // lists next to the owner's products. Reported (`preexisting`), never touched: this seed deletes
  // nothing on a site, ever. The owner removes what they do not want in the dashboard; the closing
  // message tells them it is there and where.
  const planNames = new Set(products.map((p) => p.name));
  let all = [];
  try { all = await readAllProducts(ctx); } catch (e) { console.error(`catalog read failed (skipping the pre-existing check): ${String(e.message).slice(0, 120)}`); }
  let preexisting = all.filter((p) => !planNames.has(p.name));

  // Idempotent by name: an errored bulk create (429/5xx) may still have applied server-side,
  // and SKILL.md tells the agent to re-run a failed seed — creating only the names that don't
  // exist yet makes that rerun safe instead of a duplicator.
  const existing = await queryProductsByNames(ctx, products.map((p) => p.name), all.length ? all : undefined);
  const toCreate = products.filter((p) => !existing.has(p.name));
  const { created, failures } = toCreate.length
    ? await bulkCreateProducts(ctx, toCreate)
    : { created: [], failures: [] };
  const createdByName = new Map(created.map((p) => [p.name, p]));
  const withNames = products.map((p) => {
    const hit = createdByName.get(p.name) ?? existing.get(p.name);
    return { ...(hit ?? {}), name: p.name };
  });
  const idByName = new Map(withNames.map((p) => [p.name, p.id]));

  // Category images resolve before the categories exist (the create call takes the image URL);
  // a failed image leaves the category text-only, like a product.
  const names = Object.keys(categories);
  const details = {};
  if (names.length) {
    const catFiles = await resolveItemImages(ctx, names.map((n) => ({
      url: categoryDetails[n]?.imageUrl,
      path: categoryDetails[n]?.imagePath,
      prompt: categoryDetails[n]?.imagePrompt,
      displayName: `${n.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.png`,
    })));
    names.forEach((n, i) => {
      details[n] = { description: categoryDetails[n]?.description, imageUrl: catFiles[i]?.url };
    });
  }
  const cats = names.length ? await createCategories(ctx, names, details) : [];
  if (cats.length) {
    const mapping = {};
    for (const c of cats) {
      const ids = (categories[c.name] || []).map((n) => idByName.get(n)).filter(Boolean);
      if (ids.length) mapping[c.id] = ids;
    }
    if (Object.keys(mapping).length) await addProductsToCategories(ctx, mapping);
  }

  // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then
  // bulk-attach. Failures leave the product text-only; the seed's exit never depends on images.
  // A choice's image (`options[].choices[].imageUrl|imagePath|imagePrompt`) resolves in the same
  // wave and joins the product's gallery; pass 3 links it to its choice.
  const choiceSpecs = products.map((pl, i) =>
    (pl.options ?? []).flatMap((o) =>
      (o.choices ?? [])
        .filter((c) => typeof c === "object" && c && (c.imageUrl || c.imagePath || c.imagePrompt))
        .map((c) => ({
          optionName: o.name,
          choiceName: c.name,
          productName: pl.name,
          altText: c.altText ?? `${pl.name} — ${c.name}`,
          spec: { url: c.imageUrl, path: c.imagePath, prompt: c.imagePrompt, displayName: `${withNames[i].slug || "product"}-${String(c.name).toLowerCase().replace(/[^a-z0-9]+/g, "-")}.png` },
        })),
    ));
  const flatChoiceSpecs = choiceSpecs.flat();
  const resolved = await resolveItemImagesDetailed(ctx, [
    ...withNames.map((p, i) => ({
      url: products[i]?.imageUrl,
      path: products[i]?.imagePath,
      prompt: products[i]?.imagePrompt,
      displayName: `${p.slug || "product"}.png`,
    })),
    ...flatChoiceSpecs.map((c) => c.spec),
  ]);
  const files = resolved.map((r) => r.file);
  const choiceFiles = files.slice(withNames.length);
  const choiceResolved = resolved.slice(withNames.length);
  let cursor = 0;
  const choiceLinks = choiceSpecs.map((specs) =>
    specs.map((c) => ({ ...c, file: choiceFiles[cursor++] })).filter((c) => c.file));
  // `p.id` guards this: a product that failed to create has no id, and bulk-updating an
  // undefined id would 400 the whole batch and cost every other product its image.
  const imageItems = withNames
    .map((p, i) => {
      if (!p.id) return null;
      const images = [
        ...(files[i] ? [{ url: files[i].url, altText: products[i]?.altText ?? p.slug }] : []),
        ...choiceLinks[i].map((c) => ({ url: c.file.url, altText: c.altText })),
      ];
      return images.length ? { id: p.id, images } : null;
    })
    .filter(Boolean);
  // imagesAttached counts the attaches the API CONFIRMED (per-item results), not the ones sent;
  // imageFailures names the products left text-only and why. Neither blocks the seed: a re-run of
  // the same plan reuses the products and attaches again.
  let imagesAttached = 0;
  const imageFailures = [];
  // A product whose image never resolved (a bad path, an unsupported file type, a refused
  // prompt, a timeout) is not in imageItems at all, so the attach step below never sees it:
  // it is reported here with the resolver's reason, or it ships text-only in silence.
  withNames.forEach((p, i) => {
    const src = products[i] ?? {};
    if ((src.imageUrl || src.imagePath || src.imagePrompt) && !files[i]) {
      imageFailures.push({ name: p.name, error: resolved[i]?.error ?? "image did not resolve" });
    }
  });
  const nameOf = (id) => withNames.find((p) => p.id === id)?.name;
  try {
    if (imageItems.length) {
      const r = await attachProductImages(ctx, imageItems);
      imagesAttached = r.attached.length;
      for (const f of r.failures) imageFailures.push({ name: nameOf(f.id), error: f.error });
    }
  } catch (e) {
    for (const it of imageItems) imageFailures.push({ name: nameOf(it.id), error: e?.message ?? String(e) });
  }

  // Pass 3 — link each choice's image to its choice, by the gallery item's id.
  let choiceImagesLinked = 0;
  const choiceImageFailures = [];
  flatChoiceSpecs.forEach((c, j) => {
    if (!choiceFiles[j]) choiceImageFailures.push({ name: c.productName, choice: c.choiceName, error: choiceResolved[j]?.error ?? "image did not resolve" });
  });
  for (const [i, p] of withNames.entries()) {
    const links = choiceLinks[i];
    if (!links.length || !p.id || !imagesAttached) continue;
    try {
      const r = await linkChoiceImages(ctx, p.id, links.map((c) => ({ optionName: c.optionName, choiceName: c.choiceName, url: c.file.url })));
      choiceImagesLinked += r.linked;
      for (const m of r.missing) choiceImageFailures.push({ name: p.name, choice: m, error: "image not in the product gallery" });
    } catch (e) {
      choiceImageFailures.push({ name: p.name, error: e?.message ?? String(e) });
    }
  }

  // The pre-existing list is read again now: on a fresh site the Stores app was installed moments
  // before the first read, and Wix creates its sample catalog asynchronously after the install, so
  // that read often came back empty and the closing message said nothing about a dozen samples the
  // live shop lists. The early read still serves the idempotency check above; this one serves the report.
  try {
    const after = await readAllProducts(ctx);
    preexisting = after.filter((p) => !planNames.has(p.name));
  } catch (e) {
    console.error(`catalog re-read failed (reporting the pre-existing list from the first read): ${String(e.message).slice(0, 120)}`);
  }

  // failures is part of the result, not an exception: a partial seed still leaves a usable
  // store, and the agent needs the names to report rather than silently shipping a short
  // catalog. Re-run the seed to retry them — existing names are skipped, not duplicated.
  return {
    products: withNames,
    categories: cats,
    imagesAttached,
    imageFailures,
    choiceImagesLinked,
    choiceImageFailures,
    failures,
    // Products the plan did not name (Wix's install samples on a fresh site, or the owner's own on an
    // existing one): the closing message names what the shop lists and where the owner removes it.
    preexisting: preexisting.map((p) => ({ id: p.id, name: p.name, slug: p.slug })),
    // the Stores app's slug + products list (registered dashboard route); `store/products` is no route
    dashboardProductsUrl: `https://manage.wix.com/dashboard/${ctx.siteId}/wix-stores/products`,
  };
}

// ---- CLI entry ----------------------------------------------------------------------------------

const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop());
if (invokedDirectly) {
  const planPath = process.argv[2];
  if (!planPath) {
    console.error("usage: node seed-store.mjs <plan.json>   (run from the project root)");
    process.exit(1);
  }
  const plan = JSON.parse(readFileSync(planPath, "utf8"));
  const ctx = makeCtx();
  setupStore(ctx, plan)
    .then((result) => console.log(JSON.stringify(result, null, 2)))
    .catch((e) => {
      console.error(e.message);
      process.exit(1);
    });
}

SHA-256: acae487e5bd939f2062b7b8d99545eb5861de5c149fb08c7602d731ef8f8719e