@wix/agent-skills 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/SKILL.md +14 -112
  3. package/skills/wix-app/references/APP_TOOLS.md +2 -1
  4. package/skills/wix-app/references/BACKEND_EVENT.md +3 -3
  5. package/skills/wix-app/references/CUSTOM_ELEMENT_WIDGET.md +41 -152
  6. package/skills/wix-app/references/DASHBOARD_MODAL.md +4 -4
  7. package/skills/wix-app/references/SDK_MODULE_MAP.md +44 -0
  8. package/skills/wix-app/references/SITE_PLUGIN.md +36 -190
  9. package/skills/wix-app/references/app-tools/TOOLS.md +3 -1
  10. package/skills/wix-app/references/custom-element-widget/SETTINGS_PANEL.md +21 -0
  11. package/skills/wix-app/references/dashboard-page/ECOM_NAVIGATION.md +17 -0
  12. package/skills/wix-app/references/dashboard-plugin/SLOTS.md +4 -18
  13. package/skills/wix-app/references/site-plugin/EXAMPLES.md +1 -1
  14. package/skills/wix-headless-fast/SKILL.md +134 -49
  15. package/skills/wix-headless-fast/entry/skill.md +4 -3
  16. package/skills/wix-headless-fast/install/AGENTS.md +32 -0
  17. package/skills/wix-headless-fast/install/agents-md.mjs +39 -0
  18. package/skills/wix-headless-fast/install/attach.mjs +149 -42
  19. package/skills/wix-headless-fast/install/setup.mjs +239 -0
  20. package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +2 -0
  21. package/skills/wix-headless-fast/references/blog/seed/read-site.mjs +22 -0
  22. package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +2 -0
  23. package/skills/wix-headless-fast/references/bookings/seed/read-site.mjs +28 -0
  24. package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +2 -0
  25. package/skills/wix-headless-fast/references/cms/seed/read-site.mjs +20 -0
  26. package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +2 -0
  27. package/skills/wix-headless-fast/references/events/seed/read-site.mjs +26 -0
  28. package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +2 -0
  29. package/skills/wix-headless-fast/references/forms/seed/read-site.mjs +20 -0
  30. package/skills/wix-headless-fast/references/members/INSTRUCTIONS.md +2 -0
  31. package/skills/wix-headless-fast/references/members/seed/read-site.mjs +15 -0
  32. package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +2 -0
  33. package/skills/wix-headless-fast/references/portfolio/seed/read-site.mjs +20 -0
  34. package/skills/wix-headless-fast/references/portfolio/seed/seed-portfolio.mjs +19 -21
  35. package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +2 -0
  36. package/skills/wix-headless-fast/references/pricing-plans/seed/read-site.mjs +21 -0
  37. package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +2 -0
  38. package/skills/wix-headless-fast/references/restaurants/seed/read-site.mjs +25 -0
  39. package/skills/wix-headless-fast/references/restaurants/seed/seed-restaurants.mjs +25 -5
  40. package/skills/wix-headless-fast/references/shared/seed/read-site.mjs +99 -0
  41. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +4 -0
  42. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +7 -2
  43. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +2 -0
  44. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +7 -1
  45. package/skills/wix-headless-fast/references/storefront/seed/read-site.mjs +38 -0
  46. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +52 -16
  47. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-create-coupon.md +10 -19
  48. package/skills/wix-manage/references/events/create-wix-event.md +5 -1
  49. package/skills/wix-headless-fast/install/fast-path.mjs +0 -181
