@tribe-nest/forge 3.64.0 → 3.67.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/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +63 -0
- package/src/i18n/en.json +63 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- 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/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +24 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- 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/CookieConsent.tsx +13 -9
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- 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/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}",
|
|
@@ -184,8 +185,25 @@
|
|
|
184
185
|
"forge.account_dashboard.waitlist_ticket_line_other": "{title} · {quantity} tickets",
|
|
185
186
|
"forge.account_dashboard.waitlists_empty": "You're not on any waitlists.",
|
|
186
187
|
"forge.account_dashboard.waitlists_title": "My Waitlists",
|
|
188
|
+
"forge.ai_agent_call_room.connecting": "Connecting…",
|
|
189
|
+
"forge.ai_agent_call_room.listening": "{name} is listening",
|
|
190
|
+
"forge.ai_agent_call_room.no_mic": "We could not use your microphone. Allow it in your browser, then call again.",
|
|
191
|
+
"forge.ai_agent_call_room.speaking": "{name} is speaking",
|
|
192
|
+
"forge.ai_agent_widget.aria_call": "Call us",
|
|
187
193
|
"forge.ai_agent_widget.aria_close": "Close",
|
|
188
194
|
"forge.ai_agent_widget.aria_open": "Open chat assistant",
|
|
195
|
+
"forge.ai_agent_widget.aria_show_call": "Show the call",
|
|
196
|
+
"forge.ai_agent_widget.call_again": "Call again",
|
|
197
|
+
"forge.ai_agent_widget.call_back_to_chat": "Back to chat",
|
|
198
|
+
"forge.ai_agent_widget.call_connecting": "Connecting…",
|
|
199
|
+
"forge.ai_agent_widget.call_ended": "Call ended.",
|
|
200
|
+
"forge.ai_agent_widget.call_error_busy": "All our lines are busy. Please try again in a few minutes.",
|
|
201
|
+
"forge.ai_agent_widget.call_error_check_failed": "We could not start the call. Please try again.",
|
|
202
|
+
"forge.ai_agent_widget.call_error_failed": "We could not connect your call. Please try again.",
|
|
203
|
+
"forge.ai_agent_widget.call_error_too_many": "You have called several times recently. Please try again later.",
|
|
204
|
+
"forge.ai_agent_widget.call_error_unavailable": "Calls are not available right now.",
|
|
205
|
+
"forge.ai_agent_widget.call_hang_up": "Hang up",
|
|
206
|
+
"forge.ai_agent_widget.call_try_again": "Try again",
|
|
189
207
|
"forge.ai_agent_widget.default_greeting": "Hi! Ask me anything.",
|
|
190
208
|
"forge.ai_agent_widget.default_title": "Assistant",
|
|
191
209
|
"forge.ai_agent_widget.input_placeholder": "Type a message…",
|
|
@@ -365,7 +383,10 @@
|
|
|
365
383
|
"forge.broadcast_poll.votes_other": "{count} votes",
|
|
366
384
|
"forge.broadcast_poll.your_vote": "(your vote)",
|
|
367
385
|
"forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
|
|
386
|
+
"forge.broadcast_ticket_purchase.free": "Free",
|
|
387
|
+
"forge.broadcast_ticket_purchase.free_to_price": "Free to {price}",
|
|
368
388
|
"forge.broadcast_ticket_purchase.from_price": "From {price}",
|
|
389
|
+
"forge.broadcast_ticket_purchase.reserve": "Reserve",
|
|
369
390
|
"forge.broadcast_watch.leave": "Leave broadcast",
|
|
370
391
|
"forge.broadcast_watch.load_error": "Unable to load broadcast",
|
|
371
392
|
"forge.bundle_confirmation.continue_shopping": "Continue shopping",
|
|
@@ -693,17 +714,33 @@
|
|
|
693
714
|
"forge.checkout_confirmation.total_paid": "Total paid",
|
|
694
715
|
"forge.checkout_confirmation.view_orders": "View your orders",
|
|
695
716
|
"forge.coaching_booking.aria_close": "Close",
|
|
717
|
+
"forge.coaching_booking.aria_next_month": "Next month",
|
|
696
718
|
"forge.coaching_booking.aria_next_week": "Next week",
|
|
719
|
+
"forge.coaching_booking.aria_prev_month": "Previous month",
|
|
697
720
|
"forge.coaching_booking.aria_prev_week": "Previous week",
|
|
698
721
|
"forge.coaching_booking.back": "Back",
|
|
722
|
+
"forge.coaching_booking.basket_count_package": "{picked} of {count} picked",
|
|
723
|
+
"forge.coaching_booking.basket_count_range": "{picked} picked, up to {max}",
|
|
724
|
+
"forge.coaching_booking.basket_empty": "No time picked yet",
|
|
725
|
+
"forge.coaching_booking.basket_full_limit": "That is the {max} session limit. Remove one to choose a different time.",
|
|
726
|
+
"forge.coaching_booking.basket_full_package": "All {count} sessions picked. Remove one to choose a different time.",
|
|
727
|
+
"forge.coaching_booking.basket_more": "{count} more to pick",
|
|
728
|
+
"forge.coaching_booking.basket_title_many": "Your sessions",
|
|
729
|
+
"forge.coaching_booking.basket_title_package": "Your {count} sessions",
|
|
730
|
+
"forge.coaching_booking.basket_title_single": "Your session",
|
|
699
731
|
"forge.coaching_booking.confirm_booking": "Confirm Booking",
|
|
700
732
|
"forge.coaching_booking.confirm_email_placeholder": "Confirm email",
|
|
701
733
|
"forge.coaching_booking.confirm_leave": "Are you sure you want to leave checkout?",
|
|
702
734
|
"forge.coaching_booking.consent": "By continuing, you agree to receive booking confirmations and important updates about your session via email.",
|
|
703
735
|
"forge.coaching_booking.continue": "Continue",
|
|
704
736
|
"forge.coaching_booking.continue_to_payment": "Continue to payment",
|
|
737
|
+
"forge.coaching_booking.continue_with_total": "Continue, {amount}",
|
|
738
|
+
"forge.coaching_booking.day_has_times": "{date}, times available",
|
|
739
|
+
"forge.coaching_booking.day_no_times": "{date}, no times",
|
|
705
740
|
"forge.coaching_booking.details_subtitle": "Please provide your contact information for the booking.",
|
|
706
741
|
"forge.coaching_booking.details_title": "Contact Information",
|
|
742
|
+
"forge.coaching_booking.done": "Done",
|
|
743
|
+
"forge.coaching_booking.duration_short": "{minutes} min",
|
|
707
744
|
"forge.coaching_booking.email_invalid_hint": "Please enter a valid email address",
|
|
708
745
|
"forge.coaching_booking.email_mismatch_hint": "Email addresses do not match",
|
|
709
746
|
"forge.coaching_booking.email_placeholder": "Email",
|
|
@@ -715,14 +752,29 @@
|
|
|
715
752
|
"forge.coaching_booking.first_name_placeholder": "First name",
|
|
716
753
|
"forge.coaching_booking.last_name_placeholder": "Last name",
|
|
717
754
|
"forge.coaching_booking.loading_slots": "Loading available slots…",
|
|
755
|
+
"forge.coaching_booking.next_available": "Next available",
|
|
718
756
|
"forge.coaching_booking.no_slots": "No available slots for this week.",
|
|
757
|
+
"forge.coaching_booking.no_times_day": "No open times on this day.",
|
|
758
|
+
"forge.coaching_booking.no_times_month": "No open times this month.",
|
|
719
759
|
"forge.coaching_booking.not_found": "Coaching session not found.",
|
|
760
|
+
"forge.coaching_booking.not_picked_yet": "Not picked yet",
|
|
761
|
+
"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.",
|
|
762
|
+
"forge.coaching_booking.package_help_label": "How does a package work?",
|
|
763
|
+
"forge.coaching_booking.package_one_price": "{count} sessions, one price",
|
|
764
|
+
"forge.coaching_booking.package_total": "{price} for all {count}",
|
|
720
765
|
"forge.coaching_booking.pay": "Pay {amount}",
|
|
721
766
|
"forge.coaching_booking.payment_not_configured": "Payment is not configured.",
|
|
722
767
|
"forge.coaching_booking.payment_subtitle": "Complete your purchase to secure your booking.",
|
|
723
768
|
"forge.coaching_booking.payment_title": "Payment",
|
|
769
|
+
"forge.coaching_booking.per_session_calc": "{count} × {price}",
|
|
770
|
+
"forge.coaching_booking.pick_a_time": "Pick a time",
|
|
771
|
+
"forge.coaching_booking.pick_day": "Pick a highlighted day to see its times.",
|
|
772
|
+
"forge.coaching_booking.pick_more": "Pick {count} more",
|
|
724
773
|
"forge.coaching_booking.preparing_payment": "Preparing payment…",
|
|
774
|
+
"forge.coaching_booking.price_per_session": "{price} per session",
|
|
725
775
|
"forge.coaching_booking.processing": "Processing…",
|
|
776
|
+
"forge.coaching_booking.remove": "Remove",
|
|
777
|
+
"forge.coaching_booking.remove_time": "Remove {time}",
|
|
726
778
|
"forge.coaching_booking.reserving": "Reserving…",
|
|
727
779
|
"forge.coaching_booking.select_placeholder": "Select…",
|
|
728
780
|
"forge.coaching_booking.slot_title": "Select Availability",
|
|
@@ -756,6 +808,7 @@
|
|
|
756
808
|
"forge.coaching_confirmation.row_paid": "Paid",
|
|
757
809
|
"forge.coaching_confirmation.row_reference": "Reference",
|
|
758
810
|
"forge.coaching_confirmation.row_session": "Session",
|
|
811
|
+
"forge.coaching_confirmation.row_session_n": "Session {index} of {count}",
|
|
759
812
|
"forge.coaching_confirmation.row_tax": "Tax",
|
|
760
813
|
"forge.coaching_confirmation.status_unknown": "unresolved",
|
|
761
814
|
"forge.coaching_confirmation.tax_included": "Includes {amount} tax",
|
|
@@ -764,6 +817,10 @@
|
|
|
764
817
|
"forge.coaching_detail.about_title": "About this session",
|
|
765
818
|
"forge.coaching_detail.duration_minutes": "{minutes} minutes",
|
|
766
819
|
"forge.coaching_detail.not_found": "Coaching session not found.",
|
|
820
|
+
"forge.coaching_detail.price_for_sessions": "for {count} sessions",
|
|
821
|
+
"forge.coaching_detail.price_per_session_suffix": "per session",
|
|
822
|
+
"forge.coaching_detail.price_save": "Save {amount}",
|
|
823
|
+
"forge.coaching_detail.price_up_to": "Book up to {max} at once",
|
|
767
824
|
"forge.cohort_checkout.aria_close": "Close",
|
|
768
825
|
"forge.cohort_checkout.authorise_future_payments": "Authorise the later payments on this plan.",
|
|
769
826
|
"forge.cohort_checkout.back": "Back",
|
|
@@ -1428,7 +1485,10 @@
|
|
|
1428
1485
|
"forge.event_detail.location_virtual": "Virtual",
|
|
1429
1486
|
"forge.event_detail.not_found": "Event not found.",
|
|
1430
1487
|
"forge.event_detail.presale_only": "Tickets for this event are presale only.",
|
|
1488
|
+
"forge.event_detail.price_free": "Free",
|
|
1489
|
+
"forge.event_detail.price_free_to": "Free to {max}",
|
|
1431
1490
|
"forge.event_detail.price_from_label": "From",
|
|
1491
|
+
"forge.event_detail.reserve": "Reserve",
|
|
1432
1492
|
"forge.event_seat_picker.dismiss": "Dismiss",
|
|
1433
1493
|
"forge.event_seat_picker.empty": "There are no seats to choose from yet.",
|
|
1434
1494
|
"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 +1547,7 @@
|
|
|
1487
1547
|
"forge.event_tickets.expires_hours_soon_other": "⏰ Expires in {count} hours!",
|
|
1488
1548
|
"forge.event_tickets.field_required": "This field is required",
|
|
1489
1549
|
"forge.event_tickets.first_name_placeholder": "First name",
|
|
1550
|
+
"forge.event_tickets.free": "Free",
|
|
1490
1551
|
"forge.event_tickets.invalid_email": "Please enter a valid email address",
|
|
1491
1552
|
"forge.event_tickets.item_label_event": "This event",
|
|
1492
1553
|
"forge.event_tickets.item_label_ticket": "This ticket",
|
|
@@ -1514,6 +1575,7 @@
|
|
|
1514
1575
|
"forge.event_tickets.pwyw_range_min_max": "(minimum {min}, up to {max})",
|
|
1515
1576
|
"forge.event_tickets.remove_discount": "Remove discount",
|
|
1516
1577
|
"forge.event_tickets.request_tickets": "Request tickets",
|
|
1578
|
+
"forge.event_tickets.reserve": "Reserve",
|
|
1517
1579
|
"forge.event_tickets.seat_best_available": "Best available",
|
|
1518
1580
|
"forge.event_tickets.seat_remove_aria": "Remove {seat}",
|
|
1519
1581
|
"forge.event_tickets.seats_left_one": "{count} seat left",
|
|
@@ -2228,6 +2290,7 @@
|
|
|
2228
2290
|
"forge.review_form.title_placeholder": "Sum it up in a few words",
|
|
2229
2291
|
"forge.review_form.update_submit": "Update review",
|
|
2230
2292
|
"forge.seat_legend.aria_label": "Map key",
|
|
2293
|
+
"forge.seat_legend.free": "Free",
|
|
2231
2294
|
"forge.seat_legend.left_one": "{count} left",
|
|
2232
2295
|
"forge.seat_legend.left_other": "{count} left",
|
|
2233
2296
|
"forge.seat_legend.selected": "Selected",
|
|
@@ -55,7 +55,7 @@ export interface ForgeClientProviderProps {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Low-level tenant/client provider
|
|
58
|
+
* Low-level tenant/client provider: supplies the `useForge()` context (Axios
|
|
59
59
|
* client, tenant ids, token). Most sites use the all-in-one `<ForgeProvider>`
|
|
60
60
|
* (which wraps this plus query/content/auth/cart/audio/site-config/theme); reach
|
|
61
61
|
* for `ForgeClientProvider` only when composing the tree yourself.
|
|
@@ -126,6 +126,12 @@ export const ForgeClientProvider = ({
|
|
|
126
126
|
return <ForgeContext.Provider value={value}>{children}</ForgeContext.Provider>;
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The Forge context, or `null` outside a `<ForgeProvider>`. For components that
|
|
131
|
+
* also render standalone and only enrich themselves when a provider is present.
|
|
132
|
+
*/
|
|
133
|
+
export const useOptionalForge = (): ForgeContextValue | null => useContext(ForgeContext);
|
|
134
|
+
|
|
129
135
|
/** Access the Forge context. Throws if used outside a `<ForgeProvider>`. */
|
|
130
136
|
export const useForge = (): ForgeContextValue => {
|
|
131
137
|
const ctx = useContext(ForgeContext);
|
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]);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { leadingZeroBits, solveCallChallenge } from "../callChallenge";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The bot check in front of "Call us". The backend (`callGuard.ts`) accepts a
|
|
7
|
+
* solution when SHA-256 of `${challenge}:${solution}` starts with `difficulty`
|
|
8
|
+
* zero bits, so the thing worth pinning is that what the browser finds is what
|
|
9
|
+
* the server would accept, checked here with Node's own hash rather than the
|
|
10
|
+
* Web Crypto the solver uses.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function serverLeadingZeroBits(digest: Buffer): number {
|
|
14
|
+
let bits = 0;
|
|
15
|
+
for (const byte of digest) {
|
|
16
|
+
if (byte === 0) {
|
|
17
|
+
bits += 8;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
let b = byte;
|
|
21
|
+
while ((b & 0x80) === 0) {
|
|
22
|
+
bits++;
|
|
23
|
+
b <<= 1;
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
return bits;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("leadingZeroBits", () => {
|
|
31
|
+
it("counts whole zero bytes and the zero bits of the first non-zero byte", () => {
|
|
32
|
+
expect(leadingZeroBits(new Uint8Array([0xff]))).toBe(0);
|
|
33
|
+
expect(leadingZeroBits(new Uint8Array([0x01]))).toBe(7);
|
|
34
|
+
expect(leadingZeroBits(new Uint8Array([0x00, 0x80]))).toBe(8);
|
|
35
|
+
expect(leadingZeroBits(new Uint8Array([0x00, 0x00, 0x0f]))).toBe(20);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("counts every bit of an all-zero digest", () => {
|
|
39
|
+
expect(leadingZeroBits(new Uint8Array(4))).toBe(32);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("solveCallChallenge", () => {
|
|
44
|
+
it("finds a solution the server's check accepts, at the production difficulty", async () => {
|
|
45
|
+
const challenge = "v1.eyJhIjoxfQ.c2ln";
|
|
46
|
+
const solution = await solveCallChallenge(challenge, 16);
|
|
47
|
+
expect(solution).toMatch(/^\d{1,12}$/);
|
|
48
|
+
const digest = createHash("sha256").update(`${challenge}:${solution}`).digest();
|
|
49
|
+
expect(serverLeadingZeroBits(digest)).toBeGreaterThanOrEqual(16);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns the SMALLEST solution, so a batch boundary changes nothing", async () => {
|
|
53
|
+
const challenge = "small-batches";
|
|
54
|
+
const a = await solveCallChallenge(challenge, 10, { batchSize: 7, yieldToPage: async () => undefined });
|
|
55
|
+
const b = await solveCallChallenge(challenge, 10, { batchSize: 1000 });
|
|
56
|
+
expect(a).toBe(b);
|
|
57
|
+
for (let n = 0; n < Number(a); n++) {
|
|
58
|
+
const digest = createHash("sha256").update(`${challenge}:${n}`).digest();
|
|
59
|
+
expect(serverLeadingZeroBits(digest)).toBeLessThan(10);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("gives the page a turn after every batch it did not solve in", async () => {
|
|
64
|
+
const solution = Number(await solveCallChallenge("yield-check", 12, { batchSize: 64 }));
|
|
65
|
+
let yields = 0;
|
|
66
|
+
await solveCallChallenge("yield-check", 12, {
|
|
67
|
+
batchSize: 64,
|
|
68
|
+
yieldToPage: async () => {
|
|
69
|
+
yields++;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(yields).toBe(Math.floor(solution / 64));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("stops when the call is hung up while it works", async () => {
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const solving = solveCallChallenge("abort-me", 40, {
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
yieldToPage: async () => {
|
|
80
|
+
controller.abort();
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
await expect(solving).rejects.toMatchObject({ name: "AbortError" });
|
|
84
|
+
});
|
|
85
|
+
});
|