@voyantjs/storefront 0.3.0 → 0.4.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.
@@ -0,0 +1,779 @@
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, 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 listSlots(db, filters = {}) {
97
+ const conditions = [
98
+ eq(products.status, "active"),
99
+ eq(products.activated, true),
100
+ eq(products.visibility, "public"),
101
+ ];
102
+ if (filters.productId) {
103
+ conditions.push(eq(availabilitySlots.productId, filters.productId));
104
+ }
105
+ if (filters.slotId) {
106
+ conditions.push(eq(availabilitySlots.id, filters.slotId));
107
+ }
108
+ if (filters.optionId) {
109
+ conditions.push(eq(availabilitySlots.optionId, filters.optionId));
110
+ }
111
+ if (filters.status) {
112
+ conditions.push(eq(availabilitySlots.status, filters.status));
113
+ }
114
+ else {
115
+ conditions.push(ne(availabilitySlots.status, "cancelled"));
116
+ }
117
+ if (filters.dateFrom) {
118
+ conditions.push(gte(availabilitySlots.dateLocal, filters.dateFrom));
119
+ }
120
+ if (filters.dateTo) {
121
+ conditions.push(lte(availabilitySlots.dateLocal, filters.dateTo));
122
+ }
123
+ return db
124
+ .select({
125
+ id: availabilitySlots.id,
126
+ productId: availabilitySlots.productId,
127
+ optionId: availabilitySlots.optionId,
128
+ startTimeId: availabilitySlots.startTimeId,
129
+ dateLocal: availabilitySlots.dateLocal,
130
+ startsAt: availabilitySlots.startsAt,
131
+ endsAt: availabilitySlots.endsAt,
132
+ timezone: availabilitySlots.timezone,
133
+ status: availabilitySlots.status,
134
+ unlimited: availabilitySlots.unlimited,
135
+ initialPax: availabilitySlots.initialPax,
136
+ remainingPax: availabilitySlots.remainingPax,
137
+ remainingResources: availabilitySlots.remainingResources,
138
+ pastCutoff: availabilitySlots.pastCutoff,
139
+ tooEarly: availabilitySlots.tooEarly,
140
+ nights: availabilitySlots.nights,
141
+ days: availabilitySlots.days,
142
+ startTimeLabel: availabilityStartTimes.label,
143
+ startTimeLocal: availabilityStartTimes.startTimeLocal,
144
+ durationMinutes: availabilityStartTimes.durationMinutes,
145
+ })
146
+ .from(availabilitySlots)
147
+ .innerJoin(products, eq(products.id, availabilitySlots.productId))
148
+ .leftJoin(availabilityStartTimes, eq(availabilityStartTimes.id, availabilitySlots.startTimeId))
149
+ .where(and(...conditions))
150
+ .orderBy(asc(availabilitySlots.startsAt))
151
+ .limit(filters.limit ?? 100)
152
+ .offset(filters.offset ?? 0);
153
+ }
154
+ async function countSlots(db, filters = {}) {
155
+ const conditions = [
156
+ eq(products.status, "active"),
157
+ eq(products.activated, true),
158
+ eq(products.visibility, "public"),
159
+ ];
160
+ if (filters.productId) {
161
+ conditions.push(eq(availabilitySlots.productId, filters.productId));
162
+ }
163
+ if (filters.slotId) {
164
+ conditions.push(eq(availabilitySlots.id, filters.slotId));
165
+ }
166
+ if (filters.optionId) {
167
+ conditions.push(eq(availabilitySlots.optionId, filters.optionId));
168
+ }
169
+ if (filters.status) {
170
+ conditions.push(eq(availabilitySlots.status, filters.status));
171
+ }
172
+ else {
173
+ conditions.push(ne(availabilitySlots.status, "cancelled"));
174
+ }
175
+ if (filters.dateFrom) {
176
+ conditions.push(gte(availabilitySlots.dateLocal, filters.dateFrom));
177
+ }
178
+ if (filters.dateTo) {
179
+ conditions.push(lte(availabilitySlots.dateLocal, filters.dateTo));
180
+ }
181
+ const [result] = await db
182
+ .select({ value: count() })
183
+ .from(availabilitySlots)
184
+ .innerJoin(products, eq(products.id, availabilitySlots.productId))
185
+ .where(and(...conditions));
186
+ return result?.value ?? 0;
187
+ }
188
+ async function resolvePricingContext(db, productId, optionId) {
189
+ const [product] = await db
190
+ .select({
191
+ id: products.id,
192
+ sellCurrency: products.sellCurrency,
193
+ sellAmountCents: products.sellAmountCents,
194
+ capacityMode: products.capacityMode,
195
+ })
196
+ .from(products)
197
+ .where(eq(products.id, productId))
198
+ .limit(1);
199
+ const [catalog] = await db
200
+ .select({
201
+ id: priceCatalogs.id,
202
+ currencyCode: priceCatalogs.currencyCode,
203
+ })
204
+ .from(priceCatalogs)
205
+ .where(and(eq(priceCatalogs.catalogType, "public"), eq(priceCatalogs.active, true)))
206
+ .orderBy(desc(priceCatalogs.isDefault), asc(priceCatalogs.name))
207
+ .limit(1);
208
+ const [resolvedOption] = await db
209
+ .select({
210
+ id: productOptions.id,
211
+ name: productOptions.name,
212
+ description: productOptions.description,
213
+ })
214
+ .from(productOptions)
215
+ .where(and(eq(productOptions.productId, productId), eq(productOptions.status, "active"), optionId ? eq(productOptions.id, optionId) : undefined))
216
+ .orderBy(desc(productOptions.isDefault), asc(productOptions.sortOrder), asc(productOptions.name))
217
+ .limit(1);
218
+ if (!resolvedOption || !catalog) {
219
+ return {
220
+ product: product ?? null,
221
+ catalog: catalog ?? null,
222
+ option: resolvedOption ?? null,
223
+ rule: null,
224
+ units: [],
225
+ unitRules: [],
226
+ tiers: [],
227
+ extraRules: [],
228
+ };
229
+ }
230
+ const [rule] = await db
231
+ .select({
232
+ id: optionPriceRules.id,
233
+ name: optionPriceRules.name,
234
+ description: optionPriceRules.description,
235
+ pricingMode: optionPriceRules.pricingMode,
236
+ baseSellAmountCents: optionPriceRules.baseSellAmountCents,
237
+ })
238
+ .from(optionPriceRules)
239
+ .where(and(eq(optionPriceRules.productId, productId), eq(optionPriceRules.optionId, resolvedOption.id), eq(optionPriceRules.priceCatalogId, catalog.id), eq(optionPriceRules.active, true)))
240
+ .orderBy(desc(optionPriceRules.isDefault), asc(optionPriceRules.name))
241
+ .limit(1);
242
+ const units = await db
243
+ .select({
244
+ id: optionUnits.id,
245
+ name: optionUnits.name,
246
+ unitType: optionUnits.unitType,
247
+ minAge: optionUnits.minAge,
248
+ maxAge: optionUnits.maxAge,
249
+ occupancyMin: optionUnits.occupancyMin,
250
+ occupancyMax: optionUnits.occupancyMax,
251
+ isRequired: optionUnits.isRequired,
252
+ })
253
+ .from(optionUnits)
254
+ .where(and(eq(optionUnits.optionId, resolvedOption.id), eq(optionUnits.isHidden, false)))
255
+ .orderBy(asc(optionUnits.sortOrder), asc(optionUnits.name));
256
+ if (!rule) {
257
+ return {
258
+ product: product ?? null,
259
+ catalog,
260
+ option: resolvedOption,
261
+ rule: null,
262
+ units,
263
+ unitRules: [],
264
+ tiers: [],
265
+ extraRules: [],
266
+ };
267
+ }
268
+ const unitRules = await db
269
+ .select({
270
+ id: optionUnitPriceRules.id,
271
+ unitId: optionUnitPriceRules.unitId,
272
+ pricingMode: optionUnitPriceRules.pricingMode,
273
+ sellAmountCents: optionUnitPriceRules.sellAmountCents,
274
+ minQuantity: optionUnitPriceRules.minQuantity,
275
+ maxQuantity: optionUnitPriceRules.maxQuantity,
276
+ sortOrder: optionUnitPriceRules.sortOrder,
277
+ })
278
+ .from(optionUnitPriceRules)
279
+ .where(and(eq(optionUnitPriceRules.optionPriceRuleId, rule.id), eq(optionUnitPriceRules.active, true)))
280
+ .orderBy(asc(optionUnitPriceRules.sortOrder), asc(optionUnitPriceRules.createdAt));
281
+ const tiers = unitRules.length > 0
282
+ ? await db
283
+ .select({
284
+ id: optionUnitTiers.id,
285
+ optionUnitPriceRuleId: optionUnitTiers.optionUnitPriceRuleId,
286
+ minQuantity: optionUnitTiers.minQuantity,
287
+ maxQuantity: optionUnitTiers.maxQuantity,
288
+ sellAmountCents: optionUnitTiers.sellAmountCents,
289
+ sortOrder: optionUnitTiers.sortOrder,
290
+ })
291
+ .from(optionUnitTiers)
292
+ .where(and(inArray(optionUnitTiers.optionUnitPriceRuleId, unitRules.map((unitRule) => unitRule.id)), eq(optionUnitTiers.active, true)))
293
+ .orderBy(asc(optionUnitTiers.sortOrder), asc(optionUnitTiers.minQuantity))
294
+ : [];
295
+ const extraRules = await db
296
+ .select({
297
+ id: extraPriceRules.id,
298
+ productExtraId: extraPriceRules.productExtraId,
299
+ pricingMode: extraPriceRules.pricingMode,
300
+ sellAmountCents: extraPriceRules.sellAmountCents,
301
+ sortOrder: extraPriceRules.sortOrder,
302
+ })
303
+ .from(extraPriceRules)
304
+ .where(and(eq(extraPriceRules.optionPriceRuleId, rule.id), eq(extraPriceRules.active, true)))
305
+ .orderBy(asc(extraPriceRules.sortOrder), asc(extraPriceRules.createdAt));
306
+ return {
307
+ product: product ?? null,
308
+ catalog,
309
+ option: resolvedOption,
310
+ rule,
311
+ units,
312
+ unitRules,
313
+ tiers,
314
+ extraRules,
315
+ };
316
+ }
317
+ function buildRatePlans(context) {
318
+ if (!context.rule) {
319
+ return [];
320
+ }
321
+ const currencyCode = getPreferredCurrency(context);
322
+ const roomPrices = context.units
323
+ .filter((unit) => unit.unitType === "room")
324
+ .map((unit) => {
325
+ const unitRule = context.unitRules.find((row) => row.unitId === unit.id);
326
+ const quantityHint = Math.max(1, unit.occupancyMax ?? unit.occupancyMin ?? 1);
327
+ const amount = centsToAmount(selectTierAmount(unitRule, context.tiers, quantityHint));
328
+ if (amount == null) {
329
+ return null;
330
+ }
331
+ return {
332
+ amount,
333
+ currencyCode,
334
+ roomType: {
335
+ id: unit.id,
336
+ name: unit.name,
337
+ occupancy: {
338
+ adultsMin: unit.occupancyMin ?? 1,
339
+ adultsMax: unit.occupancyMax ?? Math.max(2, unit.occupancyMin ?? 1),
340
+ childrenMax: Math.max(0, (unit.occupancyMax ?? Math.max(2, unit.occupancyMin ?? 1)) - (unit.occupancyMin ?? 1)),
341
+ },
342
+ },
343
+ };
344
+ })
345
+ .filter((value) => value !== null);
346
+ const baseAmount = centsToAmount(context.rule.baseSellAmountCents);
347
+ return [
348
+ {
349
+ id: context.rule.id,
350
+ active: true,
351
+ name: context.rule.name,
352
+ pricingModel: roomPrices.length > 0 ? "per_room_person" : context.rule.pricingMode,
353
+ basePrices: baseAmount == null
354
+ ? []
355
+ : [
356
+ {
357
+ amount: baseAmount,
358
+ currencyCode,
359
+ },
360
+ ],
361
+ roomPrices,
362
+ },
363
+ ];
364
+ }
365
+ function buildDepartureStatus(slot, context) {
366
+ if (slot.status === "open" && context.product?.capacityMode === "on_request") {
367
+ return "on_request";
368
+ }
369
+ return slot.status;
370
+ }
371
+ function computeFallbackLineItems(args) {
372
+ const lineItems = [];
373
+ const currencyCode = getPreferredCurrency(args.context);
374
+ let total = 0;
375
+ if (args.rooms.length > 0) {
376
+ for (const room of args.rooms) {
377
+ const unitRule = args.context.unitRules.find((row) => row.unitId === room.unitId);
378
+ if (!unitRule) {
379
+ continue;
380
+ }
381
+ const amountCents = selectTierAmount(unitRule, args.context.tiers, Math.max(1, room.occupancy * room.quantity));
382
+ const unitAmount = centsToAmount(amountCents) ?? 0;
383
+ const quantity = unitRule.pricingMode === "per_person"
384
+ ? Math.max(1, room.occupancy * room.quantity)
385
+ : Math.max(1, room.quantity);
386
+ const totalAmount = Number((unitAmount * quantity).toFixed(2));
387
+ total += totalAmount;
388
+ const unit = args.context.units.find((row) => row.id === room.unitId);
389
+ lineItems.push({
390
+ name: unit?.name ?? room.unitId,
391
+ total: totalAmount,
392
+ quantity,
393
+ unitPrice: unitAmount,
394
+ });
395
+ }
396
+ }
397
+ else {
398
+ const requested = buildTravelerRequestedUnits({
399
+ units: args.context.units,
400
+ adults: args.adults,
401
+ children: args.children,
402
+ infants: args.infants,
403
+ });
404
+ for (const request of requested) {
405
+ const unitRule = request.unitId
406
+ ? args.context.unitRules.find((row) => row.unitId === request.unitId)
407
+ : args.context.unitRules[0];
408
+ if (!unitRule) {
409
+ continue;
410
+ }
411
+ const unitAmount = centsToAmount(selectTierAmount(unitRule, args.context.tiers, request.quantity)) ?? 0;
412
+ const totalAmount = Number((unitAmount * request.quantity).toFixed(2));
413
+ total += totalAmount;
414
+ const unit = request.unitId
415
+ ? args.context.units.find((row) => row.id === request.unitId)
416
+ : null;
417
+ lineItems.push({
418
+ name: unit?.name ?? args.context.option?.name ?? "Passenger",
419
+ total: totalAmount,
420
+ quantity: request.quantity,
421
+ unitPrice: unitAmount,
422
+ });
423
+ }
424
+ }
425
+ if (lineItems.length === 0 && args.context.product?.sellAmountCents != null) {
426
+ const pax = Math.max(1, args.adults + args.children + args.infants);
427
+ const unitAmount = centsToAmount(args.context.product.sellAmountCents) ?? 0;
428
+ const totalAmount = Number((unitAmount * pax).toFixed(2));
429
+ total += totalAmount;
430
+ lineItems.push({
431
+ name: args.context.option?.name ?? "Base",
432
+ total: totalAmount,
433
+ quantity: pax,
434
+ unitPrice: unitAmount,
435
+ });
436
+ }
437
+ return {
438
+ currencyCode,
439
+ total: Number(total.toFixed(2)),
440
+ lineItems,
441
+ };
442
+ }
443
+ async function applyExtraLineItems(args) {
444
+ if (args.extras.length === 0) {
445
+ return { lineItems: args.lineItems, total: args.total };
446
+ }
447
+ const extras = await args.db
448
+ .select({
449
+ id: productExtras.id,
450
+ name: productExtras.name,
451
+ pricingMode: productExtras.pricingMode,
452
+ pricedPerPerson: productExtras.pricedPerPerson,
453
+ })
454
+ .from(productExtras)
455
+ .where(and(eq(productExtras.productId, args.productId), eq(productExtras.active, true), inArray(productExtras.id, args.extras.map((extra) => extra.extraId))));
456
+ const ruleByExtraId = new Map(args.context.extraRules
457
+ .filter((rule) => rule.productExtraId)
458
+ .map((rule) => [rule.productExtraId, rule]));
459
+ let total = args.total;
460
+ const lineItems = [...args.lineItems];
461
+ for (const extraSelection of args.extras) {
462
+ const extra = extras.find((row) => row.id === extraSelection.extraId);
463
+ if (!extra) {
464
+ continue;
465
+ }
466
+ const rule = ruleByExtraId.get(extraSelection.extraId);
467
+ const pricingMode = rule?.pricingMode ?? (extra.pricedPerPerson ? "per_person" : extra.pricingMode);
468
+ const unitAmount = centsToAmount(rule?.sellAmountCents) ?? 0;
469
+ if (pricingMode === "included" ||
470
+ pricingMode === "free" ||
471
+ pricingMode === "unavailable" ||
472
+ pricingMode === "on_request") {
473
+ continue;
474
+ }
475
+ const quantity = pricingMode === "per_person"
476
+ ? Math.max(1, args.paxTotal * Math.max(1, extraSelection.quantity))
477
+ : Math.max(1, extraSelection.quantity);
478
+ const totalAmount = Number((unitAmount * quantity).toFixed(2));
479
+ total += totalAmount;
480
+ lineItems.push({
481
+ name: extra.name,
482
+ total: totalAmount,
483
+ quantity,
484
+ unitPrice: unitAmount,
485
+ });
486
+ }
487
+ return {
488
+ lineItems,
489
+ total: Number(total.toFixed(2)),
490
+ };
491
+ }
492
+ async function buildDeparture(db, slot, meetingPointByProduct) {
493
+ const context = await resolvePricingContext(db, slot.productId, slot.optionId);
494
+ return {
495
+ id: slot.id,
496
+ productId: slot.productId,
497
+ itineraryId: slot.id,
498
+ optionId: slot.optionId,
499
+ dateLocal: normalizeLocalDate(slot.dateLocal),
500
+ startAt: normalizeIso(slot.startsAt),
501
+ endAt: normalizeIso(slot.endsAt),
502
+ timezone: slot.timezone,
503
+ startTime: slot.startTimeId == null
504
+ ? null
505
+ : {
506
+ id: slot.startTimeId,
507
+ label: slot.startTimeLabel,
508
+ startTimeLocal: slot.startTimeLocal ?? "00:00",
509
+ durationMinutes: slot.durationMinutes,
510
+ },
511
+ meetingPoint: meetingPointByProduct?.get(slot.productId) ?? null,
512
+ capacity: slot.unlimited ? null : (slot.initialPax ?? slot.remainingPax ?? null),
513
+ remaining: slot.remainingPax ?? slot.remainingResources ?? null,
514
+ departureStatus: buildDepartureStatus(slot, context),
515
+ nights: slot.nights,
516
+ days: slot.days,
517
+ ratePlans: buildRatePlans(context),
518
+ };
519
+ }
520
+ export async function getStorefrontDeparture(db, departureId) {
521
+ const [slot] = await listSlots(db, { slotId: departureId, limit: 1 });
522
+ if (!slot) {
523
+ return null;
524
+ }
525
+ const meetingPointByProduct = await listMeetingPointsByProductIds(db, [slot.productId]);
526
+ return buildDeparture(db, slot, meetingPointByProduct);
527
+ }
528
+ export async function listStorefrontProductDepartures(db, productId, query) {
529
+ const filters = {
530
+ productId,
531
+ optionId: query.optionId,
532
+ status: query.status,
533
+ dateFrom: query.dateFrom,
534
+ dateTo: query.dateTo,
535
+ };
536
+ const [slots, total] = await Promise.all([
537
+ listSlots(db, {
538
+ ...filters,
539
+ limit: query.limit,
540
+ offset: query.offset,
541
+ }),
542
+ countSlots(db, filters),
543
+ ]);
544
+ const meetingPointByProduct = await listMeetingPointsByProductIds(db, [productId]);
545
+ const data = await Promise.all(slots.map((slot) => buildDeparture(db, slot, meetingPointByProduct)));
546
+ return {
547
+ data,
548
+ total,
549
+ limit: query.limit,
550
+ offset: query.offset,
551
+ };
552
+ }
553
+ export async function previewStorefrontDeparturePrice(db, departureId, input) {
554
+ const [slot] = await listSlots(db, { slotId: departureId, limit: 1 });
555
+ if (!slot) {
556
+ return null;
557
+ }
558
+ const context = await resolvePricingContext(db, slot.productId, slot.optionId);
559
+ const adults = Math.max(0, input.pax?.adults ?? 1);
560
+ const children = Math.max(0, input.pax?.children ?? 0);
561
+ const infants = Math.max(0, input.pax?.infants ?? 0);
562
+ const rooms = input.rooms.map((room) => ({
563
+ unitId: room.unitId,
564
+ occupancy: room.occupancy,
565
+ quantity: room.quantity,
566
+ }));
567
+ const extras = input.extras.map((extra) => ({
568
+ extraId: extra.extraId,
569
+ quantity: extra.quantity,
570
+ }));
571
+ const requestedUnits = rooms.length > 0
572
+ ? rooms.map((room) => ({
573
+ unitId: room.unitId,
574
+ requestRef: room.unitId,
575
+ quantity: Math.max(1, room.occupancy * room.quantity),
576
+ }))
577
+ : buildTravelerRequestedUnits({
578
+ units: context.units,
579
+ adults,
580
+ children,
581
+ infants,
582
+ });
583
+ const resolved = await sellabilityService.resolve(db, {
584
+ productId: slot.productId,
585
+ optionId: slot.optionId ?? undefined,
586
+ slotId: departureId,
587
+ currencyCode: input.currencyCode ?? undefined,
588
+ requestedUnits,
589
+ limit: 25,
590
+ });
591
+ const candidate = resolved.data.find((row) => row.slot.id === departureId && (!slot.optionId || row.option.id === slot.optionId)) ?? resolved.data[0];
592
+ const seeded = candidate
593
+ ? {
594
+ currencyCode: candidate.pricing.currencyCode,
595
+ total: Number((candidate.pricing.sellAmountCents / 100).toFixed(2)),
596
+ lineItems: candidate.pricing.components.map((component) => ({
597
+ name: component.title,
598
+ total: Number((component.sellAmountCents / 100).toFixed(2)),
599
+ quantity: Math.max(1, component.quantity),
600
+ unitPrice: Number((component.sellAmountCents / 100 / Math.max(1, component.quantity)).toFixed(2)),
601
+ })),
602
+ notes: candidate.sellability.onRequest ? "on_request" : null,
603
+ }
604
+ : {
605
+ ...computeFallbackLineItems({
606
+ context,
607
+ adults,
608
+ children,
609
+ infants,
610
+ rooms,
611
+ }),
612
+ notes: null,
613
+ };
614
+ const withExtras = await applyExtraLineItems({
615
+ db,
616
+ productId: slot.productId,
617
+ context,
618
+ paxTotal: Math.max(1, adults + children + infants),
619
+ extras,
620
+ lineItems: seeded.lineItems,
621
+ total: seeded.total,
622
+ });
623
+ return {
624
+ departureId: slot.id,
625
+ productId: slot.productId,
626
+ optionId: slot.optionId,
627
+ currencyCode: seeded.currencyCode,
628
+ basePrice: seeded.lineItems[0]?.total ?? 0,
629
+ taxAmount: 0,
630
+ total: withExtras.total,
631
+ notes: seeded.notes,
632
+ lineItems: withExtras.lineItems,
633
+ };
634
+ }
635
+ export async function getStorefrontProductExtensions(db, productId, optionId) {
636
+ const context = await resolvePricingContext(db, productId, optionId);
637
+ const extras = await db
638
+ .select({
639
+ id: productExtras.id,
640
+ name: productExtras.name,
641
+ description: productExtras.description,
642
+ selectionType: productExtras.selectionType,
643
+ pricingMode: productExtras.pricingMode,
644
+ pricedPerPerson: productExtras.pricedPerPerson,
645
+ defaultQuantity: productExtras.defaultQuantity,
646
+ minQuantity: productExtras.minQuantity,
647
+ maxQuantity: productExtras.maxQuantity,
648
+ metadata: productExtras.metadata,
649
+ })
650
+ .from(productExtras)
651
+ .where(and(eq(productExtras.productId, productId), eq(productExtras.active, true)))
652
+ .orderBy(asc(productExtras.sortOrder), asc(productExtras.name));
653
+ const priceRuleByExtraId = new Map(context.extraRules
654
+ .filter((rule) => rule.productExtraId)
655
+ .map((rule) => [rule.productExtraId, rule]));
656
+ const extensions = extras.map((extra) => {
657
+ const metadata = (extra.metadata ?? {});
658
+ const rule = priceRuleByExtraId.get(extra.id);
659
+ const pricingMode = rule?.pricingMode ?? (extra.pricedPerPerson ? "per_person" : extra.pricingMode);
660
+ const amount = centsToAmount(rule?.sellAmountCents);
661
+ return {
662
+ id: extra.id,
663
+ name: extra.name,
664
+ label: extra.name,
665
+ required: extra.selectionType === "required",
666
+ selectable: extra.selectionType !== "unavailable",
667
+ hasOptions: false,
668
+ refProductId: typeof metadata.refProductId === "string"
669
+ ? metadata.refProductId
670
+ : typeof metadata.productId === "string"
671
+ ? metadata.productId
672
+ : null,
673
+ thumb: typeof metadata.thumbUrl === "string" ? metadata.thumbUrl : null,
674
+ pricePerPerson: pricingMode === "per_person" || extra.pricedPerPerson ? (amount ?? null) : null,
675
+ currencyCode: getPreferredCurrency(context),
676
+ pricingMode,
677
+ defaultQuantity: extra.defaultQuantity ?? null,
678
+ minQuantity: extra.minQuantity ?? null,
679
+ maxQuantity: extra.maxQuantity ?? null,
680
+ };
681
+ });
682
+ const details = Object.fromEntries(extras.map((extra) => {
683
+ const metadata = (extra.metadata ?? {});
684
+ const media = Array.isArray(metadata.media)
685
+ ? metadata.media
686
+ .map((entry) => entry && typeof entry === "object"
687
+ ? {
688
+ url: typeof entry.url === "string"
689
+ ? String(entry.url)
690
+ : "",
691
+ alt: typeof entry.alt === "string"
692
+ ? String(entry.alt)
693
+ : null,
694
+ }
695
+ : null)
696
+ .filter((value) => Boolean(value?.url))
697
+ : [];
698
+ return [
699
+ extra.id,
700
+ {
701
+ description: extra.description ?? null,
702
+ media,
703
+ },
704
+ ];
705
+ }));
706
+ return {
707
+ extensions,
708
+ items: extensions,
709
+ details,
710
+ currencyCode: getPreferredCurrency(context),
711
+ };
712
+ }
713
+ export async function getStorefrontDepartureItinerary(db, input) {
714
+ const days = await db
715
+ .select({
716
+ id: productDays.id,
717
+ dayNumber: productDays.dayNumber,
718
+ title: productDays.title,
719
+ description: productDays.description,
720
+ })
721
+ .from(productDays)
722
+ .where(eq(productDays.productId, input.productId))
723
+ .orderBy(asc(productDays.dayNumber));
724
+ if (days.length === 0) {
725
+ return null;
726
+ }
727
+ const dayIds = days.map((day) => day.id);
728
+ const [services, dayMedia] = await Promise.all([
729
+ db
730
+ .select({
731
+ id: productDayServices.id,
732
+ dayId: productDayServices.dayId,
733
+ name: productDayServices.name,
734
+ description: productDayServices.description,
735
+ sortOrder: productDayServices.sortOrder,
736
+ })
737
+ .from(productDayServices)
738
+ .where(inArray(productDayServices.dayId, dayIds))
739
+ .orderBy(asc(productDayServices.sortOrder), asc(productDayServices.createdAt)),
740
+ db
741
+ .select({
742
+ id: productMedia.id,
743
+ dayId: productMedia.dayId,
744
+ url: productMedia.url,
745
+ isCover: productMedia.isCover,
746
+ sortOrder: productMedia.sortOrder,
747
+ })
748
+ .from(productMedia)
749
+ .where(and(eq(productMedia.productId, input.productId), inArray(productMedia.dayId, dayIds)))
750
+ .orderBy(desc(productMedia.isCover), asc(productMedia.sortOrder), asc(productMedia.createdAt)),
751
+ ]);
752
+ const servicesByDay = new Map();
753
+ for (const service of services) {
754
+ const existing = servicesByDay.get(service.dayId) ?? [];
755
+ existing.push(service);
756
+ servicesByDay.set(service.dayId, existing);
757
+ }
758
+ const mediaByDay = new Map();
759
+ for (const media of dayMedia) {
760
+ if (!media.dayId || mediaByDay.has(media.dayId)) {
761
+ continue;
762
+ }
763
+ mediaByDay.set(media.dayId, media);
764
+ }
765
+ return {
766
+ id: input.departureId,
767
+ days: days.map((day) => ({
768
+ id: day.id,
769
+ title: day.title ?? `Day ${day.dayNumber}`,
770
+ description: day.description ?? null,
771
+ thumbnail: mediaByDay.get(day.id) ? { url: mediaByDay.get(day.id)?.url ?? "" } : null,
772
+ segments: (servicesByDay.get(day.id) ?? []).map((service) => ({
773
+ id: service.id,
774
+ title: service.name,
775
+ description: service.description ?? null,
776
+ })),
777
+ })),
778
+ };
779
+ }