1'use strict';23// rp-target-wix — the DECLARATIVE Wix target spec.4//5// This module is the single source of truth for two things the pipeline used to re-derive with an6// LLM on every run:7//8// 1. CANONICAL_FIELDS — the vendor-neutral vocabulary a source adapter maps its columns onto.9// A vendor overlay's `columnMap[].wixTarget` MUST name a field in here; anything else is a10// typo or an unmodelled concept, and `validateCanonicalPath` catches it at load time instead11// of at import time.12//13// 2. STORES_V3_TARGET — how each canonical field lands in a real Wix Stores V3 payload, plus14// every trap learned by live call. This is vendor-independent: Shopify, WooCommerce, Magento
196 // Constants the API demands regardless of source.
197 constants: {
198 productType: 'PHYSICAL',
199 // TRAP (verified): a PHYSICAL product requires an EMPTY physicalProperties object at product
200 // level. Omitting it 400s.
201 physicalProperties: {},
202 },
203 // Vendors express price as either (price, compareAt) like Shopify or (regular, sale) like Woo /
204 // Magento / BigCommerce. Wix models `actualPrice` (what is charged) + `compareAtPrice` (the
205 // strike-through), so the pair must be RESOLVED, not mapped field-for-field. Encoding the rule
206 // here once removes the single most repeated per-run judgement call.
207 priceResolution: {
208 rule: 'when variant.discountedPrice is present and strictly less than variant.price, it becomes actualPrice and variant.price becomes compareAtPrice; otherwise variant.price is actualPrice and variant.compareAtPrice is the strike-through',
209 dropCompareAtUnlessGreater: true,
210 note: 'vendors write 0.00 (and Woo writes an empty sale price) to mean "no discount"; Wix rejects compareAtPrice <= actualPrice',
211 },
212 // Canonical fields Wix Stores V3 has no home for. Naming them here is what lets the resolver
213 // report an honest "mapped but will not land" instead of silently dropping them.
214 unsupported: {
215 'product.tags': 'Wix Stores V3 has no product-tag field; a tag taxonomy must become categories or a CMS field.',
216 'tag.name': 'Same as product.tags — no native product-tag target in Stores V3.',
217 'product.sourceId': 'Identity only: tracked in the crosswalk, not written to Wix (no client-settable id).',
218 'variant.parentRef': 'Identity only: used to attach a variation row to its parent during extract.',
219 'variant.inStock': 'Fed into inventoryItem.inStock by the variants builder rather than mapped directly.',
220 'variant.image': 'Per-variant media needs Wix media ids, which only exist after ingestion; requires a follow-up patch.',
221 'variant.width': 'Dimension fields on the V3 variant create shape are unverified; not mapped rather than guessed.',
222 'variant.height': 'Dimension fields on the V3 variant create shape are unverified; not mapped rather than guessed.',
223 'variant.length': 'Dimension fields on the V3 variant create shape are unverified; not mapped rather than guessed.',
224 'image.url': 'Assembled into product.images by the adapter, then emitted via the media builder.',
225 'image.altText': 'Assembled into product.images by the adapter, then emitted via the media builder.',
226 'image.position': 'Ordering signal only: the first media item becomes the product main media.',
227 'category.path': 'Identity only: the dedupe key and crosswalk key, not a Wix field.',
228 'product.productType': 'Source product-type strings are a taxonomy, not the Wix productType enum (PHYSICAL/DIGITAL).',
229 'product.ribbon': 'Ribbon is a separate inline entity; not modelled by this spec version.',
230 'variant.cost': 'Cost sits behind the MERCHANT_DATA fieldset and is not settable on bulk create.',
231 'variant.barcode': 'No barcode field on the V3 variant create shape.',
232 'variant.inventoryPolicy': 'V3 models availability, not Shopify-style deny/continue policy.',
233 'variant.requiresShipping': 'Implied by productType PHYSICAL; not separately settable.',
234 'variant.taxable': 'Tax behaviour is a site-level/tax-group concern, not a variant field.',
235 'variant.weightUnit': 'Weight is sent in the site weight unit; the source unit only drives conversion.',
236 },
237};
238
239const CATEGORY_TARGET = {
240 entity: 'category',
241 endpoint: 'POST /categories/v1/categories',
242 provenance: 'verified',
243 // TRAP (verified): treeReference is TOP-LEVEL, a sibling of `category`. Nesting it 400s
300 if (!group || typeof group !== 'object' || Array.isArray(group)) { errors.push(`${label} must be an object`); return; }
301 if (typeof group.label !== 'string') errors.push(`${label}.label must be a string`);
302 if (!Number.isInteger(group.min) || group.min < 0) errors.push(`${label}.min must be a non-negative integer`);
303 if (group.max !== null && (!Number.isInteger(group.max) || group.max < Math.max(1, group.min || 0))) errors.push(`${label}.max must be null or an integer >= min`);
304 if (typeof group.optional !== 'boolean') errors.push(`${label}.optional must be a boolean`);
305 if (!Array.isArray(group.candidates)) errors.push(`${label}.candidates must be an array`);
308 if (rule !== undefined && rule !== null && (typeof rule !== 'object' || Array.isArray(rule))) errors.push(`${label}.candidateRule must be an object or null`);
309 if (Array.isArray(group.candidates) && !group.candidates.length && !rule) errors.push(`${label} needs candidates or a candidateRule`);
310 });
311 if (bundle.components !== undefined && bundle.components !== null && !(Array.isArray(bundle.components) && bundle.components.length === 0)) errors.push('a configurable bundle has no components');