@tribe-nest/forge 3.63.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingAvailability.ts +18 -6
  3. package/src/data/queries/useMyBookings.ts +7 -0
  4. package/src/i18n/de.json +46 -0
  5. package/src/i18n/en.json +46 -0
  6. package/src/runtime/pages.ts +2 -0
  7. package/src/seo/_tests/seo.spec.ts +202 -6
  8. package/src/seo/applySeoToHead.ts +7 -1
  9. package/src/seo/index.ts +1 -0
  10. package/src/seo/markdown.ts +76 -0
  11. package/src/seo/resolve.ts +46 -3
  12. package/src/seo/seoHead.ts +6 -0
  13. package/src/seo/siteSeoHead.ts +24 -7
  14. package/src/seo/types.ts +15 -1
  15. package/src/server/_tests/buildPwaHead.spec.ts +18 -1
  16. package/src/server/pwa.ts +16 -1
  17. package/src/types/models.ts +13 -0
  18. package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
  19. package/src/ui/format/bookingPricing.ts +55 -0
  20. package/src/ui/format/ticketPrice.ts +52 -0
  21. package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
  22. package/src/ui/index.ts +13 -0
  23. package/src/ui/styled/AccountDashboard.tsx +11 -3
  24. package/src/ui/styled/BookingPriceSummary.tsx +57 -0
  25. package/src/ui/styled/CoachingBooking.tsx +36 -158
  26. package/src/ui/styled/CoachingConfirmation.tsx +13 -2
  27. package/src/ui/styled/CoachingDetail.tsx +8 -2
  28. package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
  29. package/src/ui/styled/EventDetail.tsx +29 -6
  30. package/src/ui/styled/EventTickets.tsx +35 -11
  31. package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
  32. package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
  33. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
  34. package/src/ui/styled/forge-utilities.css +0 -5
  35. package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
  36. package/src/ui/styled/seating/SeatLegend.tsx +3 -1
  37. package/src/utils/_tests/structuredDataSeo.spec.ts +36 -0
  38. package/src/utils/structuredData.ts +28 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.63.0",