@@ -0,0 +1,20 @@
1
+ // What the CMS holds: collections, their fields, item counts, a few sample items each.
2
+ // node <SKILL_ROOT>/references/cms/seed/read-site.mjs [--site <siteId>] [--limit <n>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const D = "https://dev.wix.com/docs/api-reference/business-solutions/cms";
6
+
7
+ await runReader({
8
+ vertical: "cms",
9
+ async read(api, { limit }) {
10
+ const r = await api.call({ method: "GET", path: "/wix-data/v2/collections", docs: `${D}/collection-management/data-collections/list-data-collections` });
11
+ const all = r.collections ?? r.dataCollections ?? [];
12
+ const own = all.filter((c) => c.collectionType !== "WIX_APP");
13
+ const collections = [];
14
+ for (const c of own.slice(0, limit)) {
15
+ const items = await api.tryCall({ path: "/wix-data/v2/items/query", body: { dataCollectionId: c.id, query: { paging: { limit: 3 } }, returnTotalCount: true }, docs: `${D}/data-items/query-data-items` });
16
+ collections.push({ id: c.id, displayName: c.displayName, fields: (c.fields ?? []).map((f) => `${f.key}:${f.type}`), itemCount: items?.pagingMetadata?.total ?? null, sample: (items?.dataItems ?? []).map((i) => i.data?.title ?? i.data?.name ?? i.id) });
17
+ }
18
+ return { collectionCount: own.length, collections, appCollections: all.filter((c) => c.collectionType === "WIX_APP").map((c) => c.id) };
19
+ },
20
+ });
@@ -260,6 +260,8 @@ plugins — deploy already added the dep). Routes: `/events` → your listing; `
260
260
  callbacks point at that path). Deploy wrote the public client id into `wix/config.ts`; nothing
261
261
  else to configure.
262
262
 
263
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
264
+
263
265
  ## Hard rules
264
266
 
265
267
  - **Registration logic only through the shipped exports** — `useEventRegistration` /
@@ -0,0 +1,26 @@
1
+ // What the events site holds: events and their ticket definitions.
2
+ // node <SKILL_ROOT>/references/events/seed/read-site.mjs [--site <siteId>] [--limit <n>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const EVENTS_APP_ID = "140603ad-af8d-84a5-2c80-a0f60cb47351";
6
+ const D = "https://dev.wix.com/docs/api-reference/business-solutions/events/event-management";
7
+
8
+ await runReader({
9
+ vertical: "events",
10
+ appId: EVENTS_APP_ID,
11
+ async read(api, { limit }) {
12
+ const ev = await api.call({ path: "/events/v3/events/query", body: { query: { filter: { status: { $ne: "CANCELED" } }, paging: { limit } }, fields: ["DETAILS", "REGISTRATION"] }, docs: `${D}/events-v3/query-events` });
13
+ const td = await api.tryCall({ path: "/events-ticket-definitions/v3/ticket-definitions/query", body: { query: { paging: { limit: 100 } } }, docs: `${D}/ticket-definitions-v3/query-ticket-definitions` });
14
+ const defs = td?.ticketDefinitions ?? [];
15
+ return {
16
+ eventCount: ev.pagingMetadata?.total ?? (ev.events ?? []).length,
17
+ events: (ev.events ?? []).map((e) => ({
18
+ title: e.title, slug: e.slug, status: e.status, start: e.dateAndTimeSettings?.startDate ?? null,
19
+ location: e.location?.name ?? e.location?.type ?? null, registration: e.registration?.type ?? null,
20
+ tickets: defs.filter((d) => d.eventId === e.id).map((d) => `${d.name}${d.pricingMethod?.fixedPrice?.formattedValue ? " " + d.pricingMethod.fixedPrice.formattedValue : ""}`),
21
+ image: !!e.mainImage,
22
+ })),
23
+ ticketDefinitionCount: defs.length,
24
+ };
25
+ },
26
+ });
@@ -285,6 +285,8 @@ plugins — deploy already added the dep). `useWixForm(FORM_ID)` with no `initia
285
285
  skeleton while `form` is null. Deploy wrote the public client id into `wix/config.ts`; nothing
286
286
  else to configure.
287
287
 
288
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
289
+
288
290
  ## Hard rules
289
291
 
290
292
  - **Never name a field in code.** Map `form.fields`. A page with `values.email` hardcoded
@@ -0,0 +1,20 @@
1
+ // What forms the site has, with their fields.
2
+ // node <SKILL_ROOT>/references/forms/seed/read-site.mjs [--site <siteId>] [--limit <n>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const FORMS_APP_ID = "225dd912-7dea-4738-8688-4b8c6955ffc2";
6
+ const NAMESPACE = "wix.form_app.form";
7
+ const D = "https://dev.wix.com/docs/api-reference/crm/forms/form-schemas";
8
+
9
+ await runReader({
10
+ vertical: "forms",
11
+ appId: FORMS_APP_ID,
12
+ async read(api, { limit }) {
13
+ const r = await api.call({ method: "GET", path: `/form-schema-service/v4/forms?namespace=${encodeURIComponent(NAMESPACE)}`, docs: `${D}/list-forms` });
14
+ const forms = (r.forms ?? []).slice(0, limit);
15
+ return {
16
+ formCount: (r.forms ?? []).length,
17
+ forms: forms.map((f) => ({ id: f.id, name: f.name, fields: (f.fields ?? []).filter((x) => !x.hidden).map((x) => `${x.view?.label ?? x.target ?? x.id} (${x.view?.fieldType ?? "?"})${x.validation?.required ? "*" : ""}`), steps: (f.steps ?? []).length })),
18
+ };
19
+ },
20
+ });
@@ -271,6 +271,8 @@ Mount `MemberMenu` in the header (pass `LinkComponent` for router links). Deploy
271
271
  client id into `wix/config.ts` (`WIX_CLIENT_ID` and `WIX_MEMBERS_CLIENT_ID`); nothing else to
272
272
  configure.
273
273
 
274
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
275
+
274
276
  ## Hard rules
275
277
 
276
278
  - **No Wix-hosted login flow** — no `/api/auth/login`, no OAuth callback page, no callback URI for
