@tribe-nest/forge 3.64.0 → 3.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/types/models.ts +13 -0
- package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
- package/src/ui/format/bookingPricing.ts +55 -0
- package/src/ui/format/ticketPrice.ts +52 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/BookingPriceSummary.tsx +57 -0
- package/src/ui/styled/CoachingBooking.tsx +36 -158
- package/src/ui/styled/CoachingConfirmation.tsx +13 -2
- package/src/ui/styled/CoachingDetail.tsx +8 -2
- package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
- package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
- package/src/ui/styled/forge-utilities.css +0 -5
- package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
- package/src/ui/styled/seating/SeatLegend.tsx +3 -1
- package/src/utils/structuredData.ts +4 -1
package/package.json
CHANGED
|
@@ -35,16 +35,25 @@ export type ReserveCoachingBookingResult = {
|
|
|
35
35
|
* held, and the buyer's rights are governed by this copy, not by that read.
|
|
36
36
|
*/
|
|
37
37
|
cancellation?: CancellationTermsView | null;
|
|
38
|
+
/** How many sessions this booking holds (1 for a single session). */
|
|
39
|
+
sessionCount?: number;
|
|
40
|
+
/** Every session held, earliest first. `bookingId` above is the first one's. */
|
|
41
|
+
sessions?: { bookingId: string; slotId: string; startTime: string; endTime: string }[];
|
|
42
|
+
/** When the server releases the hold (ISO). */
|
|
43
|
+
holdExpiresAt?: string;
|
|
38
44
|
};
|
|
39
45
|
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Reserve a booking: one slot, or every session of a multi-session booking at
|
|
48
|
+
* once (`slotIds`). All or nothing: if any time was just taken, nothing is held.
|
|
49
|
+
*/
|
|
41
50
|
export function useReserveCoachingBooking(productId?: string) {
|
|
42
51
|
const { client } = useForge();
|
|
43
52
|
|
|
44
|
-
return useMutation<ReserveCoachingBookingResult, unknown, { slotId
|
|
45
|
-
mutationFn: async ({ slotId }) => {
|
|
53
|
+
return useMutation<ReserveCoachingBookingResult, unknown, { slotId?: string; slotIds?: string[] }>({
|
|
54
|
+
mutationFn: async ({ slotId, slotIds }) => {
|
|
46
55
|
const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
|
|
47
|
-
slotId,
|
|
56
|
+
...(slotIds?.length ? { slotIds } : { slotId }),
|
|
48
57
|
metaSignals: readMetaSignals(),
|
|
49
58
|
});
|
|
50
59
|
return res.data;
|
|
@@ -106,10 +115,13 @@ export function useUpdateCoachingBooking(productId?: string) {
|
|
|
106
115
|
export function useUnreserveCoachingBooking(productId?: string) {
|
|
107
116
|
const { client } = useForge();
|
|
108
117
|
|
|
109
|
-
return useMutation<unknown, unknown, { bookingId: string }>({
|
|
110
|
-
|
|
118
|
+
return useMutation<unknown, unknown, { bookingId: string; bookingSecret?: string }>({
|
|
119
|
+
// The secret is required: without it the backend answers 404 for a guest's
|
|
120
|
+
// hold and the times stay held until the hold expires.
|
|
121
|
+
mutationFn: async ({ bookingId, bookingSecret }) => {
|
|
111
122
|
const res = await client.post(`/public/coaching/products/${productId}/booking/unreserve`, {
|
|
112
123
|
bookingId,
|
|
124
|
+
bookingSecret,
|
|
113
125
|
});
|
|
114
126
|
return res.data;
|
|
115
127
|
},
|
|
@@ -83,6 +83,13 @@ export type MyBooking = {
|
|
|
83
83
|
lastName: string | null;
|
|
84
84
|
/** Already a number (or null for a free session), not a decimal string. */
|
|
85
85
|
totalAmount: number | null;
|
|
86
|
+
/**
|
|
87
|
+
* Set on every session of a multi-session booking except the first, which
|
|
88
|
+
* carries the payment for all of them. Null on a single session.
|
|
89
|
+
*/
|
|
90
|
+
packageLeadBookingId?: string | null;
|
|
91
|
+
/** How many sessions the booking this session belongs to holds (1 = single). */
|
|
92
|
+
sessionCount?: number;
|
|
86
93
|
totalAmountInChargedCurrency: string | number | null;
|
|
87
94
|
currency: string | null;
|
|
88
95
|
chargedCurrency: string | null;
|
package/src/i18n/de.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"forge.account_dashboard.booking_duration": "{minutes} Minuten",
|
|
21
21
|
"forge.account_dashboard.booking_join_label": "Teilnehmen:",
|
|
22
22
|
"forge.account_dashboard.booking_join_video": "Videoanruf beitreten",
|
|
23
|
+
"forge.account_dashboard.booking_part_of": "Teil einer Buchung mit {count} Terminen",
|
|
23
24
|
"forge.account_dashboard.booking_video_call": "Videoanruf auf dieser Website",
|
|
24
25
|
"forge.account_dashboard.booking_video_closed": "Videoanruf: Der Raum dieser Session ist geschlossen",
|
|
25
26
|
"forge.account_dashboard.booking_video_opens_at": "Videoanruf: Der Raum öffnet {datetime}",
|
|
@@ -365,7 +366,10 @@
|
|
|
365
366
|
"forge.broadcast_poll.votes_other": "{count} Stimmen",
|
|
366
367
|
"forge.broadcast_poll.your_vote": "(deine Stimme)",
|
|
367
368
|
"forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
|
|
369
|
+
"forge.broadcast_ticket_purchase.free": "Kostenlos",
|
|
370
|
+
"forge.broadcast_ticket_purchase.free_to_price": "Kostenlos bis {price}",
|
|
368
371
|
"forge.broadcast_ticket_purchase.from_price": "Ab {price}",
|
|
372
|
+
"forge.broadcast_ticket_purchase.reserve": "Reservieren",
|
|
369
373
|
"forge.broadcast_watch.leave": "Übertragung verlassen",
|
|
370
374
|
"forge.broadcast_watch.load_error": "Übertragung konnte nicht geladen werden",
|
|
371
375
|
"forge.bundle_confirmation.continue_shopping": "Weiter einkaufen",
|
|
@@ -693,17 +697,33 @@
|
|
|
693
697
|
"forge.checkout_confirmation.total_paid": "Bezahlt gesamt",
|
|
694
698
|
"forge.checkout_confirmation.view_orders": "Bestellungen ansehen",
|
|
695
699
|
"forge.coaching_booking.aria_close": "Schließen",
|
|
700
|
+
"forge.coaching_booking.aria_next_month": "Nächster Monat",
|
|
696
701
|
"forge.coaching_booking.aria_next_week": "Nächste Woche",
|
|
702
|
+
"forge.coaching_booking.aria_prev_month": "Vorheriger Monat",
|
|
697
703
|
"forge.coaching_booking.aria_prev_week": "Vorherige Woche",
|
|
698
704
|
"forge.coaching_booking.back": "Zurück",
|
|
705
|
+
"forge.coaching_booking.basket_count_package": "{picked} von {count} gewählt",
|
|
706
|
+
"forge.coaching_booking.basket_count_range": "{picked} gewählt, bis zu {max}",
|
|
707
|
+
"forge.coaching_booking.basket_empty": "Noch keine Zeit gewählt",
|
|
708
|
+
"forge.coaching_booking.basket_full_limit": "Das ist das Limit von {max} Terminen. Entferne einen, um eine andere Zeit zu wählen.",
|
|
709
|
+
"forge.coaching_booking.basket_full_package": "Alle {count} Termine gewählt. Entferne einen, um eine andere Zeit zu wählen.",
|
|
710
|
+
"forge.coaching_booking.basket_more": "Noch {count} zu wählen",
|
|
711
|
+
"forge.coaching_booking.basket_title_many": "Deine Termine",
|
|
712
|
+
"forge.coaching_booking.basket_title_package": "Deine {count} Termine",
|
|
713
|
+
"forge.coaching_booking.basket_title_single": "Dein Termin",
|
|
699
714
|
"forge.coaching_booking.confirm_booking": "Buchung bestätigen",
|
|
700
715
|
"forge.coaching_booking.confirm_email_placeholder": "E-Mail bestätigen",
|
|
701
716
|
"forge.coaching_booking.confirm_leave": "Möchtest du die Kasse wirklich verlassen?",
|
|
702
717
|
"forge.coaching_booking.consent": "Mit dem Fortfahren stimmst du zu, Buchungsbestätigungen und wichtige Infos zu deiner Session per E-Mail zu bekommen.",
|
|
703
718
|
"forge.coaching_booking.continue": "Weiter",
|
|
704
719
|
"forge.coaching_booking.continue_to_payment": "Weiter zur Zahlung",
|
|
720
|
+
"forge.coaching_booking.continue_with_total": "Weiter, {amount}",
|
|
721
|
+
"forge.coaching_booking.day_has_times": "{date}, Zeiten verfügbar",
|
|
722
|
+
"forge.coaching_booking.day_no_times": "{date}, keine Zeiten",
|
|
705
723
|
"forge.coaching_booking.details_subtitle": "Bitte gib deine Kontaktdaten für die Buchung an.",
|
|
706
724
|
"forge.coaching_booking.details_title": "Kontaktdaten",
|
|
725
|
+
"forge.coaching_booking.done": "Fertig",
|
|
726
|
+
"forge.coaching_booking.duration_short": "{minutes} Min.",
|
|
707
727
|
"forge.coaching_booking.email_invalid_hint": "Bitte gib eine gültige E-Mail-Adresse ein",
|
|
708
728
|
"forge.coaching_booking.email_mismatch_hint": "Die E-Mail-Adressen stimmen nicht überein",
|
|
709
729
|
"forge.coaching_booking.email_placeholder": "E-Mail",
|
|
@@ -715,14 +735,29 @@
|
|
|
715
735
|
"forge.coaching_booking.first_name_placeholder": "Vorname",
|
|
716
736
|
"forge.coaching_booking.last_name_placeholder": "Nachname",
|
|
717
737
|
"forge.coaching_booking.loading_slots": "Freie Termine werden geladen…",
|
|
738
|
+
"forge.coaching_booking.next_available": "Nächster freier Termin",
|
|
718
739
|
"forge.coaching_booking.no_slots": "Diese Woche sind keine Termine frei.",
|
|
740
|
+
"forge.coaching_booking.no_times_day": "An diesem Tag sind keine Zeiten frei.",
|
|
741
|
+
"forge.coaching_booking.no_times_month": "In diesem Monat sind keine Zeiten frei.",
|
|
719
742
|
"forge.coaching_booking.not_found": "Coaching-Session nicht gefunden.",
|
|
743
|
+
"forge.coaching_booking.not_picked_yet": "Noch nicht gewählt",
|
|
744
|
+
"forge.coaching_booking.package_help_body": "Du wählst jetzt alle Termine und zahlst einmal. Jeden Termin kannst du später einzeln verschieben, nach den beim Checkout gezeigten Storno- und Verschiebungsbedingungen.",
|
|
745
|
+
"forge.coaching_booking.package_help_label": "Wie funktioniert ein Paket?",
|
|
746
|
+
"forge.coaching_booking.package_one_price": "{count} Termine, ein Preis",
|
|
747
|
+
"forge.coaching_booking.package_total": "{price} für alle {count}",
|
|
720
748
|
"forge.coaching_booking.pay": "{amount} zahlen",
|
|
721
749
|
"forge.coaching_booking.payment_not_configured": "Zahlung ist nicht eingerichtet.",
|
|
722
750
|
"forge.coaching_booking.payment_subtitle": "Schließe den Kauf ab, um deine Buchung zu sichern.",
|
|
723
751
|
"forge.coaching_booking.payment_title": "Zahlung",
|
|
752
|
+
"forge.coaching_booking.per_session_calc": "{count} × {price}",
|
|
753
|
+
"forge.coaching_booking.pick_a_time": "Wähle eine Zeit",
|
|
754
|
+
"forge.coaching_booking.pick_day": "Wähle einen markierten Tag, um seine Zeiten zu sehen.",
|
|
755
|
+
"forge.coaching_booking.pick_more": "Noch {count} wählen",
|
|
724
756
|
"forge.coaching_booking.preparing_payment": "Zahlung wird vorbereitet…",
|
|
757
|
+
"forge.coaching_booking.price_per_session": "{price} pro Termin",
|
|
725
758
|
"forge.coaching_booking.processing": "Wird verarbeitet…",
|
|
759
|
+
"forge.coaching_booking.remove": "Entfernen",
|
|
760
|
+
"forge.coaching_booking.remove_time": "{time} entfernen",
|
|
726
761
|
"forge.coaching_booking.reserving": "Wird reserviert…",
|
|
727
762
|
"forge.coaching_booking.select_placeholder": "Auswählen…",
|
|
728
763
|
"forge.coaching_booking.slot_title": "Termin wählen",
|
|
@@ -756,6 +791,7 @@
|
|
|
756
791
|
"forge.coaching_confirmation.row_paid": "Bezahlt",
|
|
757
792
|
"forge.coaching_confirmation.row_reference": "Referenz",
|
|
758
793
|
"forge.coaching_confirmation.row_session": "Session",
|
|
794
|
+
"forge.coaching_confirmation.row_session_n": "Termin {index} von {count}",
|
|
759
795
|
"forge.coaching_confirmation.row_tax": "MwSt.",
|
|
760
796
|
"forge.coaching_confirmation.status_unknown": "ungeklärt",
|
|
761
797
|
"forge.coaching_confirmation.tax_included": "Enthält {amount} MwSt.",
|
|
@@ -764,6 +800,10 @@
|
|
|
764
800
|
"forge.coaching_detail.about_title": "Über diese Session",
|
|
765
801
|
"forge.coaching_detail.duration_minutes": "{minutes} Minuten",
|
|
766
802
|
"forge.coaching_detail.not_found": "Coaching-Session nicht gefunden.",
|
|
803
|
+
"forge.coaching_detail.price_for_sessions": "für {count} Termine",
|
|
804
|
+
"forge.coaching_detail.price_per_session_suffix": "pro Termin",
|
|
805
|
+
"forge.coaching_detail.price_save": "Spare {amount}",
|
|
806
|
+
"forge.coaching_detail.price_up_to": "Bis zu {max} auf einmal buchen",
|
|
767
807
|
"forge.cohort_checkout.aria_close": "Schließen",
|
|
768
808
|
"forge.cohort_checkout.authorise_future_payments": "Autorisiere die späteren Zahlungen dieses Plans.",
|
|
769
809
|
"forge.cohort_checkout.back": "Zurück",
|
|
@@ -1428,7 +1468,10 @@
|
|
|
1428
1468
|
"forge.event_detail.location_virtual": "Online",
|
|
1429
1469
|
"forge.event_detail.not_found": "Event nicht gefunden.",
|
|
1430
1470
|
"forge.event_detail.presale_only": "Tickets für dieses Event gibt es nur im Vorverkauf.",
|
|
1471
|
+
"forge.event_detail.price_free": "Kostenlos",
|
|
1472
|
+
"forge.event_detail.price_free_to": "Kostenlos bis {max}",
|
|
1431
1473
|
"forge.event_detail.price_from_label": "Ab",
|
|
1474
|
+
"forge.event_detail.reserve": "Reservieren",
|
|
1432
1475
|
"forge.event_seat_picker.dismiss": "Schließen",
|
|
1433
1476
|
"forge.event_seat_picker.empty": "Es gibt noch keine Plätze zur Auswahl.",
|
|
1434
1477
|
"forge.event_seat_picker.help_body": "Tippe auf einen Platz, um ihn auszuwählen, und noch einmal, um ihn freizugeben. Zoome mit zwei Fingern oder dem Mausrad und ziehe, um den Plan zu verschieben. Graue Plätze sind nicht verfügbar. Beste verfügbare wählt die niedrigsten zusammenhängenden Plätze für dich.",
|
|
@@ -1487,6 +1530,7 @@
|
|
|
1487
1530
|
"forge.event_tickets.expires_hours_soon_other": "⏰ Läuft in {count} Stunden ab!",
|
|
1488
1531
|
"forge.event_tickets.field_required": "Dieses Feld ist erforderlich",
|
|
1489
1532
|
"forge.event_tickets.first_name_placeholder": "Vorname",
|
|
1533
|
+
"forge.event_tickets.free": "Kostenlos",
|
|
1490
1534
|
"forge.event_tickets.invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein",
|
|
1491
1535
|
"forge.event_tickets.item_label_event": "Dieses Event",
|
|
1492
1536
|
"forge.event_tickets.item_label_ticket": "Dieses Ticket",
|
|
@@ -1514,6 +1558,7 @@
|
|
|
1514
1558
|
"forge.event_tickets.pwyw_range_min_max": "(mindestens {min}, bis {max})",
|
|
1515
1559
|
"forge.event_tickets.remove_discount": "Rabatt entfernen",
|
|
1516
1560
|
"forge.event_tickets.request_tickets": "Tickets anfragen",
|
|
1561
|
+
"forge.event_tickets.reserve": "Reservieren",
|
|
1517
1562
|
"forge.event_tickets.seat_best_available": "Beste verfügbare",
|
|
1518
1563
|
"forge.event_tickets.seat_remove_aria": "{seat} entfernen",
|
|
1519
1564
|
"forge.event_tickets.seats_left_one": "{count} Platz frei",
|
|
@@ -2228,6 +2273,7 @@
|
|
|
2228
2273
|
"forge.review_form.title_placeholder": "Fass es in ein paar Worten zusammen",
|
|
2229
2274
|
"forge.review_form.update_submit": "Bewertung aktualisieren",
|
|
2230
2275
|
"forge.seat_legend.aria_label": "Legende",
|
|
2276
|
+
"forge.seat_legend.free": "Kostenlos",
|
|
2231
2277
|
"forge.seat_legend.left_one": "{count} frei",
|
|
2232
2278
|
"forge.seat_legend.left_other": "{count} frei",
|
|
2233
2279
|
"forge.seat_legend.selected": "Ausgewählt",
|
package/src/i18n/en.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"forge.account_dashboard.booking_duration": "{minutes} minutes",
|
|
21
21
|
"forge.account_dashboard.booking_join_label": "Join:",
|
|
22
22
|
"forge.account_dashboard.booking_join_video": "Join video call",
|
|
23
|
+
"forge.account_dashboard.booking_part_of": "Part of a {count}-session booking",
|
|
23
24
|
"forge.account_dashboard.booking_video_call": "Video call on this site",
|
|
24
25
|
"forge.account_dashboard.booking_video_closed": "Video call: this session's room has closed",
|
|
25
26
|
"forge.account_dashboard.booking_video_opens_at": "Video call: the room opens {datetime}",
|
|
@@ -365,7 +366,10 @@
|
|
|
365
366
|
"forge.broadcast_poll.votes_other": "{count} votes",
|
|
366
367
|
"forge.broadcast_poll.your_vote": "(your vote)",
|
|
367
368
|
"forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
|
|
369
|
+
"forge.broadcast_ticket_purchase.free": "Free",
|
|
370
|
+
"forge.broadcast_ticket_purchase.free_to_price": "Free to {price}",
|
|
368
371
|
"forge.broadcast_ticket_purchase.from_price": "From {price}",
|
|
372
|
+
"forge.broadcast_ticket_purchase.reserve": "Reserve",
|
|
369
373
|
"forge.broadcast_watch.leave": "Leave broadcast",
|
|
370
374
|
"forge.broadcast_watch.load_error": "Unable to load broadcast",
|
|
371
375
|
"forge.bundle_confirmation.continue_shopping": "Continue shopping",
|
|
@@ -693,17 +697,33 @@
|
|
|
693
697
|
"forge.checkout_confirmation.total_paid": "Total paid",
|
|
694
698
|
"forge.checkout_confirmation.view_orders": "View your orders",
|
|
695
699
|
"forge.coaching_booking.aria_close": "Close",
|
|
700
|
+
"forge.coaching_booking.aria_next_month": "Next month",
|
|
696
701
|
"forge.coaching_booking.aria_next_week": "Next week",
|
|
702
|
+
"forge.coaching_booking.aria_prev_month": "Previous month",
|
|
697
703
|
"forge.coaching_booking.aria_prev_week": "Previous week",
|
|
698
704
|
"forge.coaching_booking.back": "Back",
|
|
705
|
+
"forge.coaching_booking.basket_count_package": "{picked} of {count} picked",
|
|
706
|
+
"forge.coaching_booking.basket_count_range": "{picked} picked, up to {max}",
|
|
707
|
+
"forge.coaching_booking.basket_empty": "No time picked yet",
|
|
708
|
+
"forge.coaching_booking.basket_full_limit": "That is the {max} session limit. Remove one to choose a different time.",
|
|
709
|
+
"forge.coaching_booking.basket_full_package": "All {count} sessions picked. Remove one to choose a different time.",
|
|
710
|
+
"forge.coaching_booking.basket_more": "{count} more to pick",
|
|
711
|
+
"forge.coaching_booking.basket_title_many": "Your sessions",
|
|
712
|
+
"forge.coaching_booking.basket_title_package": "Your {count} sessions",
|
|
713
|
+
"forge.coaching_booking.basket_title_single": "Your session",
|
|
699
714
|
"forge.coaching_booking.confirm_booking": "Confirm Booking",
|
|
700
715
|
"forge.coaching_booking.confirm_email_placeholder": "Confirm email",
|
|
701
716
|
"forge.coaching_booking.confirm_leave": "Are you sure you want to leave checkout?",
|
|
702
717
|
"forge.coaching_booking.consent": "By continuing, you agree to receive booking confirmations and important updates about your session via email.",
|
|
703
718
|
"forge.coaching_booking.continue": "Continue",
|
|
704
719
|
"forge.coaching_booking.continue_to_payment": "Continue to payment",
|
|
720
|
+
"forge.coaching_booking.continue_with_total": "Continue, {amount}",
|
|
721
|
+
"forge.coaching_booking.day_has_times": "{date}, times available",
|
|
722
|
+
"forge.coaching_booking.day_no_times": "{date}, no times",
|
|
705
723
|
"forge.coaching_booking.details_subtitle": "Please provide your contact information for the booking.",
|
|
706
724
|
"forge.coaching_booking.details_title": "Contact Information",
|
|
725
|
+
"forge.coaching_booking.done": "Done",
|
|
726
|
+
"forge.coaching_booking.duration_short": "{minutes} min",
|
|
707
727
|
"forge.coaching_booking.email_invalid_hint": "Please enter a valid email address",
|
|
708
728
|
"forge.coaching_booking.email_mismatch_hint": "Email addresses do not match",
|
|
709
729
|
"forge.coaching_booking.email_placeholder": "Email",
|
|
@@ -715,14 +735,29 @@
|
|
|
715
735
|
"forge.coaching_booking.first_name_placeholder": "First name",
|
|
716
736
|
"forge.coaching_booking.last_name_placeholder": "Last name",
|
|
717
737
|
"forge.coaching_booking.loading_slots": "Loading available slots…",
|
|
738
|
+
"forge.coaching_booking.next_available": "Next available",
|
|
718
739
|
"forge.coaching_booking.no_slots": "No available slots for this week.",
|
|
740
|
+
"forge.coaching_booking.no_times_day": "No open times on this day.",
|
|
741
|
+
"forge.coaching_booking.no_times_month": "No open times this month.",
|
|
719
742
|
"forge.coaching_booking.not_found": "Coaching session not found.",
|
|
743
|
+
"forge.coaching_booking.not_picked_yet": "Not picked yet",
|
|
744
|
+
"forge.coaching_booking.package_help_body": "You pick every session now and pay once. Each session can be rescheduled on its own later, under the cancellation and reschedule terms shown at checkout.",
|
|
745
|
+
"forge.coaching_booking.package_help_label": "How does a package work?",
|
|
746
|
+
"forge.coaching_booking.package_one_price": "{count} sessions, one price",
|
|
747
|
+
"forge.coaching_booking.package_total": "{price} for all {count}",
|
|
720
748
|
"forge.coaching_booking.pay": "Pay {amount}",
|
|
721
749
|
"forge.coaching_booking.payment_not_configured": "Payment is not configured.",
|
|
722
750
|
"forge.coaching_booking.payment_subtitle": "Complete your purchase to secure your booking.",
|
|
723
751
|
"forge.coaching_booking.payment_title": "Payment",
|
|
752
|
+
"forge.coaching_booking.per_session_calc": "{count} × {price}",
|
|
753
|
+
"forge.coaching_booking.pick_a_time": "Pick a time",
|
|
754
|
+
"forge.coaching_booking.pick_day": "Pick a highlighted day to see its times.",
|
|
755
|
+
"forge.coaching_booking.pick_more": "Pick {count} more",
|
|
724
756
|
"forge.coaching_booking.preparing_payment": "Preparing payment…",
|
|
757
|
+
"forge.coaching_booking.price_per_session": "{price} per session",
|
|
725
758
|
"forge.coaching_booking.processing": "Processing…",
|
|
759
|
+
"forge.coaching_booking.remove": "Remove",
|
|
760
|
+
"forge.coaching_booking.remove_time": "Remove {time}",
|
|
726
761
|
"forge.coaching_booking.reserving": "Reserving…",
|
|
727
762
|
"forge.coaching_booking.select_placeholder": "Select…",
|
|
728
763
|
"forge.coaching_booking.slot_title": "Select Availability",
|
|
@@ -756,6 +791,7 @@
|
|
|
756
791
|
"forge.coaching_confirmation.row_paid": "Paid",
|
|
757
792
|
"forge.coaching_confirmation.row_reference": "Reference",
|
|
758
793
|
"forge.coaching_confirmation.row_session": "Session",
|
|
794
|
+
"forge.coaching_confirmation.row_session_n": "Session {index} of {count}",
|
|
759
795
|
"forge.coaching_confirmation.row_tax": "Tax",
|
|
760
796
|
"forge.coaching_confirmation.status_unknown": "unresolved",
|
|
761
797
|
"forge.coaching_confirmation.tax_included": "Includes {amount} tax",
|
|
@@ -764,6 +800,10 @@
|
|
|
764
800
|
"forge.coaching_detail.about_title": "About this session",
|
|
765
801
|
"forge.coaching_detail.duration_minutes": "{minutes} minutes",
|
|
766
802
|
"forge.coaching_detail.not_found": "Coaching session not found.",
|
|
803
|
+
"forge.coaching_detail.price_for_sessions": "for {count} sessions",
|
|
804
|
+
"forge.coaching_detail.price_per_session_suffix": "per session",
|
|
805
|
+
"forge.coaching_detail.price_save": "Save {amount}",
|
|
806
|
+
"forge.coaching_detail.price_up_to": "Book up to {max} at once",
|
|
767
807
|
"forge.cohort_checkout.aria_close": "Close",
|
|
768
808
|
"forge.cohort_checkout.authorise_future_payments": "Authorise the later payments on this plan.",
|
|
769
809
|
"forge.cohort_checkout.back": "Back",
|
|
@@ -1428,7 +1468,10 @@
|
|
|
1428
1468
|
"forge.event_detail.location_virtual": "Virtual",
|
|
1429
1469
|
"forge.event_detail.not_found": "Event not found.",
|
|
1430
1470
|
"forge.event_detail.presale_only": "Tickets for this event are presale only.",
|
|
1471
|
+
"forge.event_detail.price_free": "Free",
|
|
1472
|
+
"forge.event_detail.price_free_to": "Free to {max}",
|
|
1431
1473
|
"forge.event_detail.price_from_label": "From",
|
|
1474
|
+
"forge.event_detail.reserve": "Reserve",
|
|
1432
1475
|
"forge.event_seat_picker.dismiss": "Dismiss",
|
|
1433
1476
|
"forge.event_seat_picker.empty": "There are no seats to choose from yet.",
|
|
1434
1477
|
"forge.event_seat_picker.help_body": "Tap a seat to choose it and tap it again to let it go. Pinch or scroll to zoom, drag to move around the map. Grey seats are not available. Best available picks the lowest seats together for you.",
|
|
@@ -1487,6 +1530,7 @@
|
|
|
1487
1530
|
"forge.event_tickets.expires_hours_soon_other": "⏰ Expires in {count} hours!",
|
|
1488
1531
|
"forge.event_tickets.field_required": "This field is required",
|
|
1489
1532
|
"forge.event_tickets.first_name_placeholder": "First name",
|
|
1533
|
+
"forge.event_tickets.free": "Free",
|
|
1490
1534
|
"forge.event_tickets.invalid_email": "Please enter a valid email address",
|
|
1491
1535
|
"forge.event_tickets.item_label_event": "This event",
|
|
1492
1536
|
"forge.event_tickets.item_label_ticket": "This ticket",
|
|
@@ -1514,6 +1558,7 @@
|
|
|
1514
1558
|
"forge.event_tickets.pwyw_range_min_max": "(minimum {min}, up to {max})",
|
|
1515
1559
|
"forge.event_tickets.remove_discount": "Remove discount",
|
|
1516
1560
|
"forge.event_tickets.request_tickets": "Request tickets",
|
|
1561
|
+
"forge.event_tickets.reserve": "Reserve",
|
|
1517
1562
|
"forge.event_tickets.seat_best_available": "Best available",
|
|
1518
1563
|
"forge.event_tickets.seat_remove_aria": "Remove {seat}",
|
|
1519
1564
|
"forge.event_tickets.seats_left_one": "{count} seat left",
|
|
@@ -2228,6 +2273,7 @@
|
|
|
2228
2273
|
"forge.review_form.title_placeholder": "Sum it up in a few words",
|
|
2229
2274
|
"forge.review_form.update_submit": "Update review",
|
|
2230
2275
|
"forge.seat_legend.aria_label": "Map key",
|
|
2276
|
+
"forge.seat_legend.free": "Free",
|
|
2231
2277
|
"forge.seat_legend.left_one": "{count} left",
|
|
2232
2278
|
"forge.seat_legend.left_other": "{count} left",
|
|
2233
2279
|
"forge.seat_legend.selected": "Selected",
|
package/src/types/models.ts
CHANGED
|
@@ -1097,6 +1097,17 @@ export type CoachingProduct = {
|
|
|
1097
1097
|
reviewAggregate?: IReviewAggregate | null;
|
|
1098
1098
|
/** The seller's CURRENT cancellation terms — present on the detail endpoint only. */
|
|
1099
1099
|
cancellation?: CancellationTermsView | null;
|
|
1100
|
+
/**
|
|
1101
|
+
* How the product is sold. Absent on older backends, which means one session.
|
|
1102
|
+
* `package`: exactly `packageSessionCount` sessions for `packagePrice`, and
|
|
1103
|
+
* `price` is the single-session price shown as the saving (0 = none).
|
|
1104
|
+
* `per_session`: 1 to `maxSessionsPerBooking` sessions at `price` each.
|
|
1105
|
+
* The buyer picks every session in one booking.
|
|
1106
|
+
*/
|
|
1107
|
+
pricingMode?: "single" | "package" | "per_session";
|
|
1108
|
+
packageSessionCount?: number | null;
|
|
1109
|
+
packagePrice?: string | number | null;
|
|
1110
|
+
maxSessionsPerBooking?: number | null;
|
|
1100
1111
|
};
|
|
1101
1112
|
|
|
1102
1113
|
// ---- Membership gates (S.4) --------------------------------------------------
|
|
@@ -1862,6 +1873,8 @@ export type CoachingBooking = {
|
|
|
1862
1873
|
productTitle?: string | null;
|
|
1863
1874
|
durationMinutes?: number | null;
|
|
1864
1875
|
timezone?: string | null;
|
|
1876
|
+
/** Every session of a multi-session booking, earliest first. Absent for one session. */
|
|
1877
|
+
sessions?: { startTime: string; endTime: string }[];
|
|
1865
1878
|
/**
|
|
1866
1879
|
* The session's `.ics`, both forms — see {@link CalendarFeedLinks}. Null
|
|
1867
1880
|
* unless the booking is CONFIRMED: a held slot is not a session, and offering
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isFreeTicket, isSingleFreeTicketEvent, summarizeTicketPrices } from "../ticketPrice";
|
|
3
|
+
|
|
4
|
+
const tier = (price: number, payWhatYouWant = false) => ({ price, payWhatYouWant });
|
|
5
|
+
|
|
6
|
+
describe("isFreeTicket", () => {
|
|
7
|
+
it("is true for a zero price", () => {
|
|
8
|
+
expect(isFreeTicket(tier(0))).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("is false for a paid tier", () => {
|
|
12
|
+
expect(isFreeTicket(tier(12))).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is false for a PWYW tier with a zero floor: the floor is not a gift", () => {
|
|
16
|
+
expect(isFreeTicket(tier(0, true))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads a decimal string price from the wire", () => {
|
|
20
|
+
expect(isFreeTicket({ price: "0.00" as unknown as number })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("summarizeTicketPrices", () => {
|
|
25
|
+
it("all free reads free", () => {
|
|
26
|
+
expect(summarizeTicketPrices([tier(0), tier(0)])).toEqual({ kind: "free" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("free and paid reads free to the dearest tier", () => {
|
|
30
|
+
expect(summarizeTicketPrices([tier(0), tier(25), tier(40)])).toEqual({ kind: "free_to", max: 40 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("all paid keeps the cheapest tier", () => {
|
|
34
|
+
expect(summarizeTicketPrices([tier(40), tier(25)])).toEqual({ kind: "from", min: 25 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("a zero-floor PWYW tier keeps the From wording", () => {
|
|
38
|
+
expect(summarizeTicketPrices([tier(0, true), tier(20)])).toEqual({ kind: "from", min: 0 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("no tiers summarise to nothing", () => {
|
|
42
|
+
expect(summarizeTicketPrices([])).toBeNull();
|
|
43
|
+
expect(summarizeTicketPrices(undefined)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isSingleFreeTicketEvent", () => {
|
|
48
|
+
it("one free tier is a reservation", () => {
|
|
49
|
+
expect(isSingleFreeTicketEvent([tier(0)])).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("two free tiers, one paid tier, or one PWYW tier are not", () => {
|
|
53
|
+
expect(isSingleFreeTicketEvent([tier(0), tier(0)])).toBe(false);
|
|
54
|
+
expect(isSingleFreeTicketEvent([tier(10)])).toBe(false);
|
|
55
|
+
expect(isSingleFreeTicketEvent([tier(0, true)])).toBe(false);
|
|
56
|
+
expect(isSingleFreeTicketEvent([])).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CoachingProduct } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a booking product is priced when one booking can hold several sessions.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the backend's `db/types/bookingPricing.ts`, which is the authority:
|
|
7
|
+
* the server re-prices at reserve and at the details step, so these numbers
|
|
8
|
+
* are only ever what the buyer is SHOWN before that. Kept in Forge rather than
|
|
9
|
+
* imported because the storefront ships without the backend.
|
|
10
|
+
*/
|
|
11
|
+
export type BookingPricingMode = "single" | "package" | "per_session";
|
|
12
|
+
|
|
13
|
+
type PricingFields = Pick<CoachingProduct, "price"> &
|
|
14
|
+
Partial<Pick<CoachingProduct, "pricingMode" | "packageSessionCount" | "packagePrice" | "maxSessionsPerBooking">>;
|
|
15
|
+
|
|
16
|
+
export function bookingPricingMode(product: PricingFields | null | undefined): BookingPricingMode {
|
|
17
|
+
const mode = product?.pricingMode;
|
|
18
|
+
return mode === "package" || mode === "per_session" ? mode : "single";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The number of sessions one booking must hold, inclusive. */
|
|
22
|
+
export function bookingSessionRange(product: PricingFields | null | undefined): { min: number; max: number } {
|
|
23
|
+
const mode = bookingPricingMode(product);
|
|
24
|
+
if (mode === "package") {
|
|
25
|
+
const n = Math.max(1, Number(product?.packageSessionCount ?? 1));
|
|
26
|
+
return { min: n, max: n };
|
|
27
|
+
}
|
|
28
|
+
if (mode === "per_session") return { min: 1, max: Math.max(1, Number(product?.maxSessionsPerBooking ?? 1)) };
|
|
29
|
+
return { min: 1, max: 1 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const money = (value: unknown) => {
|
|
33
|
+
const n = Number(value ?? 0);
|
|
34
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : 0;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The gross price of `sessionCount` sessions, before any discount or tax. */
|
|
38
|
+
export function bookingGrossAmount(product: PricingFields | null | undefined, sessionCount: number): number {
|
|
39
|
+
const mode = bookingPricingMode(product);
|
|
40
|
+
if (mode === "package") return money(product?.packagePrice);
|
|
41
|
+
if (mode === "per_session") return Math.round(money(product?.price) * 100 * sessionCount) / 100;
|
|
42
|
+
return money(product?.price);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a package saves against buying its sessions one at a time. 0 when the
|
|
47
|
+
* creator gave no single-session price, or the package is not cheaper.
|
|
48
|
+
*/
|
|
49
|
+
export function packageSaving(product: PricingFields | null | undefined): number {
|
|
50
|
+
if (bookingPricingMode(product) !== "package") return 0;
|
|
51
|
+
const single = money(product?.price);
|
|
52
|
+
const count = Number(product?.packageSessionCount ?? 0);
|
|
53
|
+
if (!single || !count) return 0;
|
|
54
|
+
return Math.max(0, Math.round((single * count - money(product?.packagePrice)) * 100) / 100);
|
|
55
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ITicket } from "../../types/models";
|
|
2
|
+
import { isPayWhatYouWant } from "./pwyw";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Free event tickets, as a buyer-facing surface has to word them.
|
|
6
|
+
*
|
|
7
|
+
* A tier priced at zero is FREE, and "$0.00" reads as a price, not as a gift.
|
|
8
|
+
* Every surface that quotes a ticket price runs it through these helpers so a
|
|
9
|
+
* free tier says "Free" everywhere, and a checkout with nothing to pay says
|
|
10
|
+
* "Reserve" rather than "Pay $0.00".
|
|
11
|
+
*
|
|
12
|
+
* Pay-what-you-want is the exception that matters: on a PWYW tier `price` is
|
|
13
|
+
* the FLOOR, so a zero floor means "pay what you like, from nothing", not
|
|
14
|
+
* "free". Those tiers keep their existing "From" wording.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type PricedTicket = Pick<ITicket, "price"> & Partial<Pick<ITicket, "payWhatYouWant">>;
|
|
18
|
+
|
|
19
|
+
/** A tier with nothing to pay: priced at zero and not pay-what-you-want. */
|
|
20
|
+
export const isFreeTicket = (ticket: PricedTicket): boolean =>
|
|
21
|
+
!isPayWhatYouWant(ticket as ITicket) && Number(ticket.price) === 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one-line price summary an event page shows beside its buy button.
|
|
25
|
+
*
|
|
26
|
+
* - every tier free: `free`
|
|
27
|
+
* - some free, some paid: `free_to` with the dearest tier, so the page reads
|
|
28
|
+
* "Free to $40" instead of "From $0.00"
|
|
29
|
+
* - none free: `from` with the cheapest tier, which is the existing wording
|
|
30
|
+
*
|
|
31
|
+
* `null` for no tiers at all, so the caller renders nothing.
|
|
32
|
+
*/
|
|
33
|
+
export type TicketPriceSummary =
|
|
34
|
+
| { kind: "free" }
|
|
35
|
+
| { kind: "free_to"; max: number }
|
|
36
|
+
| { kind: "from"; min: number };
|
|
37
|
+
|
|
38
|
+
export function summarizeTicketPrices(tickets: readonly PricedTicket[] | null | undefined): TicketPriceSummary | null {
|
|
39
|
+
if (!tickets || tickets.length === 0) return null;
|
|
40
|
+
const free = tickets.filter(isFreeTicket);
|
|
41
|
+
if (free.length === tickets.length) return { kind: "free" };
|
|
42
|
+
const prices = tickets.map((ticket) => Number(ticket.price)).filter((price) => Number.isFinite(price));
|
|
43
|
+
if (free.length > 0) return { kind: "free_to", max: Math.max(...prices) };
|
|
44
|
+
return { kind: "from", min: prices.length ? Math.min(...prices) : 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The event sells exactly one tier and it is free, so the call to action is a
|
|
49
|
+
* reservation, not a purchase ("Reserve" rather than "Buy tickets").
|
|
50
|
+
*/
|
|
51
|
+
export const isSingleFreeTicketEvent = (tickets: readonly PricedTicket[] | null | undefined): boolean =>
|
|
52
|
+
!!tickets && tickets.length === 1 && isFreeTicket(tickets[0]);
|