Setting the file. One moment.
Seed Restaurants · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 7.118
Position 118 of 148
Type JavaScript
Size 22 KB
Lines 479 references/restaurants/seed/ seed-restaurants.mjs
JavaScript · 479 lines · 22 KB
14
// "ordering"?: true | { "address"? }, // menu-first add-on; address is STEP 0
15 // "reservations"?: true | { "partySize"? { "min","max" }, "address"? } }
16 //
17 // Seeding is ADDITIVE — with ONE recipe-sanctioned exception: when THIS run installs the
18 // Menus app onto a site that didn't have it, the install's own sample "Dinner Menu" is
19 // removed (it's provably not owner content). Nothing else is ever deleted. Unexpected
20 // shapes → read the live API reference; authoritative source recipes:
21 // wix-headless/references/inline-recipes/setup-restaurants.md, setup-restaurant-orders.md,
22 // setup-restaurant-reservations.md.
23 import { execFileSync } from "node:child_process" ;
24 import { readFileSync } from "node:fs" ;
25 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
26
27 const API = "https://www.wixapis.com" ;
28 const MENUS_APP_ID = "b278a256-2757-4f19-9313-c05c783bec92" ;
29 const ORDERS_APP_ID = "9a5d83fd-8570-482e-81ab-cfa88942ee60" ;
30 const TABLE_RESERVATIONS_APP_ID = "f9c07de2-5341-40c6-b096-8eb39de391fb" ;
31
32 export function makeCtx ({ cwd = process. cwd () } = {}) {
33 const config = JSON . parse ( readFileSync ( `${ cwd }/wix.config.json` , "utf8" ));
34 const siteId = config.siteId ?? config.projectId;
35 if ( ! siteId) throw new Error ( "wix.config.json has no siteId — is this a Wix CLI project?" );
36 const token = execFileSync ( "npx" , [ "@wix/cli@latest" , "token" , "--site" , siteId], {
37 encoding: "utf8" ,
38 cwd,
39 }). trim ();
40 if ( ! token) throw new Error ( "The Wix CLI returned no token — run `npx @wix/cli@latest login` first." );
41 return { token, siteId };
42 }
43
44 async function req ( ctx , path , { method = "POST" , body } = {}) {
45 const res = await fetch ( API + path, {
46 method,
47 headers: {
48 Authorization: `Bearer ${ ctx . token }` ,
49 "wix-site-id" : ctx.siteId,
50 "Content-Type" : "application/json" ,
51 },
52 body: body ? JSON . stringify (body) : undefined ,
53 });
54 const json = await res. json (). catch (() => ({}));
55 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
56 return json;
57 }
58
59 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
60
61 // ---- app installs --------------------------------------------------------------------------------
62
63 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
64 async function installApp ( ctx , appDefId ) {
65 try {
66 await req (ctx, "/apps-installer-service/v1/app-instance/install" , { body: {
67 tenant: { tenantType: "SITE" , id: ctx.siteId },
68 appInstance: { appDefId, enabled: true },
69 } });
70 } catch {
71 /* already installed is fine */
72 }
73 }
74 export async function installMenusApp ( ctx ) { return installApp (ctx, MENUS_APP_ID ); }
75 export async function installOrdersApp ( ctx ) { return installApp (ctx, ORDERS_APP_ID ); }
76 export async function installTableReservationsApp ( ctx ) { return installApp (ctx, TABLE_RESERVATIONS_APP_ID ); }
77
78 /** True when the Menus API answers — i.e. the app is already on the site. */
79 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/menus/list-menus.md
80 export async function menusAppPresent ( ctx ) {
81 try {
82 await req (ctx, "/restaurants/menus/v1/menus" , { method: "GET" });
83 return true ;
84 } catch {
85 return false ;
86 }
87 }
88
89 // ---- menu (setup-restaurants.md) -----------------------------------------------------------------
90 // Everything is Restaurants Menus V1 on /restaurants/menus/v1/... . REST flattens the
91 // protobuf wrappers: plain values ("visible": true), never {"value": …}.
92
93 /**
94 * Recipe STEP 0 — a FRESH Menus-app install ships a populated sample "Dinner Menu"
95 * (~4 sections, ~21 items) that would render next to the seeded menu. Call ONLY when this
96 * run installed the app onto a site that didn't have it (menusAppPresent was false) — then
97 * everything present is provably the install's own sample. Polls briefly (the sample
98 * provisions async), then deletes children before parents. No-op when nothing appears.
99 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/items/items/list-items.md
100 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/sections/list-sections.md
101 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/items/items/bulk-delete-items.md
102 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/sections/bulk-delete-sections.md
103 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/menus/delete-menu.md
104 */
105 export async function removeSampleMenu ( ctx , { tries = 8 , delayMs = 2000 } = {}) {
106 let menus = [];
107 for ( let i = 0 ; i < tries; i ++ ) {
108 const r = await req (ctx, "/restaurants/menus/v1/menus" , { method: "GET" });
109 menus = r.menus ?? [];
110 if (menus. length ) break ;
111 if (i < tries - 1 ) await sleep (delayMs);
112 }
113 if ( ! menus. length ) return { removed: false };
114
115 const itemsRes = await req (ctx, "/restaurants/menus/v1/items" , { method: "GET" });
116 const sectionsRes = await req (ctx, "/restaurants/menus/v1/sections" , { method: "GET" });
117 const itemIds = (itemsRes.items ?? []). map (( it ) => it.id). filter (Boolean);
118 const sectionIds = (sectionsRes.sections ?? []). map (( s ) => s.id). filter (Boolean);
119 if (itemIds. length ) {
120 await req (ctx, "/restaurants/menus/v1/bulk/items/delete" , { method: "DELETE" , body: { ids: itemIds } });
121 }
122 if (sectionIds. length ) {
123 await req (ctx, "/restaurants/menus/v1/bulk/sections/delete" , { method: "DELETE" , body: { ids: sectionIds } });
124 }
125 for ( const m of menus) {
126 await req (ctx, `/restaurants/menus/v1/menus/${ m . id }` , { method: "DELETE" }); // no bulk delete for menus
127 }
128 return { removed: true , menus: menus. map (( m ) => m.name) };
129 }
130
131 /**
132 * Build ONE menu BOTTOM-UP (items → sections → menu) in three bulk phases.
133 * price -> priceInfo.price as a decimal STRING; currency is the site's (send none).
134 * visible:true is baked in at every level — required to render on the live site.
135 * Returns { menuId, name, sectionIds, itemIds, items: [{ id, revision, price }] } — the
136 * per-item revision/price feed the image pass (Update Item is a full replace).
137 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/items/items/bulk-create-items.md
138 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/sections/bulk-create-sections.md
139 * docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/menus/create-menu.md
140 */
141 export async function createMenu ( ctx , menu ) {
142 // STEP 1 — bulk-create every item across all sections in ONE request.
143 const flat = [];
144 menu.sections. forEach (( sec , si ) => (sec.items || []). forEach (( it ) => flat. push ({ ... it, _section: si })));
145 const itemRes = await req (ctx, "/restaurants/menus/v1/bulk/items/create" , {
146 body: {
147 items: flat. map (( it ) => ({
148 name: it.name,
149 ... (it.description ? { description: it.description } : {}),
150 priceInfo: { price: String (it.price) },
151 visible: true ,
152 })),
153 returnEntity: true ,
154 },
155 });
156 // created items are under results[].item (results[].itemMetadata.success is the per-item flag)
157 const createdItems = (itemRes.results ?? []). map (( r ) => r.item);
158 const itemIdsBySection = menu.sections. map (() => []);
159 flat. forEach (( it , i ) => {
160 const id = createdItems[i]?.id;
161 if (id) itemIdsBySection[it._section]. push (id);
162 });
163
164 // STEP 2 — bulk-create sections, each carrying the itemIds of its items in display order.
165 const secRes = await req (ctx, "/restaurants/menus/v1/bulk/sections/create" , {
166 body: {
167 sections: menu.sections. map (( sec , si ) => ({
168 name: sec.name,
169 ... (sec.description ? { description: sec.description } : {}),
170 visible: true ,
171 itemIds: itemIdsBySection[si],
172 })),
173 returnEntity: true ,
174 },
175 });
176 const sectionIds = (secRes.results ?? []). map (( r ) => r.item?.id);
177
178 // STEP 3 — create the menu (single create wraps in `menu`), carrying its sectionIds.
179 // businessLocationId omitted -> binds to the site's default (main) location.
180 const menuRes = await req (ctx, "/restaurants/menus/v1/menus" , {
181 body: {
182 menu: {
183 name: menu.name,
184 ... (menu.description ? { description: menu.description } : {}),
185 visible: true ,
186 sectionIds,
187 },
188 },
189 });
190 return {
191 menuId: menuRes.menu?.id,
192 name: menu.name,
193 sectionIds,
194 itemIds: createdItems. map (( it ) => it?.id),
195 items: createdItems. map (( it , i ) => ({ id: it?.id, revision: it?.revision, price: flat[i]?.price })),
196 };
197 }
198
199 // Restaurants binds an item image by Wix Media file ID — an external url must be imported
200 // first; a plan `imagePrompt` is generated (Wix AI, 1 credit) then imported. Both live in the
201 // shared util (parallel, resilient, never blocks the seed).
202 export { importImage } from "../../shared/seed/images.mjs" ;
203
204 // Image pass. Update Item is a FULL-ENTITY REPLACE with NO field mask — each entry MUST echo
205 // the item's current `revision` AND `priceInfo`, or it fails 428 MISSING_ITEM_PRICING and the
206 // image does NOT apply. `image` is an OBJECT { id, url, height, width } (never a bare string);
207 // the binding field is the Wix Media file `id`.
208 // items: [{ id, revision, price, image: { id, url, height, width } }]
209 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/menus/items/items/bulk-update-item.md
210 export async function attachItemImages ( ctx , items ) {
211 return req (ctx, "/restaurants/menus/v1/bulk/items/update" , {
212 body: {
213 items: items. map (( it ) => ({
214 item: {
215 id: it.id,
216 revision: it.revision,
217 priceInfo: { price: String (it.price) },
218 image: it.image,
219 },
220 })),
221 },
222 });
223 }
224
225 // ---- business location (shared STEP 0 for ordering + reservations) ------------------------------
226 // Update Location is a FULL OVERRIDE — send the WHOLE `location` object (omitted fields are
227 // wiped), echo `default:true` (omitting it 400s CHANGE_DEFAULT_FORBIDDEN) and the current
228 // `revision`. address.country is a 2-letter ISO code. Without a real address, ordering is
229 // "testing only" and checkout breaks — a placeholder must be flagged to the owner.
230 // location: { name, timeZone, email?, phone?, address: { country, subdivision, city,
231 // postalCode, streetAddress: { number, name }, formattedAddress } }
232 // docs: https://dev.wix.com/docs/api-reference/business-management/locations/list-locations.md
233 // docs: https://dev.wix.com/docs/api-reference/business-management/locations/create-location.md
234 // docs: https://dev.wix.com/docs/api-reference/business-management/locations/update-location.md
235 export async function setBusinessLocation ( ctx , location ) {
236 const list = await req (ctx, "/locations/v1/locations" , { method: "GET" });
237 const def = (list.locations ?? []). find (( l ) => l.default);
238 if ( ! def) {
239 // No default location at all (a bare site can have none) -> CREATE one; operations
240 // auto-bind on first-location-add.
241 const r = await req (ctx, "/locations/v1/locations" , { body: { location: { ... location, default: true } } });
242 return r.location;
243 }
244 const r = await req (ctx, `/locations/v1/locations/${ def . id }` , {
245 method: "PUT" ,
246 body: { location: { ... location, id: def.id, revision: def.revision, default: true } },
247 });
248 return r.location;
249 }
250
251 // ---- online ordering (setup-restaurant-orders.md) ------------------------------------------------
252 // The Orders-app install AUTO-provisions a working setup (an ENABLED operation with Pickup +
253 // Delivery attached, every menu ordering-enabled) — these helpers VERIFY it; they never POST
254 // an operation. Each micro-service is on its OWN host prefix — do not normalize.
255
256 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/online-orders/operations/list-operations.md
257 export async function listOperations ( ctx ) {
258 const r = await req (ctx, "/restaurants-operations/v1/operations" , { method: "GET" });
259 return r.operations ?? [];
260 }
261
262 // A fresh install provisions the operation ASYNC — poll until it lands.
263 export async function listOperationsWithRetry ( ctx , { tries = 15 , delayMs = 2000 } = {}) {
264 for ( let i = 0 ; i < tries; i ++ ) {
265 const ops = await listOperations (ctx). catch (() => []);
266 if (ops. length ) return ops;
267 if (i < tries - 1 ) await sleep (delayMs);
268 }
269 return [];
270 }
271
272 // Normally already ENABLED — only PATCH when DISABLED/PAUSED_UNTIL. revision mandatory + current.
273 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/online-orders/operations/update-operation.md
274 export async function enableOperation ( ctx , operationId , revision ) {
275 return req (ctx, `/restaurants-operations/v1/operations/${ operationId }` , {
276 method: "PATCH" ,
277 body: { operation: { revision, onlineOrderingStatus: "ENABLED" } },
278 });
279 }
280
281 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/online-orders/menu-ordering-settings/query-menu-ordering-settings.md
282 export async function queryMenuOrderingSettings ( ctx ) {
283 const r = await req (ctx, "/menu-ordering-settings/v1/menu-ordering-settings/query" , { body: { query: {} } });
284 return r.menuOrderingSettings ?? [];
285 }
286
287 // Only when an entry shows onlineOrderingEnabled:false / operationId:"none".
288 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/online-orders/menu-ordering-settings/update-menu-ordering-settings.md
289 export async function updateMenuOrderingSettings ( ctx , settingsId , patch ) {
290 return req (ctx, `/menu-ordering-settings/v1/menu-ordering-settings/${ settingsId }` , {
291 method: "PATCH" ,
292 body: { menuOrderingSettings: patch },
293 });
294 }
295
296 // ---- table reservations (setup-restaurant-reservations.md) ---------------------------------------
297 // The install AUTO-provisions one default reservation location with a complete config; the
298 // one thing OFF is onlineReservationsEnabled (premium-gated). A reservation location cannot
299 // be created via this API — discover, configure, enable. Post-Jan-2026 field names:
300 // partySize (not partiesSize), approval (not manualApproval).
301
302 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/reservations/reservation-locations/list-reservation-locations.md
303 export async function listReservationLocations ( ctx ) {
304 const r = await req (ctx, "/table-reservations/reservation-locations/v1/reservation-locations" , { method: "GET" });
305 return r.reservationLocations ?? [];
306 }
307
308 export async function listReservationLocationsWithRetry ( ctx , { tries = 15 , delayMs = 2000 } = {}) {
309 for ( let i = 0 ; i < tries; i ++ ) {
310 const locs = await listReservationLocations (ctx). catch (() => []);
311 if (locs. length ) return locs;
312 if (i < tries - 1 ) await sleep (delayMs);
313 }
314 return [];
315 }
316
317 // Partial PATCH; revision mandatory; works on a non-premium site. The `location` object
318 // (address/name) is IMMUTABLE here — only touch `configuration`.
319 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/reservations/reservation-locations/update-reservation-location.md
320 export async function updateReservationLocation ( ctx , reservationLocationId , revision , configuration ) {
321 return req (ctx, `/table-reservations/reservation-locations/v1/reservation-locations/${ reservationLocationId }` , {
322 method: "PATCH" ,
323 body: { reservationLocation: { id: reservationLocationId, revision, configuration } },
324 });
325 }
326
327 // PREMIUM-ONLY: on a non-premium site this THROWS `428 PREMIUM_ONLY` — expected and
328 // non-fatal; the caller records it and continues (never retry-spiral, never fail the seed).
329 // docs: https://dev.wix.com/docs/api-reference/business-solutions/restaurants/reservations/reservation-locations/update-reservation-location.md
330 export async function enableOnlineReservations ( ctx , reservationLocationId , revision ) {
331 return req (ctx, `/table-reservations/reservation-locations/v1/reservation-locations/${ reservationLocationId }` , {
332 method: "PATCH" ,
333 body: {
334 reservationLocation: {
335 id: reservationLocationId,
336 revision,
337 configuration: { onlineReservations: { onlineReservationsEnabled: true } },
338 },
339 },
340 });
341 }
342
343 /**
344 * ONE-CALL seed: Menus install (+ sample cleanup when fresh) → menus bottom-up → images →
345 * ordering add-on → reservations add-on, ids threaded in memory. The default path.
346 */
347 export async function setupRestaurants ( ctx , plan ) {
348 const menusPlan = plan.menus ?? [];
349 const wasPresent = await menusAppPresent (ctx);
350 await installMenusApp (ctx);
351 let sampleMenuRemoved = false ;
352 if ( ! wasPresent) {
353 const r = await removeSampleMenu (ctx). catch (() => ({ removed: false }));
354 sampleMenuRemoved = r.removed === true ;
355 }
356
357 const createdMenus = [];
358 for ( const m of menusPlan) createdMenus. push ( await createMenu (ctx, m));
359
360 // image pass — resolve each item's image (import by url / generate by prompt) in ONE
361 // parallel wave (restaurants binds by file id), then bulk full-replace with revision +
362 // priceInfo echoed. Never block on image failure.
363 let imagesAttached = 0 ;
364 const imageItems = [];
365 menusPlan. forEach (( m , mi ) => {
366 const flat = m.sections. flatMap (( s ) => s.items || []);
367 flat. forEach (( it , i ) => {
368 const created = createdMenus[mi]?.items?.[i];
369 if ((it.imageUrl || it.imagePrompt) && created?.id) {
370 imageItems. push ({ ... created, imageUrl: it.imageUrl, imagePrompt: it.imagePrompt, name: it.name });
371 }
372 });
373 });
374 const files = await resolveItemImages (ctx, imageItems. map (( it ) => ({
375 url: it.imageUrl,
376 path: it.imagePath,
377 prompt: it.imagePrompt,
378 displayName: `${ it . name || "item"}.png` ,
379 })));
380 const toAttach = imageItems
381 . map (( it , i ) => (files[i]
382 ? { id: it.id, revision: it.revision ?? "1" , price: it.price,
383 image: { id: files[i].id, url: files[i].url, width: 1024 , height: 1024 } }
384 : null ))
385 . filter (Boolean);
386 if (toAttach. length ) {
387 try {
388 await attachItemImages (ctx, toAttach);
389 imagesAttached = toAttach. length ;
390 } catch {
391 /* the items stay text-only */
392 }
393 }
394
395 // shared STEP 0 address — set once across both add-ons.
396 let locationSet = false ;
397 const ensureLocation = async ( address ) => {
398 if (address && ! locationSet) {
399 await setBusinessLocation (ctx, address);
400 locationSet = true ;
401 }
402 };
403
404 const ordering = { enabled: false , addressSet: false };
405 if (plan.ordering) {
406 const cfg = typeof plan.ordering === "object" ? plan.ordering : {};
407 await installOrdersApp (ctx); // auto-provisions operation + methods + per-menu settings
408 await ensureLocation (cfg.address);
409 ordering.addressSet = locationSet;
410 const ops = await listOperationsWithRetry (ctx);
411 const op = ops. find (( o ) => o.default) ?? ops[ 0 ];
412 if ( ! op) throw new Error ( "No ordering operation appeared — the Orders app install may not have completed; re-run the seed." );
413 if (op.onlineOrderingStatus !== "ENABLED" ) await enableOperation (ctx, op.id, op.revision);
414 // confirm each menu is orderable (auto-created + auto-enabled per menu; PATCH only when off)
415 const settings = await queryMenuOrderingSettings (ctx);
416 for ( const s of settings) {
417 if (s.onlineOrderingEnabled === false || s.operationId === "none" ) {
418 await updateMenuOrderingSettings (ctx, s.id, {
419 revision: s.revision,
420 operationId: op.id,
421 onlineOrderingEnabled: true ,
422 availability: { type: "ALWAYS_AVAILABLE" , timeZone: cfg.address?.timeZone ?? "America/New_York" },
423 }). catch (() => {});
424 }
425 }
426 ordering.enabled = true ;
427 ordering.operationId = op.id;
428 if ( ! ordering.addressSet) ordering.note = "No address in the plan — ordering is 'testing only' until the owner sets the real business address." ;
429 }
430
431 const reservations = { enabled: false };
432 if (plan.reservations) {
433 const cfg = typeof plan.reservations === "object" ? plan.reservations : {};
434 await installTableReservationsApp (ctx); // auto-provisions the default reservation location
435 await ensureLocation (cfg.address);
436 let [loc] = await listReservationLocationsWithRetry (ctx);
437 if ( ! loc) throw new Error ( "No reservation location appeared — the Table Reservations install may not have completed; re-run the seed." );
438 const configuration = cfg.configuration ?? (cfg.partySize ? { onlineReservations: { partySize: cfg.partySize } } : null );
439 if (configuration) {
440 await updateReservationLocation (ctx, loc.id, loc.revision, configuration);
441 [loc] = await listReservationLocations (ctx); // re-read for the bumped revision
442 }
443 reservations.reservationLocationId = loc.id;
444 try {
445 await enableOnlineReservations (ctx, loc.id, loc.revision);
446 reservations.enabled = true ;
447 } catch {
448 // 428 PREMIUM_ONLY on a free site — expected; record, don't fail.
449 reservations.premiumRequired = true ;
450 }
451 }
452
453 return {
454 menus: createdMenus. map (({ items , ... m }) => m),
455 imagesAttached,
456 sampleMenuRemoved,
457 ordering,
458 reservations,
459 };
460 }
461
462 // ---- CLI entry ----------------------------------------------------------------------------------
463
464 const invokedDirectly = process.argv[ 1 ] && import . meta .url. endsWith (process.argv[ 1 ]. split ( "/" ). pop ());
465 if (invokedDirectly) {
466 const planPath = process.argv[ 2 ];
467 if ( ! planPath) {
468 console. error ( "usage: node seed-restaurants.mjs <plan.json> (run from the project root)" );
469 process. exit ( 1 );
470 }
471 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
472 const ctx = makeCtx ();
473 setupRestaurants (ctx, plan)
474 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
475 . catch (( e ) => {
476 console. error (e.message);
477 process. exit ( 1 );
478 });
479 }