@@ -0,0 +1,15 @@
1
+ // Whether the site has a members area, and how many members.
2
+ // node <SKILL_ROOT>/references/members/seed/read-site.mjs [--site <siteId>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const MEMBERS_AREA_APP_ID = "14cc59bc-f0b7-15b8-e1c7-89ce41d0e0c9";
6
+ const D = "https://dev.wix.com/docs/api-reference/crm/members-contacts/members/members";
7
+
8
+ await runReader({
9
+ vertical: "members",
10
+ appId: MEMBERS_AREA_APP_ID,
11
+ async read(api) {
12
+ const r = await api.call({ method: "GET", path: "/members/v1/members?fieldsets=PUBLIC&paging.limit=1", docs: `${D}/list-members` });
13
+ return { memberCount: r.metadata?.total ?? r.pagingMetadata?.total ?? null };
14
+ },
15
+ });
@@ -280,6 +280,8 @@ deploy added the dep). Routes: `/portfolio` → your gallery on `useCollections(
280
280
  `useProjectDetail(slug)` (the hooks fetch client-side when no `initial*` is passed; route the 404
281
281
  view off `notFound`). Deploy wrote the public client id into `wix/config.ts`; nothing else to configure.
282
282
 
283
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
284
+
283
285
  ## Hard rules
284
286
 
285
287
  - **Reads only through the shipped exports** — they filter `hidden`, sort collections by the
@@ -0,0 +1,20 @@
1
+ // What the portfolio holds: collections and projects.
2
+ // node <SKILL_ROOT>/references/portfolio/seed/read-site.mjs [--site <siteId>] [--limit <n>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const PORTFOLIO_APP_ID = "d90652a2-f5a1-4c7c-84c4-d4cdcc41f130";
6
+ const D = "https://dev.wix.com/docs/api-reference/business-solutions/portfolio";
7
+
8
+ await runReader({
9
+ vertical: "portfolio",
10
+ appId: PORTFOLIO_APP_ID,
11
+ async read(api, { limit }) {
12
+ const cols = await api.call({ method: "GET", path: "/portfolio/v1/collections", docs: `${D}/collections/list-collections` });
13
+ const projs = await api.tryCall({ method: "GET", path: "/portfolio/v1/projects", docs: `${D}/projects/list-projects` });
14
+ return {
15
+ collections: (cols.collections ?? []).map((c) => ({ id: c.id, title: c.title, slug: c.slug, hidden: c.hidden ?? false, cover: !!c.coverImage })),
16
+ projectCount: (projs?.projects ?? []).length,
17
+ projects: (projs?.projects ?? []).slice(0, limit).map((p) => ({ title: p.title, slug: p.slug, hidden: p.hidden ?? false, collections: (p.collectionIds ?? []).length, cover: !!p.coverImage })),
18
+ };
19
+ },
20
+ });
@@ -125,22 +125,25 @@ export { importImage } from "../../shared/seed/images.mjs";
125
125
  // Cover = the listing-card thumbnail. PATCH per entity, echoing the current revision (missing/
126
126
  // stale revision fails); height + width are required alongside the imported file id.
127
127
  // docs: https://dev.wix.com/docs/api-reference/business-solutions/portfolio/projects/update-project.md
128
- export async function attachProjectCovers(ctx, items) {
128
+ // One PATCH per entity; a failure on one must not cost the rest theirs. Returns
129
+ // { attached: [id], failures: [{ id, error }] } — the caller counts what persisted, not what was sent.
130
+ async function attachEach(ctx, items, urlOf, bodyOf) {
131
+ const attached = [];
132
+ const failures = [];
129
133
  for (const it of items) {
130
- await req(ctx, `/portfolio/v1/projects/${it.id}`, {
131
- method: "PATCH",
132
- body: { project: { id: it.id, revision: it.revision, coverImage: { imageInfo: { id: it.imageId, height: it.height, width: it.width } } } },
133
- });
134
+ try { await req(ctx, urlOf(it), { method: "PATCH", body: bodyOf(it) }); attached.push(it.id); }
135
+ catch (e) { failures.push({ id: it.id, error: e?.message ?? String(e) }); }
134
136
  }
137
+ return { attached, failures };
138
+ }
139
+ export async function attachProjectCovers(ctx, items) {
140
+ return attachEach(ctx, items, (it) => `/portfolio/v1/projects/${it.id}`,
141
+ (it) => ({ project: { id: it.id, revision: it.revision, coverImage: { imageInfo: { id: it.imageId, height: it.height, width: it.width } } } }));
135
142
  }
136
143
  // docs: https://dev.wix.com/docs/api-reference/business-solutions/portfolio/collections/update-collection.md
137
144
  export async function attachCollectionCovers(ctx, items) {
138
- for (const it of items) {
139
- await req(ctx, `/portfolio/v1/collections/${it.id}`, {
140
- method: "PATCH",
141
- body: { collection: { id: it.id, revision: it.revision, coverImage: { imageInfo: { id: it.imageId, height: it.height, width: it.width } } } },
142
- });
143
- }
145
+ return attachEach(ctx, items, (it) => `/portfolio/v1/collections/${it.id}`,
146
+ (it) => ({ collection: { id: it.id, revision: it.revision, coverImage: { imageInfo: { id: it.imageId, height: it.height, width: it.width } } } }));
144
147
  }
145
148
 
146
149
  // The detail-page gallery is a SEPARATE `item` entity — one POST per image; sortOrder (1,2,3…)
@@ -218,23 +221,18 @@ export async function setupPortfolio(ctx, { collections = [], projects = [] } =
218
221
  ? { id: c.id, revision: c.revision, imageId: files[colCoverAt + i].id, ...dims }
219
222
  : null))
220
223
  .filter(Boolean);
224
+ // coversAttached counts the PATCHes that succeeded; coverFailures names the rest. Neither blocks the seed.
221
225
  let coversAttached = 0;
222
- try {
223
- if (projCovers.length) { await attachProjectCovers(ctx, projCovers); coversAttached += projCovers.length; }
224
- } catch {
225
- /* those projects stay cover-less */
226
- }
227
- try {
228
- if (colCovers.length) { await attachCollectionCovers(ctx, colCovers); coversAttached += colCovers.length; }
229
- } catch {
230
- /* those collections stay cover-less */
231
- }
226
+ const coverFailures = [];
227
+ if (projCovers.length) { const r = await attachProjectCovers(ctx, projCovers); coversAttached += r.attached.length; coverFailures.push(...r.failures.map((f) => ({ project: f.id, error: f.error }))); }
228
+ if (colCovers.length) { const r = await attachCollectionCovers(ctx, colCovers); coversAttached += r.attached.length; coverFailures.push(...r.failures.map((f) => ({ collection: f.id, error: f.error }))); }
232
229
 
233
230
  return {
234
231
  collections: cols,
235
232
  projects: projs,
236
233
  itemsCreated: items.length,
237
234
  coversAttached,
235
+ coverFailures,
238
236
  };
239
237
  }
240
238
 
@@ -238,6 +238,8 @@ when no `initialPlans` is passed); `/plans/:slug` → fetch with `fetchPlanBySlu
238
238
  client-side, then your detail surface (null → your not-found state). Deploy wrote the public
