@wix/himalaya-cli 0.1521.0 → 0.1523.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/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +18 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +13 -0
- package/dist/cli.mjs +2 -3
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +862 -35
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/branded-lite/dev/index.ts +862 -35
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/brandy/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/community-club/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/gym-fitness/dev/index.ts +862 -35
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/restaurant/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/retail-store/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/yoga-studio/dev/index.ts +857 -30
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +9 -0
|
@@ -5,7 +5,7 @@ import { defineService } from "@himalaya/state";
|
|
|
5
5
|
// instant range, not a calendar day.
|
|
6
6
|
import { addDays as addLocalDays, dayKey, dayLabel, timeLabel, toDate } from "@himalaya/time";
|
|
7
7
|
import { dispatch } from "@himalaya/dispatch";
|
|
8
|
-
import { bookSlot, getService, queryServiceAvailability, type WixBookingSlotFull } from "@himalaya/wix-bookings";
|
|
8
|
+
import { bookSchedule, bookSlot, getService, queryServiceAvailability, registerToWaitlist, type WixBookingSlotFull, type WixSelectedPaymentOption, type WixService } from "@himalaya/wix-bookings";
|
|
9
9
|
import { createCheckout, createOrder } from "@himalaya/wix-ecom";
|
|
10
10
|
import { createRedirectSession, type WixSlot } from "@himalaya/wix-redirects";
|
|
11
11
|
import { payCashierOrder } from "@himalaya/checkout";
|
|
@@ -13,11 +13,11 @@ import { hostedCheckoutOpener, isHostedWorker } from "../_shared/hosted-checkout
|
|
|
13
13
|
import { session as wixPlatformSession } from "@himalaya/wix-platform";
|
|
14
14
|
import { displayName, getMyData, type WixMyData } from "@himalaya/wix-users";
|
|
15
15
|
import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-price.js";
|
|
16
|
-
import { stashCopy, copyParam } from "../_shared/himi-copy.js";
|
|
16
|
+
import { stashCopy, copyParam, copyPlural } from "../_shared/himi-copy.js";
|
|
17
17
|
import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
|
|
18
18
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
19
19
|
import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
|
|
20
|
-
import { availabilityBeyond, slotIdAt } from "../_shared/himi-bookings.js";
|
|
20
|
+
import { availabilityBeyond, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotIdAt } from "../_shared/himi-bookings.js";
|
|
21
21
|
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
22
22
|
import { mayUseRegularBookingCheckout } from "../_shared/iap-attendance.js";
|
|
23
23
|
import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
|
|
@@ -52,6 +52,12 @@ interface SlotRow {
|
|
|
52
52
|
dayChip: string;
|
|
53
53
|
title: string;
|
|
54
54
|
subtitle: string;
|
|
55
|
+
badge: string;
|
|
56
|
+
noBadge: boolean;
|
|
57
|
+
badgeWarning: boolean;
|
|
58
|
+
badgeNeutral: boolean;
|
|
59
|
+
selectable: boolean;
|
|
60
|
+
notSelectable: boolean;
|
|
55
61
|
selected: boolean;
|
|
56
62
|
notSelected: boolean;
|
|
57
63
|
}
|
|
@@ -72,6 +78,13 @@ interface S extends AttendancePurchaseFields {
|
|
|
72
78
|
* booking through the hosted Wix bookings checkout (GPV) instead of the free
|
|
73
79
|
* bookSlot rung. Set from the service payload during load. */
|
|
74
80
|
serviceIsPaid: boolean;
|
|
81
|
+
coursePaymentOption: WixSelectedPaymentOption | "";
|
|
82
|
+
courseAvailable: boolean;
|
|
83
|
+
isCourse: boolean;
|
|
84
|
+
notCourse: boolean;
|
|
85
|
+
scheduleId: string;
|
|
86
|
+
courseStart: string;
|
|
87
|
+
courseEnd: string;
|
|
75
88
|
/** Day-strip chips: every day of the window (a day with no times shows a zero
|
|
76
89
|
* count rather than being silently skipped), in the FilterChips option shape. */
|
|
77
90
|
days: DayOption[];
|
|
@@ -106,8 +119,17 @@ interface S extends AttendancePurchaseFields {
|
|
|
106
119
|
/** #3276 — the sign-in/register card belongs to the moment of booking: hidden
|
|
107
120
|
* for a member, and for a visitor until they have picked a time. */
|
|
108
121
|
hideSignIn: boolean;
|
|
122
|
+
hideTimeSignIn: boolean;
|
|
123
|
+
hideCourseSignIn: boolean;
|
|
124
|
+
hideWaitlistSignIn: boolean;
|
|
109
125
|
canBook: boolean;
|
|
110
126
|
cannotBook: boolean;
|
|
127
|
+
hideTimeBook: boolean;
|
|
128
|
+
hideCourseBook: boolean;
|
|
129
|
+
hideWaitlistBook: boolean;
|
|
130
|
+
hideDayPicker: boolean;
|
|
131
|
+
hideDaySlots: boolean;
|
|
132
|
+
hideDayEmpty: boolean;
|
|
111
133
|
showChoosePrompt: boolean;
|
|
112
134
|
hideChoosePrompt: boolean;
|
|
113
135
|
isMember: boolean;
|
|
@@ -135,6 +157,7 @@ interface DayOption {
|
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
let slotsById = new Map<string, WixBookingSlotFull>();
|
|
160
|
+
let waitlistIds = new Set<string>();
|
|
138
161
|
let regularCheckoutAllowed = false;
|
|
139
162
|
let regularServicePrice = "";
|
|
140
163
|
let lastReady: S | undefined;
|
|
@@ -150,6 +173,40 @@ let allRows: SlotRow[] = [];
|
|
|
150
173
|
// bound that list needed is gone, and the availability query has no paging.
|
|
151
174
|
const WINDOW_DAYS = 14;
|
|
152
175
|
|
|
176
|
+
/** A course booking created by an earlier tap whose checkout or payment did not finish. It stays
|
|
177
|
+
* on Wix, so the next tap pays for THIS booking instead of creating a second one for the same
|
|
178
|
+
* customer. `ordered` once its order exists: the order is what holds the place. Cleared once a
|
|
179
|
+
* booking goes through. */
|
|
180
|
+
let pendingCourseBooking: { scheduleId: string; email: string; ordered?: boolean; booking: { id?: string; startDate?: string; endDate?: string } } | null = null;
|
|
181
|
+
|
|
182
|
+
/** A course occupies one place for its whole schedule, so it can be booked only while its
|
|
183
|
+
* booking window is open and EVERY upcoming session still has room — not just the next one
|
|
184
|
+
* (_shared/himi-bookings.ts, the same rule the service screen's button uses). */
|
|
185
|
+
/** Whether this customer may book the course now. Once an order of theirs holds a place (`held`),
|
|
186
|
+
* counting places would refuse them their own, so only the booking window is checked again. */
|
|
187
|
+
async function courseOpenTo(service: WixService, now: Date, held: boolean): Promise<boolean> {
|
|
188
|
+
return held ? !!service.schedule?.id && courseWindowOpen(service, now.getTime()) : courseIsBookable(service, now);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function courseIsBookable(service: WixService, now: Date): Promise<boolean> {
|
|
192
|
+
const current = now.getTime();
|
|
193
|
+
if (!service.schedule?.id || !courseWindowOpen(service, current)) return false;
|
|
194
|
+
const read = courseReadWindows(service, current);
|
|
195
|
+
const reads = read.windows.map(([from, to]) =>
|
|
196
|
+
queryServiceAvailability(service.id, isoDate(new Date(from)), isoDate(new Date(to)), { bookable: false }));
|
|
197
|
+
const entries = (await Promise.all(reads)).flatMap((res) => res.availabilityEntries ?? []);
|
|
198
|
+
const course = courseSessionsAvailability(entries, current, read.complete);
|
|
199
|
+
return course.found && course.bookable;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The service screen opens this screen with `course: "true"` for a course. When the service
|
|
203
|
+
* record then fails to load, the screen errors instead of listing the course's sessions as
|
|
204
|
+
* single times (where a booking would take one session, not the whole course). */
|
|
205
|
+
const courseInput = (() => {
|
|
206
|
+
const course = (globalThis as { himalaya?: { input?: { course?: unknown } } }).himalaya?.input?.course;
|
|
207
|
+
return course === true || course === "true";
|
|
208
|
+
})();
|
|
209
|
+
|
|
153
210
|
function inputId(): string {
|
|
154
211
|
// WS-C — `himalaya.input.id` is the PRIMARY contract: hosts thread the row's
|
|
155
212
|
// `id` nav param (himi.open { screenId: "himi_time", id }) into worker input.
|
|
@@ -338,25 +395,35 @@ function derive(s: S): S {
|
|
|
338
395
|
// selected day having none is a per-day line, not an empty screen.
|
|
339
396
|
const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
|
|
340
397
|
const hasDaySlots = s.slots.length > 0;
|
|
341
|
-
const hasSelected = !!s.selectedSlotId;
|
|
398
|
+
const hasSelected = s.isCourse ? !!s.scheduleId && s.courseAvailable : !!s.selectedSlotId;
|
|
399
|
+
const selectedWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
|
|
342
400
|
const hasSuccess = !!s.successMessage;
|
|
343
401
|
const canBook = regular && s.isMember && hasSelected && !s.bookingInFlight && !hasSuccess;
|
|
344
|
-
const showChoosePrompt = regular && s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
|
|
402
|
+
const showChoosePrompt = regular && !s.isCourse && s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
|
|
345
403
|
const isRegistering = !!s.isRegistering;
|
|
346
404
|
const next: S = {
|
|
347
405
|
...s,
|
|
348
406
|
_poll: undefined,
|
|
349
407
|
servicePrice: regular ? regularServicePrice : "",
|
|
350
|
-
|
|
408
|
+
// A course is booked whole, not from the 14-day window of times, so it has no window line.
|
|
409
|
+
serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : "", s.isCourse ? "" : copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · ") : s.serviceLine,
|
|
351
410
|
message: regular && !regularCheckoutAllowed && s.message === copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.message,
|
|
352
411
|
hasSlots,
|
|
353
|
-
|
|
412
|
+
// A waitlist pick or a course is not an in-app purchase: neither enables the Apple button.
|
|
413
|
+
...applePurchase.fields(hasSelected && !selectedWaitlist && !s.isCourse),
|
|
354
414
|
hasSelected,
|
|
355
415
|
noSelected: !hasSelected,
|
|
356
|
-
|
|
416
|
+
// The shared sign-in card: no sign-in for a course this screen cannot sell (as below).
|
|
417
|
+
hideSignIn: s.isMember || !hasSelected || (s.isCourse && !regular),
|
|
418
|
+
hideTimeSignIn: s.isMember || !hasSelected || s.isCourse || selectedWaitlist,
|
|
419
|
+
// A course has no in-app purchase path: where it cannot use the regular checkout, a guest is
|
|
420
|
+
// not sent to sign in for a button that will not be there.
|
|
421
|
+
hideCourseSignIn: s.isMember || !hasSelected || !s.isCourse || !regular,
|
|
422
|
+
hideWaitlistSignIn: s.isMember || !hasSelected || s.isCourse || !selectedWaitlist,
|
|
423
|
+
notCourse: !s.isCourse,
|
|
357
424
|
hasDaySlots,
|
|
358
425
|
noDaySlots: !hasDaySlots,
|
|
359
|
-
noWindowSlots: !hasSlots,
|
|
426
|
+
noWindowSlots: !hasSlots && !s.isCourse,
|
|
360
427
|
// Only a quiet DAY in a window that has times gets the per-day line; a
|
|
361
428
|
// window with no times at all is the screen-level empty state's job.
|
|
362
429
|
dayEmptyText: hasDaySlots || !s.selectedDay || !hasSlots
|
|
@@ -366,11 +433,18 @@ function derive(s: S): S {
|
|
|
366
433
|
noSuccess: !hasSuccess,
|
|
367
434
|
canBook,
|
|
368
435
|
cannotBook: !canBook,
|
|
436
|
+
hideTimeBook: !canBook || s.isCourse || selectedWaitlist,
|
|
437
|
+
hideCourseBook: !canBook || !s.isCourse,
|
|
438
|
+
// Joining a waitlist buys nothing, so it does not wait on the purchase route.
|
|
439
|
+
hideWaitlistBook: !s.isMember || !hasSelected || s.isCourse || !selectedWaitlist || s.bookingInFlight || hasSuccess,
|
|
440
|
+
hideDayPicker: s.isCourse || !hasSlots,
|
|
441
|
+
hideDaySlots: s.isCourse || !hasDaySlots,
|
|
442
|
+
hideDayEmpty: s.isCourse || hasDaySlots,
|
|
369
443
|
showChoosePrompt,
|
|
370
444
|
hideChoosePrompt: !showChoosePrompt,
|
|
371
445
|
isLoading: s.status === "loading",
|
|
372
446
|
notLoading: s.status !== "loading",
|
|
373
|
-
noEmpty: s.status === "loading" || s.errored || hasSlots,
|
|
447
|
+
noEmpty: s.status === "loading" || s.errored || hasSlots || s.isCourse,
|
|
374
448
|
notErrored: !s.errored,
|
|
375
449
|
notMember: !s.isMember,
|
|
376
450
|
notBooking: !s.bookingInFlight,
|
|
@@ -384,9 +458,11 @@ function derive(s: S): S {
|
|
|
384
458
|
authTitle: isRegistering
|
|
385
459
|
? copyParam("time.authTitleRegister", "Create account to book")
|
|
386
460
|
: copyParam("time.authTitle", "Sign in to book"),
|
|
387
|
-
authBody:
|
|
388
|
-
? copyParam("time.
|
|
389
|
-
:
|
|
461
|
+
authBody: s.isCourse
|
|
462
|
+
? copyParam("time.courseAuthBody", "Sign in to book the full course. Booking is saved to your member account.")
|
|
463
|
+
: isRegistering
|
|
464
|
+
? copyParam("time.authBodyRegister", "Create a member account, then book the selected time on this screen.")
|
|
465
|
+
: copyParam("time.authBody", "You can browse times as a visitor. Booking is saved to a member account."),
|
|
390
466
|
};
|
|
391
467
|
if (next.status === "ready") lastReady = next;
|
|
392
468
|
return next;
|
|
@@ -533,6 +609,13 @@ const svc = defineService<S, {
|
|
|
533
609
|
servicePrice: "",
|
|
534
610
|
serviceDuration: "",
|
|
535
611
|
serviceIsPaid: false,
|
|
612
|
+
coursePaymentOption: "",
|
|
613
|
+
courseAvailable: false,
|
|
614
|
+
isCourse: false,
|
|
615
|
+
notCourse: true,
|
|
616
|
+
scheduleId: "",
|
|
617
|
+
courseStart: "",
|
|
618
|
+
courseEnd: "",
|
|
536
619
|
days: [],
|
|
537
620
|
selectedDay: "",
|
|
538
621
|
selectedDayLabel: "",
|
|
@@ -557,8 +640,17 @@ const svc = defineService<S, {
|
|
|
557
640
|
hasSelected: false,
|
|
558
641
|
noSelected: true,
|
|
559
642
|
hideSignIn: true,
|
|
643
|
+
hideTimeSignIn: true,
|
|
644
|
+
hideCourseSignIn: true,
|
|
645
|
+
hideWaitlistSignIn: true,
|
|
560
646
|
canBook: false,
|
|
561
647
|
cannotBook: true,
|
|
648
|
+
hideTimeBook: true,
|
|
649
|
+
hideCourseBook: true,
|
|
650
|
+
hideWaitlistBook: true,
|
|
651
|
+
hideDayPicker: true,
|
|
652
|
+
hideDaySlots: true,
|
|
653
|
+
hideDayEmpty: true,
|
|
562
654
|
showChoosePrompt: false,
|
|
563
655
|
hideChoosePrompt: true,
|
|
564
656
|
isMember: false,
|
|
@@ -596,26 +688,83 @@ const svc = defineService<S, {
|
|
|
596
688
|
try {
|
|
597
689
|
const now = new Date();
|
|
598
690
|
const beyond = availabilityBeyond(now, farStart);
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
691
|
+
const serviceRes = await getService(s.id).catch(() => ({ service: undefined }));
|
|
692
|
+
if (courseInput && !serviceRes.service) {
|
|
693
|
+
slotsById = new Map();
|
|
694
|
+
allRows = [];
|
|
695
|
+
return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
|
|
696
|
+
}
|
|
697
|
+
if (serviceRes.service?.type === "COURSE") {
|
|
698
|
+
slotsById = new Map();
|
|
699
|
+
waitlistIds = new Set();
|
|
700
|
+
allRows = [];
|
|
701
|
+
const header = serviceHeaderFields(serviceRes.service);
|
|
702
|
+
const schedule = serviceRes.service.schedule;
|
|
703
|
+
const scheduleId = schedule?.id ?? "";
|
|
704
|
+
// A reload after a cancelled payment: this customer's order may hold the last place.
|
|
705
|
+
const held = pendingCourseBooking?.ordered === true && pendingCourseBooking.scheduleId === scheduleId
|
|
706
|
+
&& pendingCourseBooking.email === ((await signedInEmail()) || next.email);
|
|
707
|
+
const courseAvailable = await courseOpenTo(serviceRes.service, now, held);
|
|
708
|
+
// A course has no in-app purchase path: where a paid booking may not use the regular
|
|
709
|
+
// checkout (mayUseRegularBookingCheckout), it cannot be bought here.
|
|
710
|
+
regularServicePrice = header.price;
|
|
711
|
+
regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
|
|
712
|
+
return derive({
|
|
713
|
+
...next,
|
|
714
|
+
status: "ready",
|
|
715
|
+
errored: false,
|
|
716
|
+
isCourse: true,
|
|
717
|
+
courseAvailable,
|
|
718
|
+
scheduleId,
|
|
719
|
+
courseStart: schedule?.firstSessionStart ?? "",
|
|
720
|
+
courseEnd: schedule?.lastSessionEnd ?? "",
|
|
721
|
+
days: [],
|
|
722
|
+
selectedDay: "",
|
|
723
|
+
slots: [],
|
|
724
|
+
selectedSlotId: "",
|
|
725
|
+
serviceName: serviceRes.service.name || copyParam("time.courseNameFallback", "Course"),
|
|
726
|
+
serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""].filter(Boolean).join(" · "),
|
|
727
|
+
serviceImage: header.image,
|
|
728
|
+
hasServiceImage: !!header.image,
|
|
729
|
+
noServiceImage: !header.image,
|
|
730
|
+
servicePrice: regularCheckoutAllowed ? header.price : "",
|
|
731
|
+
serviceDuration: header.duration,
|
|
732
|
+
serviceIsPaid: header.paid,
|
|
733
|
+
coursePaymentOption: serviceRes.service.payment?.options?.online ? "ONLINE"
|
|
734
|
+
: serviceRes.service.payment?.options?.inPerson ? "OFFLINE" : "",
|
|
735
|
+
selectedLine: schedule?.firstSessionStart && schedule?.lastSessionEnd
|
|
736
|
+
? `${dayLabel(schedule.firstSessionStart)} – ${dayLabel(schedule.lastSessionEnd)}` : "",
|
|
737
|
+
message: !courseAvailable ? copyParam("time.courseUnavailable", "Course booking is unavailable right now.")
|
|
738
|
+
: regularCheckoutAllowed ? "" : copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable."),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const includeFull = serviceRes.service?.type === "CLASS";
|
|
742
|
+
const [availability, target] = await Promise.all([
|
|
743
|
+
queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * 86_400_000)), { bookable: !includeFull }),
|
|
602
744
|
// A host's start past the window (an Agenda session weeks out): its day alone, so it can
|
|
603
745
|
// be preselected. Its failure costs only that preselection.
|
|
604
|
-
beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until)).catch(() => ({ availabilityEntries: [] })) : undefined,
|
|
746
|
+
beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until), { bookable: !includeFull }).catch(() => ({ availabilityEntries: [] })) : undefined,
|
|
605
747
|
]);
|
|
606
748
|
slotsById = new Map();
|
|
749
|
+
waitlistIds = new Set();
|
|
607
750
|
const rows: SlotRow[] = [];
|
|
608
751
|
// Group by LOCAL calendar day, not by the formatted heading: two
|
|
609
752
|
// distinct days can render the same text, and a UTC-derived key can
|
|
610
753
|
// name a different day than the heading beside it.
|
|
754
|
+
const classService = serviceRes.service?.type === "CLASS";
|
|
611
755
|
for (const [index, entry] of [...(availability.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])]
|
|
612
|
-
.filter((candidate) => candidate.bookable !== false && candidate.
|
|
756
|
+
.filter((candidate) => candidate.slot?.startDate && (candidate.bookable !== false || (classService && candidate.openSpots === 0)))
|
|
613
757
|
.entries()) {
|
|
614
758
|
const slot = entry.slot as WixBookingSlotFull;
|
|
615
759
|
const key = dayKey(slot.startDate);
|
|
616
760
|
if (!key) continue;
|
|
617
761
|
const slotId = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
|
|
618
|
-
|
|
762
|
+
const openSpots = classService && typeof entry.openSpots === "number" && entry.openSpots >= 0 ? entry.openSpots : undefined;
|
|
763
|
+
const waitlist = openSpots === 0 && serviceRes.service?.bookingPolicy?.waitlistPolicy?.enabled === true &&
|
|
764
|
+
typeof entry.waitingList?.openSpots === "number" && entry.waitingList.openSpots > 0;
|
|
765
|
+
const selectable = (entry.bookable !== false && openSpots !== 0) || waitlist;
|
|
766
|
+
if (waitlist) waitlistIds.add(slotId);
|
|
767
|
+
else if (selectable) slotsById.set(slotId, slot);
|
|
619
768
|
const label = fmtRange(slot.startDate, slot.endDate);
|
|
620
769
|
rows.push({
|
|
621
770
|
id: slotId,
|
|
@@ -624,6 +773,14 @@ const svc = defineService<S, {
|
|
|
624
773
|
dayChip: dayLabel(slot.startDate, { format: "compact" }),
|
|
625
774
|
title: label.title,
|
|
626
775
|
subtitle: label.subtitle,
|
|
776
|
+
badge: openSpots == null ? "" : openSpots > 0
|
|
777
|
+
? copyPlural("time.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", openSpots)
|
|
778
|
+
: waitlist ? copyParam("time.waitlistBadge", "Waitlist") : copyParam("time.fullBadge", "Full"),
|
|
779
|
+
noBadge: openSpots == null,
|
|
780
|
+
badgeWarning: openSpots != null && openSpots > 0 && openSpots <= 3,
|
|
781
|
+
badgeNeutral: openSpots == null || openSpots === 0 || openSpots > 3,
|
|
782
|
+
selectable,
|
|
783
|
+
notSelectable: !selectable,
|
|
627
784
|
selected: false,
|
|
628
785
|
notSelected: true,
|
|
629
786
|
});
|
|
@@ -642,13 +799,13 @@ const svc = defineService<S, {
|
|
|
642
799
|
// The named session (a slot row's id IS its session id), else the slot at that instant. A
|
|
643
800
|
// named session that is gone is "taken since" — never another session at the same time.
|
|
644
801
|
const startRow = !wantedStart ? undefined
|
|
645
|
-
: pendingSession ? rows.find((r) => r.id === pendingSession)
|
|
646
|
-
: rows.find((r) => r.id === slotIdAt(slotsById, wantedStart));
|
|
802
|
+
: pendingSession ? rows.find((r) => r.id === pendingSession && r.selectable)
|
|
803
|
+
: rows.find((r) => r.id === slotIdAt(slotsById, wantedStart) && r.selectable);
|
|
647
804
|
// The requested day even with no time left on it: "taken since" opens there, nothing selected.
|
|
648
805
|
const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
|
|
649
|
-
const selectionAlive = !!startRow || slotsById.has(chosen.selectedSlotId);
|
|
806
|
+
const selectionAlive = !!startRow || slotsById.has(chosen.selectedSlotId) || waitlistIds.has(chosen.selectedSlotId);
|
|
650
807
|
// A member with no live selection, and no host start, gets back the time they picked as a guest.
|
|
651
|
-
const picked = selectionAlive || wantedStart || !next.isMember ? undefined : rows.find((row) => row.id === readPick(s.id));
|
|
808
|
+
const picked = selectionAlive || wantedStart || !next.isMember ? undefined : rows.find((row) => row.id === readPick(s.id) && row.selectable);
|
|
652
809
|
// Keep the customer's day across a reload when it still has times;
|
|
653
810
|
// otherwise open on the first day that has any.
|
|
654
811
|
const keep = days.find((d) => d.id === chosen.selectedDay && d.hasSlots);
|
|
@@ -674,6 +831,7 @@ const svc = defineService<S, {
|
|
|
674
831
|
}, initialDay));
|
|
675
832
|
} catch {
|
|
676
833
|
slotsById = new Map();
|
|
834
|
+
waitlistIds = new Set();
|
|
677
835
|
allRows = [];
|
|
678
836
|
return derive({ ...next, status: "ready", errored: true, days: [], selectedDay: "", selectedDayLabel: "", slots: [] });
|
|
679
837
|
}
|
|
@@ -694,7 +852,7 @@ const svc = defineService<S, {
|
|
|
694
852
|
const slotId = typeof arg === "string"
|
|
695
853
|
? arg
|
|
696
854
|
: ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
|
|
697
|
-
const selected = allRows.find((slot) => slot.id === slotId);
|
|
855
|
+
const selected = allRows.find((slot) => slot.id === slotId && slot.selectable);
|
|
698
856
|
if (selected) {
|
|
699
857
|
selectionRevision++;
|
|
700
858
|
pendingStart = "";
|
|
@@ -729,7 +887,9 @@ const svc = defineService<S, {
|
|
|
729
887
|
firstName: "",
|
|
730
888
|
lastName: "",
|
|
731
889
|
isRegistering: false,
|
|
732
|
-
message:
|
|
890
|
+
message: s.isCourse
|
|
891
|
+
? copyParam("time.courseSignedInReady", "Signed in. You can book this course now.")
|
|
892
|
+
: copyParam("time.signedInReady", "Signed in. You can book this time now."),
|
|
733
893
|
});
|
|
734
894
|
} catch {
|
|
735
895
|
return derive({ ...s, password: "", message: copyParam("time.signInFailed", "Sign in failed. Try again.") });
|
|
@@ -752,7 +912,9 @@ const svc = defineService<S, {
|
|
|
752
912
|
firstName: "",
|
|
753
913
|
lastName: "",
|
|
754
914
|
isRegistering: false,
|
|
755
|
-
message:
|
|
915
|
+
message: s.isCourse
|
|
916
|
+
? copyParam("time.courseRegisterReady", "Account created. You can book this course now.")
|
|
917
|
+
: copyParam("time.registerReady", "Account created. You can book this time now."),
|
|
756
918
|
});
|
|
757
919
|
} catch {
|
|
758
920
|
return derive({ ...s, password: "", message: copyParam("time.registerFailed", "Account creation failed. Try another email.") });
|
|
@@ -760,17 +922,26 @@ const svc = defineService<S, {
|
|
|
760
922
|
},
|
|
761
923
|
bookSelected: async (s) => {
|
|
762
924
|
if (!s.isMember) return derive({ ...s, message: copyParam("time.bookNeedsSignIn", "Sign in before booking.") });
|
|
763
|
-
if (!s.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
925
|
+
if (s.isCourse && !s.scheduleId) return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
926
|
+
if (!s.isCourse && !s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
|
|
927
|
+
const joiningWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
|
|
928
|
+
const slot = s.isCourse ? undefined : slotsById.get(s.selectedSlotId);
|
|
929
|
+
if (!s.isCourse && !slot && !joiningWaitlist) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
|
|
930
|
+
// A booking is paid through the regular checkout only where that is allowed; joining a
|
|
931
|
+
// waitlist buys nothing, so it skips this check.
|
|
932
|
+
if (!joiningWaitlist) {
|
|
933
|
+
try {
|
|
934
|
+
const current = await getService(s.id);
|
|
935
|
+
// A course is booked whole, never one of its sessions as a time: a screen opened with no
|
|
936
|
+
// `course` input whose first service read failed lists the sessions as times.
|
|
937
|
+
if (!s.isCourse && current.service?.type === "COURSE") return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
938
|
+
regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
|
|
939
|
+
if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
940
|
+
} catch {
|
|
941
|
+
if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
942
|
+
}
|
|
943
|
+
if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
772
944
|
}
|
|
773
|
-
if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
774
945
|
// A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
|
|
775
946
|
// host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
|
|
776
947
|
// The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
|
|
@@ -779,12 +950,28 @@ const svc = defineService<S, {
|
|
|
779
950
|
bookingId: booking?.id,
|
|
780
951
|
serviceId: s.id,
|
|
781
952
|
serviceName: s.serviceName,
|
|
782
|
-
start: hostEventInstant(booking?.startDate) || slot.
|
|
783
|
-
end: hostEventInstant(booking?.endDate) || slot.
|
|
953
|
+
start: hostEventInstant(booking?.startDate) || slot?.startDate || s.courseStart,
|
|
954
|
+
end: hostEventInstant(booking?.endDate) || slot?.endDate || s.courseEnd,
|
|
784
955
|
});
|
|
785
956
|
// The member acts on the pick now; a later visit must not restore it.
|
|
786
957
|
clearPick();
|
|
787
|
-
|
|
958
|
+
if (joiningWaitlist) {
|
|
959
|
+
const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
|
|
960
|
+
const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
|
|
961
|
+
const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
|
|
962
|
+
const name = contactName(displayName(my) || email);
|
|
963
|
+
try {
|
|
964
|
+
await registerToWaitlist(s.selectedSlotId, { email, firstName: name.first, lastName: name.last });
|
|
965
|
+
return derive({ ...pending, bookingInFlight: false,
|
|
966
|
+
successMessage: copyParam("time.waitlistJoined", "You're on the waitlist. Check My Bookings for updates."), message: "" });
|
|
967
|
+
} catch {
|
|
968
|
+
return derive({ ...s, bookingInFlight: false,
|
|
969
|
+
message: copyParam("time.waitlistFailed", "Couldn't join the waitlist. Try again.") });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// Paid service or any course — create a Wix eCommerce order so a free
|
|
973
|
+
// course does not remain in CREATED status with no reserved capacity.
|
|
974
|
+
// FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
|
|
788
975
|
// hosted-checkout WebView / browser redirect. The chain is
|
|
789
976
|
// createBooking → ecom.createCheckout(booking line item) → ecom.createOrder
|
|
790
977
|
// → @himalaya/wix-cashier.proceedToPayment(snapshotId)
|
|
@@ -792,17 +979,39 @@ const svc = defineService<S, {
|
|
|
792
979
|
// (snapshotId) settled on-device via Apple/Google Pay. We do NOT optimistically
|
|
793
980
|
// write a local booking row (My Bookings revalidates from Wix on success). Any
|
|
794
981
|
// failure degrades to an inline retry message, never a dead button.
|
|
795
|
-
if (s.serviceIsPaid) {
|
|
982
|
+
if (s.serviceIsPaid || s.isCourse) {
|
|
796
983
|
const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
|
|
797
984
|
// Booker identity — same resolution as the free path (never throws).
|
|
798
985
|
const payerData = await getMyData().catch(() => ({ email: s.email } as WixMyData));
|
|
799
986
|
const payerEmail = (await signedInEmail()) || payerData.email || payerData.contactEmail || s.email;
|
|
800
987
|
const payerName = contactName(displayName(payerData) || payerEmail);
|
|
988
|
+
// Only the same customer's own unfinished booking for this course is reused.
|
|
989
|
+
const retried = s.isCourse && pendingCourseBooking?.scheduleId === s.scheduleId && pendingCourseBooking.email === payerEmail
|
|
990
|
+
? pendingCourseBooking : undefined;
|
|
991
|
+
if (s.isCourse) {
|
|
992
|
+
try {
|
|
993
|
+
const latest = (await getService(s.id)).service;
|
|
994
|
+
const open = latest != null && await courseOpenTo(latest, new Date(), retried?.ordered === true);
|
|
995
|
+
if (!open) {
|
|
996
|
+
return derive({ ...pending, bookingInFlight: false, courseAvailable: false,
|
|
997
|
+
message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
return derive({ ...pending, bookingInFlight: false,
|
|
1001
|
+
message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
801
1004
|
try {
|
|
802
1005
|
// 1. Create the booking (CREATED status — payment pending).
|
|
803
|
-
|
|
1006
|
+
// A course retried after a failed checkout reuses the booking it already made.
|
|
1007
|
+
const created = retried ? { booking: retried.booking }
|
|
1008
|
+
: s.isCourse
|
|
1009
|
+
? await bookSchedule(s.scheduleId, payerName.first, payerName.last, payerEmail,
|
|
1010
|
+
s.coursePaymentOption ? { selectedPaymentOption: s.coursePaymentOption } : undefined)
|
|
1011
|
+
: await bookSlot(slot!, payerName.first, payerName.last, payerEmail);
|
|
804
1012
|
const bookingId = created.booking?.id;
|
|
805
1013
|
if (!bookingId) throw new Error("booking not created");
|
|
1014
|
+
if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
|
|
806
1015
|
// 2. Wrap the booking as a checkout line item; create the checkout.
|
|
807
1016
|
const checkoutRes = await createCheckout([
|
|
808
1017
|
{ quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
|
|
@@ -816,10 +1025,14 @@ const svc = defineService<S, {
|
|
|
816
1025
|
// RN does) — creating it here first fails on a site with no payment method set up.
|
|
817
1026
|
const hosted = isHostedWorker();
|
|
818
1027
|
const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
|
|
1028
|
+
if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
|
|
819
1029
|
const snapshotId = orderRes.paymentGatewayOrderId ?? "";
|
|
820
|
-
if (!snapshotId && !hosted) {
|
|
1030
|
+
if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
|
|
1031
|
+
pendingCourseBooking = null;
|
|
821
1032
|
await reportBooked(created.booking);
|
|
822
|
-
return derive({ ...pending, bookingInFlight: false, successMessage:
|
|
1033
|
+
return derive({ ...pending, bookingInFlight: false, successMessage: s.isCourse
|
|
1034
|
+
? copyParam("time.courseSubmitted", "Course booking submitted. Check My Bookings for confirmation.")
|
|
1035
|
+
: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
823
1036
|
}
|
|
824
1037
|
// 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
|
|
825
1038
|
// the in-app Wix cashier. Routed through the shared helper so a
|
|
@@ -833,13 +1046,14 @@ const svc = defineService<S, {
|
|
|
833
1046
|
// Hosted (Spaces): the host opens this booking's checkout as the lent member.
|
|
834
1047
|
...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
|
|
835
1048
|
cashierUrl: async () => {
|
|
836
|
-
const redirect = await createRedirectSession(
|
|
837
|
-
|
|
838
|
-
|
|
1049
|
+
const redirect = await createRedirectSession(s.isCourse
|
|
1050
|
+
? { ecomCheckout: { checkoutId } }
|
|
1051
|
+
: { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
|
|
839
1052
|
return redirect.redirectSession?.fullUrl;
|
|
840
1053
|
},
|
|
841
1054
|
});
|
|
842
1055
|
if (pay.kind === "paid") {
|
|
1056
|
+
pendingCourseBooking = null;
|
|
843
1057
|
await reportBooked(created.booking);
|
|
844
1058
|
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
845
1059
|
}
|
|
@@ -849,14 +1063,20 @@ const svc = defineService<S, {
|
|
|
849
1063
|
return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
|
|
850
1064
|
}
|
|
851
1065
|
if (pay.kind === "cancelled") {
|
|
852
|
-
return derive({ ...s, bookingInFlight: false, message:
|
|
1066
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1067
|
+
? copyParam("time.coursePaymentCancelled", "Payment cancelled — your course booking wasn't confirmed. Try again.")
|
|
1068
|
+
: copyParam("time.paymentCancelled", "Payment cancelled — your booking wasn't confirmed. Pick a time to try again.") });
|
|
853
1069
|
}
|
|
854
|
-
return derive({ ...s, bookingInFlight: false, message:
|
|
1070
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1071
|
+
? copyParam("time.coursePaymentFailed", "Payment couldn't be completed. Try again.")
|
|
1072
|
+
: copyParam("time.paymentFailed", "Payment couldn't be completed. Pick another time or try again.") });
|
|
855
1073
|
} catch {
|
|
856
|
-
return derive({ ...s, bookingInFlight: false, message:
|
|
1074
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1075
|
+
? copyParam("time.courseCheckoutFailed", "Checkout could not start. Try again.")
|
|
1076
|
+
: copyParam("time.checkoutFailed", "Checkout could not start. Pick another time or try again.") });
|
|
857
1077
|
}
|
|
858
1078
|
}
|
|
859
|
-
// FREE
|
|
1079
|
+
// FREE appointment/class — the original member bookSlot rung (offline-safe below).
|
|
860
1080
|
// getMyData/signedInEmail carry their own fallbacks (never throw), so the
|
|
861
1081
|
// booker identity is resolved up front — needed for both the optimistic
|
|
862
1082
|
// cache row and the offline replay params.
|
|
@@ -869,13 +1089,13 @@ const svc = defineService<S, {
|
|
|
869
1089
|
const localRow = (id: string): DbEntity => ({
|
|
870
1090
|
id,
|
|
871
1091
|
title: s.serviceName || "Booking",
|
|
872
|
-
subtitle: selectedSlot?.title || "Booked time",
|
|
1092
|
+
subtitle: s.isCourse ? (s.selectedLine || "Full course") : (selectedSlot?.title || "Booked time"),
|
|
873
1093
|
status: "Booked",
|
|
874
1094
|
});
|
|
875
1095
|
await cachePut(localRow(localId));
|
|
876
1096
|
const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
|
|
877
1097
|
try {
|
|
878
|
-
const created = await bookSlot(slot
|
|
1098
|
+
const created = await bookSlot(slot!, name.first, name.last, email);
|
|
879
1099
|
// Reconcile the optimistic local id with the server-assigned booking id.
|
|
880
1100
|
const realId = created.booking?.id;
|
|
881
1101
|
if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
|
|
@@ -893,11 +1113,11 @@ const svc = defineService<S, {
|
|
|
893
1113
|
// connectivity returns.
|
|
894
1114
|
await queueWrite("tier5.himiBookingTime.bookSelected", {
|
|
895
1115
|
serviceId: s.id,
|
|
896
|
-
slotId: s.selectedSlotId,
|
|
1116
|
+
...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
|
|
897
1117
|
email,
|
|
898
1118
|
firstName: name.first,
|
|
899
1119
|
lastName: name.last,
|
|
900
|
-
slot: JSON.stringify(slot),
|
|
1120
|
+
...(slot ? { slot: JSON.stringify(slot) } : {}),
|
|
901
1121
|
});
|
|
902
1122
|
return derive({
|
|
903
1123
|
...pending,
|
|
@@ -906,7 +1126,9 @@ const svc = defineService<S, {
|
|
|
906
1126
|
message: "",
|
|
907
1127
|
});
|
|
908
1128
|
}
|
|
909
|
-
return derive({ ...s, bookingInFlight: false, message:
|
|
1129
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1130
|
+
? copyParam("time.courseBookFailed", "Course booking failed. Try again.")
|
|
1131
|
+
: copyParam("time.bookFailed", "Booking failed. Pick another time or try again.") });
|
|
910
1132
|
}
|
|
911
1133
|
},
|
|
912
1134
|
},
|
|
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
|
|
|
54
54
|
name in values ? String(values[name]) : "",
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** The small ICU plural form used by Bookings spot counts. The provisioned copy may
|
|
59
|
+
* translate both branches without adding a second key for the singular. */
|
|
60
|
+
export function copyPlural(key: string, fallback: string, count: number): string {
|
|
61
|
+
const pattern = copyParam(key, fallback);
|
|
62
|
+
const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
|
|
63
|
+
if (!match) return copyTemplate(key, fallback, { n: count });
|
|
64
|
+
return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
|
|
65
|
+
}
|
|
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
|
|
|
54
54
|
name in values ? String(values[name]) : "",
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** The small ICU plural form used by Bookings spot counts. The provisioned copy may
|
|
59
|
+
* translate both branches without adding a second key for the singular. */
|
|
60
|
+
export function copyPlural(key: string, fallback: string, count: number): string {
|
|
61
|
+
const pattern = copyParam(key, fallback);
|
|
62
|
+
const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
|
|
63
|
+
if (!match) return copyTemplate(key, fallback, { n: count });
|
|
64
|
+
return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
|
|
65
|
+
}
|
|
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
|
|
|
54
54
|
name in values ? String(values[name]) : "",
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** The small ICU plural form used by Bookings spot counts. The provisioned copy may
|
|
59
|
+
* translate both branches without adding a second key for the singular. */
|
|
60
|
+
export function copyPlural(key: string, fallback: string, count: number): string {
|
|
61
|
+
const pattern = copyParam(key, fallback);
|
|
62
|
+
const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
|
|
63
|
+
if (!match) return copyTemplate(key, fallback, { n: count });
|
|
64
|
+
return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
|
|
65
|
+
}
|