Setting the file. One moment.
Seed Rentals · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 16.661
Position 661 of 828
Type JavaScript
Size 16 KB
Lines 315 rentals/seed/ seed-rentals.mjs
JavaScript · 315 lines · 16 KB
14 // { "currency"?,
15 // "resourceTypes": [{ "name": "Kayaks", "resources": ["Kayak 1", "Kayak 2"] }],
16 // "rentals": [{ "name", "description", "tagLine"?, "unit": "HOUR"|"DAY", "rate": 25, "min"?, "max"?,
17 // "resourceType": "Kayaks", "resources"?: ["Kayak 1"], "free"?, "requireManualApproval"?,
18 // "imageMediaId"? | "imageUrl"? | "imagePath"? | "imagePrompt"? }] }
19 //
20 // Seeding is ADDITIVE — never deletes or overwrites existing content. Unexpected shapes →
21 // read the live API reference; every call below carries a docs: line with its reference page.
22 import { setSiteCurrency } from "../../shared/seed/site.mjs" ;
23 import { readFileSync } from "node:fs" ;
24 import { resolveItemImages } from "../../shared/seed/images.mjs" ;
25 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
26 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
27 import { isMain } from "../../shared/seed/main.mjs" ;
28
29 const API = "https://www.wixapis.com" ;
30 /** The Wix Rentals app: the service's immutable appId, the catalog filter, the cart's appId. */
31 export const RENTALS_APP_ID = "ff5d6eb1-65e4-4f9a-8b14-64d34c12cc2e" ;
32 /** The Rentals default booking form, provisioned by the install with the same id on every site. */
33 export const RENTALS_FORM_ID = "3a2ea2ce-91f4-4617-ab24-629933c0c31a" ;
34
35 export function makeCtx ({ cwd = process. cwd () } = {}) {
36 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
37 // a seed there unless --allow-parent is passed after the user confirmed).
38 const siteId = seedSiteId ({ cwd, argv: process.argv });
39 const token = wixToken (siteId, cwd);
40 return { token, siteId };
41 }
42
43 async function req ( ctx , path , { method = "POST" , body } = {}) {
44 const res = await fetch ( API + path, {
45 method,
46 headers: { Authorization: `Bearer ${ ctx . token }` , "wix-site-id" : ctx.siteId, "Content-Type" : "application/json" },
47 body: body ? JSON . stringify (body) : undefined ,
48 });
49 const json = await res. json (). catch (() => ({}));
50 if ( ! res.ok) {
51 const err = new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
52 err.status = res.status;
53 err.body = json;
54 throw err;
55 }
56 return json;
57 }
58
59 const sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms));
60 const slugify = ( name ) => String (name || "" ). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , "-" ). replace ( / ^ - +| - +$ / g , "" );
61 const money = ( n , currency ) => ({ value: Number (n). toFixed ( 2 ), currency: currency ?? "USD" });
62
63 // plain rental -> flat Services V2 create object carrying the five rentals values.
64 // unit HOUR: min/max in HOURS (0.5 to 24; stored as minutes 30–1440); unit DAY: min/max in DAYS (1–8).
65 // rate: per unit; free -> NO_FEE paid in person (the API needs one payment option true).
66 export function buildRental ( r , { resourceTypeId , resourceIds , currency }) {
67 if ( ! resourceTypeId) throw new Error ( `rental "${ r . name }" names no resource type` );
68 if ( ! resourceIds?. length ) throw new Error ( `rental "${ r . name }": its resource type holds no resources — availability would be permanently empty` );
69 const unit = String (r.unit ?? "HOUR" ). toUpperCase ();
70 if (unit !== "HOUR" && unit !== "DAY" ) throw new Error ( `rental "${ r . name }": unit must be HOUR or DAY` );
71 let durationRange;
72 if (unit === "DAY" ) {
73 const min = Math. max ( 1 , Math. floor ( Number (r.min ?? 1 )));
74 const max = Math. min ( 8 , Math. max (min, Math. floor ( Number (r.max ?? 5 ))));
75 durationRange = { unitType: "DAY" , dayOptions: { minDurationInDays: min, maxDurationInDays: max } };
76 } else {
77 const min = Math. max ( 30 , Math. round ( Number (r.min ?? 1 ) * 60 ));
78 const max = Math. min ( 1440 , Math. max (min, Math. round ( Number (r.max ?? 8 ) * 60 )));
79 durationRange = { unitType: "HOUR" , hourOptions: { minDurationInMinutes: min, maxDurationInMinutes: max } };
80 }
81 const free = r.free === true || r.rate == null || Number (r.rate) === 0 ;
82 const payment = free
83 ? { rateType: "NO_FEE" , options: { online: false , inPerson: true } }
84 : { rateType: "FIXED" , fixed: { price: money (r.rate, currency) }, options: { online: true , inPerson: false } };
85 return {
86 type: "APPOINTMENT" ,
87 appId: RENTALS_APP_ID , // immutable: a service created without it is a plain Bookings service forever
88 name: r.name,
89 description: r.description,
90 tagLine: r.tagLine,
91 defaultCapacity: 1 , // one customer per resource at a time; parallel capacity = more resources
92 serviceResources: [{ resourceType: { id: resourceTypeId }, resourceIds: { values: resourceIds. slice ( 0 , 100 ) } }],
93 primaryResourceType: resourceTypeId, // availability from the resources, not from staff
94 form: { id: RENTALS_FORM_ID },
95 onlineBooking: { enabled: true , requireManualApproval: r.requireManualApproval === true , allowMultipleRequests: false },
96 schedule: { availabilityConstraints: { durationRange } }, // never with sessionDurations or workingHours
97 payment,
98 locations: [{ type: "BUSINESS" }],
99 // no category (rentals surface through the appId-filtered read), no staffMemberIds (resource-driven)
100 };
101 }
102
103 // ---- operations ----------------------------------------------------------------------------------
104
105 // Rentals alone: it owns the services and the dashboard, and brings the availability it needs
106 // (the Wix Bookings app is not needed, for hourly and daily rentals, one resource or several).
107 // docs: https://dev.wix.com/docs/api-reference/articles/work-with-wix-apis/platform/about-apps-created-by-wix.md
108 export async function installRentalsApp ( ctx ) {
109 try {
110 await req (ctx, "/apps-installer-service/v1/app-instance/install" , {
111 body: { tenant: { tenantType: "SITE" , id: ctx.siteId }, appInstance: { appDefId: RENTALS_APP_ID , enabled: true } },
112 });
113 } catch {
114 /* already installed is fine */
115 }
116 }
117
118 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/resources/resource-types-v2/query-resource-types.md
119 export async function queryResourceTypes ( ctx ) {
120 const r = await req (ctx, "/bookings/v2/resources/resource-types/query" , { body: { query: { cursorPaging: { limit: 100 } } } });
121 return (r.resourceTypes ?? []). map (( t ) => ({ id: t.id, name: t.name }));
122 }
123
124 // Resource types by name — idempotent (an existing name is reused; a 409 on create re-reads).
125 // `name` is required, max 40 characters, unique per site.
126 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/resources/resource-types-v2/create-resource-type.md
127 export async function createResourceTypes ( ctx , names ) {
128 const byName = new Map (( await queryResourceTypes (ctx)). map (( t ) => [t.name, t]));
129 for ( const name of names) {
130 if (byName. has (name)) continue ;
131 try {
132 const r = await req (ctx, "/bookings/v2/resources/resource-types" , { body: { resourceType: { name: String (name). slice ( 0 , 40 ) } } });
133 byName. set (name, { id: r.resourceType?.id, name });
134 } catch (e) {
135 if (e.status !== 409 ) throw e;
136 const fresh = ( await queryResourceTypes (ctx)). find (( t ) => t.name === name);
137 if ( ! fresh) throw e;
138 byName. set (name, fresh);
139 }
140 }
141 return names. map (( n ) => byName. get (n));
142 }
143
144 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/resources/resources-v2/query-resources.md
145 export async function queryResources ( ctx , typeId ) {
146 const r = await req (ctx, "/bookings/v2/resources/query" , { body: { query: { filter: { typeId }, cursorPaging: { limit: 100 } } } });
147 return (r.resources ?? []). map (( x ) => ({ id: x.id, name: x.name, typeId: x.typeId ?? typeId }));
148 }
149
150 // Resources of one type by name — idempotent. No workingHoursSchedules: bookable 24/7, so a multi-day
151 // daily rental stays ONE booking (working hours split it into one booking per day — dashboard work).
152 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/resources/resources-v2/create-resource.md
153 export async function createResources ( ctx , typeId , names ) {
154 const byName = new Map (( await queryResources (ctx, typeId)). map (( x ) => [x.name, x]));
155 for ( const name of names) {
156 if (byName. has (name)) continue ;
157 const r = await req (ctx, "/bookings/v2/resources" , { body: { resource: { name, typeId } } });
158 byName. set (name, { id: r.resource?.id, name, typeId });
159 }
160 return names. map (( n ) => byName. get (n));
161 }
162
163 // ONE service per call. The bulk endpoint tags the schedule with the Bookings app id, which hides every
164 // booking from the Rentals calendar — never use it here. A failed create is retried once.
165 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/create-service.md
166 export async function createRental ( ctx , service ) {
167 let r;
168 try {
169 r = await req (ctx, "/bookings/v2/services" , { body: { service } });
170 } catch (e) {
171 await sleep ( 1500 );
172 r = await req (ctx, "/bookings/v2/services" , { body: { service } });
173 }
174 const item = r.service ?? {};
175 return {
176 id: item.id,
177 slug: item.mainSlug?.name ?? slugify (item.name ?? service.name),
178 revision: item.revision,
179 scheduleId: item.schedule?.id,
180 unit: item.schedule?.availabilityConstraints?.durationRange?.unitType ?? null ,
181 };
182 }
183
184 // Every rental the site holds (the Rentals app's services), for the pre-existing report: a fresh
185 // Rentals install adds its own sample ("Conference room", $45/hour), and the live listing shows it
186 // next to the owner's rentals. Reported, never touched: this seed deletes nothing on a site, ever.
187 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/query-services.md
188 export async function readRentals ( ctx ) {
189 const r = await req (ctx, "/bookings/v2/services/query" , { body: { query: { filter: { appId: RENTALS_APP_ID }, paging: { limit: 100 } } } });
190 return (r.services ?? []). map (( s ) => ({ id: s.id, name: s.name, slug: s.mainSlug?.name ?? slugify (s.name) }));
191 }
192
193 // The range is a newer field; a silently dropped one yields a service that books as a fixed slot.
194 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/get-service.md
195 export async function confirmDurationRange ( ctx , serviceId ) {
196 const r = await req (ctx, `/bookings/v2/services/${ serviceId }` , { method: "GET" });
197 return r.service?.schedule?.availabilityConstraints?.durationRange?.unitType ?? null ;
198 }
199
200 export { importImage } from "../../shared/seed/images.mjs" ;
201
202 // Writes under media.mainMedia + media.coverMedia (writing media.image 200s but silently drops).
203 // docs: https://dev.wix.com/docs/api-reference/business-solutions/bookings/services/services-v2/update-service.md
204 export async function attachRentalImage ( ctx , it ) {
205 return req (ctx, `/bookings/v2/services/${ it . serviceId }` , {
206 method: "PATCH" ,
207 body: { service: { id: it.serviceId, revision: it.revision, media: { mainMedia: { image: it.image }, coverMedia: { image: it.image } } } },
208 });
209 }
210
211 /**
212 * ONE-CALL seed: install → resource types → resources → services one at a time → confirm the range →
213 * images, ids threaded in memory. The default path.
214 */
215 export async function setupRentals ( ctx , { resourceTypes = [], rentals = [], currency } = {}) {
216 // Before any service exists: a service's rate is stored in the site currency.
217 if (currency) await setSiteCurrency (ctx, currency);
218 await installRentalsApp (ctx);
219
220 const typeNames = [ ...new Set ([ ... resourceTypes. map (( t ) => t.name), ... rentals. map (( r ) => r.resourceType)]. filter (Boolean))];
221 if ( ! typeNames. length ) throw new Error ( "the plan names no resource types — a rental needs the things it rents" );
222 const types = await createResourceTypes (ctx, typeNames);
223 const typeByName = new Map (types. map (( t ) => [t.name, t]));
224
225 const resourcesByType = new Map ();
226 for ( const t of resourceTypes) {
227 const type = typeByName. get (t.name);
228 const names = [ ...new Set ((t.resources ?? []). filter (Boolean))];
229 resourcesByType. set (t.name, names. length ? await createResources (ctx, type.id, names) : await queryResources (ctx, type.id));
230 }
231 for ( const name of typeNames) if ( ! resourcesByType. has (name)) resourcesByType. set (name, await queryResources (ctx, typeByName. get (name).id));
232
233 const created = [];
234 const errors = [];
235 for ( const r of rentals) {
236 const type = typeByName. get (r.resourceType);
237 const pool = resourcesByType. get (r.resourceType) ?? [];
238 const chosen = Array. isArray (r.resources) && r.resources. length ? pool. filter (( x ) => r.resources. includes (x.name)) : pool;
239 try {
240 created. push ( await createRental (ctx, buildRental (r, { resourceTypeId: type?.id, resourceIds: chosen. map (( x ) => x.id), currency })));
241 } catch (e) {
242 errors. push ( `${ r . name }: ${ e . message }` );
243 created. push ( null );
244 }
245 }
246
247 const first = created. find (Boolean);
248 const durationRangeConfirmed = first ? await confirmDurationRange (ctx, first.id) : null ;
249
250 // What the site lists that this run did not create and the plan does not name (the install's own
251 // sample, an earlier seed, the owner's work). The closing message names it with the dashboard link.
252 const createdIds = new Set (created. filter (Boolean). map (( c ) => c.id));
253 const planNames = new Set (rentals. map (( r ) => r.name));
254 let preexisting = [];
255 try {
256 preexisting = ( await readRentals (ctx)). filter (( s ) => ! createdIds. has (s.id) && ! planNames. has (s.name));
257 } catch (e) {
258 console. error ( `rentals read failed (skipping the pre-existing check): ${ String ( e . message ). slice ( 0 , 120 ) }` );
259 }
260
261 // Pass 2 — images: resolve (import by url / generate by prompt) in one parallel wave, then attach.
262 // Failures leave the rental text-only; the seed's exit never depends on images.
263 const files = await resolveItemImages (ctx, created. map (( c , i ) => ({
264 mediaId: rentals[i]?.imageMediaId,
265 url: rentals[i]?.imageUrl,
266 path: rentals[i]?.imagePath,
267 prompt: rentals[i]?.imagePrompt,
268 displayName: `${ c ?. slug || "rental"}.png` ,
269 })));
270 let imagesAttached = 0 ;
271 for ( let i = 0 ; i < created. length ; i ++ ) {
272 if ( ! files[i] || ! created[i]?.id) continue ;
273 try {
274 await attachRentalImage (ctx, { serviceId: created[i].id, revision: created[i].revision, image: { id: files[i].id, url: files[i].url, width: 1024 , height: 1024 } });
275 imagesAttached ++ ;
276 } catch {
277 /* never block on image failure — the rental stays text-only */
278 }
279 }
280
281 return {
282 rentals: created. filter (Boolean),
283 resourceTypes: types. map (( t ) => ({ ... t, resources: resourcesByType. get (t.name) ?? [] })),
284 durationRangeConfirmed,
285 preexisting,
286 errors,
287 imagesAttached,
288 // The Rentals app's own page (slug wix-rental, route rental-list). Rentals are Bookings services,
289 // but the Bookings services page lists only services with the Bookings app id (its global filter),
290 // so on a rentals site it shows an empty "Add a New Service" wizard instead of the rooms.
291 dashboardUrl: `https://manage.wix.com/dashboard/${ ctx . siteId }/wix-rental/rental-list` ,
292 };
293 }
294
295 // ---- CLI entry ----------------------------------------------------------------------------------
296
297 const invokedDirectly = isMain ( import . meta .url);
298 if (invokedDirectly) {
299 const planPath = process.argv[ 2 ];
300 if ( ! planPath) {
301 console. error ( "usage: node seed-rentals.mjs <plan.json> (run from the project root)" );
302 process. exit ( 1 );
303 }
304 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
305 const ctx = makeCtx ();
306 setupRentals (ctx, plan)
307 . then (( result ) => {
308 console. log ( JSON . stringify (result, null , 2 ));
309 if (result.errors. length || (result.rentals. length && ! result.durationRangeConfirmed)) process. exit ( 1 );
310 })
311 . catch (( e ) => {
312 console. error (e.message);
313 process. exit ( 1 );
314 });
315 }