@resira/ui 0.4.15 → 0.4.16

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/dist/index.cjs CHANGED
@@ -783,6 +783,28 @@ function useCheckoutSession(token) {
783
783
  }, [client, token]);
784
784
  return { session, loading, error, errorCode };
785
785
  }
786
+ function normalizeMinutes(value) {
787
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
788
+ if (typeof value === "string" && value.trim()) {
789
+ const parsed = Number(value);
790
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
791
+ }
792
+ return void 0;
793
+ }
794
+ function resolveServicePrice(service, params = {}) {
795
+ const partySize = Math.max(1, Number(params.partySize ?? 1));
796
+ const selectedMinutes = normalizeMinutes(params.durationMinutes);
797
+ const options = Array.isArray(service.options) ? service.options : [];
798
+ const matchedOption = selectedMinutes ? options.find((option) => option.durationMinutes === selectedMinutes) : void 0;
799
+ const fallbackOption = options.find((option) => option.durationMinutes === service.durationMinutes) ?? options[0];
800
+ const unitPriceCents = matchedOption?.priceCents ?? fallbackOption?.priceCents ?? service.priceCents;
801
+ const totalPriceCents = service.pricingModel === "per_person" ? unitPriceCents * partySize : unitPriceCents;
802
+ return {
803
+ unitPriceCents,
804
+ totalPriceCents,
805
+ matchedDurationMinutes: matchedOption?.durationMinutes ?? fallbackOption?.durationMinutes
806
+ };
807
+ }
786
808
  function formatPriceCentsUtil(cents, currency) {
787
809
  return new Intl.NumberFormat("default", { style: "currency", currency }).format(cents / 100);
788
810
  }
@@ -1679,6 +1701,7 @@ function TimeSlotPicker({
1679
1701
  showDuration = false,
1680
1702
  selectedDuration,
1681
1703
  onDurationChange,
1704
+ minPartySizeOverride,
1682
1705
  maxPartySizeOverride,
1683
1706
  durationPricing,
1684
1707
  currency,
@@ -1686,7 +1709,7 @@ function TimeSlotPicker({
1686
1709
  showRemainingSpots = false
1687
1710
  }) {
1688
1711
  const { locale, domainConfig, domain, classNames } = useResira();
1689
- const minParty = domainConfig.minPartySize ?? 1;
1712
+ const minParty = minPartySizeOverride ?? domainConfig.minPartySize ?? 1;
1690
1713
  const maxParty = maxPartySizeOverride ?? domainConfig.maxPartySize ?? 12;
1691
1714
  const durations = react.useMemo(() => {
1692
1715
  if (durationPricing && durationPricing.length > 0) {
@@ -3087,6 +3110,56 @@ function ConfirmationView({ reservation }) {
3087
3110
  ] })
3088
3111
  ] });
3089
3112
  }