239
239
  client id into `wix/config.ts`; nothing else to configure.
240
240
 
241
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
242
+
241
243
  ## Hard rules
242
244
 
243
245
  - **Purchase only through the shipped exports** — `SubscribeButton` / `usePlanPurchase` /
@@ -0,0 +1,21 @@
1
+ // What the site sells as plans: public pricing plans.
2
+ // node <SKILL_ROOT>/references/pricing-plans/seed/read-site.mjs [--site <siteId>] [--limit <n>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const PRICING_PLANS_APP_ID = "1522827f-c56c-a5c9-2ac9-00f9e6ae12d3";
6
+ const D = "https://dev.wix.com/docs/api-reference/business-solutions/pricing-plans";
7
+
8
+ await runReader({
9
+ vertical: "pricing-plans",
10
+ appId: PRICING_PLANS_APP_ID,
11
+ async read(api, { limit }) {
12
+ const r = await api.call({ path: "/pricing-plans/v3/plans/query", body: { query: { filter: { visibility: "PUBLIC" }, cursorPaging: { limit } } }, docs: `${D}/plans-v3/query-plans` });
13
+ return {
14
+ planCount: r.pagingMetadata?.total ?? (r.plans ?? []).length,
15
+ plans: (r.plans ?? []).map((p) => {
16
+ const v = p.pricingVariants?.[0]; const s = v?.pricingStrategies?.[0];
17
+ return { name: p.name, slug: p.slug ?? null, visibility: p.visibility, price: s?.flatRate?.amount ?? null, currency: p.currency ?? null, billing: v?.billingTerms?.billingCycle ? `${v.billingTerms.billingCycle.count} ${v.billingTerms.billingCycle.period}` : (v?.billingTerms?.startType ?? null), perks: (p.perks?.values ?? []).length };
18
+ }),
19
+ };
20
+ },
21
+ });
@@ -298,6 +298,8 @@ client-side when no `initialMenus`); `/reservations` → your reservation surfac
298
298
  order button in the header and your drawer once. Deploy wrote the public client id into
299
299
  `wix/config.ts`; nothing else to configure.
300
300
 
301
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
302
+
301
303
  ## Hard rules
302
304
 
303
305
  - **Data and ordering logic only through the shipped exports** — `useOrderCart`/`addToOrder`