3
+ "version": "3.65.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
- /** Reserve a coaching booking slot. Returns the created booking + its agreed terms. */
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: string }>({
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
- mutationFn: async ({ bookingId }) => {
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",
@@ -108,6 +108,8 @@ export const INTERNAL_PAGES = [
108
108
  "live",
109
109
  "live/$broadcastId",
110
110
  "login",
111
+ // SEO step 7, lane S2: a customer connects their assistant to the site MCP.
112
+ "oauth/authorize",
111
113
  "payment-link/$paymentLinkId",
112
114
  "payment-link/$paymentLinkId/finalise",
113
115
  "podcasts",
@@ -1,9 +1,13 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  applySeoToHead,
4
4
  applyTitleTemplate,
5
5
  findOverride,
6
+ isMarkdownPath,
7
+ isNotFoundContext,
8
+ markdownAlternateHref,
6
9
  normalizePathname,
10
+ pathnameFromContext,
7
11
  resolvePageSeo,
8
12
  seoHead,
9
13
  siteSeoHead,
@@ -38,6 +42,9 @@ const RECORD: SeoRecord = {
38
42
 
39
43
  const record = (patch: Partial<SeoRecord> = {}): SeoRecord => ({ ...RECORD, ...patch });
40
44
 
45
+ /** The readable-copy link a page states beside its canonical. */
46
+ const md = (url: string) => ({ rel: "alternate", type: "text/markdown", href: `${url}?md` });
47
+
41
48
  /** A head() context shaped like TanStack's: root match first, leaf last. */
42
49
  function ctx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
43
50
  const leaf = { pathname, fullPath, routeId: fullPath, loaderData: {} };
@@ -86,6 +93,31 @@ describe("applyTitleTemplate", () => {
86
93
  });
87
94
  });
88
95
 
96
+ describe("a stored title template without {title}", () => {
97
+ it("is ignored for the default rather than printed", () => {
98
+ expect(applyTitleTemplate("About", "Clay Studio", "%s | Clay Studio")).toBe("About | Clay Studio");
99
+ expect(applyTitleTemplate("About", "Clay Studio", "Clay Studio")).toBe("About | Clay Studio");
100
+ // A usable one still applies, with either site placeholder.
101
+ expect(applyTitleTemplate("About", "Clay Studio", "{site.name}: {title}")).toBe("Clay Studio: About");
102
+ });
103
+
104
+ it("on the site falls back to the default, and on a page type falls back to the site's", () => {
105
+ const broken = resolvePageSeo(record({ titleTemplate: "%s | Clay Studio" }), { pathname: "/about", title: "About" });
106
+ expect(broken.title).toBe("About | Clay Studio");
107
+
108
+ const seo = record({ titleTemplate: "{title} - {site}", typeTemplates: { product: { title: "Shop" } } });
109
+ expect(resolvePageSeo(seo, { pathname: "/i/store/mug", type: "product", title: "Mug" }).title).toBe("Mug - Clay Studio");
110
+ const good = record({ typeTemplates: { product: { title: "{title} | Shop | {site}" } } });
111
+ expect(resolvePageSeo(good, { pathname: "/i/store/mug", type: "product", title: "Mug" }).title).toBe("Mug | Shop | Clay Studio");
112
+ });
113
+
114
+ it("never reaches a page's document title through seoHead", () => {
115
+ const t = tags(seoHead(ctx("/about", record({ titleTemplate: "%s | Clay Studio" })), { title: "About" }).meta);
116
+ expect(t.title).toBe("About | Clay Studio");
117
+ expect(t.title).not.toContain("%s");
118
+ });
119
+ });
120
+
89
121
  describe("findOverride", () => {
90
122
  it("prefers the exact pathname and fills gaps from the route pattern", () => {
91
123
  const seo = record({
@@ -214,19 +246,25 @@ describe("siteSeoHead", () => {
214
246
  "msvalidate.01": "b-token",
215
247
  });
216
248
  expect(t.robots).toBeUndefined();
217
- expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/about" }]);
249
+ expect(out.links).toEqual([
250
+ { rel: "canonical", href: "https://claystudio.com/about" },
251
+ md("https://claystudio.com/about"),
252
+ ]);
218
253
  });
219
254
 
220
255
  it("uses an override canonical", () => {
221
256
  const seo = record({ pages: { "/about": { canonical: "https://claystudio.com/story" } } });
222
- expect(siteSeoHead(ctx("/about", seo)).links).toEqual([{ rel: "canonical", href: "https://claystudio.com/story" }]);
257
+ expect(siteSeoHead(ctx("/about", seo)).links).toEqual([
258
+ { rel: "canonical", href: "https://claystudio.com/story" },
259
+ md("https://claystudio.com/story"),
260
+ ]);
223
261
  });
224
262
 
225
263
  it("emits no canonical under /i/, where the hosted page emits its own", () => {
226
264
  expect(siteSeoHead(ctx("/i/store/mug", RECORD)).links).toEqual([]);
227
265
  expect(siteSeoHead(ctx("/i", RECORD)).links).toEqual([]);
228
266
  // A creator page that merely starts with "i" is not hosted.
229
- expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(1);
267
+ expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(2);
230
268
  });
231
269
 
232
270
  it("noindexes when the site is not indexing or the path's override says so", () => {
@@ -266,6 +304,94 @@ describe("siteSeoHead", () => {
266
304
  });
267
305
  });
268
306
 
307
+ /**
308
+ * A not-found render, shaped as TanStack builds it.
309
+ *
310
+ * A URL no route matches leaves ONLY the root match, at "/", marked
311
+ * `_notFound`. A route whose loader threw `notFound()` keeps its own match with
312
+ * `status: "notFound"`, and the boundary above it carries `_notFound`.
313
+ */
314
+ function globalNotFoundCtx(seo: SeoRecord | null | undefined): SeoHeadContext {
315
+ const root = { pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo }, _notFound: true, status: "success" };
316
+ return { matches: [root], match: root, params: {} };
317
+ }
318
+ function thrownNotFoundCtx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
319
+ const leaf = { pathname, fullPath, routeId: fullPath, loaderData: undefined, status: "notFound", _notFound: true };
320
+ return {
321
+ matches: [{ pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo }, status: "success" }, leaf],
322
+ match: leaf,
323
+ params: {},
324
+ };
325
+ }
326
+
327
+ describe("a not-found render", () => {
328
+ afterEach(() => {
329
+ vi.unstubAllGlobals();
330
+ });
331
+
332
+ it("is recognised from either TanStack marker, and a normal render is not", () => {
333
+ expect(isNotFoundContext(globalNotFoundCtx(RECORD))).toBe(true);
334
+ expect(isNotFoundContext(thrownNotFoundCtx("/blog/gone", RECORD))).toBe(true);
335
+ expect(isNotFoundContext(ctx("/", RECORD))).toBe(false);
336
+ expect(isNotFoundContext(ctx("/about", RECORD))).toBe(false);
337
+ expect(isNotFoundContext(undefined)).toBe(false);
338
+ });
339
+
340
+ it("reads its pathname from the location, not the root match's \"/\"", () => {
341
+ vi.stubGlobal("location", { pathname: "/no-such-page/" });
342
+ expect(pathnameFromContext(globalNotFoundCtx(RECORD))).toBe("/no-such-page");
343
+ // A page that exists keeps its leaf, whatever the location says.
344
+ expect(pathnameFromContext(ctx("/about", RECORD))).toBe("/about");
345
+ });
346
+
347
+ it("on an unmatched URL states no canonical, no Markdown copy and no site JSON-LD, and is noindex", () => {
348
+ const out = siteSeoHead(globalNotFoundCtx(RECORD));
349
+ expect(out.links).toEqual([]);
350
+ expect(jsonLd(out.meta)).toEqual([]);
351
+ const t = tags(out.meta);
352
+ expect(t.robots).toBe("noindex, nofollow");
353
+ // The site-wide fallbacks still hold, so the page is not bare.
354
+ expect(t.title).toBe("Clay Studio");
355
+ expect(t["og:site_name"]).toBe("Clay Studio");
356
+ expect(t["google-site-verification"]).toBe("g-token");
357
+ });
358
+
359
+ it("does the same in the browser, where the location is known", () => {
360
+ vi.stubGlobal("location", { pathname: "/no-such-page" });
361
+ const out = siteSeoHead(globalNotFoundCtx(RECORD));
362
+ expect(out.links).toEqual([]);
363
+ expect(jsonLd(out.meta)).toEqual([]);
364
+ expect(tags(out.meta).robots).toBe("noindex, nofollow");
365
+ });
366
+
367
+ it("ignores an override for \"/\" that would otherwise apply to the root match", () => {
368
+ const seo = record({ pages: { "/": { canonical: "https://claystudio.com/home" } } });
369
+ expect(siteSeoHead(globalNotFoundCtx(seo)).links).toEqual([]);
370
+ });
371
+
372
+ it("when a loader threw notFound(), the root and the page both say noindex and state no URL", () => {
373
+ const root = siteSeoHead(thrownNotFoundCtx("/blog/gone", RECORD, "/blog/$slug"));
374
+ expect(root.links).toEqual([]);
375
+ expect(tags(root.meta).robots).toBe("noindex, nofollow");
376
+
377
+ const page = seoHead(thrownNotFoundCtx("/blog/gone", RECORD, "/blog/$slug"), {
378
+ title: "Gone",
379
+ jsonLd: { "@type": "BlogPosting" },
380
+ });
381
+ const t = tags(page.meta);
382
+ expect(t.robots).toBe("noindex, nofollow");
383
+ expect(t["og:url"]).toBeUndefined();
384
+ expect(jsonLd(page.meta)).toEqual([]);
385
+ });
386
+
387
+ it("leaves the home page exactly as it was", () => {
388
+ const home = siteSeoHead(ctx("/", RECORD));
389
+ expect(home.links[0]).toEqual({ rel: "canonical", href: "https://claystudio.com/" });
390
+ expect(jsonLd(home.meta).map((n) => n["@type"])).toEqual(["WebSite", "LocalBusiness"]);
391
+ expect(tags(home.meta).robots).toBeUndefined();
392
+ });
393
+ });
394
+
269
395
  describe("applySeoToHead", () => {
270
396
  const productHead = () => ({
271
397
  ...buildHeadMeta({ title: "Mug", description: "A mug.", image: "https://cdn.example/mug.jpg", ogType: "product" }),
@@ -286,7 +412,10 @@ describe("applySeoToHead", () => {
286
412
  expect(t["og:type"]).toBe("product");
287
413
  expect(t["og:site_name"]).toBe("Clay Studio");
288
414
  expect(t["og:url"]).toBe("https://claystudio.com/i/store/mug");
289
- expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/i/store/mug" }]);
415
+ expect(out.links).toEqual([
416
+ { rel: "canonical", href: "https://claystudio.com/i/store/mug" },
417
+ md("https://claystudio.com/i/store/mug"),
418
+ ]);
290
419
  expect(out.scripts).toEqual([{ type: "application/ld+json", children: "{}" }]);
291
420
  // Exactly one of each: the old entries were replaced, not appended to.
292
421
  expect(out.meta!.filter((m) => m.name === "description")).toHaveLength(1);
@@ -296,7 +425,7 @@ describe("applySeoToHead", () => {
296
425
  it("canonicalizes an index page to its own website", () => {
297
426
  const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
298
427
  const out = applySeoToHead(buildHeadMeta({ title: "Store" }), seo, "/i/store", "page");
299
- expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }]);
428
+ expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }, md("https://shop.example/i/store")]);
300
429
  });
301
430
 
302
431
  it("applies overrides by pathname and by pattern", () => {
@@ -324,3 +453,70 @@ describe("applySeoToHead", () => {
324
453
  expect(out.links).toEqual([]);
325
454
  });
326
455
  });
456
+
457
+ describe("Markdown alternate (section 5.4)", () => {
458
+ /** The same table as apps/dispatch-worker/src/_tests/markdown.spec.ts: the two rules must agree. */
459
+ it.each([
460
+ ["/", true],
461
+ ["/about", true],
462
+ ["/team/ada/", true],
463
+ ["/i/store", true],
464
+ ["/i/store/mug", true],
465
+ ["/i/events/launch-night", true],
466
+ ["/i/podcasts/show/episode-1", true],
467
+ ["/i/communities/potters", true],
468
+ ["/i/store/mug/extra", false],
469
+ ["/i/events/launch-night/finalise", false],
470
+ ["/i/films/library", false],
471
+ ["/i/films/doc/watch/e1", false],
472
+ ["/i/account", false],
473
+ ["/i/account/orders", false],
474
+ ["/i/checkout", false],
475
+ ["/i/login", false],
476
+ ["/i/downloads/tok", false],
477
+ ["/i/app", false],
478
+ ["/i", false],
479
+ ["/api/things", false],
480
+ ["/__tn/unlock", false],
481
+ ["/.well-known/mcp", false],
482
+ ["/logo.png", false],
483
+ ["/sitemap.xml", false],
484
+ ])("isMarkdownPath(%s) is %s", (path, expected) => {
485
+ expect(isMarkdownPath(path)).toBe(expected);
486
+ });
487
+
488
+ it("is left out when readable copies are off, the page is noindex or the site is not indexing", () => {
489
+ const off = (seo: SeoRecord, path = "/about") =>
490
+ (siteSeoHead(ctx(path, seo)).links as Array<{ rel: string }>).filter((l) => l.rel === "alternate");
491
+ expect(off(record({ markdownEnabled: false }))).toEqual([]);
492
+ expect(off(record({ indexing: false }))).toEqual([]);
493
+ expect(off(record({ pages: { "/about": { noindex: true } } }))).toEqual([]);
494
+ // Absent means on: an API older than the field.
495
+ expect(off(record({ markdownEnabled: undefined }))).toHaveLength(1);
496
+ });
497
+
498
+ it("is left out of a hosted page that is noindex or private", () => {
499
+ const head = buildHeadMeta({ title: "Orders" });
500
+ expect(applySeoToHead(head, RECORD, "/i/account/orders").links).toEqual([
501
+ { rel: "canonical", href: "https://claystudio.com/i/account/orders" },
502
+ ]);
503
+ const off = applySeoToHead(buildHeadMeta({ title: "Mug" }), record({ markdownEnabled: false }), "/i/store/mug", "product");
504
+ expect(off.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/i/store/mug" }]);
505
+ });
506
+
507
+ it("replaces a markdown link the page already had rather than adding a second", () => {
508
+ const head = { ...buildHeadMeta({ title: "Mug" }), links: [{ rel: "alternate", type: "text/markdown", href: "/old" }] };
509
+ const out = applySeoToHead(head, RECORD, "/i/store/mug", "product");
510
+ expect(out.links!.filter((l) => l.rel === "alternate")).toEqual([md("https://claystudio.com/i/store/mug")]);
511
+ });
512
+
513
+ it("points at this site when the canonical is somewhere else, and appends to a canonical with a query", () => {
514
+ expect(markdownAlternateHref(RECORD, "/about", "https://elsewhere.example/about")).toBe(
515
+ "https://claystudio.com/about?md",
516
+ );
517
+ expect(markdownAlternateHref(RECORD, "/about", "https://claystudio.com/about?lang=en")).toBe(
518
+ "https://claystudio.com/about?lang=en&md",
519
+ );
520
+ expect(markdownAlternateHref(RECORD, "/", null)).toBe("https://claystudio.com/?md");
521
+ });
522
+ });
@@ -1,3 +1,4 @@
1
+ import { markdownAlternateLink } from "./markdown";
1
2
  import { normalizePathname, resolvePageSeo } from "./resolve";
2
3
  import type { SeoPageType, SeoRecord } from "./types";
3
4
 
@@ -91,8 +92,13 @@ export function applySeoToHead<T extends HeadTags>(
91
92
  ];
92
93
 
93
94
  // A noindex page states no canonical: there is no indexed copy to point at.
94
- const links = (head.links ?? []).filter((l) => l.rel !== "canonical");
95
+ const links = (head.links ?? []).filter(
96
+ (l) => l.rel !== "canonical" && !(l.rel === "alternate" && l.type === "text/markdown"),
97
+ );
95
98
  if (resolved.canonical && !resolved.noindex) links.push({ rel: "canonical", href: resolved.canonical });
99
+ // The readable copy for agents, on the same terms as the canonical.
100
+ const markdown = markdownAlternateLink(seo, path, { canonical: resolved.canonical, noindex: resolved.noindex });
101
+ if (markdown) links.push({ ...markdown });
96
102
 
97
103
  return { ...head, meta: nextMeta, links };
98
104
  }