riverbankcms 0.5.0 → 0.6.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.
@@ -52,7 +52,7 @@ const unsafeAsOccurrenceId = (id) => id;
52
52
  const unsafeAsEventOfferingId = (id) => id;
53
53
  const unsafeAsEventSeriesId = (id) => id;
54
54
  const unsafeAsEventPageRouteSlug = (value) => value;
55
- const unsafeAsTicketTypeId = (id) => id;
55
+ const unsafeAsEventAttendeeTicketLineId = (id) => id;
56
56
  const unsafeAsEventSeriesRegistrationId = (id) => id;
57
57
  const unsafeAsEventCategoryId = (id) => id;
58
58
  const unsafeAsVenueId = (id) => id;
@@ -90,8 +90,6 @@ const unsafeAsFormId = (id) => id;
90
90
  const unsafeAsFormSubmissionId = (id) => id;
91
91
  const unsafeAsPassId = (id) => id;
92
92
  const unsafeAsMembershipId = (id) => id;
93
- const unsafeAsCourseId = (id) => id;
94
- const unsafeAsCourseProgramId = (id) => id;
95
93
  const unsafeAsCustomerId = (id) => id;
96
94
  const unsafeAsOrderId = (id) => id;
97
95
  const unsafeAsOrderItemId = (id) => id;
@@ -103,6 +101,9 @@ const unsafeAsOrderPaymentPlanInstalmentId = (id) => id;
103
101
  const unsafeAsManualPaymentOperationId = (id) => id;
104
102
  const unsafeAsCheckoutLineId = (id) => id;
105
103
  const unsafeAsAttendeeId = (id) => id;
104
+ const unsafeAsEventRegistrationCommandId = (id) => id;
105
+ const unsafeAsEventRegistrationCommandRowId = (id) => id;
106
+ const unsafeAsEventRegistrationProjectionClaimToken = (id) => id;
106
107
  const unsafeAsCustomerPassId = (id) => id;
107
108
  const unsafeAsCustomerMembershipId = (id) => id;
108
109
  const unsafeAsAppointmentId = (id) => id;
@@ -160,8 +161,7 @@ const parsePageId = (value) => parseUuidBrand(value, unsafeAsPageId);
160
161
  const parsePageSurfaceId = (value) => parseUuidBrand(value, unsafeAsPageSurfaceId);
161
162
  const parseOccurrenceId = (value) => parseUuidBrand(value, unsafeAsOccurrenceId);
162
163
  const parseEventOfferingId = (value) => parseUuidBrand(value, unsafeAsEventOfferingId);
163
- const parseCourseProgramId = (value) => parseUuidBrand(value, unsafeAsCourseProgramId);
164
- const parseTicketTypeId = (value) => parseUuidBrand(value, unsafeAsTicketTypeId);
164
+ const parseEventAttendeeTicketLineId = (value) => parseUuidBrand(value, unsafeAsEventAttendeeTicketLineId);
165
165
  const parsePricingOptionId = (value) => parseUuidBrand(value, unsafeAsPricingOptionId);
166
166
  const parseUserId = (value) => parseUuidBrand(value, unsafeAsUserId);
167
167
  const parseProductId = (value) => parseUuidBrand(value, unsafeAsProductId);