3113
+
3114
+ // src/service-rules.ts
3115
+ function isPositiveInt(value) {
3116
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
3117
+ }
3118
+ function getRiderTierBounds(product) {
3119
+ const tiers = product.riderTierPricing ?? [];
3120
+ if (!tiers.length) return {};
3121
+ let min;
3122
+ let max;
3123
+ for (const tier of tiers) {
3124
+ const tierMin = isPositiveInt(tier.minRiders) ? tier.minRiders : isPositiveInt(tier.riders) ? tier.riders : void 0;
3125
+ const tierMax = isPositiveInt(tier.maxRiders) ? tier.maxRiders : isPositiveInt(tier.riders) ? tier.riders : isPositiveInt(tier.minRiders) ? tier.minRiders : void 0;
3126
+ if (isPositiveInt(tierMin)) {
3127
+ min = min == null ? tierMin : Math.min(min, tierMin);
3128
+ }
3129
+ if (isPositiveInt(tierMax)) {
3130
+ max = max == null ? tierMax : Math.max(max, tierMax);
3131
+ }
3132
+ }
3133
+ return { min, max };
3134
+ }
3135
+ function getServicePartySizeBounds(product, domainConfig) {
3136
+ const baseMin = isPositiveInt(domainConfig?.minPartySize) ? domainConfig.minPartySize : 1;
3137
+ const baseMax = isPositiveInt(domainConfig?.maxPartySize) ? domainConfig.maxPartySize : 12;
3138
+ let min = baseMin;
3139
+ let max = baseMax;
3140
+ if (product) {
3141
+ if (isPositiveInt(product.maxPartySize)) {
3142
+ max = Math.min(max, product.maxPartySize);
3143
+ }
3144
+ if (product.pricingModel === "per_rider" && product.riderTierPricing?.length) {
3145
+ const tierBounds = getRiderTierBounds(product);
3146
+ if (isPositiveInt(tierBounds.min)) {
3147
+ min = Math.max(min, tierBounds.min);
3148
+ }
3149
+ if (isPositiveInt(tierBounds.max)) {
3150
+ max = Math.min(max, tierBounds.max);
3151
+ }
3152
+ }
3153
+ }
3154
+ if (max < min) {
3155
+ return { min, max: min };
3156
+ }
3157
+ return { min, max };
3158
+ }
3159
+ function clampPartySize(value, bounds) {
3160
+ if (!Number.isFinite(value)) return bounds.min;
3161
+ return Math.min(bounds.max, Math.max(bounds.min, Math.round(value)));
3162
+ }
3090
3163
  function todayStr() {
3091
3164
  const d = /* @__PURE__ */ new Date();
3092
3165
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -3206,6 +3279,7 @@ function ResiraBookingWidget() {
3206
3279
  partySize: domainConfig.defaultPartySize ?? 2,
3207
3280
  duration: domainConfig.defaultDuration
3208
3281
  });
