@resira/ui 0.4.14 → 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")}`;
@@ -3097,6 +3170,14 @@ function formatPrice5(cents, currency) {
3097
3170
  currency
3098
3171
  }).format(cents / 100);
3099
3172
  }
3173
+ function normalizeDurationMinutes(value) {
3174
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
3175
+ if (typeof value === "string" && value.trim()) {
3176
+ const parsed = Number(value);
3177
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
3178
+ }
3179
+ return void 0;
3180
+ }
3100
3181
  function buildSteps(domain, hasPayment, catalogMode) {
3101
3182
  const steps = [];
3102
3183
  if (domain === "watersport" || domain === "service" || catalogMode && domain !== "restaurant") {
@@ -3198,6 +3279,7 @@ function ResiraBookingWidget() {
3198
3279
  partySize: domainConfig.defaultPartySize ?? 2,
3199
3280
  duration: domainConfig.defaultDuration
3200
3281
  });
3282
+ const [partySizeByProductId, setPartySizeByProductId] = react.useState({});
3201
3283
  const [guest, setGuest] = react.useState({
3202
3284
  guestName: "",
3203
3285
  guestEmail: "",
@@ -3214,6 +3296,11 @@ function ResiraBookingWidget() {
3214
3296
  setPromoValidation(result);
3215
3297
  }, []);
3216
3298
  const [slotDate, setSlotDate] = react.useState(todayStr());
3299
+ const activeDurationMinutes = normalizeDurationMinutes(selection.duration);
3300
+ const selectedProductPartyBounds = react.useMemo(
3301
+ () => getServicePartySizeBounds(selectedProduct, domainConfig),
3302
+ [selectedProduct, domainConfig]
3303
+ );
3217
3304
  const availabilityParams = react.useMemo(() => {
3218
3305
  if (isDateBased) {
3219
3306
  if (selection.startDate && selection.endDate) {
@@ -3224,9 +3311,9 @@ function ResiraBookingWidget() {
3224
3311
  return {
3225
3312
  date: slotDate,
3226
3313
  partySize: selection.partySize,
3227
- durationMinutes: selection.duration
3314
+ durationMinutes: activeDurationMinutes
3228
3315
  };
3229
- }, [isDateBased, selection.startDate, selection.endDate, slotDate, selection.partySize, selection.duration]);
3316
+ }, [isDateBased, selection.startDate, selection.endDate, slotDate, selection.partySize, activeDurationMinutes]);
3230
3317
  const availabilityProductId = isServiceBased ? selectedProduct?.id : void 0;
3231
3318
  const { data: availability, loading, error, refetch } = useAvailability(
3232
3319
  availabilityParams,
@@ -3270,7 +3357,7 @@ function ResiraBookingWidget() {
3270
3357
  productId: selectedProduct?.id ?? "",
3271
3358
  resourceId,
3272
3359
  partySize: selection.partySize,
3273
- durationMinutes: selection.duration,
3360
+ durationMinutes: activeDurationMinutes,
3274
3361
  startDate: selection.startDate,
3275
3362
  startTime: selection.startTime,
3276
3363
  endTime: selection.endTime,
@@ -3282,7 +3369,7 @@ function ResiraBookingWidget() {
3282
3369
  termsAccepted: termsAccepted || void 0,
3283
3370
  waiverAccepted: waiverAccepted || void 0
3284
3371
  };
3285
- }, [activeResourceId, selectedProduct, selection, guest, discountCode, termsAccepted, waiverAccepted, isCheckoutMode, checkoutSession, checkoutSessionToken]);
3372
+ }, [activeResourceId, selectedProduct, selection, guest, discountCode, termsAccepted, waiverAccepted, isCheckoutMode, checkoutSession, checkoutSessionToken, activeDurationMinutes]);
3286
3373
  const blockedDates = react.useMemo(() => {
3287
3374
  const dates = calendarData?.dates?.blockedDates ?? availability?.dates?.blockedDates ?? [];
3288
3375
  return new Set(dates);
@@ -3302,11 +3389,11 @@ function ResiraBookingWidget() {
3302
3389
  if (selectedProduct) {
3303
3390
  let base = selectedProduct.priceCents;
3304
3391
  if (selectedProduct.pricingModel === "per_rider" && activeRiderDurationPricing?.length) {
3305
- const match = selection.duration ? activeRiderDurationPricing.find((dp) => dp.durationMinutes === selection.duration) : activeRiderDurationPricing[0];
3392
+ const match = activeDurationMinutes ? activeRiderDurationPricing.find((dp) => dp.durationMinutes === activeDurationMinutes) : activeRiderDurationPricing[0];
3306
3393
  if (match) base = match.priceCents;
3307
- } else if (selectedProduct.durationPricing?.length && selection.duration) {
3394
+ } else if (selectedProduct.durationPricing?.length && activeDurationMinutes) {
3308
3395
  const match = selectedProduct.durationPricing.find(
3309
- (dp) => dp.durationMinutes === selection.duration
3396
+ (dp) => dp.durationMinutes === activeDurationMinutes
3310
3397
  );
3311
3398
  if (match) base = match.priceCents;
3312
3399
  }
@@ -3324,7 +3411,7 @@ function ResiraBookingWidget() {
3324
3411
  return { total, amountNow, amountAtVenue };
3325
3412
  }
3326
3413
  return null;
3327
- }, [selectedProduct, availability, selection.partySize, selection.duration, depositPercent, activeRiderDurationPricing]);
3414
+ }, [selectedProduct, availability, selection.partySize, activeDurationMinutes, depositPercent, activeRiderDurationPricing]);
3328
3415
  const stepTitle = react.useMemo(() => {
3329
3416
  switch (step) {
3330
3417
  case "resource":
@@ -3411,8 +3498,12 @@ function ResiraBookingWidget() {
3411
3498
  [slotDate, promoterEnabled, promoterMode.autoAdvanceAvailability, step, STEPS]
3412
3499
  );
3413
3500
  const handlePartySizeChange = react.useCallback((size) => {
3414
- setSelection((prev) => ({ ...prev, partySize: size }));
3415
- }, []);
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]);
3416
3507
  const handleDurationChange = react.useCallback((minutes) => {
3417
3508
  setSelection((prev) => ({
3418
3509
  ...prev,
@@ -3428,16 +3519,29 @@ function ResiraBookingWidget() {
3428
3519
  setActiveResourceId(product.equipmentIds[0]);
3429
3520
  }
3430
3521
  setSelection((prev) => {
3431
- const maxParty = product.maxPartySize ?? domainConfig.maxPartySize ?? 10;
3432
- 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
+ );
3433
3528
  const defaultDuration = product.durationPricing?.[0]?.durationMinutes ?? product.durationMinutes ?? prev.duration;
3434
3529
  return {
3435
3530
  ...prev,
3436
3531
  productId: product.id,
3437
3532
  duration: defaultDuration,
3438
- partySize: clampedPartySize
3533
+ partySize: nextPartySize
3439
3534
  };
3440
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
+ });
3441
3545
  if (step === "resource" && isServiceBased) {
3442
3546
  const nextIdx = stepIndex("resource", STEPS) + 1;
3443
3547
  if (nextIdx < STEPS.length) {
@@ -3445,7 +3549,7 @@ function ResiraBookingWidget() {
3445
3549
  }
3446
3550
  }
3447
3551
  },
3448
- [setActiveResourceId, domainConfig.maxPartySize, step, isServiceBased, STEPS]
3552
+ [setActiveResourceId, domainConfig, partySizeByProductId, selection.partySize, step, isServiceBased, STEPS]
3449
3553
  );
3450
3554
  const handleResourceSelect = react.useCallback(
3451
3555
  (resourceId) => {
@@ -3645,6 +3749,11 @@ function ResiraBookingWidget() {
3645
3749
  ...deeplink.duration ? { duration: deeplink.duration } : {},
3646
3750
  ...deeplink.date ? { startDate: deeplink.date } : {}
3647
3751
  }));
3752
+ if (deeplink.productId && deeplink.partySize) {
3753
+ const deeplinkProductId = deeplink.productId;
3754
+ const deeplinkPartySize = deeplink.partySize;
3755
+ setPartySizeByProductId((prev) => ({ ...prev, [deeplinkProductId]: deeplinkPartySize }));
3756
+ }
3648
3757
  if (deeplink.date) setSlotDate(deeplink.date);
3649
3758
  }
3650
3759
  if (deeplinkGuest) {
@@ -3768,9 +3877,10 @@ function ResiraBookingWidget() {
3768
3877
  onPartySizeChange: handlePartySizeChange,
3769
3878
  showPartySize: true,
3770
3879
  showDuration: domain === "watersport" || domain === "service",
3771
- selectedDuration: selection.duration,
3880
+ selectedDuration: activeDurationMinutes,
3772
3881
  onDurationChange: handleDurationChange,
3773
- maxPartySizeOverride: selectedProduct?.maxPartySize,
3882
+ minPartySizeOverride: selectedProductPartyBounds.min,
3883
+ maxPartySizeOverride: selectedProductPartyBounds.max,
3774
3884
  durationPricing: activeRiderDurationPricing ?? selectedProduct?.durationPricing,
3775
3885
  currency,
3776
3886
  showRemainingSpots
@@ -3780,19 +3890,19 @@ function ResiraBookingWidget() {
3780
3890
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-price-preview-row", children: [
3781
3891
  /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
3782
3892
  selectedProduct.name,
3783
- selection.duration && selectedProduct.durationPricing && selectedProduct.durationPricing.length > 1 && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-price-preview-label", children: [
3893
+ activeDurationMinutes && selectedProduct.durationPricing && selectedProduct.durationPricing.length > 1 && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-price-preview-label", children: [
3784
3894
  " ",
3785
3895
  "(",
3786
- selection.duration < 60 ? `${selection.duration} min` : `${Math.floor(selection.duration / 60)}h${selection.duration % 60 ? selection.duration % 60 : ""}`,
3896
+ activeDurationMinutes < 60 ? `${activeDurationMinutes} min` : `${Math.floor(activeDurationMinutes / 60)}h${activeDurationMinutes % 60 ? activeDurationMinutes % 60 : ""}`,
3787
3897
  ")"
3788
3898
  ] })
3789
3899
  ] }),
3790
3900
  /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
3791
3901
  (() => {
3792
3902
  let unitPrice = selectedProduct.priceCents;
3793
- if (selectedProduct.durationPricing?.length && selection.duration) {
3903
+ if (selectedProduct.durationPricing?.length && activeDurationMinutes) {
3794
3904
  const match = selectedProduct.durationPricing.find(
3795
- (dp) => dp.durationMinutes === selection.duration
3905
+ (dp) => dp.durationMinutes === activeDurationMinutes
3796
3906
  );
3797
3907
  if (match) unitPrice = match.priceCents;
3798
3908
  }
@@ -4669,6 +4779,7 @@ exports.ViewfinderIcon = ViewfinderIcon;
4669
4779
  exports.WaiverConsent = WaiverConsent;
4670
4780
  exports.XIcon = XIcon;
4671
4781
  exports.fetchServices = fetchServices;
4782
+ exports.resolveServicePrice = resolveServicePrice;
4672
4783
  exports.resolveTheme = resolveTheme;
4673
4784
  exports.themeToCSS = themeToCSS;
4674
4785
  exports.useAvailability = useAvailability;