@@ -175,7 +175,8 @@ const parseNavigationMenuId = (value) => parseUuidBrand(value, unsafeAsNavigatio
175
175
  const parseNavigationItemId = (value) => parseUuidBrand(value, unsafeAsNavigationItemId);
176
176
  const parsePassId = (value) => parseUuidBrand(value, unsafeAsPassId);
177
177
  const parseMembershipId = (value) => parseUuidBrand(value, unsafeAsMembershipId);
178
- const parseCourseId = (value) => parseUuidBrand(value, unsafeAsCourseId);
178
+ const asRetiredCourseId = (id) => id;
179
+ const parseRetiredCourseId = (value) => parseUuidBrand(value, asRetiredCourseId);
179
180
  const parseFormId = (value) => parseUuidBrand(value, unsafeAsFormId);
180
181
  const parseAppointmentId = (value) => parseUuidBrand(value, unsafeAsAppointmentId);
181
182
  const parseAppointmentServiceId = (value) => parseUuidBrand(value, unsafeAsAppointmentServiceId);
@@ -183,6 +184,9 @@ const parseAppointmentResourceId = (value) => parseUuidBrand(value, unsafeAsAppo
183
184
  const parseAppointmentReservationId = (value) => parseUuidBrand(value, unsafeAsAppointmentReservationId);
184
185
  const parseAppointmentReservationRunId = (value) => parseUuidBrand(value, unsafeAsAppointmentReservationRunId);
185
186
  const parseAttendeeId = (value) => parseUuidBrand(value, unsafeAsAttendeeId);
187
+ const parseEventRegistrationCommandId = (value) => parseUuidBrand(value, unsafeAsEventRegistrationCommandId);
188
+ const parseEventRegistrationCommandRowId = (value) => parseUuidBrand(value, unsafeAsEventRegistrationCommandRowId);
189
+ const parseEventRegistrationProjectionClaimToken = (value) => parseUuidBrand(value, unsafeAsEventRegistrationProjectionClaimToken);
186
190
  const parseDatedOfferingCapacityHoldId = (value) => parseUuidBrand(value, unsafeAsDatedOfferingCapacityHoldId);
187
191
  const parseDatedOfferingCapacityHoldAuditRef = (value) => parseUuidBrand(value, unsafeAsDatedOfferingCapacityHoldAuditRef);
188
192
  const parseDatedOfferingRevocationAuditEventId = (value) => parseUuidBrand(value, unsafeAsDatedOfferingRevocationAuditEventId);
@@ -475,6 +479,44 @@ function assertNever(value, message = "Unexpected variant") {
475
479
  throw new Error(`${message}: ${String(value)}`);
476
480
  }
477
481
 
482
+ //#endregion
483
+ //#region ../core/src/event-booking-offer.ts
484
+ const EVENT_PRICING_STRATEGIES = [
485
+ "free",
486
+ "single",
487
+ "tiered",
488
+ "sliding_scale"
489
+ ];
490
+ function createEventBookingOffer(input) {
491
+ if (input.bookingRequirement === "none") {
492
+ return input.pricing.kind === "free" ? ok({
493
+ bookingRequirement: "none",
494
+ pricing: input.pricing,
495
+ paymentTerms: input.paymentTerms
496
+ }) : err({ kind: "paid_without_registration" });
497
+ }
498
+ if (input.bookingRequirement === "required") {
499
+ return ok({
500
+ bookingRequirement: "required",
501
+ pricing: input.pricing,
502
+ paymentTerms: input.paymentTerms
503
+ });
504
+ }
505
+ return err({
506
+ kind: "invalid_booking_requirement",
507
+ value: input.bookingRequirement
508
+ });
509
+ }
510
+ function eventPricingRequiresPayment(pricing) {
511
+ switch (pricing.kind) {
512
+ case "free": return false;
513
+ case "single": return true;
514
+ case "tiered": return pricing.options.some((option) => option.priceAmount > 0);
515
+ case "sliding_scale": return pricing.maximum > 0;
516
+ default: return assertNever(pricing);
517
+ }
518
+ }
519
+
478
520
  //#endregion
479
521
  //#region ../core/src/currency-format.ts
480
522
  const CURRENCY_CODE_INPUT_PATTERN = /^[A-Za-z]{3}$/;
@@ -538,62 +580,559 @@ function formatCurrencyAmount(amountCents, currency, options) {
538
580
  }
539
581
  }
540
582
 
583
+ //#endregion
584
+ //#region ../core/src/money-input.ts
585
+ function unsafeAsCents(value) {
586
+ return value;
587
+ }
588
+ function unsafeAsNonNegativeCents(value) {
589
+ return value;
590
+ }
591
+ function unsafeAsPositiveCents(value) {
592
+ return value;
593
+ }
594
+ function unwrapCents(value) {
595
+ return value;
596
+ }
597
+ function isWholeCents(value) {
598
+ return Number.isFinite(value) && Number.isInteger(value);
599
+ }
600
+ function isNonNegativeWholeCents(value) {
601
+ return isWholeCents(value) && value >= 0;
602
+ }
603
+ function isPositiveWholeCents(value) {
604
+ return isWholeCents(value) && value > 0;
605
+ }
606
+ /**
607
+ * Normalizes a derived amount to signed whole cents. Use parse*Cents for
608
+ * untrusted input that should fail instead of truncating or clamping.
609
+ */
610
+ function toCents(value) {
611
+ return unsafeAsCents(Math.trunc(value));
612
+ }
613
+ /**
614
+ * Normalizes a derived amount to non-negative whole cents. This intentionally
615
+ * clamps negatives to zero; use parseNonNegativeCents for validation.
616
+ */
617
+ function toNonNegativeCents(value) {
618
+ return unsafeAsNonNegativeCents(Math.max(0, Math.trunc(value)));
619
+ }
620
+ function parseCents(value) {
621
+ if (!Number.isFinite(value)) {
622
+ return err({ kind: "invalid-number" });
623
+ }
624
+ if (!Number.isInteger(value)) {
625
+ return err({ kind: "not-whole-cents" });
626
+ }
627
+ return ok(unsafeAsCents(value));
628
+ }
629
+ function parseNonNegativeCents(value) {
630
+ const parsed = parseCents(value);
631
+ if (!parsed.ok) {
632
+ return parsed;
633
+ }
634
+ if (parsed.value < 0) {
635
+ return err({ kind: "negative" });
636
+ }
637
+ return ok(unsafeAsNonNegativeCents(parsed.value));
638
+ }
639
+ function parsePositiveCents(value) {
640
+ const parsed = parseNonNegativeCents(value);
641
+ if (!parsed.ok) {
642
+ return parsed;
643
+ }
644
+ if (parsed.value === 0) {
645
+ return err({ kind: "zero-not-allowed" });
646
+ }
647
+ return ok(unsafeAsPositiveCents(parsed.value));
648
+ }
649
+ function normalizeMoneyInput(value, options) {
650
+ return (options.stripFormatting === true ? value.replace(/[^0-9.-]/g, "") : value).trim();
651
+ }
652
+ function parseFiniteMoneyAmount(value, options) {
653
+ const normalized = normalizeMoneyInput(value, options);
654
+ if (normalized.length === 0) {
655
+ return err({ kind: "empty" });
656
+ }
657
+ const amount = Number(normalized);
658
+ if (!Number.isFinite(amount)) {
659
+ return err({ kind: "invalid-number" });
660
+ }
661
+ return ok(amount);
662
+ }
663
+ function roundMoneyAmountToCents(amount) {
664
+ return Math.round(amount * 100);
665
+ }
666
+ function parsePositiveMoneyInputToCents(value, options = {}) {
667
+ const parsed = parseFiniteMoneyAmount(value, options);
668
+ if (!parsed.ok) {
669
+ return parsed;
670
+ }
671
+ if (parsed.value < 0) {
672
+ return err({ kind: "negative" });
673
+ }
674
+ const amountCents = roundMoneyAmountToCents(parsed.value);
675
+ if (amountCents <= 0) {
676
+ return err({ kind: "zero-not-allowed" });
677
+ }
678
+ return ok(unsafeAsPositiveCents(amountCents));
679
+ }
680
+ function parseNonNegativeMoneyInputToCents(value, options = {}) {
681
+ const parsed = parseFiniteMoneyAmount(value, options);
682
+ if (!parsed.ok) {
683
+ return parsed;
684
+ }
685
+ if (parsed.value < 0) {
686
+ return err({ kind: "negative" });
687
+ }
688
+ return ok(unsafeAsNonNegativeCents(roundMoneyAmountToCents(parsed.value)));
689
+ }
690
+ function parseMoneyInputToCents(value, options = {}) {
691
+ const parsed = parseFiniteMoneyAmount(value, options);
692
+ if (!parsed.ok) {
693
+ return null;
694
+ }
695
+ if (options.allowZero === true ? parsed.value < 0 : parsed.value <= 0) {
696
+ return null;
697
+ }
698
+ return roundMoneyAmountToCents(parsed.value);
699
+ }
700
+ function formatMoneyInputFromCents(amountCents) {
701
+ if (typeof amountCents !== "number" || !Number.isFinite(amountCents)) {
702
+ return "";
703
+ }
704
+ const sign = amountCents < 0 ? "-" : "";
705
+ const absoluteCents = Math.abs(Math.trunc(amountCents));
706
+ const units = Math.floor(absoluteCents / 100);
707
+ const fractional = String(absoluteCents % 100).padStart(2, "0");
708
+ return `${sign}${units}.${fractional}`;
709
+ }
710
+
711
+ //#endregion
712
+ //#region ../core/src/non-empty-array.ts
713
+ function fromArray(items) {
714
+ const [first, ...rest] = items;
715
+ if (first === undefined) {
716
+ return err("empty_array");
717
+ }
718
+ return ok([first, ...rest]);
719
+ }
720
+ function mapNonEmptyArray(items, map) {
721
+ return items.map(map);
722
+ }
723
+ function fromReadonlyArray(items) {
724
+ const [first, ...rest] = items;
725
+ return first === undefined ? err("empty_array") : ok([first, ...rest]);
726
+ }
727
+ function mapReadonlyNonEmptyArray(items, map) {
728
+ const [first, ...rest] = items;
729
+ return [map(first, 0), ...rest.map((item, index) => map(item, index + 1))];
730
+ }
731
+
732
+ //#endregion
733
+ //#region ../core/src/event-booking-offer-parser.ts
734
+ function oneIssue(issue) {
735
+ return [issue];
736
+ }
737
+ function isNullish(value) {
738
+ return value === null || value === undefined;
739
+ }
740
+ function parseOptionIdentity(input) {
741
+ const id = parsePricingOptionId(input.id);
742
+ if (!id.ok) return err({ kind: "invalid_id" });
743
+ if (typeof input.name !== "string" || input.name.trim().length === 0) {
744
+ return err({ kind: "invalid_name" });
745
+ }
746
+ if (!isNullish(input.description) && typeof input.description !== "string") {
747
+ return err({ kind: "invalid_description" });
748
+ }
749
+ return ok({
750
+ id: id.value,
751
+ name: input.name.trim(),
752
+ description: input.description ?? null
753
+ });
754
+ }
755
+ function parseOptionCommercials(input) {
756
+ const price = typeof input.priceAmount === "number" ? parseNonNegativeCents(input.priceAmount) : err({ kind: "invalid-number" });
757
+ if (!price.ok) return err({
758
+ kind: "invalid_price",
759
+ error: price.error
760
+ });
761
+ if (typeof input.isActive !== "boolean") return err({ kind: "invalid_active" });
762
+ if (typeof input.sortOrder !== "number" || !Number.isInteger(input.sortOrder) || input.sortOrder < 0) {
763
+ return err({ kind: "invalid_sort_order" });
764
+ }
765
+ if (!isNullish(input.capacityLimit) && (typeof input.capacityLimit !== "number" || !Number.isInteger(input.capacityLimit) || input.capacityLimit < 1)) {
766
+ return err({ kind: "invalid_capacity" });
767
+ }
768
+ return ok({
769
+ priceAmount: price.value,
770
+ isActive: input.isActive,
771
+ sortOrder: input.sortOrder,
772
+ capacityLimit: input.capacityLimit ?? null
773
+ });
774
+ }
775
+ function parseNullableIsoDateTime(value, issue) {
776
+ if (isNullish(value)) return ok(null);
777
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
778
+ return err({ kind: issue });
779
+ }
780
+ return ok(value);
781
+ }
782
+ function parseAttendanceScope(value) {
783
+ if (value === "in_person" || value === "online" || value === "both") {
784
+ return ok(value);
785
+ }
786
+ return err({ kind: "invalid_attendance_scope" });
787
+ }
788
+ function parseRecipeIdentifier(value) {
789
+ if (isNullish(value)) return ok(null);
790
+ if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(value)) {
791
+ return err({ kind: "invalid_recipe_identifier" });
792
+ }
793
+ return ok(value);
794
+ }
795
+ function parseOptionAvailability(input) {
796
+ const start = parseNullableIsoDateTime(input.saleStartsAt, "invalid_sale_start");
797
+ if (!start.ok) return start;
798
+ const end = parseNullableIsoDateTime(input.saleEndsAt, "invalid_sale_end");
799
+ if (!end.ok) return end;
800
+ if (start.value !== null && end.value !== null && Date.parse(end.value) <= Date.parse(start.value)) {
801
+ return err({ kind: "invalid_sale_window" });
802
+ }
803
+ const scope = parseAttendanceScope(input.attendanceScope);
804
+ if (!scope.ok) return scope;
805
+ const recipe = parseRecipeIdentifier(input.recipeIdentifier);
806
+ if (!recipe.ok) return recipe;
807
+ return ok({
808
+ saleStartsAt: start.value,
809
+ saleEndsAt: end.value,
810
+ attendanceScope: scope.value,
811
+ recipeIdentifier: recipe.value
812
+ });
813
+ }
814
+ function parseEventPricingOption(input) {
815
+ const identity = parseOptionIdentity(input);
816
+ const commercials = parseOptionCommercials(input);
817
+ const availability = parseOptionAvailability(input);
818
+ const issues = [
819
+ identity,
820
+ commercials,
821
+ availability
822
+ ].flatMap((result) => result.ok ? [] : [result.error]);
823
+ if (!identity.ok || !commercials.ok || !availability.ok) {
824
+ const nonEmptyIssues = fromReadonlyArray(issues);
825
+ return err(nonEmptyIssues.ok ? nonEmptyIssues.value : oneIssue({ kind: "invalid_object" }));
826
+ }
827
+ return ok({
828
+ ...identity.value,
829
+ ...commercials.value,
830
+ ...availability.value
831
+ });
832
+ }
833
+ function unexpectedValues(input, fields) {
834
+ return fields.flatMap((field) => isNullish(input[field]) ? [] : [{
835
+ kind: "unexpected_value",
836
+ field
837
+ }]);
838
+ }
839
+ function invalidOptionsIssue(value) {
840
+ return Array.isArray(value) && value.length === 0 ? [] : [{
841
+ kind: "unexpected_value",
842
+ field: "options"
843
+ }];
844
+ }
845
+ function parseFreePricing(input, currency) {
846
+ const issues = [...unexpectedValues(input, [
847
+ "singlePriceAmount",
848
+ "slidingMinAmount",
849
+ "slidingMaxAmount",
850
+ "slidingSuggestedAmount"
851
+ ]), ...invalidOptionsIssue(input.options)];
852
+ const nonEmptyIssues = fromReadonlyArray(issues);
853
+ return nonEmptyIssues.ok ? err(nonEmptyIssues.value) : ok({
854
+ kind: "free",
855
+ currency
856
+ });
857
+ }
858
+ function parseSinglePricing(input, currency) {
859
+ const amount = typeof input.singlePriceAmount === "number" ? parsePositiveCents(input.singlePriceAmount) : err({ kind: "invalid-number" });
860
+ const issues = [
861
+ ...unexpectedValues(input, [
862
+ "slidingMinAmount",
863
+ "slidingMaxAmount",
864
+ "slidingSuggestedAmount"
865
+ ]),
866
+ ...invalidOptionsIssue(input.options),
867
+ ...amount.ok ? [] : [{
868
+ kind: "invalid_amount",
869
+ field: "singlePriceAmount",
870
+ error: amount.error
871
+ }]
872
+ ];
873
+ const nonEmptyIssues = fromReadonlyArray(issues);
874
+ if (nonEmptyIssues.ok) return err(nonEmptyIssues.value);
875
+ if (!amount.ok) return err(oneIssue({
876
+ kind: "invalid_amount",
877
+ field: "singlePriceAmount",
878
+ error: amount.error
879
+ }));
880
+ return ok({
881
+ kind: "single",
882
+ currency,
883
+ amount: amount.value
884
+ });
885
+ }
886
+ function parseRawPricingOption(raw, index) {
887
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
888
+ return err(oneIssue({
889
+ kind: "invalid_option",
890
+ index,
891
+ issue: { kind: "invalid_object" }
892
+ }));
893
+ }
894
+ const parsed = parseEventPricingOption(raw);
895
+ if (parsed.ok) return parsed;
896
+ const [first, ...rest] = parsed.error;
897
+ return err([{
898
+ kind: "invalid_option",
899
+ index,
900
+ issue: first
901
+ }, ...rest.map((issue) => ({
902
+ kind: "invalid_option",
903
+ index,
904
+ issue
905
+ }))]);
906
+ }
907
+ function parseTieredPricing(input, currency) {
908
+ const structuralIssues = unexpectedValues(input, [
909
+ "singlePriceAmount",
910
+ "slidingMinAmount",
911
+ "slidingMaxAmount",
912
+ "slidingSuggestedAmount"
913
+ ]);
914
+ if (!Array.isArray(input.options) || input.options.length === 0) {
915
+ return err([{ kind: "tiered_options_empty" }, ...structuralIssues]);
916
+ }
917
+ const parsed = input.options.map(parseRawPricingOption);
918
+ const issues = [...structuralIssues, ...parsed.flatMap((result) => result.ok ? [] : result.error)];
919
+ const nonEmptyIssues = fromReadonlyArray(issues);
920
+ if (nonEmptyIssues.ok) return err(nonEmptyIssues.value);
921
+ const options = parsed.flatMap((result) => result.ok ? [result.value] : []);
922
+ const nonEmptyOptions = fromReadonlyArray(options);
923
+ if (!nonEmptyOptions.ok) {
924
+ return err(oneIssue({ kind: "tiered_options_empty" }));
925
+ }
926
+ return ok({
927
+ kind: "tiered",
928
+ currency,
929
+ options: nonEmptyOptions.value
930
+ });
931
+ }
932
+ function amountIssue(field, result) {
933
+ return result.ok ? [] : [{
934
+ kind: "invalid_amount",
935
+ field,
936
+ error: result.error
937
+ }];
938
+ }
939
+ function slidingRangeIssues(minimum, maximum, suggested) {
940
+ if (!minimum.ok || !maximum.ok || !suggested.ok) return [];
941
+ const issues = [];
942
+ if (minimum.value > maximum.value) issues.push({ kind: "invalid_sliding_range" });
943
+ if (suggested.value !== null && (suggested.value < minimum.value || suggested.value > maximum.value)) {
944
+ issues.push({ kind: "suggested_outside_range" });
945
+ }
946
+ return issues;
947
+ }
948
+ function parseSlidingPricing(input, currency) {
949
+ const minimum = typeof input.slidingMinAmount === "number" ? parseNonNegativeCents(input.slidingMinAmount) : err({ kind: "invalid-number" });
950
+ const maximum = typeof input.slidingMaxAmount === "number" ? parsePositiveCents(input.slidingMaxAmount) : err({ kind: "invalid-number" });
951
+ const suggested = isNullish(input.slidingSuggestedAmount) ? ok(null) : typeof input.slidingSuggestedAmount === "number" ? parseNonNegativeCents(input.slidingSuggestedAmount) : err({ kind: "invalid-number" });
952
+ const issues = [
953
+ ...unexpectedValues(input, ["singlePriceAmount"]),
954
+ ...invalidOptionsIssue(input.options),
955
+ ...amountIssue("slidingMinAmount", minimum),
956
+ ...amountIssue("slidingMaxAmount", maximum),
957
+ ...amountIssue("slidingSuggestedAmount", suggested),
958
+ ...slidingRangeIssues(minimum, maximum, suggested)
959
+ ];
960
+ const nonEmptyIssues = fromReadonlyArray(issues);
961
+ if (nonEmptyIssues.ok) return err(nonEmptyIssues.value);
962
+ if (!minimum.ok) return err(oneIssue({
963
+ kind: "invalid_amount",
964
+ field: "slidingMinAmount",
965
+ error: minimum.error
966
+ }));
967
+ if (!maximum.ok) return err(oneIssue({
968
+ kind: "invalid_amount",
969
+ field: "slidingMaxAmount",
970
+ error: maximum.error
971
+ }));
972
+ if (!suggested.ok) return err(oneIssue({
973
+ kind: "invalid_amount",
974
+ field: "slidingSuggestedAmount",
975
+ error: suggested.error
976
+ }));
977
+ return ok({
978
+ kind: "sliding_scale",
979
+ currency,
980
+ minimum: minimum.value,
981
+ maximum: maximum.value,
982
+ suggested: suggested.value
983
+ });
984
+ }
985
+ function parseEventPricing(input) {
986
+ if (!isEventPricingStrategy(input.pricingStrategy)) {
987
+ return err(oneIssue({
988
+ kind: "invalid_strategy",
989
+ value: input.pricingStrategy
990
+ }));
991
+ }
992
+ const currency = typeof input.currency === "string" ? parseCurrencyCode(input.currency) : null;
993
+ if (currency === null) return err(oneIssue({
994
+ kind: "invalid_currency",
995
+ value: input.currency
996
+ }));
997
+ switch (input.pricingStrategy) {
998
+ case "free": return parseFreePricing(input, currency);
999
+ case "single": return parseSinglePricing(input, currency);
1000
+ case "tiered": return parseTieredPricing(input, currency);
1001
+ case "sliding_scale": return parseSlidingPricing(input, currency);
1002
+ default: return assertNever(input.pricingStrategy);
1003
+ }
1004
+ }
1005
+ function isEventPricingStrategy(value) {
1006
+ return EVENT_PRICING_STRATEGIES.some((strategy) => strategy === value);
1007
+ }
1008
+
1009
+ //#endregion
1010
+ //#region ../core/src/planner.ts
1011
+ function plannerNoChange(details = {}) {
1012
+ return {
1013
+ ...details,
1014
+ tag: "no_change"
1015
+ };
1016
+ }
1017
+ function plannerUpdate(details = {}) {
1018
+ return {
1019
+ ...details,
1020
+ tag: "update"
1021
+ };
1022
+ }
1023
+ /** Identity at runtime; marks a planner-derived patch as shallow readonly. */
1024
+ function plannerPatch(patch) {
1025
+ return patch;
1026
+ }
1027
+ /** Default clock for production callers that do not inject time explicitly. */
1028
+ const systemPlanningClock = { now: () => Date.now() };
1029
+ /** Resolve current time from an explicit timestamp, injected clock, or system clock. */
1030
+ function resolvePlanningNow(input = {}) {
1031
+ if (typeof input.now === "number") {
1032
+ return input.now;
1033
+ }
1034
+ return (input.clock ?? systemPlanningClock).now();
1035
+ }
1036
+ /** Resolve current time as a Date from an explicit Date, injected clock, or system clock. */
1037
+ function resolvePlanningNowDate(input = {}) {
1038
+ if (input.now !== undefined) {
1039
+ return input.now;
1040
+ }
1041
+ return new Date((input.clock ?? systemPlanningClock).now());
1042
+ }
1043
+ /**
1044
+ * Resolve a Date-compatible planner input.
1045
+ *
1046
+ * Precedence: bare Date, `{ now }`, `{ clock }`, then `systemPlanningClock`.
1047
+ * Explicit Date inputs are returned by reference, not cloned.
1048
+ */
1049
+ function resolvePlanningDateInput(input = {}) {
1050
+ if (input instanceof Date) {
1051
+ return input;
1052
+ }
1053
+ return resolvePlanningNowDate(input);
1054
+ }
1055
+ /** Build an enabled selection while preserving literal value types. */
1056
+ function enabledSelection(value) {
1057
+ return {
1058
+ enabled: true,
1059
+ value
1060
+ };
1061
+ }
1062
+ /** Build a disabled selection. */
1063
+ function disabledSelection() {
1064
+ return { enabled: false };
1065
+ }
1066
+
541
1067
  //#endregion
