@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.
@@ -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 selectTierAmount(unitRule, tiers, quantity) {
47
+ function selectUnitTier(unitRule, tiers, quantity) {
36
48
  if (!unitRule) {
37
49
  return null;
38
50
  }
39
- const tier = tiers
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
- async function applyExtraLineItems(args) {
459
- if (args.extras.length === 0) {
460
- return { lineItems: args.lineItems, total: args.total };
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), inArray(productExtras.id, args.extras.map((extra) => extra.extraId))));
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
- 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);
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 = centsToAmount(rule?.sellAmountCents) ?? 0;
484
- if (pricingMode === "included" ||
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
- 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({
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
- total: totalAmount,
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.unitId,
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: 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
- })),
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: Math.max(1, adults + children + infants),
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: seeded.lineItems[0]?.total ?? 0,
1100
+ basePrice,
783
1101
  taxAmount: 0,
784
- total: withExtras.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"}