@voyantjs/storefront 0.24.1 → 0.24.2

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.
@@ -0,0 +1,943 @@
1
+ import { availabilitySlots, availabilityStartTimes } from "@voyantjs/availability/schema";
2
+ import { productExtras } from "@voyantjs/extras/schema";
3
+ import { extraPriceRules, optionPriceRules, optionUnitPriceRules, optionUnitTiers, priceCatalogs, } from "@voyantjs/pricing/schema";
4
+ import { optionUnits, productDayServices, productDays, productItineraries, productLocations, productMedia, productOptions, products, } from "@voyantjs/products/schema";
5
+ import { sellabilityService } from "@voyantjs/sellability";
6
+ import { and, asc, count, desc, eq, gte, inArray, lte, ne } from "drizzle-orm";
7
+ function normalizeIso(value) {
8
+ if (!value) {
9
+ return null;
10
+ }
11
+ if (value instanceof Date) {
12
+ return value.toISOString();
13
+ }
14
+ const parsed = new Date(value);
15
+ return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
16
+ }
17
+ function normalizeLocalDate(value) {
18
+ if (!value) {
19
+ return null;
20
+ }
21
+ if (value instanceof Date) {
22
+ return value.toISOString().slice(0, 10);
23
+ }
24
+ return String(value).slice(0, 10);
25
+ }
26
+ function centsToAmount(cents) {
27
+ if (cents == null) {
28
+ return null;
29
+ }
30
+ return Number((cents / 100).toFixed(2));
31
+ }
32
+ function getPreferredCurrency(context) {
33
+ return context.catalog?.currencyCode ?? context.product?.sellCurrency ?? "EUR";
34
+ }
35
+ function selectTierAmount(unitRule, tiers, quantity) {
36
+ if (!unitRule) {
37
+ return null;
38
+ }
39
+ const tier = tiers
40
+ .filter((row) => row.optionUnitPriceRuleId === unitRule.id &&
41
+ quantity >= row.minQuantity &&
42
+ (row.maxQuantity == null || quantity <= row.maxQuantity))
43
+ .sort((a, b) => a.sortOrder - b.sortOrder)[0];
44
+ return tier?.sellAmountCents ?? unitRule.sellAmountCents ?? null;
45
+ }
46
+ function findNamedUnit(units, matcher) {
47
+ return units.find(matcher) ?? null;
48
+ }
49
+ function buildTravelerRequestedUnits(args) {
50
+ const requestedUnits = [];
51
+ const normalized = args.units.filter((unit) => unit.unitType === "person" && !unit.isRequired);
52
+ const adultUnit = findNamedUnit(normalized, (unit) => (unit.maxAge == null || unit.maxAge >= 18) &&
53
+ (unit.minAge == null || unit.minAge < 18) &&
54
+ !/child|infant/i.test(unit.name)) ?? normalized[0];
55
+ const childUnit = findNamedUnit(normalized, (unit) => /child/i.test(unit.name) || ((unit.maxAge ?? 99) < 18 && (unit.maxAge ?? 99) > 2)) ?? null;
56
+ const infantUnit = findNamedUnit(normalized, (unit) => /infant/i.test(unit.name) || (unit.maxAge != null && unit.maxAge <= 2)) ?? null;
57
+ if (args.adults > 0) {
58
+ requestedUnits.push(adultUnit
59
+ ? { unitId: adultUnit.id, requestRef: adultUnit.id, quantity: args.adults }
60
+ : { quantity: args.adults });
61
+ }
62
+ if (args.children > 0) {
63
+ requestedUnits.push(childUnit
64
+ ? { unitId: childUnit.id, requestRef: childUnit.id, quantity: args.children }
65
+ : { quantity: args.children });
66
+ }
67
+ if (args.infants > 0) {
68
+ requestedUnits.push(infantUnit
69
+ ? { unitId: infantUnit.id, requestRef: infantUnit.id, quantity: args.infants }
70
+ : { quantity: args.infants });
71
+ }
72
+ return requestedUnits;
73
+ }
74
+ async function listMeetingPointsByProductIds(db, productIds) {
75
+ if (productIds.length === 0) {
76
+ return new Map();
77
+ }
78
+ const rows = await db
79
+ .select({
80
+ productId: productLocations.productId,
81
+ title: productLocations.title,
82
+ locationType: productLocations.locationType,
83
+ })
84
+ .from(productLocations)
85
+ .where(inArray(productLocations.productId, productIds))
86
+ .orderBy(productLocations.locationType, asc(productLocations.sortOrder), asc(productLocations.createdAt));
87
+ const byProduct = new Map();
88
+ for (const row of rows) {
89
+ if (byProduct.has(row.productId)) {
90
+ continue;
91
+ }
92
+ byProduct.set(row.productId, row.title);
93
+ }
94
+ return byProduct;
95
+ }
96
+ async function listDefaultItineraryIdsByProductIds(db, productIds) {
97
+ if (productIds.length === 0) {
98
+ return new Map();
99
+ }
100
+ const rows = await db
101
+ .select({
102
+ productId: productItineraries.productId,
103
+ itineraryId: productItineraries.id,
104
+ })
105
+ .from(productItineraries)
106
+ .where(and(inArray(productItineraries.productId, productIds), eq(productItineraries.isDefault, true)))
107
+ .orderBy(asc(productItineraries.sortOrder), asc(productItineraries.createdAt));
108
+ return new Map(rows.map((row) => [row.productId, row.itineraryId]));
109
+ }
110
+ async function listSlots(db, filters = {}) {
111
+ const conditions = [
112
+ eq(products.status, "active"),
113
+ eq(products.activated, true),
114
+ eq(products.visibility, "public"),
115
+ ];
116
+ if (filters.productId) {
117
+ conditions.push(eq(availabilitySlots.productId, filters.productId));
118
+ }
119
+ if (filters.slotId) {
120
+ conditions.push(eq(availabilitySlots.id, filters.slotId));
121
+ }
122
+ if (filters.optionId) {
123
+ conditions.push(eq(availabilitySlots.optionId, filters.optionId));
124
+ }
125
+ if (filters.status) {
126
+ conditions.push(eq(availabilitySlots.status, filters.status));
127
+ }
128
+ else if (!filters.includeCancelled) {
129
+ conditions.push(ne(availabilitySlots.status, "cancelled"));
130
+ }
131
+ if (filters.dateFrom) {
132
+ conditions.push(gte(availabilitySlots.dateLocal, filters.dateFrom));
133
+ }
134
+ if (filters.dateTo) {
135
+ conditions.push(lte(availabilitySlots.dateLocal, filters.dateTo));
136
+ }
137
+ return db
138
+ .select({
139
+ id: availabilitySlots.id,
140
+ productId: availabilitySlots.productId,
141
+ itineraryId: availabilitySlots.itineraryId,
142
+ optionId: availabilitySlots.optionId,
143
+ startTimeId: availabilitySlots.startTimeId,
144
+ dateLocal: availabilitySlots.dateLocal,
145
+ startsAt: availabilitySlots.startsAt,
146
+ endsAt: availabilitySlots.endsAt,
147
+ timezone: availabilitySlots.timezone,
148
+ status: availabilitySlots.status,
149
+ unlimited: availabilitySlots.unlimited,
150
+ initialPax: availabilitySlots.initialPax,
151
+ remainingPax: availabilitySlots.remainingPax,
152
+ remainingResources: availabilitySlots.remainingResources,
153
+ pastCutoff: availabilitySlots.pastCutoff,
154
+ tooEarly: availabilitySlots.tooEarly,
155
+ nights: availabilitySlots.nights,
156
+ days: availabilitySlots.days,
157
+ startTimeLabel: availabilityStartTimes.label,
158
+ startTimeLocal: availabilityStartTimes.startTimeLocal,
159
+ durationMinutes: availabilityStartTimes.durationMinutes,
160
+ })
161
+ .from(availabilitySlots)
162
+ .innerJoin(products, eq(products.id, availabilitySlots.productId))
163
+ .leftJoin(availabilityStartTimes, eq(availabilityStartTimes.id, availabilitySlots.startTimeId))
164
+ .where(and(...conditions))
165
+ .orderBy(asc(availabilitySlots.startsAt))
166
+ .limit(filters.limit ?? 100)
167
+ .offset(filters.offset ?? 0);
168
+ }
169
+ async function countSlots(db, filters = {}) {
170
+ const conditions = [
171
+ eq(products.status, "active"),
172
+ eq(products.activated, true),
173
+ eq(products.visibility, "public"),
174
+ ];
175
+ if (filters.productId) {
176
+ conditions.push(eq(availabilitySlots.productId, filters.productId));
177
+ }
178
+ if (filters.slotId) {
179
+ conditions.push(eq(availabilitySlots.id, filters.slotId));
180
+ }
181
+ if (filters.optionId) {
182
+ conditions.push(eq(availabilitySlots.optionId, filters.optionId));
183
+ }
184
+ if (filters.status) {
185
+ conditions.push(eq(availabilitySlots.status, filters.status));
186
+ }
187
+ else if (!filters.includeCancelled) {
188
+ conditions.push(ne(availabilitySlots.status, "cancelled"));
189
+ }
190
+ if (filters.dateFrom) {
191
+ conditions.push(gte(availabilitySlots.dateLocal, filters.dateFrom));
192
+ }
193
+ if (filters.dateTo) {
194
+ conditions.push(lte(availabilitySlots.dateLocal, filters.dateTo));
195
+ }
196
+ const [result] = await db
197
+ .select({ value: count() })
198
+ .from(availabilitySlots)
199
+ .innerJoin(products, eq(products.id, availabilitySlots.productId))
200
+ .where(and(...conditions));
201
+ return result?.value ?? 0;
202
+ }
203
+ async function resolvePricingContext(db, productId, optionId) {
204
+ const [product] = await db
205
+ .select({
206
+ id: products.id,
207
+ sellCurrency: products.sellCurrency,
208
+ sellAmountCents: products.sellAmountCents,
209
+ capacityMode: products.capacityMode,
210
+ })
211
+ .from(products)
212
+ .where(eq(products.id, productId))
213
+ .limit(1);
214
+ const [catalog] = await db
215
+ .select({
216
+ id: priceCatalogs.id,
217
+ currencyCode: priceCatalogs.currencyCode,
218
+ })
219
+ .from(priceCatalogs)
220
+ .where(and(eq(priceCatalogs.catalogType, "public"), eq(priceCatalogs.active, true)))
221
+ .orderBy(desc(priceCatalogs.isDefault), asc(priceCatalogs.name))
222
+ .limit(1);
223
+ const [resolvedOption] = await db
224
+ .select({
225
+ id: productOptions.id,
226
+ name: productOptions.name,
227
+ description: productOptions.description,
228
+ })
229
+ .from(productOptions)
230
+ .where(and(eq(productOptions.productId, productId), eq(productOptions.status, "active"), optionId ? eq(productOptions.id, optionId) : undefined))
231
+ .orderBy(desc(productOptions.isDefault), asc(productOptions.sortOrder), asc(productOptions.name))
232
+ .limit(1);
233
+ if (!resolvedOption || !catalog) {
234
+ return {
235
+ product: product ?? null,
236
+ catalog: catalog ?? null,
237
+ option: resolvedOption ?? null,
238
+ rule: null,
239
+ units: [],
240
+ unitRules: [],
241
+ tiers: [],
242
+ extraRules: [],
243
+ };
244
+ }
245
+ const [rule] = await db
246
+ .select({
247
+ id: optionPriceRules.id,
248
+ name: optionPriceRules.name,
249
+ description: optionPriceRules.description,
250
+ pricingMode: optionPriceRules.pricingMode,
251
+ baseSellAmountCents: optionPriceRules.baseSellAmountCents,
252
+ })
253
+ .from(optionPriceRules)
254
+ .where(and(eq(optionPriceRules.productId, productId), eq(optionPriceRules.optionId, resolvedOption.id), eq(optionPriceRules.priceCatalogId, catalog.id), eq(optionPriceRules.active, true)))
255
+ .orderBy(desc(optionPriceRules.isDefault), asc(optionPriceRules.name))
256
+ .limit(1);
257
+ const units = await db
258
+ .select({
259
+ id: optionUnits.id,
260
+ name: optionUnits.name,
261
+ unitType: optionUnits.unitType,
262
+ minAge: optionUnits.minAge,
263
+ maxAge: optionUnits.maxAge,
264
+ occupancyMin: optionUnits.occupancyMin,
265
+ occupancyMax: optionUnits.occupancyMax,
266
+ isRequired: optionUnits.isRequired,
267
+ })
268
+ .from(optionUnits)
269
+ .where(and(eq(optionUnits.optionId, resolvedOption.id), eq(optionUnits.isHidden, false)))
270
+ .orderBy(asc(optionUnits.sortOrder), asc(optionUnits.name));
271
+ if (!rule) {
272
+ return {
273
+ product: product ?? null,
274
+ catalog,
275
+ option: resolvedOption,
276
+ rule: null,
277
+ units,
278
+ unitRules: [],
279
+ tiers: [],
280
+ extraRules: [],
281
+ };
282
+ }
283
+ const unitRules = await db
284
+ .select({
285
+ id: optionUnitPriceRules.id,
286
+ unitId: optionUnitPriceRules.unitId,
287
+ pricingMode: optionUnitPriceRules.pricingMode,
288
+ sellAmountCents: optionUnitPriceRules.sellAmountCents,
289
+ minQuantity: optionUnitPriceRules.minQuantity,
290
+ maxQuantity: optionUnitPriceRules.maxQuantity,
291
+ sortOrder: optionUnitPriceRules.sortOrder,
292
+ })
293
+ .from(optionUnitPriceRules)
294
+ .where(and(eq(optionUnitPriceRules.optionPriceRuleId, rule.id), eq(optionUnitPriceRules.active, true)))
295
+ .orderBy(asc(optionUnitPriceRules.sortOrder), asc(optionUnitPriceRules.createdAt));
296
+ const tiers = unitRules.length > 0
297
+ ? await db
298
+ .select({
299
+ id: optionUnitTiers.id,
300
+ optionUnitPriceRuleId: optionUnitTiers.optionUnitPriceRuleId,
301
+ minQuantity: optionUnitTiers.minQuantity,
302
+ maxQuantity: optionUnitTiers.maxQuantity,
303
+ sellAmountCents: optionUnitTiers.sellAmountCents,
304
+ sortOrder: optionUnitTiers.sortOrder,
305
+ })
306
+ .from(optionUnitTiers)
307
+ .where(and(inArray(optionUnitTiers.optionUnitPriceRuleId, unitRules.map((unitRule) => unitRule.id)), eq(optionUnitTiers.active, true)))
308
+ .orderBy(asc(optionUnitTiers.sortOrder), asc(optionUnitTiers.minQuantity))
309
+ : [];
310
+ const extraRules = await db
311
+ .select({
312
+ id: extraPriceRules.id,
313
+ productExtraId: extraPriceRules.productExtraId,
314
+ pricingMode: extraPriceRules.pricingMode,
315
+ sellAmountCents: extraPriceRules.sellAmountCents,
316
+ sortOrder: extraPriceRules.sortOrder,
317
+ })
318
+ .from(extraPriceRules)
319
+ .where(and(eq(extraPriceRules.optionPriceRuleId, rule.id), eq(extraPriceRules.active, true)))
320
+ .orderBy(asc(extraPriceRules.sortOrder), asc(extraPriceRules.createdAt));
321
+ return {
322
+ product: product ?? null,
323
+ catalog,
324
+ option: resolvedOption,
325
+ rule,
326
+ units,
327
+ unitRules,
328
+ tiers,
329
+ extraRules,
330
+ };
331
+ }
332
+ function buildRatePlans(context) {
333
+ if (!context.rule) {
334
+ return [];
335
+ }
336
+ const currencyCode = getPreferredCurrency(context);
337
+ const roomPrices = context.units
338
+ .filter((unit) => unit.unitType === "room")
339
+ .map((unit) => {
340
+ const unitRule = context.unitRules.find((row) => row.unitId === unit.id);
341
+ const quantityHint = Math.max(1, unit.occupancyMax ?? unit.occupancyMin ?? 1);
342
+ const amount = centsToAmount(selectTierAmount(unitRule, context.tiers, quantityHint));
343
+ if (amount == null) {
344
+ return null;
345
+ }
346
+ return {
347
+ amount,
348
+ currencyCode,
349
+ roomType: {
350
+ id: unit.id,
351
+ name: unit.name,
352
+ occupancy: {
353
+ adultsMin: unit.occupancyMin ?? 1,
354
+ adultsMax: unit.occupancyMax ?? Math.max(2, unit.occupancyMin ?? 1),
355
+ childrenMax: Math.max(0, (unit.occupancyMax ?? Math.max(2, unit.occupancyMin ?? 1)) - (unit.occupancyMin ?? 1)),
356
+ },
357
+ },
358
+ };
359
+ })
360
+ .filter((value) => value !== null);
361
+ const baseAmount = centsToAmount(context.rule.baseSellAmountCents);
362
+ return [
363
+ {
364
+ id: context.rule.id,
365
+ active: true,
366
+ name: context.rule.name,
367
+ pricingModel: roomPrices.length > 0 ? "per_room_person" : context.rule.pricingMode,
368
+ basePrices: baseAmount == null
369
+ ? []
370
+ : [
371
+ {
372
+ amount: baseAmount,
373
+ currencyCode,
374
+ },
375
+ ],
376
+ roomPrices,
377
+ },
378
+ ];
379
+ }
380
+ function buildDepartureStatus(slot, context) {
381
+ if (slot.status === "open" && context.product?.capacityMode === "on_request") {
382
+ return "on_request";
383
+ }
384
+ return slot.status;
385
+ }
386
+ function computeFallbackLineItems(args) {
387
+ const lineItems = [];
388
+ const currencyCode = getPreferredCurrency(args.context);
389
+ let total = 0;
390
+ if (args.rooms.length > 0) {
391
+ for (const room of args.rooms) {
392
+ const unitRule = args.context.unitRules.find((row) => row.unitId === room.unitId);
393
+ if (!unitRule) {
394
+ continue;
395
+ }
396
+ const amountCents = selectTierAmount(unitRule, args.context.tiers, Math.max(1, room.occupancy * room.quantity));
397
+ const unitAmount = centsToAmount(amountCents) ?? 0;
398
+ const quantity = unitRule.pricingMode === "per_person"
399
+ ? Math.max(1, room.occupancy * room.quantity)
400
+ : Math.max(1, room.quantity);
401
+ const totalAmount = Number((unitAmount * quantity).toFixed(2));
402
+ total += totalAmount;
403
+ const unit = args.context.units.find((row) => row.id === room.unitId);
404
+ lineItems.push({
405
+ name: unit?.name ?? room.unitId,
406
+ total: totalAmount,
407
+ quantity,
408
+ unitPrice: unitAmount,
409
+ });
410
+ }
411
+ }
412
+ else {
413
+ const requested = buildTravelerRequestedUnits({
414
+ units: args.context.units,
415
+ adults: args.adults,
416
+ children: args.children,
417
+ infants: args.infants,
418
+ });
419
+ for (const request of requested) {
420
+ const unitRule = request.unitId
421
+ ? args.context.unitRules.find((row) => row.unitId === request.unitId)
422
+ : args.context.unitRules[0];
423
+ if (!unitRule) {
424
+ continue;
425
+ }
426
+ const unitAmount = centsToAmount(selectTierAmount(unitRule, args.context.tiers, request.quantity)) ?? 0;
427
+ const totalAmount = Number((unitAmount * request.quantity).toFixed(2));
428
+ total += totalAmount;
429
+ const unit = request.unitId
430
+ ? args.context.units.find((row) => row.id === request.unitId)
431
+ : null;
432
+ lineItems.push({
433
+ name: unit?.name ?? args.context.option?.name ?? "Traveler",
434
+ total: totalAmount,
435
+ quantity: request.quantity,
436
+ unitPrice: unitAmount,
437
+ });
438
+ }
439
+ }
440
+ if (lineItems.length === 0 && args.context.product?.sellAmountCents != null) {
441
+ const pax = Math.max(1, args.adults + args.children + args.infants);
442
+ const unitAmount = centsToAmount(args.context.product.sellAmountCents) ?? 0;
443
+ const totalAmount = Number((unitAmount * pax).toFixed(2));
444
+ total += totalAmount;
445
+ lineItems.push({
446
+ name: args.context.option?.name ?? "Base",
447
+ total: totalAmount,
448
+ quantity: pax,
449
+ unitPrice: unitAmount,
450
+ });
451
+ }
452
+ return {
453
+ currencyCode,
454
+ total: Number(total.toFixed(2)),
455
+ lineItems,
456
+ };
457
+ }
458
+ async function applyExtraLineItems(args) {
459
+ if (args.extras.length === 0) {
460
+ return { lineItems: args.lineItems, total: args.total };
461
+ }
462
+ const extras = await args.db
463
+ .select({
464
+ id: productExtras.id,
465
+ name: productExtras.name,
466
+ pricingMode: productExtras.pricingMode,
467
+ pricedPerPerson: productExtras.pricedPerPerson,
468
+ })
469
+ .from(productExtras)
470
+ .where(and(eq(productExtras.productId, args.productId), eq(productExtras.active, true), inArray(productExtras.id, args.extras.map((extra) => extra.extraId))));
471
+ const ruleByExtraId = new Map(args.context.extraRules
472
+ .filter((rule) => rule.productExtraId)
473
+ .map((rule) => [rule.productExtraId, rule]));
474
+ let total = args.total;
475
+ const lineItems = [...args.lineItems];
476
+ for (const extraSelection of args.extras) {
477
+ const extra = extras.find((row) => row.id === extraSelection.extraId);
478
+ if (!extra) {
479
+ continue;
480
+ }
481
+ const rule = ruleByExtraId.get(extraSelection.extraId);
482
+ const pricingMode = rule?.pricingMode ?? (extra.pricedPerPerson ? "per_person" : extra.pricingMode);
483
+ const unitAmount = centsToAmount(rule?.sellAmountCents) ?? 0;
484
+ if (pricingMode === "included" ||
485
+ pricingMode === "free" ||
486
+ pricingMode === "unavailable" ||
487
+ pricingMode === "on_request") {
488
+ continue;
489
+ }
490
+ const quantity = pricingMode === "per_person"
491
+ ? Math.max(1, args.paxTotal * Math.max(1, extraSelection.quantity))
492
+ : Math.max(1, extraSelection.quantity);
493
+ const totalAmount = Number((unitAmount * quantity).toFixed(2));
494
+ total += totalAmount;
495
+ lineItems.push({
496
+ name: extra.name,
497
+ total: totalAmount,
498
+ quantity,
499
+ unitPrice: unitAmount,
500
+ });
501
+ }
502
+ return {
503
+ lineItems,
504
+ total: Number(total.toFixed(2)),
505
+ };
506
+ }
507
+ async function buildDeparture(db, slot, defaultItineraryByProduct, meetingPointByProduct) {
508
+ const context = await resolvePricingContext(db, slot.productId, slot.optionId);
509
+ const itineraryId = slot.itineraryId ?? defaultItineraryByProduct.get(slot.productId) ?? null;
510
+ return {
511
+ id: slot.id,
512
+ productId: slot.productId,
513
+ itineraryId: itineraryId ?? slot.id,
514
+ optionId: slot.optionId,
515
+ dateLocal: normalizeLocalDate(slot.dateLocal),
516
+ startAt: normalizeIso(slot.startsAt),
517
+ endAt: normalizeIso(slot.endsAt),
518
+ timezone: slot.timezone,
519
+ startTime: slot.startTimeId == null
520
+ ? null
521
+ : {
522
+ id: slot.startTimeId,
523
+ label: slot.startTimeLabel,
524
+ startTimeLocal: slot.startTimeLocal ?? "00:00",
525
+ durationMinutes: slot.durationMinutes,
526
+ },
527
+ meetingPoint: meetingPointByProduct?.get(slot.productId) ?? null,
528
+ capacity: slot.unlimited ? null : (slot.initialPax ?? slot.remainingPax ?? null),
529
+ remaining: slot.remainingPax ?? slot.remainingResources ?? null,
530
+ departureStatus: buildDepartureStatus(slot, context),
531
+ nights: slot.nights,
532
+ days: slot.days,
533
+ ratePlans: buildRatePlans(context),
534
+ };
535
+ }
536
+ export async function getStorefrontDeparture(db, departureId) {
537
+ const [slot] = await listSlots(db, { slotId: departureId, limit: 1 });
538
+ if (!slot) {
539
+ return null;
540
+ }
541
+ const [meetingPointByProduct, defaultItineraryByProduct] = await Promise.all([
542
+ listMeetingPointsByProductIds(db, [slot.productId]),
543
+ listDefaultItineraryIdsByProductIds(db, [slot.productId]),
544
+ ]);
545
+ return buildDeparture(db, slot, defaultItineraryByProduct, meetingPointByProduct);
546
+ }
547
+ export async function listStorefrontProductDepartures(db, productId, query) {
548
+ const filters = {
549
+ productId,
550
+ optionId: query.optionId,
551
+ status: query.status,
552
+ dateFrom: query.dateFrom,
553
+ dateTo: query.dateTo,
554
+ };
555
+ const [slots, total] = await Promise.all([
556
+ listSlots(db, {
557
+ ...filters,
558
+ limit: query.limit,
559
+ offset: query.offset,
560
+ }),
561
+ countSlots(db, filters),
562
+ ]);
563
+ const [meetingPointByProduct, defaultItineraryByProduct] = await Promise.all([
564
+ listMeetingPointsByProductIds(db, [productId]),
565
+ listDefaultItineraryIdsByProductIds(db, [productId]),
566
+ ]);
567
+ const data = await Promise.all(slots.map((slot) => buildDeparture(db, slot, defaultItineraryByProduct, meetingPointByProduct)));
568
+ return {
569
+ data,
570
+ total,
571
+ limit: query.limit,
572
+ offset: query.offset,
573
+ };
574
+ }
575
+ function todayLocalDate() {
576
+ return new Date().toISOString().slice(0, 10);
577
+ }
578
+ function buildAvailabilityState(args) {
579
+ if (args.status === "cancelled")
580
+ return "cancelled";
581
+ if (args.status === "closed")
582
+ return "closed";
583
+ if (args.status === "sold_out")
584
+ return "sold_out";
585
+ if (args.status === "on_request")
586
+ return "on_request";
587
+ if (args.pastCutoff)
588
+ return "past_cutoff";
589
+ if (args.tooEarly)
590
+ return "too_early";
591
+ if (args.capacity != null && args.remaining === 0)
592
+ return "sold_out";
593
+ return "available";
594
+ }
595
+ function summarizeProductAvailability(departures) {
596
+ if (departures.some((departure) => departure.availabilityState === "available")) {
597
+ return "available";
598
+ }
599
+ if (departures.some((departure) => departure.availabilityState === "on_request")) {
600
+ return "on_request";
601
+ }
602
+ if (departures.some((departure) => departure.availabilityState === "too_early")) {
603
+ return "too_early";
604
+ }
605
+ if (departures.some((departure) => departure.availabilityState === "past_cutoff")) {
606
+ return "past_cutoff";
607
+ }
608
+ if (departures.some((departure) => departure.availabilityState === "sold_out")) {
609
+ return "sold_out";
610
+ }
611
+ if (departures.some((departure) => departure.availabilityState === "closed")) {
612
+ return "closed";
613
+ }
614
+ if (departures.some((departure) => departure.availabilityState === "cancelled")) {
615
+ return "cancelled";
616
+ }
617
+ return "unavailable";
618
+ }
619
+ export async function getStorefrontProductAvailabilitySummary(db, productId, query) {
620
+ const requestedStatus = query.status;
621
+ const persistedStatus = requestedStatus === "on_request" ? "open" : requestedStatus;
622
+ const filters = {
623
+ productId,
624
+ optionId: query.optionId,
625
+ status: persistedStatus,
626
+ dateFrom: query.dateFrom ?? todayLocalDate(),
627
+ dateTo: query.dateTo,
628
+ includeCancelled: true,
629
+ };
630
+ const [slots, total] = await Promise.all([
631
+ listSlots(db, {
632
+ ...filters,
633
+ limit: query.limit,
634
+ offset: query.offset,
635
+ }),
636
+ countSlots(db, filters),
637
+ ]);
638
+ const [meetingPointByProduct, defaultItineraryByProduct] = await Promise.all([
639
+ listMeetingPointsByProductIds(db, [productId]),
640
+ listDefaultItineraryIdsByProductIds(db, [productId]),
641
+ ]);
642
+ const departures = (await Promise.all(slots.map(async (slot) => {
643
+ const departure = await buildDeparture(db, slot, defaultItineraryByProduct, meetingPointByProduct);
644
+ const availabilityState = buildAvailabilityState({
645
+ status: departure.departureStatus,
646
+ remaining: departure.remaining,
647
+ capacity: departure.capacity,
648
+ pastCutoff: slot.pastCutoff,
649
+ tooEarly: slot.tooEarly,
650
+ });
651
+ return {
652
+ id: departure.id,
653
+ productId: departure.productId,
654
+ optionId: departure.optionId,
655
+ dateLocal: departure.dateLocal,
656
+ startAt: departure.startAt,
657
+ endAt: departure.endAt,
658
+ timezone: departure.timezone,
659
+ status: departure.departureStatus,
660
+ availabilityState,
661
+ capacity: departure.capacity,
662
+ remaining: departure.remaining,
663
+ pastCutoff: slot.pastCutoff,
664
+ tooEarly: slot.tooEarly,
665
+ };
666
+ }))).filter((departure) => !requestedStatus || departure.status === requestedStatus);
667
+ const counts = departures.reduce((acc, departure) => {
668
+ acc.total += 1;
669
+ if (departure.status === "open")
670
+ acc.open += 1;
671
+ if (departure.status === "closed")
672
+ acc.closed += 1;
673
+ if (departure.status === "sold_out")
674
+ acc.soldOut += 1;
675
+ if (departure.status === "cancelled")
676
+ acc.cancelled += 1;
677
+ if (departure.status === "on_request")
678
+ acc.onRequest += 1;
679
+ if (departure.availabilityState === "past_cutoff")
680
+ acc.pastCutoff += 1;
681
+ if (departure.availabilityState === "too_early")
682
+ acc.tooEarly += 1;
683
+ if (departure.availabilityState === "available")
684
+ acc.available += 1;
685
+ return acc;
686
+ }, {
687
+ total: 0,
688
+ open: 0,
689
+ closed: 0,
690
+ soldOut: 0,
691
+ cancelled: 0,
692
+ onRequest: 0,
693
+ pastCutoff: 0,
694
+ tooEarly: 0,
695
+ available: 0,
696
+ });
697
+ return {
698
+ productId,
699
+ availabilityState: summarizeProductAvailability(departures),
700
+ counts,
701
+ departures,
702
+ total: requestedStatus === "on_request" ? departures.length : total,
703
+ limit: query.limit,
704
+ offset: query.offset,
705
+ };
706
+ }
707
+ export async function previewStorefrontDeparturePrice(db, departureId, input) {
708
+ const [slot] = await listSlots(db, { slotId: departureId, limit: 1 });
709
+ if (!slot) {
710
+ return null;
711
+ }
712
+ const context = await resolvePricingContext(db, slot.productId, slot.optionId);
713
+ const adults = Math.max(0, input.pax?.adults ?? 1);
714
+ const children = Math.max(0, input.pax?.children ?? 0);
715
+ const infants = Math.max(0, input.pax?.infants ?? 0);
716
+ const rooms = input.rooms.map((room) => ({
717
+ unitId: room.unitId,
718
+ occupancy: room.occupancy,
719
+ quantity: room.quantity,
720
+ }));
721
+ const extras = input.extras.map((extra) => ({
722
+ extraId: extra.extraId,
723
+ quantity: extra.quantity,
724
+ }));
725
+ const requestedUnits = rooms.length > 0
726
+ ? rooms.map((room) => ({
727
+ unitId: room.unitId,
728
+ requestRef: room.unitId,
729
+ quantity: Math.max(1, room.occupancy * room.quantity),
730
+ }))
731
+ : buildTravelerRequestedUnits({
732
+ units: context.units,
733
+ adults,
734
+ children,
735
+ infants,
736
+ });
737
+ const resolved = await sellabilityService.resolve(db, {
738
+ productId: slot.productId,
739
+ optionId: slot.optionId ?? undefined,
740
+ slotId: departureId,
741
+ currencyCode: input.currencyCode ?? undefined,
742
+ requestedUnits,
743
+ limit: 25,
744
+ });
745
+ const candidate = resolved.data.find((row) => row.slot.id === departureId && (!slot.optionId || row.option.id === slot.optionId)) ?? resolved.data[0];
746
+ const seeded = candidate
747
+ ? {
748
+ currencyCode: candidate.pricing.currencyCode,
749
+ total: Number((candidate.pricing.sellAmountCents / 100).toFixed(2)),
750
+ lineItems: candidate.pricing.components.map((component) => ({
751
+ name: component.title,
752
+ total: Number((component.sellAmountCents / 100).toFixed(2)),
753
+ quantity: Math.max(1, component.quantity),
754
+ unitPrice: Number((component.sellAmountCents / 100 / Math.max(1, component.quantity)).toFixed(2)),
755
+ })),
756
+ notes: candidate.sellability.onRequest ? "on_request" : null,
757
+ }
758
+ : {
759
+ ...computeFallbackLineItems({
760
+ context,
761
+ adults,
762
+ children,
763
+ infants,
764
+ rooms,
765
+ }),
766
+ notes: null,
767
+ };
768
+ const withExtras = await applyExtraLineItems({
769
+ db,
770
+ productId: slot.productId,
771
+ context,
772
+ paxTotal: Math.max(1, adults + children + infants),
773
+ extras,
774
+ lineItems: seeded.lineItems,
775
+ total: seeded.total,
776
+ });
777
+ return {
778
+ departureId: slot.id,
779
+ productId: slot.productId,
780
+ optionId: slot.optionId,
781
+ currencyCode: seeded.currencyCode,
782
+ basePrice: seeded.lineItems[0]?.total ?? 0,
783
+ taxAmount: 0,
784
+ total: withExtras.total,
785
+ notes: seeded.notes,
786
+ lineItems: withExtras.lineItems,
787
+ };
788
+ }
789
+ export async function getStorefrontProductExtensions(db, productId, optionId) {
790
+ const context = await resolvePricingContext(db, productId, optionId);
791
+ const extras = await db
792
+ .select({
793
+ id: productExtras.id,
794
+ name: productExtras.name,
795
+ description: productExtras.description,
796
+ selectionType: productExtras.selectionType,
797
+ pricingMode: productExtras.pricingMode,
798
+ pricedPerPerson: productExtras.pricedPerPerson,
799
+ defaultQuantity: productExtras.defaultQuantity,
800
+ minQuantity: productExtras.minQuantity,
801
+ maxQuantity: productExtras.maxQuantity,
802
+ metadata: productExtras.metadata,
803
+ })
804
+ .from(productExtras)
805
+ .where(and(eq(productExtras.productId, productId), eq(productExtras.active, true)))
806
+ .orderBy(asc(productExtras.sortOrder), asc(productExtras.name));
807
+ const priceRuleByExtraId = new Map(context.extraRules
808
+ .filter((rule) => rule.productExtraId)
809
+ .map((rule) => [rule.productExtraId, rule]));
810
+ const extensions = extras.map((extra) => {
811
+ const metadata = (extra.metadata ?? {});
812
+ const rule = priceRuleByExtraId.get(extra.id);
813
+ const pricingMode = rule?.pricingMode ?? (extra.pricedPerPerson ? "per_person" : extra.pricingMode);
814
+ const amount = centsToAmount(rule?.sellAmountCents);
815
+ return {
816
+ id: extra.id,
817
+ name: extra.name,
818
+ label: extra.name,
819
+ required: extra.selectionType === "required",
820
+ selectable: extra.selectionType !== "unavailable",
821
+ hasOptions: false,
822
+ refProductId: typeof metadata.refProductId === "string"
823
+ ? metadata.refProductId
824
+ : typeof metadata.productId === "string"
825
+ ? metadata.productId
826
+ : null,
827
+ thumb: typeof metadata.thumbUrl === "string" ? metadata.thumbUrl : null,
828
+ pricePerPerson: pricingMode === "per_person" || extra.pricedPerPerson ? (amount ?? null) : null,
829
+ currencyCode: getPreferredCurrency(context),
830
+ pricingMode,
831
+ defaultQuantity: extra.defaultQuantity ?? null,
832
+ minQuantity: extra.minQuantity ?? null,
833
+ maxQuantity: extra.maxQuantity ?? null,
834
+ };
835
+ });
836
+ const details = Object.fromEntries(extras.map((extra) => {
837
+ const metadata = (extra.metadata ?? {});
838
+ const media = Array.isArray(metadata.media)
839
+ ? metadata.media
840
+ .map((entry) => entry && typeof entry === "object"
841
+ ? {
842
+ url: typeof entry.url === "string"
843
+ ? String(entry.url)
844
+ : "",
845
+ alt: typeof entry.alt === "string"
846
+ ? String(entry.alt)
847
+ : null,
848
+ }
849
+ : null)
850
+ .filter((value) => Boolean(value?.url))
851
+ : [];
852
+ return [
853
+ extra.id,
854
+ {
855
+ description: extra.description ?? null,
856
+ media,
857
+ },
858
+ ];
859
+ }));
860
+ return {
861
+ extensions,
862
+ items: extensions,
863
+ details,
864
+ currencyCode: getPreferredCurrency(context),
865
+ };
866
+ }
867
+ export async function getStorefrontDepartureItinerary(db, input) {
868
+ const [slot] = await listSlots(db, {
869
+ productId: input.productId,
870
+ slotId: input.departureId,
871
+ limit: 1,
872
+ });
873
+ const defaultItineraryByProduct = await listDefaultItineraryIdsByProductIds(db, [input.productId]);
874
+ const itineraryId = slot?.itineraryId ?? defaultItineraryByProduct.get(input.productId);
875
+ if (!itineraryId) {
876
+ return null;
877
+ }
878
+ const days = await db
879
+ .select({
880
+ id: productDays.id,
881
+ dayNumber: productDays.dayNumber,
882
+ title: productDays.title,
883
+ description: productDays.description,
884
+ })
885
+ .from(productDays)
886
+ .where(eq(productDays.itineraryId, itineraryId))
887
+ .orderBy(asc(productDays.dayNumber));
888
+ if (days.length === 0) {
889
+ return null;
890
+ }
891
+ const dayIds = days.map((day) => day.id);
892
+ const [services, dayMedia] = await Promise.all([
893
+ db
894
+ .select({
895
+ id: productDayServices.id,
896
+ dayId: productDayServices.dayId,
897
+ name: productDayServices.name,
898
+ description: productDayServices.description,
899
+ sortOrder: productDayServices.sortOrder,
900
+ })
901
+ .from(productDayServices)
902
+ .where(inArray(productDayServices.dayId, dayIds))
903
+ .orderBy(asc(productDayServices.sortOrder), asc(productDayServices.createdAt)),
904
+ db
905
+ .select({
906
+ id: productMedia.id,
907
+ dayId: productMedia.dayId,
908
+ url: productMedia.url,
909
+ isCover: productMedia.isCover,
910
+ sortOrder: productMedia.sortOrder,
911
+ })
912
+ .from(productMedia)
913
+ .where(and(eq(productMedia.productId, input.productId), inArray(productMedia.dayId, dayIds)))
914
+ .orderBy(desc(productMedia.isCover), asc(productMedia.sortOrder), asc(productMedia.createdAt)),
915
+ ]);
916
+ const servicesByDay = new Map();
917
+ for (const service of services) {
918
+ const existing = servicesByDay.get(service.dayId) ?? [];
919
+ existing.push(service);
920
+ servicesByDay.set(service.dayId, existing);
921
+ }
922
+ const mediaByDay = new Map();
923
+ for (const media of dayMedia) {
924
+ if (!media.dayId || mediaByDay.has(media.dayId)) {
925
+ continue;
926
+ }
927
+ mediaByDay.set(media.dayId, media);
928
+ }
929
+ return {
930
+ id: input.departureId,
931
+ days: days.map((day) => ({
932
+ id: day.id,
933
+ title: day.title ?? `Day ${day.dayNumber}`,
934
+ description: day.description ?? null,
935
+ thumbnail: mediaByDay.get(day.id) ? { url: mediaByDay.get(day.id)?.url ?? "" } : null,
936
+ segments: (servicesByDay.get(day.id) ?? []).map((service) => ({
937
+ id: service.id,
938
+ title: service.name,
939
+ description: service.description ?? null,
940
+ })),
941
+ })),
942
+ };
943
+ }