@@ -0,0 +1,25 @@
1
+ // What the restaurant holds: menus, sections, items, locations.
2
+ // node <SKILL_ROOT>/references/restaurants/seed/read-site.mjs [--site <siteId>] [--limit <n>]
3
+ import { runReader } from "../../shared/seed/read-site.mjs";
4
+
5
+ const MENUS_APP_ID = "b278a256-2757-4f19-9313-c05c783bec92";
6
+ const D = "https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus";
7
+ const DL = "https://dev.wix.com/docs/api-reference/business-management/locations";
8
+
9
+ await runReader({
10
+ vertical: "restaurants",
11
+ appId: MENUS_APP_ID,
12
+ async read(api, { limit }) {
13
+ const menus = await api.call({ method: "GET", path: "/restaurants/menus/v1/menus", docs: `${D}/menus/list-menus` });
14
+ const sections = await api.tryCall({ method: "GET", path: "/restaurants/menus/v1/sections", docs: `${D}/sections/list-sections` });
15
+ const items = await api.tryCall({ method: "GET", path: "/restaurants/menus/v1/items", docs: `${D}/items/items/list-items` });
16
+ const locs = await api.tryCall({ method: "GET", path: "/locations/v1/locations", docs: `${DL}/list-locations` });
17
+ const secs = sections?.sections ?? []; const its = items?.items ?? [];
18
+ return {
19
+ menus: (menus.menus ?? []).map((m) => ({ name: m.name, visible: m.visible !== false, sections: (m.sectionIds ?? []).map((id) => secs.find((s) => s.id === id)?.name ?? id) })),
20
+ sectionCount: secs.length, itemCount: its.length,
21
+ items: its.slice(0, limit).map((i) => ({ name: i.name, price: i.priceInfo?.price ?? i.priceVariants ? "variants" : null, image: !!i.image, labels: (i.labels ?? []).length })),
22
+ locations: (locs?.locations ?? []).map((l) => ({ name: l.name, default: !!l.default, address: l.address?.formattedAddress ?? null })),
23
+ };
24
+ },
25
+ });
@@ -207,8 +207,10 @@ export { importImage } from "../../shared/seed/images.mjs";
207
207
  // the binding field is the Wix Media file `id`.
208
208
  // items: [{ id, revision, price, image: { id, url, height, width } }]
209
209
  // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/items/items/bulk-update-item.md
