@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.
- 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/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +202 -6
- package/src/seo/applySeoToHead.ts +7 -1
- package/src/seo/index.ts +1 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +46 -3
- package/src/seo/seoHead.ts +6 -0
- package/src/seo/siteSeoHead.ts +24 -7
- package/src/seo/types.ts +15 -1
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/pwa.ts +16 -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/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/_tests/structuredDataSeo.spec.ts +36 -0
- package/src/utils/structuredData.ts +28 -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/runtime/pages.ts
CHANGED
|
@@ -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([
|
|
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([
|
|
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(
|
|
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([
|
|
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(
|
|
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
|
}
|