@voyantjs/storefront 0.47.0 → 0.50.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.
- package/README.md +75 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/routes-admin.d.ts +192 -0
- package/dist/routes-admin.d.ts.map +1 -0
- package/dist/routes-admin.js +28 -0
- package/dist/routes-public.d.ts +598 -10
- package/dist/routes-public.d.ts.map +1 -1
- package/dist/routes-public.js +104 -3
- package/dist/service-booking-session-bootstrap.d.ts +227 -0
- package/dist/service-booking-session-bootstrap.d.ts.map +1 -0
- package/dist/service-booking-session-bootstrap.js +297 -0
- package/dist/service-departures.d.ts +301 -2
- package/dist/service-departures.d.ts.map +1 -1
- package/dist/service-departures.js +406 -42
- package/dist/service-intake.d.ts +40 -0
- package/dist/service-intake.d.ts.map +1 -0
- package/dist/service-intake.js +231 -0
- package/dist/service.d.ts +634 -6
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +127 -2
- package/dist/validation-settings.d.ts +489 -0
- package/dist/validation-settings.d.ts.map +1 -0
- package/dist/validation-settings.js +205 -0
- package/dist/validation.d.ts +1458 -433
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +238 -124
- package/package.json +17 -9
|
@@ -29,18 +29,36 @@ function centsToAmount(cents) {
|
|
|
29
29
|
}
|
|
30
30
|
return Number((cents / 100).toFixed(2));
|
|
31
31
|
}
|
|
32
|
+
function amountToCents(amount) {
|
|
33
|
+
return Math.round(amount * 100);
|
|
34
|
+
}
|
|
35
|
+
function convertCents(cents, rate) {
|
|
36
|
+
if (cents == null) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return rate == null ? cents : Math.round(cents * rate);
|
|
40
|
+
}
|
|
41
|
+
function convertedAmount(cents, rate) {
|
|
42
|
+
return centsToAmount(convertCents(cents, rate));
|
|
43
|
+
}
|
|
32
44
|
function getPreferredCurrency(context) {
|
|
33
45
|
return context.catalog?.currencyCode ?? context.product?.sellCurrency ?? "EUR";
|
|
34
46
|
}
|
|
35
|
-
function
|
|
47
|
+
function selectUnitTier(unitRule, tiers, quantity) {
|
|
36
48
|
if (!unitRule) {
|
|
37
49
|
return null;
|
|
38
50
|
}
|
|
39
|
-
|
|
51
|
+
return (tiers
|
|
40
52
|
.filter((row) => row.optionUnitPriceRuleId === unitRule.id &&
|
|
41
53
|
quantity >= row.minQuantity &&
|
|
42
54
|
(row.maxQuantity == null || quantity <= row.maxQuantity))
|
|
43
|
-
.sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
55
|
+
.sort((a, b) => a.sortOrder - b.sortOrder)[0] ?? null);
|
|
56
|
+
}
|
|
57
|
+
function selectTierAmount(unitRule, tiers, quantity) {
|
|
58
|
+
if (!unitRule) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const tier = selectUnitTier(unitRule, tiers, quantity);
|
|
44
62
|
return tier?.sellAmountCents ?? unitRule.sellAmountCents ?? null;
|
|
45
63
|
}
|
|
46
64
|
function findNamedUnit(units, matcher) {
|
|
@@ -455,53 +473,318 @@ function computeFallbackLineItems(args) {
|
|
|
455
473
|
lineItems,
|
|
456
474
|
};
|
|
457
475
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
476
|
+
function buildResolvedLineItems(components, conversionRate) {
|
|
477
|
+
return components.map((component) => {
|
|
478
|
+
const total = convertedAmount(component.sellAmountCents, conversionRate) ?? 0;
|
|
479
|
+
const quantity = Math.max(1, component.quantity);
|
|
480
|
+
return {
|
|
481
|
+
name: component.title,
|
|
482
|
+
total,
|
|
483
|
+
quantity,
|
|
484
|
+
unitPrice: Number((total / quantity).toFixed(2)),
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
function buildRequestedUnitRows(args) {
|
|
489
|
+
return args.requestedUnits.map((request) => {
|
|
490
|
+
const unit = request.unitId ? args.context.units.find((row) => row.id === request.unitId) : null;
|
|
491
|
+
const unitRule = request.unitId
|
|
492
|
+
? args.context.unitRules.find((row) => row.unitId === request.unitId)
|
|
493
|
+
: args.context.unitRules[0];
|
|
494
|
+
const tier = selectUnitTier(unitRule, args.context.tiers, request.quantity);
|
|
495
|
+
const component = args.components.find((row) => row.kind === "unit" && request.requestRef && row.requestRef === request.requestRef) ??
|
|
496
|
+
args.components.find((row) => row.kind === "unit" && request.unitId && row.unitId === request.unitId);
|
|
497
|
+
const quantity = Math.max(1, request.quantity);
|
|
498
|
+
const total = component != null
|
|
499
|
+
? (convertedAmount(component.sellAmountCents, args.conversionRate) ?? 0)
|
|
500
|
+
: Number(((convertedAmount(tier?.sellAmountCents ?? unitRule?.sellAmountCents, args.conversionRate) ?? 0) * quantity).toFixed(2));
|
|
501
|
+
const unitAmount = Number((total / quantity).toFixed(2));
|
|
502
|
+
return {
|
|
503
|
+
unitId: request.unitId ?? null,
|
|
504
|
+
requestRef: request.requestRef ?? request.unitId ?? null,
|
|
505
|
+
name: unit?.name ?? args.context.option?.name ?? "Traveler",
|
|
506
|
+
unitType: unit?.unitType ?? null,
|
|
507
|
+
quantity,
|
|
508
|
+
pricingMode: component?.pricingMode ?? unitRule?.pricingMode ?? null,
|
|
509
|
+
unitPrice: unitAmount,
|
|
510
|
+
total,
|
|
511
|
+
currencyCode: args.currencyCode,
|
|
512
|
+
tierId: component?.tierId ?? tier?.id ?? null,
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function buildRoomRows(args) {
|
|
517
|
+
return args.rooms.map((room) => {
|
|
518
|
+
const unit = args.context.units.find((row) => row.id === room.unitId);
|
|
519
|
+
const unitRule = args.context.unitRules.find((row) => row.unitId === room.unitId);
|
|
520
|
+
const pax = Math.max(1, room.occupancy * room.quantity);
|
|
521
|
+
const quantity = unitRule?.pricingMode === "per_person" ? pax : Math.max(1, room.quantity);
|
|
522
|
+
const tier = selectUnitTier(unitRule, args.context.tiers, pax);
|
|
523
|
+
const component = args.components.find((row) => row.kind === "unit" && row.requestRef === room.requestRef) ??
|
|
524
|
+
args.components.find((row) => row.kind === "unit" && row.unitId === room.unitId);
|
|
525
|
+
const total = component != null
|
|
526
|
+
? (convertedAmount(component.sellAmountCents, args.conversionRate) ?? 0)
|
|
527
|
+
: Number(((convertedAmount(tier?.sellAmountCents ?? unitRule?.sellAmountCents, args.conversionRate) ?? 0) * quantity).toFixed(2));
|
|
528
|
+
const unitAmount = Number((total / quantity).toFixed(2));
|
|
529
|
+
return {
|
|
530
|
+
unitId: room.unitId,
|
|
531
|
+
name: unit?.name ?? room.unitId,
|
|
532
|
+
occupancy: room.occupancy,
|
|
533
|
+
quantity: room.quantity,
|
|
534
|
+
pax,
|
|
535
|
+
pricingMode: component?.pricingMode ?? unitRule?.pricingMode ?? null,
|
|
536
|
+
unitPrice: unitAmount,
|
|
537
|
+
total,
|
|
538
|
+
currencyCode: args.currencyCode,
|
|
539
|
+
tierId: component?.tierId ?? tier?.id ?? null,
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
async function buildExtraImpacts(args) {
|
|
544
|
+
const selectedQuantityByExtraId = new Map(args.extras.map((extra) => [extra.extraId, extra.quantity]));
|
|
462
545
|
const extras = await args.db
|
|
463
546
|
.select({
|
|
464
547
|
id: productExtras.id,
|
|
465
548
|
name: productExtras.name,
|
|
549
|
+
selectionType: productExtras.selectionType,
|
|
466
550
|
pricingMode: productExtras.pricingMode,
|
|
467
551
|
pricedPerPerson: productExtras.pricedPerPerson,
|
|
552
|
+
defaultQuantity: productExtras.defaultQuantity,
|
|
553
|
+
minQuantity: productExtras.minQuantity,
|
|
468
554
|
})
|
|
469
555
|
.from(productExtras)
|
|
470
|
-
.where(and(eq(productExtras.productId, args.productId), eq(productExtras.active, true)
|
|
556
|
+
.where(and(eq(productExtras.productId, args.productId), eq(productExtras.active, true)))
|
|
557
|
+
.orderBy(asc(productExtras.sortOrder), asc(productExtras.name));
|
|
471
558
|
const ruleByExtraId = new Map(args.context.extraRules
|
|
472
559
|
.filter((rule) => rule.productExtraId)
|
|
473
560
|
.map((rule) => [rule.productExtraId, rule]));
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
const rule = ruleByExtraId.get(extraSelection.extraId);
|
|
561
|
+
return extras.map((extra) => {
|
|
562
|
+
const rule = ruleByExtraId.get(extra.id);
|
|
563
|
+
const selectedQuantity = selectedQuantityByExtraId.get(extra.id);
|
|
564
|
+
const required = extra.selectionType === "required";
|
|
565
|
+
const selected = selectedQuantity != null || required;
|
|
482
566
|
const pricingMode = rule?.pricingMode ?? (extra.pricedPerPerson ? "per_person" : extra.pricingMode);
|
|
483
|
-
const unitAmount =
|
|
484
|
-
|
|
567
|
+
const unitAmount = convertedAmount(rule?.sellAmountCents, args.conversionRate) ?? 0;
|
|
568
|
+
const chargeable = pricingMode === "included" ||
|
|
485
569
|
pricingMode === "free" ||
|
|
486
570
|
pricingMode === "unavailable" ||
|
|
487
|
-
pricingMode === "on_request"
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
?
|
|
492
|
-
:
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
571
|
+
pricingMode === "on_request"
|
|
572
|
+
? false
|
|
573
|
+
: selected;
|
|
574
|
+
const baseQuantity = selected
|
|
575
|
+
? (selectedQuantity ?? extra.defaultQuantity ?? extra.minQuantity ?? 1)
|
|
576
|
+
: 0;
|
|
577
|
+
const quantity = chargeable && pricingMode === "per_person"
|
|
578
|
+
? Math.max(1, args.paxTotal * Math.max(1, baseQuantity))
|
|
579
|
+
: Math.max(0, baseQuantity);
|
|
580
|
+
const total = chargeable ? Number((unitAmount * quantity).toFixed(2)) : 0;
|
|
581
|
+
return {
|
|
582
|
+
extraId: extra.id,
|
|
496
583
|
name: extra.name,
|
|
497
|
-
|
|
584
|
+
required,
|
|
585
|
+
selectable: extra.selectionType !== "unavailable",
|
|
586
|
+
selected,
|
|
587
|
+
pricingMode,
|
|
498
588
|
quantity,
|
|
499
589
|
unitPrice: unitAmount,
|
|
500
|
-
|
|
501
|
-
|
|
590
|
+
total,
|
|
591
|
+
currencyCode: args.currencyCode,
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
async function applyExtraLineItems(args) {
|
|
596
|
+
const impacts = await buildExtraImpacts(args);
|
|
597
|
+
const selectedImpacts = impacts.filter((extra) => extra.selected && extra.total > 0);
|
|
598
|
+
const lineItems = [
|
|
599
|
+
...args.lineItems,
|
|
600
|
+
...selectedImpacts.map((extra) => ({
|
|
601
|
+
name: extra.name,
|
|
602
|
+
total: extra.total,
|
|
603
|
+
quantity: Math.max(1, extra.quantity),
|
|
604
|
+
unitPrice: extra.unitPrice,
|
|
605
|
+
})),
|
|
606
|
+
];
|
|
607
|
+
const total = selectedImpacts.reduce((sum, extra) => sum + extra.total, args.total);
|
|
502
608
|
return {
|
|
503
609
|
lineItems,
|
|
504
610
|
total: Number(total.toFixed(2)),
|
|
611
|
+
impacts,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function computeOfferDiscountCents(offer, basePriceCents) {
|
|
615
|
+
if (basePriceCents <= 0)
|
|
616
|
+
return 0;
|
|
617
|
+
if (offer.discountType === "percentage") {
|
|
618
|
+
const percent = Number(offer.discountValue);
|
|
619
|
+
return Number.isFinite(percent) ? Math.round((basePriceCents * percent) / 100) : 0;
|
|
620
|
+
}
|
|
621
|
+
const discountCents = Number.parseInt(offer.discountValue, 10);
|
|
622
|
+
return Number.isFinite(discountCents) ? Math.min(discountCents, basePriceCents) : 0;
|
|
623
|
+
}
|
|
624
|
+
function buildAppliedOfferFromDto(input) {
|
|
625
|
+
const discountPercent = input.offer.discountType === "percentage" ? Number(input.offer.discountValue) : null;
|
|
626
|
+
const discountAmountCents = input.offer.discountType === "fixed_amount"
|
|
627
|
+
? Number.parseInt(input.offer.discountValue, 10)
|
|
628
|
+
: null;
|
|
629
|
+
return {
|
|
630
|
+
offerId: input.offer.id,
|
|
631
|
+
offerName: input.offer.name,
|
|
632
|
+
discountAppliedCents: input.discountAppliedCents,
|
|
633
|
+
discountedPriceCents: Math.max(0, input.basePriceCents - input.discountAppliedCents),
|
|
634
|
+
currency: input.currencyCode,
|
|
635
|
+
discountKind: input.offer.discountType,
|
|
636
|
+
discountPercent: Number.isFinite(discountPercent) ? discountPercent : null,
|
|
637
|
+
discountAmountCents: Number.isFinite(discountAmountCents) ? discountAmountCents : null,
|
|
638
|
+
appliedCode: null,
|
|
639
|
+
stackable: input.offer.stackable,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function evaluateAvailableOfferImpacts(input) {
|
|
643
|
+
const candidates = input.offers.map((offer) => {
|
|
644
|
+
const standaloneDiscount = computeOfferDiscountCents(offer, input.basePriceCents);
|
|
645
|
+
const reason = offer.minTravelers != null && input.paxTotal < offer.minTravelers
|
|
646
|
+
? "min_pax"
|
|
647
|
+
: offer.discountType === "fixed_amount" && offer.currency !== input.currencyCode
|
|
648
|
+
? "currency"
|
|
649
|
+
: standaloneDiscount <= 0
|
|
650
|
+
? "no_discount"
|
|
651
|
+
: null;
|
|
652
|
+
return { offer, standaloneDiscount, reason };
|
|
653
|
+
});
|
|
654
|
+
const applicable = candidates.filter((candidate) => candidate.reason == null);
|
|
655
|
+
const stackable = applicable
|
|
656
|
+
.filter((candidate) => candidate.offer.stackable)
|
|
657
|
+
.sort((a, b) => (a.offer.id < b.offer.id ? -1 : a.offer.id > b.offer.id ? 1 : 0));
|
|
658
|
+
const nonStackable = applicable.filter((candidate) => !candidate.offer.stackable);
|
|
659
|
+
let bestNonStackable = null;
|
|
660
|
+
for (const candidate of nonStackable) {
|
|
661
|
+
if (bestNonStackable == null ||
|
|
662
|
+
candidate.standaloneDiscount > bestNonStackable.standaloneDiscount) {
|
|
663
|
+
bestNonStackable = candidate;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
let runningBase = input.basePriceCents;
|
|
667
|
+
const selectedStackable = stackable
|
|
668
|
+
.map((candidate) => {
|
|
669
|
+
const discount = computeOfferDiscountCents(candidate.offer, runningBase);
|
|
670
|
+
runningBase = Math.max(0, runningBase - discount);
|
|
671
|
+
return { ...candidate, selectedDiscount: discount };
|
|
672
|
+
})
|
|
673
|
+
.filter((candidate) => candidate.selectedDiscount > 0);
|
|
674
|
+
const stackableDiscount = input.basePriceCents - runningBase;
|
|
675
|
+
const selected = bestNonStackable && bestNonStackable.standaloneDiscount >= stackableDiscount
|
|
676
|
+
? [{ ...bestNonStackable, selectedDiscount: bestNonStackable.standaloneDiscount }]
|
|
677
|
+
: selectedStackable;
|
|
678
|
+
const selectedByOfferId = new Map(selected.map((candidate) => [candidate.offer.id, candidate]));
|
|
679
|
+
const applied = selected.map((candidate) => buildAppliedOfferFromDto({
|
|
680
|
+
offer: candidate.offer,
|
|
681
|
+
discountAppliedCents: candidate.selectedDiscount,
|
|
682
|
+
basePriceCents: input.basePriceCents,
|
|
683
|
+
currencyCode: input.currencyCode,
|
|
684
|
+
}));
|
|
685
|
+
const discountTotalCents = applied.reduce((sum, offer) => sum + offer.discountAppliedCents, 0);
|
|
686
|
+
const eligibleButNotSelected = applicable.filter((candidate) => !selectedByOfferId.has(candidate.offer.id));
|
|
687
|
+
const conflict = eligibleButNotSelected.length > 0
|
|
688
|
+
? {
|
|
689
|
+
policy: selected.every((candidate) => candidate.offer.stackable)
|
|
690
|
+
? "stackable_compose"
|
|
691
|
+
: "best_discount_wins",
|
|
692
|
+
autoAppliedOfferIds: selected
|
|
693
|
+
.filter((candidate) => candidate.offer.slug == null)
|
|
694
|
+
.map((candidate) => candidate.offer.id),
|
|
695
|
+
manualOfferId: null,
|
|
696
|
+
selectedOfferIds: selected.map((candidate) => candidate.offer.id),
|
|
697
|
+
message: selected.every((candidate) => candidate.offer.stackable)
|
|
698
|
+
? "Stackable offers compose when they beat the best standalone discount."
|
|
699
|
+
: "The best discount wins when non-stackable offers compete.",
|
|
700
|
+
}
|
|
701
|
+
: null;
|
|
702
|
+
return {
|
|
703
|
+
available: candidates.map((candidate) => {
|
|
704
|
+
const selectedCandidate = selectedByOfferId.get(candidate.offer.id);
|
|
705
|
+
const selectedDiscount = selectedCandidate?.selectedDiscount ?? 0;
|
|
706
|
+
const conflictReason = candidate.reason == null && !selectedCandidate && selected.length > 0 ? "conflict" : null;
|
|
707
|
+
const discountAppliedCents = selectedCandidate?.selectedDiscount ?? candidate.standaloneDiscount;
|
|
708
|
+
return {
|
|
709
|
+
offer: candidate.offer,
|
|
710
|
+
status: selectedCandidate ? "applied" : conflictReason ? "conflict" : "not_applicable",
|
|
711
|
+
reason: candidate.reason ?? conflictReason,
|
|
712
|
+
selected: Boolean(selectedCandidate),
|
|
713
|
+
discountAppliedCents,
|
|
714
|
+
discountedPriceCents: Math.max(0, input.basePriceCents -
|
|
715
|
+
(selectedCandidate ? selectedDiscount : candidate.standaloneDiscount)),
|
|
716
|
+
};
|
|
717
|
+
}),
|
|
718
|
+
applied,
|
|
719
|
+
conflict,
|
|
720
|
+
discountTotalCents,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
async function buildOfferPreview(input) {
|
|
724
|
+
const availableOffers = (await input.resolvers?.listApplicableOffers?.({
|
|
725
|
+
productId: input.productId,
|
|
726
|
+
departureId: input.departureId,
|
|
727
|
+
locale: input.locale,
|
|
728
|
+
})) ?? [];
|
|
729
|
+
const autoPreview = evaluateAvailableOfferImpacts({
|
|
730
|
+
offers: availableOffers,
|
|
731
|
+
basePriceCents: input.basePriceCents,
|
|
732
|
+
currencyCode: input.currencyCode,
|
|
733
|
+
paxTotal: input.paxTotal,
|
|
734
|
+
});
|
|
735
|
+
const target = {
|
|
736
|
+
productId: input.productId,
|
|
737
|
+
departureId: input.departureId,
|
|
738
|
+
pax: input.paxTotal,
|
|
739
|
+
audience: "customer",
|
|
740
|
+
market: input.market,
|
|
741
|
+
basePriceCents: input.basePriceCents,
|
|
742
|
+
currency: input.currencyCode,
|
|
743
|
+
...(input.locale ? { locale: input.locale } : {}),
|
|
744
|
+
};
|
|
745
|
+
const requested = [];
|
|
746
|
+
for (const offer of input.requestedOffers) {
|
|
747
|
+
requested.push({
|
|
748
|
+
kind: "slug",
|
|
749
|
+
value: offer.slug,
|
|
750
|
+
result: (await input.resolvers?.applyOffer?.({
|
|
751
|
+
slug: offer.slug,
|
|
752
|
+
body: target,
|
|
753
|
+
})) ?? null,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (input.offerCode) {
|
|
757
|
+
requested.push({
|
|
758
|
+
kind: "code",
|
|
759
|
+
value: input.offerCode,
|
|
760
|
+
result: (await input.resolvers?.redeemOffer?.({
|
|
761
|
+
body: { ...target, code: input.offerCode },
|
|
762
|
+
})) ?? null,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
const bestRequested = requested
|
|
766
|
+
.map((entry) => entry.result)
|
|
767
|
+
.filter((result) => Boolean(result))
|
|
768
|
+
.filter((result) => result.status === "applied" || result.status === "conflict")
|
|
769
|
+
.sort((a, b) => b.pricing.discountAppliedCents - a.pricing.discountAppliedCents)[0];
|
|
770
|
+
const applied = bestRequested && bestRequested.pricing.discountAppliedCents > autoPreview.discountTotalCents
|
|
771
|
+
? bestRequested.appliedOffers
|
|
772
|
+
: autoPreview.applied;
|
|
773
|
+
const conflict = bestRequested && bestRequested.pricing.discountAppliedCents > autoPreview.discountTotalCents
|
|
774
|
+
? bestRequested.conflict
|
|
775
|
+
: autoPreview.conflict;
|
|
776
|
+
const discountTotalCents = bestRequested && bestRequested.pricing.discountAppliedCents > autoPreview.discountTotalCents
|
|
777
|
+
? bestRequested.pricing.discountAppliedCents
|
|
778
|
+
: autoPreview.discountTotalCents;
|
|
779
|
+
return {
|
|
780
|
+
available: autoPreview.available,
|
|
781
|
+
requested,
|
|
782
|
+
applied,
|
|
783
|
+
conflict,
|
|
784
|
+
discountTotal: centsToAmount(discountTotalCents) ?? 0,
|
|
785
|
+
discountTotalCents,
|
|
786
|
+
totalAfterDiscount: centsToAmount(Math.max(0, input.basePriceCents - discountTotalCents)) ?? 0,
|
|
787
|
+
currencyCode: input.currencyCode,
|
|
505
788
|
};
|
|
506
789
|
}
|
|
507
790
|
async function buildDeparture(db, slot, defaultItineraryByProduct, meetingPointByProduct) {
|
|
@@ -704,7 +987,7 @@ export async function getStorefrontProductAvailabilitySummary(db, productId, que
|
|
|
704
987
|
offset: query.offset,
|
|
705
988
|
};
|
|
706
989
|
}
|
|
707
|
-
export async function previewStorefrontDeparturePrice(db, departureId, input) {
|
|
990
|
+
export async function previewStorefrontDeparturePrice(db, departureId, input, offerResolvers) {
|
|
708
991
|
const [slot] = await listSlots(db, { slotId: departureId, limit: 1 });
|
|
709
992
|
if (!slot) {
|
|
710
993
|
return null;
|
|
@@ -713,8 +996,9 @@ export async function previewStorefrontDeparturePrice(db, departureId, input) {
|
|
|
713
996
|
const adults = Math.max(0, input.pax?.adults ?? 1);
|
|
714
997
|
const children = Math.max(0, input.pax?.children ?? 0);
|
|
715
998
|
const infants = Math.max(0, input.pax?.infants ?? 0);
|
|
716
|
-
const rooms = input.rooms.map((room) => ({
|
|
999
|
+
const rooms = input.rooms.map((room, index) => ({
|
|
717
1000
|
unitId: room.unitId,
|
|
1001
|
+
requestRef: `${room.unitId}:${index}`,
|
|
718
1002
|
occupancy: room.occupancy,
|
|
719
1003
|
quantity: room.quantity,
|
|
720
1004
|
}));
|
|
@@ -725,7 +1009,7 @@ export async function previewStorefrontDeparturePrice(db, departureId, input) {
|
|
|
725
1009
|
const requestedUnits = rooms.length > 0
|
|
726
1010
|
? rooms.map((room) => ({
|
|
727
1011
|
unitId: room.unitId,
|
|
728
|
-
requestRef: room.
|
|
1012
|
+
requestRef: room.requestRef,
|
|
729
1013
|
quantity: Math.max(1, room.occupancy * room.quantity),
|
|
730
1014
|
}))
|
|
731
1015
|
: buildTravelerRequestedUnits({
|
|
@@ -743,16 +1027,13 @@ export async function previewStorefrontDeparturePrice(db, departureId, input) {
|
|
|
743
1027
|
limit: 25,
|
|
744
1028
|
});
|
|
745
1029
|
const candidate = resolved.data.find((row) => row.slot.id === departureId && (!slot.optionId || row.option.id === slot.optionId)) ?? resolved.data[0];
|
|
1030
|
+
const conversionRate = candidate?.pricing.fx?.rateDecimal != null ? Number(candidate.pricing.fx.rateDecimal) : null;
|
|
1031
|
+
const components = candidate ? candidate.pricing.components : [];
|
|
746
1032
|
const seeded = candidate
|
|
747
1033
|
? {
|
|
748
1034
|
currencyCode: candidate.pricing.currencyCode,
|
|
749
1035
|
total: Number((candidate.pricing.sellAmountCents / 100).toFixed(2)),
|
|
750
|
-
lineItems:
|
|
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
|
-
})),
|
|
1036
|
+
lineItems: buildResolvedLineItems(components, conversionRate),
|
|
756
1037
|
notes: candidate.sellability.onRequest ? "on_request" : null,
|
|
757
1038
|
}
|
|
758
1039
|
: {
|
|
@@ -765,25 +1046,108 @@ export async function previewStorefrontDeparturePrice(db, departureId, input) {
|
|
|
765
1046
|
}),
|
|
766
1047
|
notes: null,
|
|
767
1048
|
};
|
|
1049
|
+
const roomPaxTotal = rooms.reduce((sum, room) => sum + Math.max(1, room.occupancy * room.quantity), 0);
|
|
1050
|
+
const travelerPaxTotal = Math.max(1, adults + children + infants);
|
|
1051
|
+
const paxTotal = rooms.length > 0 ? Math.max(1, roomPaxTotal) : travelerPaxTotal;
|
|
768
1052
|
const withExtras = await applyExtraLineItems({
|
|
769
1053
|
db,
|
|
770
1054
|
productId: slot.productId,
|
|
771
1055
|
context,
|
|
772
|
-
paxTotal
|
|
1056
|
+
paxTotal,
|
|
773
1057
|
extras,
|
|
1058
|
+
currencyCode: seeded.currencyCode,
|
|
1059
|
+
conversionRate,
|
|
774
1060
|
lineItems: seeded.lineItems,
|
|
775
1061
|
total: seeded.total,
|
|
776
1062
|
});
|
|
1063
|
+
const unitRows = rooms.length > 0
|
|
1064
|
+
? []
|
|
1065
|
+
: buildRequestedUnitRows({
|
|
1066
|
+
context,
|
|
1067
|
+
requestedUnits,
|
|
1068
|
+
components,
|
|
1069
|
+
currencyCode: seeded.currencyCode,
|
|
1070
|
+
conversionRate,
|
|
1071
|
+
});
|
|
1072
|
+
const roomRows = buildRoomRows({
|
|
1073
|
+
context,
|
|
1074
|
+
rooms,
|
|
1075
|
+
components,
|
|
1076
|
+
currencyCode: seeded.currencyCode,
|
|
1077
|
+
conversionRate,
|
|
1078
|
+
});
|
|
1079
|
+
const subtotal = withExtras.total;
|
|
1080
|
+
const offers = await buildOfferPreview({
|
|
1081
|
+
resolvers: offerResolvers,
|
|
1082
|
+
productId: slot.productId,
|
|
1083
|
+
departureId: slot.id,
|
|
1084
|
+
basePriceCents: amountToCents(subtotal),
|
|
1085
|
+
currencyCode: seeded.currencyCode,
|
|
1086
|
+
paxTotal,
|
|
1087
|
+
requestedOffers: input.offers,
|
|
1088
|
+
offerCode: input.offerCode,
|
|
1089
|
+
locale: input.locale,
|
|
1090
|
+
market: input.market,
|
|
1091
|
+
});
|
|
1092
|
+
const total = offers.totalAfterDiscount;
|
|
1093
|
+
const extrasTotal = withExtras.impacts.reduce((sum, extra) => sum + extra.total, 0);
|
|
1094
|
+
const basePrice = Number((subtotal - extrasTotal).toFixed(2));
|
|
777
1095
|
return {
|
|
778
1096
|
departureId: slot.id,
|
|
779
1097
|
productId: slot.productId,
|
|
780
1098
|
optionId: slot.optionId,
|
|
781
1099
|
currencyCode: seeded.currencyCode,
|
|
782
|
-
basePrice
|
|
1100
|
+
basePrice,
|
|
783
1101
|
taxAmount: 0,
|
|
784
|
-
total
|
|
1102
|
+
total,
|
|
785
1103
|
notes: seeded.notes,
|
|
786
1104
|
lineItems: withExtras.lineItems,
|
|
1105
|
+
allocation: {
|
|
1106
|
+
slot: {
|
|
1107
|
+
id: slot.id,
|
|
1108
|
+
productId: slot.productId,
|
|
1109
|
+
optionId: slot.optionId,
|
|
1110
|
+
dateLocal: normalizeLocalDate(slot.dateLocal),
|
|
1111
|
+
startAt: normalizeIso(slot.startsAt),
|
|
1112
|
+
endAt: normalizeIso(slot.endsAt),
|
|
1113
|
+
timezone: slot.timezone,
|
|
1114
|
+
status: buildDepartureStatus(slot, context),
|
|
1115
|
+
availabilityState: buildAvailabilityState({
|
|
1116
|
+
status: buildDepartureStatus(slot, context),
|
|
1117
|
+
remaining: slot.remainingPax ?? slot.remainingResources ?? null,
|
|
1118
|
+
capacity: slot.unlimited ? null : (slot.initialPax ?? slot.remainingPax ?? null),
|
|
1119
|
+
pastCutoff: slot.pastCutoff,
|
|
1120
|
+
tooEarly: slot.tooEarly,
|
|
1121
|
+
}),
|
|
1122
|
+
capacity: slot.unlimited ? null : (slot.initialPax ?? slot.remainingPax ?? null),
|
|
1123
|
+
remaining: slot.remainingPax ?? slot.remainingResources ?? null,
|
|
1124
|
+
pastCutoff: slot.pastCutoff,
|
|
1125
|
+
tooEarly: slot.tooEarly,
|
|
1126
|
+
},
|
|
1127
|
+
pax: {
|
|
1128
|
+
adults,
|
|
1129
|
+
children,
|
|
1130
|
+
infants,
|
|
1131
|
+
total: paxTotal,
|
|
1132
|
+
},
|
|
1133
|
+
requestedUnits: unitRows,
|
|
1134
|
+
rooms: roomRows,
|
|
1135
|
+
},
|
|
1136
|
+
units: unitRows,
|
|
1137
|
+
rooms: roomRows,
|
|
1138
|
+
extras: withExtras.impacts,
|
|
1139
|
+
offers,
|
|
1140
|
+
totals: {
|
|
1141
|
+
currencyCode: seeded.currencyCode,
|
|
1142
|
+
base: basePrice,
|
|
1143
|
+
extras: Number(extrasTotal.toFixed(2)),
|
|
1144
|
+
subtotal,
|
|
1145
|
+
discount: offers.discountTotal,
|
|
1146
|
+
tax: 0,
|
|
1147
|
+
total,
|
|
1148
|
+
perPerson: Number((total / paxTotal).toFixed(2)),
|
|
1149
|
+
perBooking: total,
|
|
1150
|
+
},
|
|
787
1151
|
};
|
|
788
1152
|
}
|
|
789
1153
|
export async function getStorefrontProductExtensions(db, productId, optionId) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CUSTOMER_SIGNAL_CREATED_EVENT } from "@voyantjs/crm/events";
|
|
2
|
+
import type { StorefrontRequestContext } from "./service.js";
|
|
3
|
+
import type { StorefrontIntakeResponse, StorefrontLeadIntakeInput, StorefrontNewsletterSubscribeInput, StorefrontNewsletterSubscribeResponse } from "./validation.js";
|
|
4
|
+
export { CUSTOMER_SIGNAL_CREATED_EVENT };
|
|
5
|
+
export interface StorefrontIntakeGuardDecision {
|
|
6
|
+
allowed: boolean;
|
|
7
|
+
status?: 400 | 403 | 429;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
export type StorefrontIntakeGuard = (input: {
|
|
11
|
+
kind: "lead";
|
|
12
|
+
body: StorefrontLeadIntakeInput;
|
|
13
|
+
context: StorefrontRequestContext;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "newsletter";
|
|
16
|
+
body: StorefrontNewsletterSubscribeInput;
|
|
17
|
+
context: StorefrontRequestContext;
|
|
18
|
+
}) => Promise<StorefrontIntakeGuardDecision | undefined> | StorefrontIntakeGuardDecision | undefined;
|
|
19
|
+
export type StorefrontNewsletterDoubleOptInHook = (input: {
|
|
20
|
+
email: string;
|
|
21
|
+
personId: string;
|
|
22
|
+
signalId: string;
|
|
23
|
+
sourceSubmissionId: string;
|
|
24
|
+
body: StorefrontNewsletterSubscribeInput;
|
|
25
|
+
context: StorefrontRequestContext;
|
|
26
|
+
}) => Promise<void> | void;
|
|
27
|
+
export interface StorefrontIntakeOptions {
|
|
28
|
+
guard?: StorefrontIntakeGuard;
|
|
29
|
+
requestNewsletterDoubleOptIn?: StorefrontNewsletterDoubleOptInHook;
|
|
30
|
+
}
|
|
31
|
+
export declare function createStorefrontLeadSignal(input: {
|
|
32
|
+
body: StorefrontLeadIntakeInput;
|
|
33
|
+
context: StorefrontRequestContext;
|
|
34
|
+
}): Promise<StorefrontIntakeResponse>;
|
|
35
|
+
export declare function subscribeStorefrontNewsletter(input: {
|
|
36
|
+
body: StorefrontNewsletterSubscribeInput;
|
|
37
|
+
context: StorefrontRequestContext;
|
|
38
|
+
requestDoubleOptIn?: StorefrontNewsletterDoubleOptInHook;
|
|
39
|
+
}): Promise<StorefrontNewsletterSubscribeResponse>;
|
|
40
|
+
//# sourceMappingURL=service-intake.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-intake.d.ts","sourceRoot":"","sources":["../src/service-intake.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,6BAA6B,EAA6B,MAAM,sBAAsB,CAAA;AAI/F,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAA;AAC5D,OAAO,KAAK,EACV,wBAAwB,EAExB,yBAAyB,EACzB,kCAAkC,EAClC,qCAAqC,EACtC,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,6BAA6B,EAAE,CAAA;AAExC,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,MAAM,qBAAqB,GAAG,CAClC,KAAK,EACD;IACE,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,yBAAyB,CAAA;IAC/B,OAAO,EAAE,wBAAwB,CAAA;CAClC,GACD;IACE,IAAI,EAAE,YAAY,CAAA;IAClB,IAAI,EAAE,kCAAkC,CAAA;IACxC,OAAO,EAAE,wBAAwB,CAAA;CAClC,KACF,OAAO,CAAC,6BAA6B,GAAG,SAAS,CAAC,GAAG,6BAA6B,GAAG,SAAS,CAAA;AAEnG,MAAM,MAAM,mCAAmC,GAAG,CAAC,KAAK,EAAE;IACxD,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,IAAI,EAAE,kCAAkC,CAAA;IACxC,OAAO,EAAE,wBAAwB,CAAA;CAClC,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;AAE1B,MAAM,WAAW,uBAAuB;IACtC,KAAK,CAAC,EAAE,qBAAqB,CAAA;IAC7B,4BAA4B,CAAC,EAAE,mCAAmC,CAAA;CACnE;AA+GD,wBAAsB,0BAA0B,CAAC,KAAK,EAAE;IACtD,IAAI,EAAE,yBAAyB,CAAA;IAC/B,OAAO,EAAE,wBAAwB,CAAA;CAClC,GAAG,OAAO,CAAC,wBAAwB,CAAC,CA8DpC;AAED,wBAAsB,6BAA6B,CAAC,KAAK,EAAE;IACzD,IAAI,EAAE,kCAAkC,CAAA;IACxC,OAAO,EAAE,wBAAwB,CAAA;IACjC,kBAAkB,CAAC,EAAE,mCAAmC,CAAA;CACzD,GAAG,OAAO,CAAC,qCAAqC,CAAC,CA6FjD"}
|