3282
+ const [partySizeByProductId, setPartySizeByProductId] = react.useState({});
3209
3283
  const [guest, setGuest] = react.useState({
3210
3284
  guestName: "",
3211
3285
  guestEmail: "",
@@ -3223,6 +3297,10 @@ function ResiraBookingWidget() {
3223
3297
  }, []);
3224
3298
  const [slotDate, setSlotDate] = react.useState(todayStr());
3225
3299
  const activeDurationMinutes = normalizeDurationMinutes(selection.duration);
3300
+ const selectedProductPartyBounds = react.useMemo(
3301
+ () => getServicePartySizeBounds(selectedProduct, domainConfig),
3302
+ [selectedProduct, domainConfig]
3303
+ );
3226
3304
  const availabilityParams = react.useMemo(() => {
3227
3305
  if (isDateBased) {
3228
3306
  if (selection.startDate && selection.endDate) {
@@ -3420,8 +3498,12 @@ function ResiraBookingWidget() {
3420
3498
  [slotDate, promoterEnabled, promoterMode.autoAdvanceAvailability, step, STEPS]
3421
3499
  );
3422
3500
  const handlePartySizeChange = react.useCallback((size) => {
3423
- setSelection((prev) => ({ ...prev, partySize: size }));
3424
- }, []);
3501
+ const clamped = clampPartySize(size, selectedProductPartyBounds);
3502
+ setSelection((prev) => ({ ...prev, partySize: clamped }));
3503
+ if (selectedProduct?.id) {
3504
+ setPartySizeByProductId((prev) => ({ ...prev, [selectedProduct.id]: clamped }));
3505
+ }
3506
+ }, [selectedProduct?.id, selectedProductPartyBounds]);
3425
3507
  const handleDurationChange = react.useCallback((minutes) => {
3426
3508
  setSelection((prev) => ({
3427
3509
  ...prev,
@@ -3437,16 +3519,29 @@ function ResiraBookingWidget() {
3437
3519
  setActiveResourceId(product.equipmentIds[0]);
3438
3520
  }
3439
3521
  setSelection((prev) => {
3440
- const maxParty = product.maxPartySize ?? domainConfig.maxPartySize ?? 10;
3441
- const clampedPartySize = Math.min(prev.partySize, maxParty);
3522
+ const bounds = getServicePartySizeBounds(product, domainConfig);
3523
+ const persistedPartySize = partySizeByProductId[product.id];
3524
+ const nextPartySize = clampPartySize(
3525
+ persistedPartySize ?? prev.partySize,
3526
+ bounds
3527
+ );
3442
3528
  const defaultDuration = product.durationPricing?.[0]?.durationMinutes ?? product.durationMinutes ?? prev.duration;
3443
3529
  return {
3444
3530
  ...prev,
3445
3531
  productId: product.id,
3446
3532
  duration: defaultDuration,
3447
- partySize: clampedPartySize
3533
+ partySize: nextPartySize
3448
3534
  };
3449
3535
  });
3536
+ setPartySizeByProductId((prev) => {
3537
+ const bounds = getServicePartySizeBounds(product, domainConfig);
3538
+ const persistedPartySize = prev[product.id];
3539
+ const nextPartySize = clampPartySize(
3540
+ persistedPartySize ?? selection.partySize,
3541
+ bounds
3542
+ );
3543
+ return { ...prev, [product.id]: nextPartySize };
3544
+ });
3450
3545
  if (step === "resource" && isServiceBased) {
3451
3546
  const nextIdx = stepIndex("resource", STEPS) + 1;
3452
3547
  if (nextIdx < STEPS.length) {
@@ -3454,7 +3549,7 @@ function ResiraBookingWidget() {
3454
3549
  }
3455
3550
  }
3456
3551
  },
3457
- [setActiveResourceId, domainConfig.maxPartySize, step, isServiceBased, STEPS]
3552
+ [setActiveResourceId, domainConfig, partySizeByProductId, selection.partySize, step, isServiceBased, STEPS]
3458
3553
  );
3459
3554
  const handleResourceSelect = react.useCallback(
3460
3555
  (resourceId) => {
@@ -3654,6 +3749,11 @@ function ResiraBookingWidget() {
3654
3749
  ...deeplink.duration ? { duration: deeplink.duration } : {},
3655
3750
  ...deeplink.date ? { startDate: deeplink.date } : {}
3656
3751
  }));
3752
+ if (deeplink.productId && deeplink.partySize) {
3753
+ const deeplinkProductId = deeplink.productId;
3754
+ const deeplinkPartySize = deeplink.partySize;
3755
+ setPartySizeByProductId((prev) => ({ ...prev, [deeplinkProductId]: deeplinkPartySize }));
3756
+ }
3657
3757
  if (deeplink.date) setSlotDate(deeplink.date);
3658
3758
  }
3659
3759
  if (deeplinkGuest) {
@@ -3779,7 +3879,8 @@ function ResiraBookingWidget() {
3779
3879
  showDuration: domain === "watersport" || domain === "service",
3780
3880
  selectedDuration: activeDurationMinutes,
3781
3881
  onDurationChange: handleDurationChange,
3782
- maxPartySizeOverride: selectedProduct?.maxPartySize,
3882
+ minPartySizeOverride: selectedProductPartyBounds.min,
3883
+ maxPartySizeOverride: selectedProductPartyBounds.max,
3783
3884
  durationPricing: activeRiderDurationPricing ?? selectedProduct?.durationPricing,
3784
3885
  currency,
3785
3886
  showRemainingSpots
@@ -4678,6 +4779,7 @@ exports.ViewfinderIcon = ViewfinderIcon;
4678
4779
  exports.WaiverConsent = WaiverConsent;
4679
4780
  exports.XIcon = XIcon;
4680
4781
  exports.fetchServices = fetchServices;
4782
+ exports.resolveServicePrice = resolveServicePrice;
4681
4783
  exports.resolveTheme = resolveTheme;
4682
4784
  exports.themeToCSS = themeToCSS;
4683
4785
  exports.useAvailability = useAvailability;