210
+ // The bulk update returns 200 on PARTIAL failure: each item's outcome is in
211
+ // results[].itemMetadata (success, error, originalIndex). Returns { attached: [id], failures: [{ id, error }] }.
210
212
  export async function attachItemImages(ctx, items) {
211
- return req(ctx, "/restaurants/menus/v1/bulk/items/update", {
213
+ const r = await req(ctx, "/restaurants/menus/v1/bulk/items/update", {
212
214
  body: {
213
215
  items: items.map((it) => ({
214
216
  item: {
@@ -220,6 +222,21 @@ export async function attachItemImages(ctx, items) {
220
222
  })),
221
223
  },
222
224
  });
225
+ return bulkOutcome(items, r.results);
226
+ }
227
+
228
+ // Pair a bulk response's results[] to the inputs by originalIndex; an input with no result did not persist.
229
+ export function bulkOutcome(items, results) {
230
+ const attached = [];
231
+ const failures = [];
232
+ for (const x of results ?? []) {
233
+ const src = items[x.itemMetadata?.originalIndex];
234
+ if (!src) continue;
235
+ if (x.itemMetadata?.success) attached.push(src.id);
236
+ else failures.push({ id: src.id, error: x.itemMetadata?.error?.description ?? x.itemMetadata?.error?.code ?? "unknown" });
237
+ }
238
+ for (const it of items) if (!attached.includes(it.id) && !failures.some((f) => f.id === it.id)) failures.push({ id: it.id, error: "no result for item" });
239
+ return { attached, failures };
223
240
  }
224
241
 
225
242
  // ---- business location (shared STEP 0 for ordering + reservations) ------------------------------
@@ -383,12 +400,14 @@ export async function setupRestaurants(ctx, plan) {
383
400
  image: { id: files[i].id, url: files[i].url, width: 1024, height: 1024 } }
384
401
  : null))
385
402
  .filter(Boolean);
403
+ const imageFailures = [];
386
404
  if (toAttach.length) {
387
405
  try {
388
- await attachItemImages(ctx, toAttach);
389
- imagesAttached = toAttach.length;
390
- } catch {
391
- /* the items stay text-only */
406
+ const r = await attachItemImages(ctx, toAttach);
407
+ imagesAttached = r.attached.length;
408
+ for (const f of r.failures) imageFailures.push(f);
409
+ } catch (e) {
410
+ for (const it of toAttach) imageFailures.push({ id: it.id, error: e?.message ?? String(e) });
392
411
  }
393
412
  }
394
413
 
@@ -453,6 +472,7 @@ export async function setupRestaurants(ctx, plan) {
453
472
  return {
454
473
  menus: createdMenus.map(({ items, ...m }) => m),
455
474
  imagesAttached,
475
+ imageFailures,
456
476
  sampleMenuRemoved,
457
477
  ordering,
458
478
  reservations,
@@ -0,0 +1,99 @@
1
+ // The shared runner behind every vertical's `seed/read-site.mjs` — the build-time read of what a
2
+ // site holds, so the agent sizes the content without composing a single Wix request.
3
+ //
4
+ // node <SKILL_ROOT>/references/<vertical>/seed/read-site.mjs [--site <siteId>] [--limit <n>]
5
+ //
6
+ // The site is `--site`, else the `siteId` in ./wix.config.json. The token is minted by the Wix CLI
7
+ // inside this process and never written anywhere. Output is ONE JSON object on stdout:
8
+ // { vertical, siteId, installed, ...what the vertical reads..., calls: [{ method, path, status, docs }] }
9
+ // `calls` lists every request made, with the method's documentation URL — open one of those (the
10
+ // URL plus `.md` is the full page) when the brief needs more than the summary shows. A failed call
11
+ // is reported in `calls` with its status and message; the reader continues and marks the field null.
12
+ import { execFileSync } from "node:child_process";
13
+ import { readFileSync, existsSync } from "node:fs";
14
+
15
+ const API = "https://www.wixapis.com";
16
+ const DOCS_INSTALLED_APPS = "https://dev.wix.com/docs/api-reference/business-management/app-installation/app-installation/get-installed-apps";
17
+
18
+ function flag(name) {
19
+ const i = process.argv.indexOf(`--${name}`);
20
+ return i !== -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--") ? process.argv[i + 1] : null;
21
+ }
22
+
23
+ export function siteIdFromArgsOrConfig() {
24
+ const fromFlag = flag("site");
25
+ if (fromFlag) return fromFlag;
26
+ if (existsSync("wix.config.json")) {
27
+ const c = JSON.parse(readFileSync("wix.config.json", "utf8"));
28
+ return c.siteId ?? c.projectId ?? null;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function makeApi(siteId) {
34
+ const token = execFileSync("npx", ["-y", "@wix/cli@latest", "token", "--site", siteId], { encoding: "utf8" }).trim();
35
+ if (!token) throw new Error("the Wix CLI returned no token — run `npx @wix/cli@latest login` first");
36
+ const calls = [];
37
+ async function call({ method = "POST", path, body, docs }) {
38
+ const entry = { method, path: path.split("?")[0], docs };
39
+ calls.push(entry);
40
+ const res = await fetch(API + path, {
41
+ method,
42
+ headers: { Authorization: token, "wix-site-id": siteId, "Content-Type": "application/json" },
43
+ body: body === undefined ? undefined : JSON.stringify(body),
44
+ });
45
+ const text = await res.text();
46
+ entry.status = res.status;
47
+ if (!res.ok) {
48
+ entry.error = text.slice(0, 200) || "(empty body)";
49
+ const e = new Error(`${method} ${entry.path} → ${res.status}: ${entry.error}`);
50
+ e.status = res.status;
51
+ throw e;
52
+ }
53
+ try { return text ? JSON.parse(text) : {}; } catch { return {}; }
54
+ }
55
+ // Same call, but a failure becomes null and stays in `calls` — for reads whose absence is data.
56
+ async function tryCall(opts) {
57
+ try { return await call(opts); } catch { return null; }
58
+ }
59
+ async function installed(appId) {
60
+ const r = await tryCall({ method: "GET", path: "/apps-installer-service/v1/app-instances", docs: DOCS_INSTALLED_APPS });
61
+ if (!r) return null;
62
+ return (r.appInstances ?? []).some((a) => a.appDefId === appId || a.appId === appId);
63
+ }
64
+ return { call, tryCall, installed, calls };
65
+ }
66
+
67
+ export const limit = () => Number(flag("limit") ?? 12);
68
+
69
+ // read(api, { limit }) returns the vertical's summary; the runner adds the envelope and prints.
70
+ export async function runReader({ vertical, appId, read }) {
71
+ const siteId = siteIdFromArgsOrConfig();
72
+ if (!siteId) {
73
+ console.log(JSON.stringify({ error: "no site: pass --site <siteId> or run in a folder with wix.config.json" }));
74
+ process.exit(1);
75
+ }
76
+ const api = makeApi(siteId);
77
+ const out = { vertical, siteId, scope: null };
78
+ out.installed = appId ? await api.installed(appId) : undefined;
79
+ let data = {};
80
+ if (out.installed === false) {
81
+ out.scope = `${vertical}: the app is not installed on this site — nothing to read; the vertical's seed installs it`;
82
+ } else {
83
+ try { data = await read(api, { limit: limit() }); }
84
+ catch (e) { out.error = String(e.message).slice(0, 300); }
85
+ }
86
+ // What this output is and is not: a SIZING. Every list is a sample page; the counts are the site.
87
+ const partial = Object.entries(data)
88
+ .filter(([k, v]) => Array.isArray(v) && typeof data[`${k.replace(/s$/, "")}Count`] === "number" && data[`${k.replace(/s$/, "")}Count`] > v.length)
89
+ .map(([k, v]) => `${k}: ${v.length} of ${data[`${k.replace(/s$/, "")}Count`]} shown`);
90
+ if (!out.scope) {
91
+ out.scope = "A sizing, not the content. Lists are one page (--limit, max 100); counts are the whole site. " +
92
+ (partial.length ? `Partial here: ${partial.join("; ")}. ` : "") +
93
+ "The pages read everything live through the deployed data layer. To read more or filter, page the same call: its method and documentation URL are in `calls`.";
94
+ }
95
+ Object.assign(out, data);
96
+ out.calls = api.calls;
97
+ out.docs = [...new Set(api.calls.map((c) => c.docs).filter(Boolean))];
98
+ console.log(JSON.stringify(out, null, 2));
99
+ }
@@ -404,6 +404,8 @@ export default function ProductDetailView(props: {
404
404
  // to full-width images stacked down the column (products with per-color
405
405
  // linked media carry several gallery urls, so that stacks big duplicates).
406
406
  // A single-image gallery is just the one primary — no empty strip.
407
+ // When d.variant resolves and carries imageUrl, that image becomes the primary (it is
408
+ // one of the gallery urls) — a shopper who picks a color sees that color.
407
409
  // • name, EVERY ribbon (d.product.ribbons), live d.price (the range until every option
408
410
  // is picked) with d.compareAtPrice as a labelled "was" when present — never invent one;
409
411
  // descriptionHtml rendered as HTML, then d.product.infoSections as sections/accordions
@@ -546,6 +548,8 @@ client-side when no `initial` is passed).
546
548
  Mount the shipped `CartButton` in the header and `CartDrawer` once. Deploy wrote the public
547
549
  client id into `wix/config.ts`; nothing else to configure.
548
550
 
551
+ Routes on Wix hosting: the host serves files only, so a clean route answers 404 when loaded directly — hash routes, or one HTML file per route, decided before the first route is written; any URL handed to Wix as a return target must be one the host serves (SKILL.md step 1).
552
+
549
553
  ## Hard rules
550
554
 
551
555
  - **Data and commerce logic only through the shipped exports** — never rewrite their
@@ -232,7 +232,10 @@ export function toModifiers(raw: Raw): ProductModifier[] {
232
232
  }
233
233
 
234
234
  /** Variants keyed by optionName → choiceName. Buyable when in stock OR pre-orderable. */
235
- export function toVariants(raw: Raw): ProductVariant[] {
235
+ // `variant.media` is read-only on the API: Wix derives it from the media linked to the variant's
236
+ // choice (a product with several options derives it only when the choices agree). Mapped to a
237
+ // URL so a PDP can show the selected variant's picture without knowing how Wix derives it.
238
+ export function toVariants(raw: Raw, imgSrc: ImgSrc): ProductVariant[] {
236
239
  return ((raw.variantsInfo?.variants ?? []) as Raw[])
237
240
  .filter((v) => v.visible !== false)
238
241
  .map((v) => {
@@ -242,6 +245,7 @@ export function toVariants(raw: Raw): ProductVariant[] {
242
245
  if (on && cn) choices[on] = cn;
243
246
  }
244
247
  const { current, original } = sellingPrice(v.price);
248
+ const vm = v.media ? (v.media.image ?? v.media) : null;
245
249
  return {
246
250
  variantId: id(v),
247
251
  choices,
@@ -249,6 +253,7 @@ export function toVariants(raw: Raw): ProductVariant[] {
249
253
  compareAtPrice: strike(original, current),
250
254
  inStock: v.inventoryStatus?.inStock !== false,
251
255
  preorderEnabled: v.inventoryStatus?.preorderEnabled === true,
256
+ imageUrl: vm ? imgSrc(vm, 1200, 1200) || null : null,
252
257
  };
253
258
  });
254
259
  }
@@ -264,7 +269,7 @@ export function toDetail(raw: Raw, imgSrc: ImgSrc, mediaKey: MediaKey): ProductD
264
269
  gallery: mediaEntries(raw, mediaKey).map((m) => imgSrc(m, 1200, 1200)).filter(Boolean),
265
270
  options: toOptions(raw),
266
271
  modifiers: toModifiers(raw),
267
- variants: toVariants(raw),
272
+ variants: toVariants(raw, imgSrc),
268
273
  };
269
274
  }
270
275
 
@@ -82,6 +82,8 @@ export interface ProductVariant {
82
82
  inStock: boolean;
83
83
  /** Out of stock but pre-orderable — still buyable (the add carries preOrderRequested). */
84
84
  preorderEnabled: boolean;
85
+ /** This variant's own image (from its choice's linked media), resolved to an https URL; null when it has none. */
86
+ imageUrl: string | null;
85
87
  }
86
88
 
87
89
  /** A product as the detail page needs it. */
@@ -17,7 +17,7 @@ node <SKILL_ROOT>/references/storefront/seed/seed-store.mjs plan.json
17
17
 
18
18
  Run this way, the result is the process itself: exit code `0` and the JSON on stdout (redirect it
19
19
  to a file if you want it later). `.seed-exit` and `seed-result.json` are written by
20
- `install/fast-path.mjs` when IT starts the seed — don't wait for them after a manual run.
20
+ `install/setup.mjs` when IT starts the seed — don't wait for them after a manual run.
21
21
 
22
22
  `plan.json` is plain data — write it from the brief:
23
23
 
@@ -97,6 +97,12 @@ own sample products to a new catalog; they stay, and the owner removes them in t
97
97
  (the Manage products link is in your summary). If a cleanup genuinely seems needed, ask the user
98
98
  first. Categories are idempotent by name — a re-run reuses "Donuts" instead of creating a second one.
99
99
 
100
+ **Images are confirmed, not assumed.** `imagesAttached` counts the attaches the API reported as
101
+ successful; `imageFailures: [{ name, error }]` names the products left without an image and why
102
+ (one miss out of three was seen live — a revision moved between read and update). The seed already
103
+ retries a miss once. To retry again, re-run the same plan: existing products are reused and their
104
+ images attached; nothing is duplicated.
105
+
100
106
  **A bulk create can partially succeed.** The result carries `failures: [{ name, error }]` next
101
107
  to `products` — read it. A non-empty `failures` means those products are genuinely absent, not
102
108
  mis-mapped, so the rest of the catalog is fine to build on. To retry, re-run the **same** plan:
@@ -0,0 +1,38 @@
1
+ // What the store holds: products (names, prices, options, media, stock), categories. Run it before
2
+ // designing a storefront for a site that already has content:
3
+ // node <SKILL_ROOT>/references/storefront/seed/read-site.mjs [--site <siteId>] [--limit <n>]
4
+ // Every request here is the one the deployed data layer makes (rest/catalog.ts); the doc URL of
5
+ // each method is in the output's `calls`.
6
+ import { runReader } from "../../shared/seed/read-site.mjs";
7
+
8
+ const STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
9
+ const D = "https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3";
10
+ const LIST_FIELDS = ["CURRENCY", "MEDIA_ITEMS_INFO", "MIN_PRICE_VARIANT", "DISCOUNT_INFO"];
11
+ const VISIBLE = { $and: [{ visible: true }] };
12
+ const TREE = { appNamespace: "@wix/stores", treeKey: null };
13
+
14
+ await runReader({
15
+ vertical: "storefront",
16
+ appId: STORES_APP_ID,
17
+ async read(api, { limit }) {
18
+ const count = await api.tryCall({ path: "/stores/v3/products/count", body: { filter: VISIBLE }, docs: `${D}/products-v3/count-products` });
19
+ const search = await api.call({ path: "/stores/v3/products/search", body: { fields: LIST_FIELDS, search: { filter: VISIBLE, cursorPaging: { limit } } }, docs: `${D}/products-v3/search-products` });
20
+ const cats = await api.tryCall({ path: "/categories/v1/categories/query", body: { treeReference: TREE, query: { filter: { name: { $exists: true } }, cursorPaging: { limit: 100 } } }, docs: `${D}/categories/query-categories` });
21
+ const products = (search.products ?? []).map((p) => ({
22
+ name: p.name, slug: p.slug,
23
+ price: p.actualPriceRange?.minValue?.formattedAmount ?? null,
24
+ priceMax: p.actualPriceRange?.maxValue?.formattedAmount ?? null,
25
+ compareAt: p.compareAtPriceRange?.minValue?.formattedAmount ?? null,
26
+ options: (p.options ?? []).map((o) => `${o.name} (${o.choicesSettings?.choices?.length ?? 0})`),
27
+ media: p.media?.itemsInfo?.items?.length ?? 0,
28
+ availability: p.inventory?.availabilityStatus ?? null,
29
+ }));
30
+ return {
31
+ currency: search.products?.[0]?.currency ?? null,
32
+ productCount: typeof count?.count === "number" ? count.count : null,
33
+ products,
34
+ productsShown: products.length,
35
+ categories: (cats?.categories ?? []).filter((c) => c.slug !== "all-products").map((c) => ({ id: c.id, name: c.name, slug: c.slug, visible: c.visible !== false })),
36
+ };
37
+ },
38
+ });