542
1068
  //#region ../core/src/event-pricing.ts
543
- const EVENT_PRICING_MODES = [
544
- "single",
545
- "ticket_types",
546
- "sliding_scale"
547
- ];
548
- function isEventPricingMode(value) {
549
- return typeof value === "string" && EVENT_PRICING_MODES.includes(value);
1069
+ /** Capacity closes booking, but does not make an otherwise eligible price unknown. */
1070
+ function isPublicEventPricingOptionDisplayable(option) {
1071
+ if (!option.isActive) return false;
1072
+ if (option.availability.kind === "available") return true;
1073
+ const reason = option.availability.reason;
1074
+ switch (reason) {
1075
+ case "sold_out":
1076
+ case "overallCapacityUnavailable": return true;
1077
+ case "inactive":
1078
+ case "not_on_sale_yet":
1079
+ case "sale_ended":
1080
+ case "attendance_mode_unavailable": return false;
1081
+ default: return assertNever(reason);
1082
+ }
550
1083
  }
551
1084
  function publicEventPricingDisplay(pricing) {
552
- return pricing.display;
1085
+ switch (pricing.kind) {
1086
+ case "free":
1087
+ case "single":
1088
+ case "sliding_scale": return canonicalEventPricingDisplay(pricing);
1089
+ case "tiered": return resolvePricingOptionsPriceDisplay({
1090
+ options: pricing.options.filter(isPublicEventPricingOptionDisplayable),
1091
+ currency: pricing.currency,
1092
+ purpose: "configured"
1093
+ });
1094
+ case "external": return {
1095
+ kind: "external",
1096
+ label: pricing.label
1097
+ };
1098
+ }
553
1099
  }
554
1100
  function publicEventPricingType(pricing) {
555
1101
  switch (pricing.kind) {
556
- case "drop_in":
557
- case "free_registration": return "free";
558
- case "single_paid":
559
- case "ticket_types":
560
- case "sliding_scale":
561
- case "whole_series": return isFreePublicEventPriceDisplay(pricing.display) ? "free" : "paid";
1102
+ case "free": return "free";
1103
+ case "single":
1104
+ case "tiered":
1105
+ case "sliding_scale": return isFreePublicEventPriceDisplay(publicEventPricingDisplay(pricing)) ? "free" : "paid";
562
1106
  case "external": return "external";
563
1107
  }
564
1108
  }
565
1109
  function publicEventPricingCurrency(pricing) {
566
1110
  switch (pricing.kind) {
567
- case "drop_in":
568
- case "free_registration":
569
- case "single_paid":
570
- case "ticket_types":
1111
+ case "free":
1112
+ case "single":
1113
+ case "tiered":
571
1114
  case "sliding_scale": return pricing.currency;
572
- case "whole_series": return pricing.display.display.currency;
573
1115
  case "external": return null;
574
1116
  }
575
1117
  }
576
1118
  function publicEventScalarPrice(pricing) {
577
- return pricing.kind === "single_paid" ? pricing.amount : null;
1119
+ return pricing.kind === "single" ? pricing.amount : null;
578
1120
  }
579
- function publicEventPricingMode(pricing) {
1121
+ function publicEventPricingStrategy(pricing) {
580
1122
  switch (pricing.kind) {
581
- case "single_paid":
582
- case "free_registration": return "single";
583
- case "ticket_types": return "ticket_types";
1123
+ case "single": return "single";
1124
+ case "free": return "free";
1125
+ case "tiered": return "tiered";
584
1126
  case "sliding_scale": return "sliding_scale";
585
- case "drop_in":
586
- case "whole_series":
587
1127
  case "external": return undefined;
588
1128
  }
589
1129
  }
590
- function publicEventTicketTypes(pricing) {
591
- return pricing.kind === "ticket_types" ? pricing.ticketTypes : [];
1130
+ function publicEventPricingOptions(pricing) {
1131
+ return pricing.kind === "tiered" ? pricing.options : [];
592
1132
  }
593
1133
  function isFreePublicEventPriceDisplay(display) {
594
1134
  switch (display.kind) {
595
1135
  case "free": return true;
596
- case "whole_series": return display.display.kind === "free";
597
1136
  case "exact":
598
1137
  case "from":
599
1138
  case "range":
@@ -602,15 +1141,96 @@ function isFreePublicEventPriceDisplay(display) {
602
1141
  case "external": return false;
603
1142
  }
604
1143
  }
605
- function formatCoursePriceDisplayLabel(display, options) {
606
- switch (display.kind) {
607
- case "free":
608
- case "unavailable": return "Course";
609
- case "exact": return `Course (${formatCurrencyAmount(display.amount, display.currency, options)})`;
610
- case "from": return `Course (From ${formatCurrencyAmount(display.amount, display.currency, options)})`;
611
- case "range": return `Course (${formatCurrencyAmount(display.minAmount, display.currency, options)} - ${formatCurrencyAmount(display.maxAmount, display.currency, options)})`;
612
- case "mixed": return display.paidToAmount > display.paidFromAmount ? `Course (Free / ${formatCurrencyAmount(display.paidFromAmount, display.currency, options)} - ${formatCurrencyAmount(display.paidToAmount, display.currency, options)})` : `Course (Free / ${formatCurrencyAmount(display.paidFromAmount, display.currency, options)})`;
613
- default: return assertNever(display);
1144
+ function resolvePricingOptionSaleWindowState(option, now) {
1145
+ if (option.saleStartsAt !== null && option.saleStartsAt !== undefined && new Date(option.saleStartsAt) > now) {
1146
+ return "not_started";
1147
+ }
1148
+ if (option.saleEndsAt !== null && option.saleEndsAt !== undefined && new Date(option.saleEndsAt) < now) {
1149
+ return "ended";
1150
+ }
1151
+ return "open";
1152
+ }
1153
+ function resolveExactOrFreePriceDisplay(amount, currency) {
1154
+ const resolvedAmount = amount ?? 0;
1155
+ return resolvedAmount > 0 ? {
1156
+ kind: "exact",
1157
+ amount: resolvedAmount,
1158
+ currency
1159
+ } : {
1160
+ kind: "free",
1161
+ currency
1162
+ };
1163
+ }
1164
+ function resolvePricingOptionsPriceDisplay(input) {
1165
+ const now = input.purpose === "available_now" ? resolvePlanningNowDate({
1166
+ now: input.now,
1167
+ clock: input.clock
1168
+ }) : null;
1169
+ const amounts = input.options.filter((option) => option.isActive && (now === null || resolvePricingOptionSaleWindowState(option, now) === "open")).map((option) => option.priceAmount);
1170
+ return amounts.length === 0 ? {
1171
+ kind: "unavailable",
1172
+ currency: input.currency
1173
+ } : amountRangeDisplay(amounts, input.currency);
1174
+ }
1175
+ function amountRangeDisplay(amounts, currency) {
1176
+ const minAmount = Math.min(...amounts);
1177
+ const maxAmount = Math.max(...amounts);
1178
+ if (minAmount === maxAmount) {
1179
+ return minAmount === 0 ? {
1180
+ kind: "free",
1181
+ currency
1182
+ } : {
1183
+ kind: "exact",
1184
+ amount: minAmount,
1185
+ currency
1186
+ };
1187
+ }
1188
+ if (minAmount === 0) {
1189
+ const paidAmounts = amounts.filter((amount) => amount > 0);
1190
+ return {
1191
+ kind: "mixed",
1192
+ paidFromAmount: Math.min(...paidAmounts),
1193
+ paidToAmount: maxAmount,
1194
+ currency
1195
+ };
1196
+ }
1197
+ return {
1198
+ kind: "range",
1199
+ minAmount,
1200
+ maxAmount,
1201
+ currency
1202
+ };
1203
+ }
1204
+ /** Derives configured price presentation directly from canonical pricing. */
1205
+ function canonicalEventPricingDisplay(pricing) {
1206
+ switch (pricing.kind) {
1207
+ case "free": return {
1208
+ kind: "free",
1209
+ currency: pricing.currency
1210
+ };
1211
+ case "single": return {
1212
+ kind: "exact",
1213
+ amount: pricing.amount,
1214
+ currency: pricing.currency
1215
+ };
1216
+ case "sliding_scale": return pricing.minimum === pricing.maximum ? {
1217
+ kind: "exact",
1218
+ amount: pricing.maximum,
1219
+ currency: pricing.currency
1220
+ } : {
1221
+ kind: "range",
1222
+ minAmount: pricing.minimum,
1223
+ maxAmount: pricing.maximum,
1224
+ currency: pricing.currency
1225
+ };
1226
+ case "tiered": {
1227
+ return resolvePricingOptionsPriceDisplay({
1228
+ options: pricing.options,
1229
+ currency: pricing.currency,
1230
+ purpose: "configured"
1231
+ });
1232
+ }
1233
+ default: return assertNever(pricing);
614
1234
  }
615
1235
  }
616
1236
  /**
@@ -629,7 +1249,6 @@ function formatBookingPriceDisplay(display, options = {}) {
629
1249
  case "mixed": return display.paidToAmount > display.paidFromAmount ? `Free / ${formatCurrencyAmount(display.paidFromAmount, display.currency, options)} - ${formatCurrencyAmount(display.paidToAmount, display.currency, options)}` : `Free / ${formatCurrencyAmount(display.paidFromAmount, display.currency, options)}`;
630
1250
  case "unavailable": return "Pricing unavailable";
631
1251
  case "external": return display.label;
632
- case "whole_series": return formatCoursePriceDisplayLabel(display.display, options);
633
1252
  default: return assertNever(display);
634
1253
  }
635
1254
  }
@@ -640,17 +1259,48 @@ function formatEventPricing(pricing, options = {}) {
640
1259
 
641
1260
  //#endregion
642
1261
  //#region ../core/src/event-page-surface-subject.ts
643
- function createEventSeriesPageSurfaceSubject(eventSeriesId, bookingScope) {
1262
+ function createEventSeriesPageSurfaceSubject(eventSeriesId, bookingScope, eventDestinationMode) {
1263
+ const mode = eventDestinationMode ? { eventDestinationMode } : {};
644
1264
  return bookingScope === "whole_series" ? {
645
1265
  kind: "eventSeries",
646
1266
  eventSeriesId,
647
1267
  bookingScope,
648
- isWholeSeriesBooking: true
1268
+ isWholeSeriesBooking: true,
1269
+ ...mode
649
1270
  } : {
650
1271
  kind: "eventSeries",
651
1272
  eventSeriesId,
652
- bookingScope
1273
+ bookingScope,
1274
+ ...mode
1275
+ };
1276
+ }
1277
+ function createEventOfferingPageSurfaceSubject(eventOfferingId, input) {
1278
+ const mode = input.eventDestinationMode ? { eventDestinationMode: input.eventDestinationMode } : {};
1279
+ const identity = {
1280
+ kind: "eventOffering",
1281
+ eventOfferingId,
1282
+ catalogueEventSeriesIds: input.catalogueEventSeriesIds
653
1283
  };
1284
+ return input.bookingScope === "whole_series" ? {
1285
+ ...identity,
1286
+ bookingScope: input.bookingScope,
1287
+ isWholeSeriesBooking: true,
1288
+ ...mode
1289
+ } : {
1290
+ ...identity,
1291
+ bookingScope: input.bookingScope,
1292
+ ...mode
1293
+ };
1294
+ }
1295
+ /**
1296
+ * Read the destination mode off a wire value.
1297
+ *
1298
+ * Absent or unrecognised reads as absent, which leaves the routed Event record
1299
+ * as the only authority — the behaviour every caller had before the field
1300
+ * existed.
1301
+ */
1302
+ function parseEventPageSurfaceDestinationMode(value) {
1303
+ return value === "cms-managed" || value === "system-managed" ? value : undefined;
654
1304
  }
655
1305
  function parseEventPageSurfaceSubject(value) {
656
1306
  if (!isRecord$6(value)) return err({ kind: "notObject" });
@@ -669,7 +1319,7 @@ function parseEventPageSurfaceSubject(value) {
669
1319
  bookingScope: value.bookingScope
670
1320
  });
671
1321
  }
672
- return ok(createEventSeriesPageSurfaceSubject(eventSeriesId.value, value.bookingScope));
1322
+ return ok(createEventSeriesPageSurfaceSubject(eventSeriesId.value, value.bookingScope, parseEventPageSurfaceDestinationMode(value.eventDestinationMode)));
673
1323
  }
674
1324
  case "eventOffering": {
675
1325
  const eventOfferingId = parseEventOfferingId(value.eventOfferingId);
@@ -679,28 +1329,26 @@ function parseEventPageSurfaceSubject(value) {
679
1329
  error: eventOfferingId.error
680
1330
  });
681
1331
  }
682
- if (!Array.isArray(value.availableEventSeriesIds)) {
1332
+ if (!Array.isArray(value.catalogueEventSeriesIds)) {
683
1333
  return err({
684
- kind: "unsupportedSubject",
685
- subjectKind: value.kind
1334
+ kind: "invalidEventOfferingCatalogue",
1335
+ catalogue: value.catalogueEventSeriesIds
686
1336
  });
687
1337
  }
688
- const parsedSeriesIds = [];
689
- for (const valueId of value.availableEventSeriesIds) {
690
- const eventSeriesId = parseEventSeriesId(valueId);
691
- if (!eventSeriesId.ok) {
692
- return err({
693
- kind: "invalidEventSeriesId",
694
- error: eventSeriesId.error
695
- });
696
- }
697
- parsedSeriesIds.push(eventSeriesId.value);
1338
+ if (value.bookingScope !== "single_occurrence" && value.bookingScope !== "whole_series") {
1339
+ return err({
1340
+ kind: "invalidEventOfferingBookingScope",
1341
+ bookingScope: value.bookingScope
1342
+ });
698
1343
  }
699
- return ok({
700
- kind: "eventOffering",
701
- eventOfferingId: eventOfferingId.value,
702
- availableEventSeriesIds: parsedSeriesIds
703
- });
1344
+ const parsedCatalogueIds = parseEventSeriesIdList(value.catalogueEventSeriesIds);
1345
+ if (!parsedCatalogueIds.ok) return parsedCatalogueIds;
1346
+ const eventDestinationMode = parseEventPageSurfaceDestinationMode(value.eventDestinationMode);
1347
+ return ok(createEventOfferingPageSurfaceSubject(eventOfferingId.value, {
1348
+ catalogueEventSeriesIds: parsedCatalogueIds.value,
1349
+ bookingScope: value.bookingScope,
1350
+ ...eventDestinationMode ? { eventDestinationMode } : {}
1351
+ }));
704
1352
  }
705
1353
  default: return err({
706
1354
  kind: "unsupportedSubject",
@@ -711,136 +1359,22 @@ function parseEventPageSurfaceSubject(value) {
711
1359
  function toEventPageSurfaceSubjectWire(subject) {
712
1360
  return subject;
713
1361
  }
714
- function isRecord$6(value) {
715
- return typeof value === "object" && value !== null && !Array.isArray(value);
716
- }
717
-
718
- //#endregion
719
- //#region ../core/src/money-input.ts
720
- function unsafeAsCents(value) {
721
- return value;
722
- }
723
- function unsafeAsNonNegativeCents(value) {
724
- return value;
725
- }
726
- function unsafeAsPositiveCents(value) {
727
- return value;
728
- }
729
- function unwrapCents(value) {
730
- return value;
731
- }
732
- function isWholeCents(value) {
733
- return Number.isFinite(value) && Number.isInteger(value);
734
- }
735
- function isNonNegativeWholeCents(value) {
736
- return isWholeCents(value) && value >= 0;
737
- }
738
- function isPositiveWholeCents(value) {
739
- return isWholeCents(value) && value > 0;
740
- }
741
- /**
742
- * Normalizes a derived amount to signed whole cents. Use parse*Cents for
743
- * untrusted input that should fail instead of truncating or clamping.
744
- */
745
- function toCents(value) {
746
- return unsafeAsCents(Math.trunc(value));
747
- }
748
- /**
749
- * Normalizes a derived amount to non-negative whole cents. This intentionally
750
- * clamps negatives to zero; use parseNonNegativeCents for validation.
751
- */
752
- function toNonNegativeCents(value) {
753
- return unsafeAsNonNegativeCents(Math.max(0, Math.trunc(value)));
754
- }
755
- function parseCents(value) {
756
- if (!Number.isFinite(value)) {
757
- return err({ kind: "invalid-number" });
758
- }
759
- if (!Number.isInteger(value)) {
760
- return err({ kind: "not-whole-cents" });
761
- }
762
- return ok(unsafeAsCents(value));
763
- }
764
- function parseNonNegativeCents(value) {
765
- const parsed = parseCents(value);
766
- if (!parsed.ok) {
767
- return parsed;
768
- }
769
- if (parsed.value < 0) {
770
- return err({ kind: "negative" });
771
- }
772
- return ok(unsafeAsNonNegativeCents(parsed.value));
773
- }
774
- function parsePositiveCents(value) {
775
- const parsed = parseNonNegativeCents(value);
776
- if (!parsed.ok) {
777
- return parsed;
778
- }
779
- if (parsed.value === 0) {
780
- return err({ kind: "zero-not-allowed" });
781
- }
782
- return ok(unsafeAsPositiveCents(parsed.value));
783
- }
784
- function normalizeMoneyInput(value, options) {
785
- return (options.stripFormatting === true ? value.replace(/[^0-9.-]/g, "") : value).trim();
786
- }
787
- function parseFiniteMoneyAmount(value, options) {
788
- const normalized = normalizeMoneyInput(value, options);
789
- if (normalized.length === 0) {
790
- return err({ kind: "empty" });
791
- }
792
- const amount = Number(normalized);
793
- if (!Number.isFinite(amount)) {
794
- return err({ kind: "invalid-number" });
795
- }
796
- return ok(amount);
797
- }
798
- function roundMoneyAmountToCents(amount) {
799
- return Math.round(amount * 100);
800
- }
801
- function parsePositiveMoneyInputToCents(value, options = {}) {
802
- const parsed = parseFiniteMoneyAmount(value, options);
803
- if (!parsed.ok) {
804
- return parsed;
805
- }
806
- if (parsed.value < 0) {
807
- return err({ kind: "negative" });
808
- }
809
- const amountCents = roundMoneyAmountToCents(parsed.value);
810
- if (amountCents <= 0) {
811
- return err({ kind: "zero-not-allowed" });
812
- }
813
- return ok(unsafeAsPositiveCents(amountCents));
814
- }
815
- function parseNonNegativeMoneyInputToCents(value, options = {}) {
816
- const parsed = parseFiniteMoneyAmount(value, options);
817
- if (!parsed.ok) {
818
- return parsed;
819
- }
820
- if (parsed.value < 0) {
821
- return err({ kind: "negative" });
822
- }
823
- return ok(unsafeAsNonNegativeCents(roundMoneyAmountToCents(parsed.value)));
824
- }
825
- function parseMoneyInputToCents(value, options = {}) {
826
- const parsed = parseFiniteMoneyAmount(value, options);
827
- if (!parsed.ok) {
828
- return null;
829
- }
830
- if (options.allowZero === true ? parsed.value < 0 : parsed.value <= 0) {
831
- return null;
1362
+ function parseEventSeriesIdList(values) {
1363
+ const parsed = [];
1364
+ for (const value of values) {
1365
+ const eventSeriesId = parseEventSeriesId(value);
1366
+ if (!eventSeriesId.ok) {
1367
+ return err({
1368
+ kind: "invalidEventSeriesId",
1369
+ error: eventSeriesId.error
1370
+ });
1371
+ }
1372
+ parsed.push(eventSeriesId.value);
832
1373
  }
833
- return roundMoneyAmountToCents(parsed.value);
1374
+ return ok(parsed);
834
1375
  }
835
- function formatMoneyInputFromCents(amountCents) {
836
- if (typeof amountCents !== "number" || !Number.isFinite(amountCents)) {
837
- return "";
838
- }
839
- const sign = amountCents < 0 ? "-" : "";
840
- const absoluteCents = Math.abs(Math.trunc(amountCents));
841
- const units = Math.floor(absoluteCents / 100);
842
- const fractional = String(absoluteCents % 100).padStart(2, "0");
843
- return `${sign}${units}.${fractional}`;
1376
+ function isRecord$6(value) {
1377
+ return typeof value === "object" && value !== null && !Array.isArray(value);
844
1378
  }
845
1379
 
846
1380
  //#endregion
@@ -1824,19 +2358,6 @@ function unwrapNarrow(result) {
1824
2358
  return result.value;
1825
2359
  }
1826
2360
 
1827
- //#endregion
1828
- //#region ../core/src/non-empty-array.ts
1829
- function fromArray(items) {
1830
- const [first, ...rest] = items;
1831
- if (first === undefined) {
1832
- return err("empty_array");
1833
- }
1834
- return ok([first, ...rest]);
1835
- }
1836
- function mapNonEmptyArray(items, map) {
1837
- return items.map(map);
1838
- }
1839
-
1840
2361
  //#endregion
1841
2362
  //#region ../core/src/editor-state.ts
1842
2363
  const unsafeAsEditorSiteId = (value) => value;
@@ -4653,61 +5174,104 @@ function foldSiteRouteTarget(target, cases) {
4653
5174
  }
4654
5175
 
4655
5176
  //#endregion
4656
- //#region ../core/src/planner.ts
4657
- function plannerNoChange(details = {}) {
4658
- return {
4659
- ...details,
4660
- tag: "no_change"
4661
- };
5177
+ //#region ../core/src/whole-series-run-schedule.ts
5178
+ /**
5179
+ * A session that is not happening.
5180
+ *
5181
+ * Exported because every surface that renders a run's schedule has to mark the
5182
+ * same weeks, and a second copy of the string is a second chance to disagree.
5183
+ */
5184
+ const CANCELLED_SESSION_STATUS = "cancelled";
5185
+ /** A run the organiser called off. Spelled the same, about a different row. */
5186
+ const CANCELLED_SERIES_STATUS = "cancelled";
5187
+ function sessionStartMs(session) {
5188
+ return Date.parse(session.startsAt);
4662
5189
  }
4663
- function plannerUpdate(details = {}) {
4664
- return {
4665
- ...details,
4666
- tag: "update"
4667
- };
5190
+ /**
5191
+ * The run's sessions that still count, in schedule order.
5192
+ *
5193
+ * Sessions whose start cannot be read are dropped rather than sorted to an
5194
+ * arbitrary end: an unparseable date is not a date a visitor can attend.
5195
+ */
5196
+ function orderedLiveRunSessions(sessions) {
5197
+ return sessions.filter((session) => session.status !== CANCELLED_SESSION_STATUS && Number.isFinite(sessionStartMs(session))).sort((left, right) => sessionStartMs(left) - sessionStartMs(right));
4668
5198
  }
4669
- /** Identity at runtime; marks a planner-derived patch as shallow readonly. */
4670
- function plannerPatch(patch) {
4671
- return patch;
5199
+ /** When the first session a visitor could attend begins. */
5200
+ function firstLiveRunSessionStartMs(sessions) {
5201
+ const first = orderedLiveRunSessions(sessions)[0];
5202
+ return first === undefined ? null : sessionStartMs(first);
4672
5203
  }
4673
- /** Default clock for production callers that do not inject time explicitly. */
4674
- const systemPlanningClock = { now: () => Date.now() };
4675
- /** Resolve current time from an explicit timestamp, injected clock, or system clock. */
4676
- function resolvePlanningNow(input = {}) {
4677
- if (typeof input.now === "number") {
4678
- return input.now;
4679
- }
4680
- return (input.clock ?? systemPlanningClock).now();
5204
+ /**
5205
+ * Booking stays open until the run's first surviving session has started.
5206
+ *
5207
+ * There has to be something left to sell: a run with no schedule at all, or one
5208
+ * whose every session has been cancelled, is closed rather than open. Adding a
5209
+ * future session opens it again, subject to the usual status, visibility and
5210
+ * admission rules.
5211
+ */
5212
+ function isWholeSeriesRegistrationOpen(sessions, now) {
5213
+ const firstStart = firstLiveRunSessionStartMs(sessions);
5214
+ return firstStart !== null && firstStart > now.getTime();
4681
5215
  }
4682
- /** Resolve current time as a Date from an explicit Date, injected clock, or system clock. */
4683
- function resolvePlanningNowDate(input = {}) {
4684
- if (input.now !== undefined) {
4685
- return input.now;
4686
- }
4687
- return new Date((input.clock ?? systemPlanningClock).now());
5216
+ /**
5217
+ * Where the run sits in its own life, independent of any one session.
5218
+ *
5219
+ * A cancelled or completed series status says so whatever the dates read.
5220
+ * Otherwise the surviving schedule decides: before the first session it is
5221
+ * upcoming, after the last session ends it is complete, and in between it is
5222
+ * under way. A run with nothing left to attend is `upcoming` and never
5223
+ * `in_progress` — an empty schedule has not started.
5224
+ */
5225
+ function resolveWholeSeriesRunLifecycle(input, now) {
5226
+ if (input.seriesStatus === CANCELLED_SERIES_STATUS) return "cancelled";
5227
+ if (input.seriesStatus === "completed") return "completed";
5228
+ const live = orderedLiveRunSessions(input.sessions);
5229
+ const first = live[0];
5230
+ const last = live[live.length - 1];
5231
+ if (first === undefined || last === undefined) return "upcoming";
5232
+ if (sessionStartMs(first) > now.getTime()) return "upcoming";
5233
+ return Date.parse(last.endsAt) < now.getTime() ? "completed" : "in_progress";
4688
5234
  }
4689
5235
  /**
4690
- * Resolve a Date-compatible planner input.
5236
+ * Whether a prepared run is still inviting bookings at render time.
4691
5237
  *
4692
- * Precedence: bare Date, `{ now }`, `{ clock }`, then `systemPlanningClock`.
4693
- * Explicit Date inputs are returned by reference, not cloned.
5238
+ * `lifecycle` and `enrollmentOpen` come from the projection; the first session
5239
+ * start is the one fact re-read against `now`, because that is the boundary a
5240
+ * cached page can cross while it sits in front of a visitor.
4694
5241
  */
4695
- function resolvePlanningDateInput(input = {}) {
4696
- if (input instanceof Date) {
4697
- return input;
5242
+ function resolveWholeSeriesBookingPhase(run, now) {
5243
+ switch (run.lifecycle) {
5244
+ case "cancelled": return {
5245
+ kind: "closed",
5246
+ reason: "cancelled"
5247
+ };
5248
+ case "in_progress": return {
5249
+ kind: "closed",
5250
+ reason: "in-progress"
5251
+ };
5252
+ case "completed": return {
5253
+ kind: "closed",
5254
+ reason: "completed"
5255
+ };
5256
+ case "upcoming": {
5257
+ if (firstLiveRunSessionStartMs(run.sessions) === null) {
5258
+ return {
5259
+ kind: "closed",
5260
+ reason: "no-sessions"
5261
+ };
5262
+ }
5263
+ if (!isWholeSeriesRegistrationOpen(run.sessions, now)) {
5264
+ return {
5265
+ kind: "closed",
5266
+ reason: "in-progress"
5267
+ };
5268
+ }
5269
+ return run.enrollmentOpen ? { kind: "open" } : {
5270
+ kind: "closed",
5271
+ reason: "enrolment-closed"
5272
+ };
5273
+ }
4698
5274
  }
4699
- return resolvePlanningNowDate(input);
4700
- }
4701
- /** Build an enabled selection while preserving literal value types. */
4702
- function enabledSelection(value) {
4703
- return {
4704
- enabled: true,
4705
- value
4706
- };
4707
- }
4708
- /** Build a disabled selection. */
4709
- function disabledSelection() {
4710
- return { enabled: false };
4711
5275
  }
4712
5276
 
4713
5277
  //#endregion
@@ -5382,4 +5946,4 @@ function orderPresetIdsForVertical(verticalId, presetIds) {
5382
5946
  }
5383
5947
 
5384
5948
  //#endregion
5385
- export { parseEventSeriesId as $, normalizeLocationAddressText as A, ok as At, parseUtcIsoDateTimeString as B, parseNewsletterSignupDestinationProvider as C, unsafeAsSiteScopedStoragePath as Ct, mapNonEmptyArray as D, unsafeAsUploadSessionId as Dt, fromArray as E, unsafeAsStoragePath as Et, formatLocalScheduleDate as F, parseEventPageSurfaceSubject as G, toScheduledBookingDateRange as H, formatLocalScheduleTime as I, publicEventPricingCurrency as J, formatBookingPriceDisplay as K, getBookingLocalDateKey as L, formatBookingDate as M, formatBookingDateParts as N, hasText as O, unsafeAsUserId as Ot, formatBookingTime as P, parseApiKeyId as Q, getLocalScheduleDate as R, parseNewsletterSignupAuthoringDestinationValue as S, unsafeAsSiteId as St, assertAllowedPublicUrl as T, unsafeAsStorageBucketName as Tt, toScheduledBookingDateTime as U, toBookingTimeZone as V, parseBookingTimeZone as W, formatCurrencyAmount as X, publicEventPricingType as Y, assertNever as Z, parseFooterCompositionV1 as _, unsafeAsPositiveByteCount as _t, parseVerticalPresetId as a, unsafeAsBookingRequirementId as at, parseMailchimpTagName as b, unsafeAsSafeDownloadFilename as bt, EVENT_PRESET_VALUES as c, unsafeAsDemoCapability as ct, resolvePlanningDateInput as d, unsafeAsEventOfferingId as dt, parseMediaAssetId as et, createSecret as f, unsafeAsEventSeriesId as ft, footerNavigationLayouts as g, unsafeAsOriginalFilename as gt, stableJsonStringify as h, unsafeAsOccurrenceId as ht, parseVerticalId as i, unsafeAsAppointmentResourceId as it, toLocationCoordinates as j, isPresent as k, err as kt, ORDINARY_EVENT_PRESET_VALUES as l, unsafeAsDetectedMimeType as lt, parseManagementKey as m, unsafeAsMediaAssetId as mt, canonicalizeConfiguredBookingVerticalConfig as n, parseSiteId as nt, resolveSiteVerticalBookingConfig as o, unsafeAsBookingSessionId as ot, exposeSecret as p, unsafeAsFileChecksumSha256 as pt, formatEventPricing as q, isEventSeriesVerticalPresetDefinition as r, parseUserId as rt, DEFAULT_EVENT_TYPE_PRESETS as s, unsafeAsDeclaredMimeType as st, areConfiguredBookingVerticalConfigsEqual as t, parsePageId as tt, SURFACE_EVENT_PRESET_VALUES as u, unsafeAsEntitlementId as ut, parseFooterModuleId as v, unsafeAsPositiveSeconds as vt, isCanonicalRichTextDoc as w, unsafeAsStaffMemberId as wt, parseNewsletterListId as x, unsafeAsSiteFontId as xt, parseMailchimpAudienceId as y, unsafeAsProductId as yt, getLocalScheduleTime as z };
5949
+ export { fromArray as $, normalizeLocationAddressText as A, unsafeAsStorageBucketName as At, getLocalScheduleTime as B, parseMailchimpTagName as C, unsafeAsPositiveSeconds as Ct, isCanonicalRichTextDoc as D, unsafeAsSiteId as Dt, parseNewsletterSignupDestinationProvider as E, unsafeAsSiteFontId as Et, formatBookingTime as F, ok as Ft, parseBookingTimeZone as G, toBookingTimeZone as H, formatLocalScheduleDate as I, formatEventPricing as J, parseEventPageSurfaceSubject as K, formatLocalScheduleTime as L, buildBookingUtcDateWindow as M, unsafeAsUploadSessionId as Mt, formatBookingDate as N, unsafeAsUserId as Nt, assertAllowedPublicUrl as O, unsafeAsSiteScopedStoragePath as Ot, formatBookingDateParts as P, err as Pt, publicEventPricingType as Q, getBookingLocalDateKey as R, parseMailchimpAudienceId as S, unsafeAsPositiveByteCount as St, parseNewsletterSignupAuthoringDestinationValue as T, unsafeAsSafeDownloadFilename as Tt, toScheduledBookingDateRange as U, parseUtcIsoDateTimeString as V, toScheduledBookingDateTime as W, publicEventPricingCurrency as X, isPublicEventPricingOptionDisplayable as Y, publicEventPricingDisplay as Z, parseManagementKey as _, unsafeAsEventSeriesId as _t, parseVerticalPresetId as a, parseMediaAssetId as at, parseFooterCompositionV1 as b, unsafeAsOccurrenceId as bt, EVENT_PRESET_VALUES as c, parseUserId as ct, WHOLE_SERIES_EVENT_PRESET_VALUES as d, unsafeAsBookingSessionId as dt, mapNonEmptyArray as et, CANCELLED_SESSION_STATUS as f, unsafeAsDeclaredMimeType as ft, exposeSecret as g, unsafeAsEventOfferingId as gt, createSecret as h, unsafeAsEntitlementId as ht, parseVerticalId as i, parseEventSeriesId as it, toLocationCoordinates as j, unsafeAsStoragePath as jt, isPresent as k, unsafeAsStaffMemberId as kt, ORDINARY_EVENT_PRESET_VALUES as l, unsafeAsAppointmentResourceId as lt, resolveWholeSeriesBookingPhase as m, unsafeAsDetectedMimeType as mt, canonicalizeConfiguredBookingVerticalConfig as n, assertNever as nt, resolveSiteVerticalBookingConfig as o, parsePageId as ot, orderedLiveRunSessions as p, unsafeAsDemoCapability as pt, formatBookingPriceDisplay as q, isEventSeriesVerticalPresetDefinition as r, parseApiKeyId as rt, DEFAULT_EVENT_TYPE_PRESETS as s, parseSiteId as st, areConfiguredBookingVerticalConfigsEqual as t, formatCurrencyAmount as tt, SURFACE_EVENT_PRESET_VALUES as u, unsafeAsBookingRequirementId as ut, stableJsonStringify as v, unsafeAsFileChecksumSha256 as vt, parseNewsletterListId as w, unsafeAsProductId as wt, parseFooterModuleId as x, unsafeAsOriginalFilename as xt, footerNavigationLayouts as y, unsafeAsMediaAssetId as yt, getLocalScheduleDate as z };