@wix/himalaya-cli 0.1525.0 → 0.1527.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/core/dev-server/src/builder.d.ts +14 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/connectivity.d.ts +5 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +22 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/worker-subtree-safe-types.generated.d.ts +4 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +7 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +13 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/state.mjs +14 -0
- package/dist/cli.mjs +157 -40
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/designing-himi-screens.md +43 -18
- package/guides/himi-test.md +3 -3
- package/guides/recipe-dynamic-list.md +7 -7
- package/guides/recipe-four-state.md +47 -41
- package/guides/recipe-search.md +1 -1
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +1943 -646
- package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/beauty-salon/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +1 -0
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +803 -284
- package/templates/beauty-salon/tier5-src/himi-branded-lite-address/index.ts +3 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +3 -0
- package/templates/beauty-salon/tier5-src/himi-branded-lite-order/index.ts +3 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-plans/index.ts +13 -1
- package/templates/beauty-salon/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +10 -4
- package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/branded-lite/dev/index.ts +2404 -1020
- package/templates/branded-lite/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/branded-lite/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +4 -0
- package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +1 -0
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +803 -284
- package/templates/branded-lite/tier5-src/himi-branded-lite-address/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +3 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-feed/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-group/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +6 -4
- package/templates/branded-lite/tier5-src/himi-branded-lite-notifsettings/index.ts +6 -4
- package/templates/branded-lite/tier5-src/himi-branded-lite-order/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-plans/index.ts +13 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-program/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +6 -4
- package/templates/branded-lite/tier5-src/himi-branded-lite-reserve/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/branded-lite/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/branded-lite/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +10 -4
- package/templates/community-club/dev/index.ts +40 -21
- package/templates/community-club/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/community-club/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/community-club/tier5-src/himi-branded-lite-feed/index.ts +3 -1
- package/templates/community-club/tier5-src/himi-branded-lite-group/index.ts +3 -1
- package/templates/community-club/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/community-club/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/community-club/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/community-club-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/events-meetups/dev/index.ts +36 -19
- package/templates/events-meetups/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/events-meetups/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/events-meetups/tier5-src/himi-branded-lite-feed/index.ts +3 -1
- package/templates/events-meetups/tier5-src/himi-branded-lite-group/index.ts +3 -1
- package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/events-meetups/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/events-meetups/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/events-meetups-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/gym-fitness/dev/index.ts +1944 -645
- package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/gym-fitness/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +1 -0
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +803 -284
- package/templates/gym-fitness/tier5-src/himi-branded-lite-address/index.ts +3 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +3 -0
- package/templates/gym-fitness/tier5-src/himi-branded-lite-order/index.ts +3 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-plans/index.ts +13 -1
- package/templates/gym-fitness/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/gym-fitness/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +10 -4
- package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/nonprofit-cause/dev/index.ts +47 -29
- package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/nonprofit-cause/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-feed/index.ts +3 -1
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group/index.ts +3 -1
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-plans/index.ts +13 -1
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/nonprofit-cause/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/owner-lite/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/owner-lite/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/podcast-creator/dev/index.ts +47 -29
- package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/podcast-creator/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/podcast-creator/tier5-src/himi-branded-lite-feed/index.ts +3 -1
- package/templates/podcast-creator/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
- package/templates/podcast-creator/tier5-src/himi-branded-lite-plans/index.ts +13 -1
- package/templates/podcast-creator/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/podcast-creator/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/portfolio-creative/dev/index.ts +52 -27
- package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/portfolio-creative/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-address/index.ts +3 -1
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +3 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-order/index.ts +3 -1
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/portfolio-creative/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +10 -4
- package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/restaurant/dev/index.ts +48 -25
- package/templates/restaurant/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/restaurant/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-address/index.ts +3 -1
- package/templates/restaurant/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
- package/templates/restaurant/tier5-src/himi-branded-lite-reserve/index.ts +3 -1
- package/templates/restaurant/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/restaurant/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/restaurant/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/restaurant/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/restaurant-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/retail-store/dev/index.ts +44 -23
- package/templates/retail-store/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/retail-store/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-address/index.ts +3 -1
- package/templates/retail-store/tier5-src/himi-branded-lite-addresses/index.ts +3 -1
- package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +3 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-order/index.ts +3 -1
- package/templates/retail-store/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/retail-store/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +10 -4
- package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/retail-store-owner/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/yoga-studio/dev/index.ts +2102 -811
- package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/yoga-studio/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +1 -0
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +803 -284
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +9 -1
- package/templates/yoga-studio/tier5-src/himi-branded-lite-place-summary/index.ts +12 -1
- package/templates/yoga-studio/tier5-src/himi-branded-lite-plans/index.ts +13 -1
- package/templates/yoga-studio/tier5-src/himi-branded-lite-program/index.ts +3 -1
- package/templates/yoga-studio/tier5-src/himi-branded-lite-wallet/index.ts +3 -1
- package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +8 -1
- package/templates/yoga-studio/tier5-src/himi-events-rsvp/index.ts +7 -4
- package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +69 -10
- package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +5 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/online-actions.ts +76 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { withOnlineWrites, offlineWritesEnabled } from "../_shared/online-actions.js";
|
|
1
2
|
// Tier-5 bundle: himi-booking — availability and member booking.
|
|
2
|
-
import { defineService } from "@himalaya/state";
|
|
3
|
+
import { defineService as registerService, navigate } from "@himalaya/state";
|
|
3
4
|
// Local-day keying + shared day/time labelling. `addDays` below stays local and
|
|
4
5
|
// UTC-based on purpose: it bounds the availability QUERY window, which is an
|
|
5
6
|
// instant range, not a calendar day.
|
|
@@ -13,12 +14,12 @@ import { hostedCheckoutOpener, isHostedWorker } from "../_shared/hosted-checkout
|
|
|
13
14
|
import { session as wixPlatformSession } from "@himalaya/wix-platform";
|
|
14
15
|
import { displayName, getMyData, type WixMyData } from "@himalaya/wix-users";
|
|
15
16
|
import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-price.js";
|
|
16
|
-
import { stashCopy, copyParam, copyPlural } from "../_shared/himi-copy.js";
|
|
17
|
+
import { stashCopy, copyParam, copyPlural, copyTemplate } from "../_shared/himi-copy.js";
|
|
17
18
|
import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
|
|
18
19
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
19
20
|
import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
|
|
20
21
|
import { availabilityBeyond, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotIdAt } from "../_shared/himi-bookings.js";
|
|
21
|
-
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
22
|
+
import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
|
|
22
23
|
import { mayUseRegularBookingCheckout } from "../_shared/iap-attendance.js";
|
|
23
24
|
import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
|
|
24
25
|
import { resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
|
|
@@ -60,6 +61,55 @@ interface SlotRow {
|
|
|
60
61
|
notSelectable: boolean;
|
|
61
62
|
selected: boolean;
|
|
62
63
|
notSelected: boolean;
|
|
64
|
+
/** The slot's start (ISO), for ordering and the pill label. */
|
|
65
|
+
start: string;
|
|
66
|
+
/** The staff member (`slot.resource`): id, else name; "" when the slot names none. */
|
|
67
|
+
staffKey: string;
|
|
68
|
+
staffName: string;
|
|
69
|
+
/** The slot's location key (id, else name + address) and its labels; "" when it has none. */
|
|
70
|
+
locKey: string;
|
|
71
|
+
locLabel: string;
|
|
72
|
+
locSub: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One time in a staff group: a pill with three looks. */
|
|
76
|
+
interface TimePill {
|
|
77
|
+
id: string;
|
|
78
|
+
label: string;
|
|
79
|
+
badge: string;
|
|
80
|
+
hasBadge: boolean;
|
|
81
|
+
noBadge: boolean;
|
|
82
|
+
a11y: string;
|
|
83
|
+
available: boolean;
|
|
84
|
+
notAvailable: boolean;
|
|
85
|
+
picked: boolean;
|
|
86
|
+
notPicked: boolean;
|
|
87
|
+
unavailable: boolean;
|
|
88
|
+
notUnavailable: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The selected day's times for one staff member (or the times that name no one). */
|
|
92
|
+
interface StaffGroup {
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
image: string;
|
|
96
|
+
hasImage: boolean;
|
|
97
|
+
noImage: boolean;
|
|
98
|
+
noStaffHeader: boolean;
|
|
99
|
+
hasStaffHeader: boolean;
|
|
100
|
+
times: TimePill[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One row of the location sheet. `id` "" is "All locations". */
|
|
104
|
+
interface LocationOption {
|
|
105
|
+
id: string;
|
|
106
|
+
label: string;
|
|
107
|
+
sub: string;
|
|
108
|
+
hasSub: boolean;
|
|
109
|
+
noSub: boolean;
|
|
110
|
+
selected: boolean;
|
|
111
|
+
notSelected: boolean;
|
|
112
|
+
a11y: string;
|
|
63
113
|
}
|
|
64
114
|
|
|
65
115
|
interface S extends AttendancePurchaseFields {
|
|
@@ -146,6 +196,38 @@ interface S extends AttendancePurchaseFields {
|
|
|
146
196
|
bookingInFlight: boolean;
|
|
147
197
|
notBooking: boolean;
|
|
148
198
|
bookingsScreenId: string;
|
|
199
|
+
/** First day of the day strip (`YYYY-MM-DD`): today, or the first two weeks with times. */
|
|
200
|
+
windowStart: string;
|
|
201
|
+
/** The location filter: "" is every location. */
|
|
202
|
+
locationId: string;
|
|
203
|
+
locationLabel: string;
|
|
204
|
+
locationA11y: string;
|
|
205
|
+
locationOptions: LocationOption[];
|
|
206
|
+
locationSheetOpen: boolean;
|
|
207
|
+
/** Two or more locations: the trailing side of the day row opens the sheet. */
|
|
208
|
+
hasLocationMenu: boolean;
|
|
209
|
+
noLocationMenu: boolean;
|
|
210
|
+
/** Exactly one location with a name or an address: its name, not tappable. */
|
|
211
|
+
singleLocation: string;
|
|
212
|
+
hasSingleLocation: boolean;
|
|
213
|
+
noSingleLocation: boolean;
|
|
214
|
+
/** The selected day's times, grouped by staff member. */
|
|
215
|
+
groups: StaffGroup[];
|
|
216
|
+
/** The selected day has no time to book (the window has some). */
|
|
217
|
+
dayEmpty: boolean;
|
|
218
|
+
hasDayTimes: boolean;
|
|
219
|
+
/** Nothing to book in the next 90 days: one message under the header. */
|
|
220
|
+
noUpcoming: boolean;
|
|
221
|
+
hasUpcoming: boolean;
|
|
222
|
+
/** The result of a tap, in a sheet above the tab bar: a booking stays until closed, other
|
|
223
|
+
* results close by themselves. */
|
|
224
|
+
resultText: string;
|
|
225
|
+
resultSticky: boolean;
|
|
226
|
+
resultAuto: boolean;
|
|
227
|
+
resultIsBooking: boolean;
|
|
228
|
+
resultNotBooking: boolean;
|
|
229
|
+
/** A course has no times: its messages stay on the screen, under its button. */
|
|
230
|
+
courseMessage: string;
|
|
149
231
|
}
|
|
150
232
|
|
|
151
233
|
/** One day-strip chip — the `FilterChips` option shape `dateStrip` binds. */
|
|
@@ -172,6 +254,19 @@ let allRows: SlotRow[] = [];
|
|
|
172
254
|
// could neither see nor book. Worker state now carries one day's rows, so the
|
|
173
255
|
// bound that list needed is gone, and the availability query has no paging.
|
|
174
256
|
const WINDOW_DAYS = 14;
|
|
257
|
+
/** How far ahead the screen looks for a first time, and for "Check Next Availability". */
|
|
258
|
+
const HORIZON_DAYS = 90;
|
|
259
|
+
const DAY_MS = 86_400_000;
|
|
260
|
+
|
|
261
|
+
/** The service record of the last load (its type and waitlist policy shape new rows). */
|
|
262
|
+
let lastService: WixService | undefined;
|
|
263
|
+
/** The selected day's times that cannot be booked (full, too early, too late), by day. They show
|
|
264
|
+
* greyed. Read with `bookable: false` for one day at a time, because that response is large. */
|
|
265
|
+
let greyByDay = new Map<string, SlotRow[]>();
|
|
266
|
+
/** Staff photos from the service's `staffMemberDetails`, by staff id and by name. */
|
|
267
|
+
let staffPhotos = { byId: new Map<string, string>(), byName: new Map<string, string>() };
|
|
268
|
+
/** True while a tap books. A second tap, or a tap during a booking, sends nothing. */
|
|
269
|
+
let bookingLock = false;
|
|
175
270
|
|
|
176
271
|
/** A course booking created by an earlier tap whose checkout or payment did not finish. It stays
|
|
177
272
|
* on Wix, so the next tap pays for THIS booking instead of creating a second one for the same
|
|
@@ -356,7 +451,187 @@ function chosenDay(arg: unknown): string {
|
|
|
356
451
|
return typeof a?.id === "string" ? a.id : "";
|
|
357
452
|
}
|
|
358
453
|
|
|
359
|
-
/**
|
|
454
|
+
/** A slot's location: a key to compare by (id, else name + address) and its labels. A location
|
|
455
|
+
* with neither a name nor an address has no key: it is not offered and not counted. */
|
|
456
|
+
function locationOf(slot: WixBookingSlotFull): { key: string; label: string; sub: string } {
|
|
457
|
+
const loc = (slot.location ?? {}) as { id?: unknown; name?: unknown; formattedAddress?: unknown; locationType?: unknown };
|
|
458
|
+
const name = typeof loc.name === "string" ? loc.name.trim() : "";
|
|
459
|
+
const address = typeof loc.formattedAddress === "string" ? loc.formattedAddress.trim() : "";
|
|
460
|
+
const id = typeof loc.id === "string" ? loc.id : "";
|
|
461
|
+
if (loc.locationType === "CUSTOMER") return { key: id || "customer", label: copyParam("bookingCommon.clientPlace", "Client's place"), sub: "" };
|
|
462
|
+
if (!name && !address) return { key: "", label: "", sub: "" };
|
|
463
|
+
return { key: id || `${name}|${address}`, label: name || address, sub: name && address ? address : "" };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function staffOf(slot: WixBookingSlotFull): { key: string; name: string } {
|
|
467
|
+
const resource = (slot.resource ?? {}) as { id?: unknown; name?: unknown };
|
|
468
|
+
const name = typeof resource.name === "string" ? resource.name.trim() : "";
|
|
469
|
+
const id = typeof resource.id === "string" ? resource.id : "";
|
|
470
|
+
return { key: id || name, name };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The rows for one availability response. A class lists its full sessions too (greyed, or a
|
|
474
|
+
* waitlist). Two slots at the same instant with different staff get distinct ids. */
|
|
475
|
+
function slotRows(entries: ReadonlyArray<{ slot?: WixBookingSlotFull; bookable?: boolean; openSpots?: number; waitingList?: { openSpots?: number } }>, service: WixService | undefined): { rows: SlotRow[]; slots: Map<string, WixBookingSlotFull>; waitlist: Set<string> } {
|
|
476
|
+
const slots = new Map<string, WixBookingSlotFull>();
|
|
477
|
+
const waitlist = new Set<string>();
|
|
478
|
+
const rows: SlotRow[] = [];
|
|
479
|
+
const used = new Set<string>();
|
|
480
|
+
// Group by LOCAL calendar day, not by the formatted heading: two
|
|
481
|
+
// distinct days can render the same text, and a UTC-derived key can
|
|
482
|
+
// name a different day than the heading beside it.
|
|
483
|
+
const classService = service?.type === "CLASS";
|
|
484
|
+
for (const [index, entry] of entries
|
|
485
|
+
.filter((candidate) => candidate.slot?.startDate && (candidate.bookable !== false || (classService && candidate.openSpots === 0)))
|
|
486
|
+
.entries()) {
|
|
487
|
+
const slot = entry.slot as WixBookingSlotFull;
|
|
488
|
+
const key = dayKey(slot.startDate);
|
|
489
|
+
if (!key) continue;
|
|
490
|
+
const staff = staffOf(slot);
|
|
491
|
+
const base = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
|
|
492
|
+
const slotId = used.has(base) ? `${base}#${staff.key || index}` : base;
|
|
493
|
+
used.add(slotId);
|
|
494
|
+
const openSpots = classService && typeof entry.openSpots === "number" && entry.openSpots >= 0 ? entry.openSpots : undefined;
|
|
495
|
+
const isWaitlist = openSpots === 0 && service?.bookingPolicy?.waitlistPolicy?.enabled === true &&
|
|
496
|
+
typeof entry.waitingList?.openSpots === "number" && entry.waitingList.openSpots > 0;
|
|
497
|
+
const selectable = (entry.bookable !== false && openSpots !== 0) || isWaitlist;
|
|
498
|
+
if (isWaitlist) waitlist.add(slotId);
|
|
499
|
+
else if (selectable) slots.set(slotId, slot);
|
|
500
|
+
const label = fmtRange(slot.startDate, slot.endDate);
|
|
501
|
+
const loc = locationOf(slot);
|
|
502
|
+
rows.push({
|
|
503
|
+
id: slotId,
|
|
504
|
+
dayKey: key,
|
|
505
|
+
day: label.day,
|
|
506
|
+
dayChip: dayLabel(slot.startDate, { format: "compact" }),
|
|
507
|
+
title: label.title,
|
|
508
|
+
subtitle: label.subtitle,
|
|
509
|
+
badge: openSpots == null ? "" : openSpots > 0
|
|
510
|
+
? copyPlural("time.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", openSpots)
|
|
511
|
+
: isWaitlist ? copyParam("time.waitlistBadge", "Waitlist") : copyParam("time.fullBadge", "Full"),
|
|
512
|
+
noBadge: openSpots == null,
|
|
513
|
+
badgeWarning: openSpots != null && openSpots > 0 && openSpots <= 3,
|
|
514
|
+
badgeNeutral: openSpots == null || openSpots === 0 || openSpots > 3,
|
|
515
|
+
selectable,
|
|
516
|
+
notSelectable: !selectable,
|
|
517
|
+
selected: false,
|
|
518
|
+
notSelected: true,
|
|
519
|
+
start: slot.startDate ?? "",
|
|
520
|
+
staffKey: staff.key,
|
|
521
|
+
staffName: staff.name,
|
|
522
|
+
locKey: loc.key,
|
|
523
|
+
locLabel: loc.label,
|
|
524
|
+
locSub: loc.sub,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return { rows, slots, waitlist };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The unbookable times of one day's `bookable: false` response that are still ahead (full,
|
|
531
|
+
* too early or too late to book). Never a past time, and never one the window already lists. */
|
|
532
|
+
function greyRows(entries: ReadonlyArray<{ slot?: WixBookingSlotFull; bookable?: boolean }>, day: string, known: SlotRow[], now: number): SlotRow[] {
|
|
533
|
+
const seen = new Set(known.map((row) => `${row.start}|${row.staffKey}`));
|
|
534
|
+
const unbookable = entries.filter((entry) => entry.bookable === false && entry.slot?.startDate
|
|
535
|
+
&& Date.parse(entry.slot.startDate) > now && dayKey(entry.slot.startDate) === day)
|
|
536
|
+
.map((entry) => ({ ...entry, bookable: true }));
|
|
537
|
+
return slotRows(unbookable, undefined).rows
|
|
538
|
+
.filter((row) => !seen.has(`${row.start}|${row.staffKey}`))
|
|
539
|
+
.map((row) => ({ ...row, id: `grey:${row.id}`, selectable: false, notSelectable: true }));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Earliest day first. A plain string compare: `YYYY-MM-DD` keys sort as text, and a worker on
|
|
543
|
+
* a watch engine has no locale-aware string compare. */
|
|
544
|
+
function byDay(a: SlotRow, b: SlotRow): number {
|
|
545
|
+
return a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function locationMatches(row: SlotRow, locationId: string): boolean {
|
|
549
|
+
return !locationId || row.locKey === locationId;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Every distinct location of the window's times (the greyed ones read so far included), in the
|
|
553
|
+
* order they first appear. */
|
|
554
|
+
function windowLocations(): Array<{ key: string; label: string; sub: string }> {
|
|
555
|
+
const seen = new Map<string, { key: string; label: string; sub: string }>();
|
|
556
|
+
for (const row of [...allRows, ...[...greyByDay.values()].flat()]) if (row.locKey && !seen.has(row.locKey)) seen.set(row.locKey, { key: row.locKey, label: row.locLabel, sub: row.locSub });
|
|
557
|
+
return [...seen.values()];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function staffPhoto(key: string, name: string): string {
|
|
561
|
+
return staffPhotos.byId.get(key) ?? (name ? staffPhotos.byName.get(name.toLowerCase()) : undefined) ?? "";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function readStaffPhotos(service: WixService | undefined): void {
|
|
565
|
+
const byId = new Map<string, string>();
|
|
566
|
+
const byName = new Map<string, string>();
|
|
567
|
+
for (const member of service?.staffMemberDetails?.staffMembers ?? []) {
|
|
568
|
+
const url = wixMediaUrl(member?.mainMedia?.image?.url, WIX_LIST_IMAGE);
|
|
569
|
+
if (!url) continue;
|
|
570
|
+
const m = member as { staffMemberId?: string; resourceId?: string; name?: string };
|
|
571
|
+
if (m.staffMemberId) byId.set(m.staffMemberId, url);
|
|
572
|
+
if (m.resourceId) byId.set(m.resourceId, url);
|
|
573
|
+
if (m.name) byName.set(m.name.trim().toLowerCase(), url);
|
|
574
|
+
}
|
|
575
|
+
staffPhotos = { byId, byName };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The selected day's times after the location filter, grouped by staff member. Groups are ordered
|
|
579
|
+
* by their first time (ties by name); a group's times by start. Times that name no staff member
|
|
580
|
+
* share one group with no header. */
|
|
581
|
+
function groupsOf(s: S): StaffGroup[] {
|
|
582
|
+
const day = [...allRows, ...(greyByDay.get(s.selectedDay) ?? [])]
|
|
583
|
+
.filter((row) => row.dayKey === s.selectedDay && locationMatches(row, s.locationId))
|
|
584
|
+
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
585
|
+
const anyStaff = day.some((row) => row.staffKey);
|
|
586
|
+
const groups = new Map<string, { name: string; rows: SlotRow[] }>();
|
|
587
|
+
for (const row of day) {
|
|
588
|
+
const key = anyStaff ? row.staffKey : "";
|
|
589
|
+
const group = groups.get(key) ?? { name: row.staffName, rows: [] };
|
|
590
|
+
group.rows.push(row);
|
|
591
|
+
groups.set(key, group);
|
|
592
|
+
}
|
|
593
|
+
return [...groups.entries()]
|
|
594
|
+
.sort(([ka, a], [kb, b]) => (!ka ? 1 : !kb ? -1 : 0) || Date.parse(a.rows[0]!.start) - Date.parse(b.rows[0]!.start) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
595
|
+
.map(([key, group]) => {
|
|
596
|
+
const image = key ? staffPhoto(key, group.name) : "";
|
|
597
|
+
return {
|
|
598
|
+
id: key || "none",
|
|
599
|
+
name: group.name,
|
|
600
|
+
image,
|
|
601
|
+
hasImage: !!image,
|
|
602
|
+
noImage: !image,
|
|
603
|
+
noStaffHeader: !key,
|
|
604
|
+
hasStaffHeader: !!key,
|
|
605
|
+
times: group.rows.map((row) => {
|
|
606
|
+
const picked = row.selectable && row.id === s.selectedSlotId;
|
|
607
|
+
const available = row.selectable && !picked;
|
|
608
|
+
const label = row.title;
|
|
609
|
+
const named = group.name
|
|
610
|
+
? copyTemplate("time.slotLabel", "{time} with {staff}", { time: label, staff: group.name })
|
|
611
|
+
: label;
|
|
612
|
+
const a11y = row.selectable
|
|
613
|
+
? [named, row.badge, picked ? copyParam("bookingCommon.selected", "Selected") : ""].filter(Boolean).join(", ")
|
|
614
|
+
: copyTemplate("time.slotUnavailable", "{time}, unavailable", { time: label });
|
|
615
|
+
return {
|
|
616
|
+
id: row.id,
|
|
617
|
+
label,
|
|
618
|
+
badge: row.badge,
|
|
619
|
+
hasBadge: !!row.badge,
|
|
620
|
+
noBadge: !row.badge,
|
|
621
|
+
a11y,
|
|
622
|
+
available,
|
|
623
|
+
notAvailable: !available,
|
|
624
|
+
picked,
|
|
625
|
+
notPicked: !picked,
|
|
626
|
+
unavailable: !row.selectable,
|
|
627
|
+
notUnavailable: row.selectable,
|
|
628
|
+
};
|
|
629
|
+
}),
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Every day of the window from `now` (local), plus any day a slot lands on
|
|
360
635
|
* outside it, sorted — each with its slot count. */
|
|
361
636
|
function daysOf(rows: SlotRow[], now: Date = new Date()): DayOption[] {
|
|
362
637
|
const counts = new Map<string, number>();
|
|
@@ -377,7 +652,7 @@ function daysOf(rows: SlotRow[], now: Date = new Date()): DayOption[] {
|
|
|
377
652
|
});
|
|
378
653
|
}
|
|
379
654
|
|
|
380
|
-
/** Show `day`: its
|
|
655
|
+
/** Show `day`: its heading. `derive` fills its rows from the current selection and location. */
|
|
381
656
|
function withDay(s: S, day: string): S {
|
|
382
657
|
const rows = allRows.filter((row) => row.dayKey === day);
|
|
383
658
|
const noon = day ? new Date(`${day}T12:00:00`) : null;
|
|
@@ -389,12 +664,48 @@ function withDay(s: S, day: string): S {
|
|
|
389
664
|
};
|
|
390
665
|
}
|
|
391
666
|
|
|
392
|
-
|
|
667
|
+
/** The day strip, the day's rows, the staff groups and the location row, from the window's rows,
|
|
668
|
+
* the selected day and the location filter. Only for a loaded list of times (not a course). */
|
|
669
|
+
function timesView(s: S): Pick<S, "days" | "slots" | "groups" | "locationOptions" | "locationLabel" | "locationA11y" | "hasLocationMenu" | "noLocationMenu" | "singleLocation" | "hasSingleLocation" | "noSingleLocation"> {
|
|
670
|
+
const all = copyParam("time.allLocations", "All locations");
|
|
671
|
+
const shown = s.status === "ready" && !s.isCourse && !s.errored && !!s.windowStart;
|
|
672
|
+
const locations = shown ? windowLocations() : [];
|
|
673
|
+
const visible = allRows.filter((row) => locationMatches(row, s.locationId));
|
|
674
|
+
const label = s.locationId ? (locations.find((l) => l.key === s.locationId)?.label ?? all) : all;
|
|
675
|
+
const single = locations.length === 1 ? locations[0]!.label : "";
|
|
676
|
+
return {
|
|
677
|
+
days: shown ? daysOf(visible, new Date(`${s.windowStart}T12:00:00`)) : s.days,
|
|
678
|
+
slots: shown
|
|
679
|
+
? visible.filter((row) => row.dayKey === s.selectedDay).map((row) => ({ ...row, selected: row.id === s.selectedSlotId, notSelected: row.id !== s.selectedSlotId }))
|
|
680
|
+
: s.slots,
|
|
681
|
+
groups: shown ? groupsOf(s) : [],
|
|
682
|
+
locationOptions: locations.length >= 2 ? [{ key: "", label: all, sub: "" }, ...locations].map((l) => {
|
|
683
|
+
const selected = l.key === s.locationId;
|
|
684
|
+
return { id: l.key, label: l.label, sub: l.sub, hasSub: !!l.sub, noSub: !l.sub, selected, notSelected: !selected,
|
|
685
|
+
a11y: [l.label, l.sub, selected ? copyParam("bookingCommon.selected", "Selected") : ""].filter(Boolean).join(", ") };
|
|
686
|
+
}) : [],
|
|
687
|
+
locationLabel: label,
|
|
688
|
+
locationA11y: copyTemplate("time.locationLabel", "Location, {label}", { label }),
|
|
689
|
+
hasLocationMenu: locations.length >= 2,
|
|
690
|
+
noLocationMenu: locations.length < 2,
|
|
691
|
+
singleLocation: single,
|
|
692
|
+
hasSingleLocation: !!single,
|
|
693
|
+
noSingleLocation: !single,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function derive(input: S): S {
|
|
698
|
+
const s: S = { ...input, ...timesView(input) };
|
|
393
699
|
const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
|
|
394
700
|
// `hasSlots` is the WHOLE window (it drives the screen-level empty state); the
|
|
395
701
|
// selected day having none is a per-day line, not an empty screen.
|
|
396
702
|
const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
|
|
397
703
|
const hasDaySlots = s.slots.length > 0;
|
|
704
|
+
const timesShown = s.status === "ready" && !s.isCourse && !s.errored;
|
|
705
|
+
// Nothing to BOOK in the 90 days: a class's full sessions alone (grey rows) are no upcoming session.
|
|
706
|
+
const noUpcoming = timesShown && !allRows.some((row) => row.selectable);
|
|
707
|
+
const dayEmpty = timesShown && !noUpcoming && !s.slots.some((row) => row.selectable);
|
|
708
|
+
const resultText = s.isCourse ? "" : (s.successMessage || s.message);
|
|
398
709
|
const hasSelected = s.isCourse ? !!s.scheduleId && s.courseAvailable : !!s.selectedSlotId;
|
|
399
710
|
const selectedWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
|
|
400
711
|
const hasSuccess = !!s.successMessage;
|
|
@@ -406,7 +717,7 @@ function derive(s: S): S {
|
|
|
406
717
|
_poll: undefined,
|
|
407
718
|
servicePrice: regular ? regularServicePrice : "",
|
|
408
719
|
// 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 : ""
|
|
720
|
+
serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : ""].filter(Boolean).join(" · ") : s.serviceLine,
|
|
410
721
|
message: regular && !regularCheckoutAllowed && s.message === copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.message,
|
|
411
722
|
hasSlots,
|
|
412
723
|
// A waitlist pick or a course is not an in-app purchase: neither enables the Apple button.
|
|
@@ -426,9 +737,21 @@ function derive(s: S): S {
|
|
|
426
737
|
noWindowSlots: !hasSlots && !s.isCourse,
|
|
427
738
|
// Only a quiet DAY in a window that has times gets the per-day line; a
|
|
428
739
|
// window with no times at all is the screen-level empty state's job.
|
|
429
|
-
|
|
740
|
+
// The window (not the location filter) decides: a location with only greyed times keeps the
|
|
741
|
+
// strip, the location row and "Check Next Availability".
|
|
742
|
+
dayEmptyText: !dayEmpty || !s.selectedDay
|
|
430
743
|
? ""
|
|
431
|
-
: copyParam("time.dayEmptyText", "
|
|
744
|
+
: copyParam("time.dayEmptyText", "Nothing available for this date."),
|
|
745
|
+
dayEmpty: dayEmpty && !!s.selectedDay,
|
|
746
|
+
hasDayTimes: !(dayEmpty && !!s.selectedDay),
|
|
747
|
+
noUpcoming,
|
|
748
|
+
hasUpcoming: !noUpcoming,
|
|
749
|
+
resultText,
|
|
750
|
+
resultSticky: !!resultText && !!s.successMessage,
|
|
751
|
+
resultAuto: !!resultText && !s.successMessage,
|
|
752
|
+
resultIsBooking: !!s.successMessage,
|
|
753
|
+
resultNotBooking: !s.successMessage,
|
|
754
|
+
courseMessage: s.isCourse ? (s.successMessage || s.message) : "",
|
|
432
755
|
hasSuccess,
|
|
433
756
|
noSuccess: !hasSuccess,
|
|
434
757
|
canBook,
|
|
@@ -437,8 +760,8 @@ function derive(s: S): S {
|
|
|
437
760
|
hideCourseBook: !canBook || !s.isCourse,
|
|
438
761
|
// Joining a waitlist buys nothing, so it does not wait on the purchase route.
|
|
439
762
|
hideWaitlistBook: !s.isMember || !hasSelected || s.isCourse || !selectedWaitlist || s.bookingInFlight || hasSuccess,
|
|
440
|
-
hideDayPicker: s.isCourse || !
|
|
441
|
-
hideDaySlots: s.isCourse ||
|
|
763
|
+
hideDayPicker: s.isCourse || !timesShown || noUpcoming,
|
|
764
|
+
hideDaySlots: s.isCourse || s.groups.length === 0,
|
|
442
765
|
hideDayEmpty: s.isCourse || hasDaySlots,
|
|
443
766
|
showChoosePrompt,
|
|
444
767
|
hideChoosePrompt: !showChoosePrompt,
|
|
@@ -575,12 +898,108 @@ async function resolveMsid(): Promise<string> {
|
|
|
575
898
|
}
|
|
576
899
|
}
|
|
577
900
|
|
|
901
|
+
/** Read `day`'s unbookable times once (cached). A failure shows the bookable times only. */
|
|
902
|
+
async function loadGreyDay(serviceId: string, day: string): Promise<void> {
|
|
903
|
+
if (!day || greyByDay.has(day)) return;
|
|
904
|
+
const from = new Date(`${day}T00:00:00`);
|
|
905
|
+
const until = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);
|
|
906
|
+
if (Number.isNaN(from.getTime())) return;
|
|
907
|
+
try {
|
|
908
|
+
const res = await queryServiceAvailability(serviceId, isoDate(from), isoDate(until), { bookable: false });
|
|
909
|
+
greyByDay.set(day, greyRows(res.availabilityEntries ?? [], day, allRows, Date.now()));
|
|
910
|
+
} catch {
|
|
911
|
+
greyByDay.set(day, []);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The first two-week window from `from` with a time to book (at `locationId`, "" for any), up to
|
|
916
|
+
* 90 days after `now`: at most seven reads. `undefined` when there is none. */
|
|
917
|
+
async function nextWindowWithTimes(serviceId: string, from: Date, now: Date, includeFull: boolean, service: WixService | undefined, locationId: string): Promise<{ built: ReturnType<typeof slotRows>; windowStart: string } | undefined> {
|
|
918
|
+
const horizon = now.getTime() + HORIZON_DAYS * DAY_MS;
|
|
919
|
+
for (let start = new Date(from.getFullYear(), from.getMonth(), from.getDate()); start.getTime() < horizon;
|
|
920
|
+
start = new Date(start.getFullYear(), start.getMonth(), start.getDate() + WINDOW_DAYS)) {
|
|
921
|
+
// Calendar days, as the step above: across a DST change 14 × 24 h would end the window an hour
|
|
922
|
+
// short of where the next one starts.
|
|
923
|
+
const until = new Date(Math.min(new Date(start.getFullYear(), start.getMonth(), start.getDate() + WINDOW_DAYS).getTime(), horizon));
|
|
924
|
+
const res = await queryServiceAvailability(serviceId, isoDate(new Date(Math.max(start.getTime(), now.getTime()))), isoDate(until), { bookable: !includeFull });
|
|
925
|
+
const built = slotRows(res.availabilityEntries ?? [], service);
|
|
926
|
+
if (built.rows.some((row) => row.selectable && locationMatches(row, locationId))) return { built, windowStart: dayKey(start) };
|
|
927
|
+
}
|
|
928
|
+
return undefined;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** After a booking went through (or its checkout opened, which already holds a booking), the time
|
|
932
|
+
* is no longer bookable here: it shows greyed and is no longer selected, so another tap on it cannot
|
|
933
|
+
* send a second booking or charge. */
|
|
934
|
+
function afterBooking(result: S, slotId: string): S {
|
|
935
|
+
const booked = !!result.successMessage || result.message === copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.");
|
|
936
|
+
if (!booked || !slotId || result.isCourse) return result;
|
|
937
|
+
allRows = allRows.map((row) => row.id === slotId ? { ...row, selectable: false, notSelectable: true } : row);
|
|
938
|
+
slotsById.delete(slotId);
|
|
939
|
+
waitlistIds.delete(slotId);
|
|
940
|
+
return derive({ ...result, selectedSlotId: "", selectedLine: "" });
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** The selection, kept only while its time is still in the window and at the chosen location: a
|
|
944
|
+
* hidden pick would leave the in-app purchase button buying a time the screen no longer shows. */
|
|
945
|
+
function visibleSelection(s: S): S {
|
|
946
|
+
const row = allRows.find((r) => r.id === s.selectedSlotId);
|
|
947
|
+
return row && row.selectable && locationMatches(row, s.locationId) ? s : { ...s, selectedSlotId: "", selectedLine: "" };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Counts day changes, so a slow read for an older day choice cannot move the screen back to it. */
|
|
951
|
+
let dayRevision = 0;
|
|
952
|
+
/** True while "Check Next Availability" searches; a second tap waits for the first. */
|
|
953
|
+
let checkingNextLock = false;
|
|
954
|
+
|
|
955
|
+
/** `{ slotId, signInScreenId }` from a pill tap (or a bare slot id). */
|
|
956
|
+
function tapped(arg: unknown): { slotId: string; signInScreenId: string } {
|
|
957
|
+
if (typeof arg === "string") return { slotId: arg, signInScreenId: "" };
|
|
958
|
+
const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown };
|
|
959
|
+
const slotId = typeof a.slotId === "string" ? a.slotId : typeof a.id === "string" ? a.id : "";
|
|
960
|
+
return { slotId, signInScreenId: typeof a.signInScreenId === "string" ? a.signInScreenId : "" };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Select a bookable time, as a tap does: the pick-storage rules and the selection revision. */
|
|
964
|
+
function selectSlotIn(s: S, slotId: string): S {
|
|
965
|
+
const selected = allRows.find((slot) => slot.id === slotId && slot.selectable);
|
|
966
|
+
if (selected) {
|
|
967
|
+
selectionRevision++;
|
|
968
|
+
pendingStart = "";
|
|
969
|
+
pendingSession = "";
|
|
970
|
+
if (s.isMember) clearPick();
|
|
971
|
+
else rememberPick(s.id, slotId);
|
|
972
|
+
}
|
|
973
|
+
return derive({
|
|
974
|
+
...s,
|
|
975
|
+
selectedSlotId: selected ? slotId : s.selectedSlotId,
|
|
976
|
+
selectedLine: selected ? [selected.day, selected.title, selected.subtitle].filter(Boolean).join(" · ") : s.selectedLine,
|
|
977
|
+
message: "",
|
|
978
|
+
successMessage: "",
|
|
979
|
+
slots: s.slots.map((slot) => ({ ...slot, selected: slot.id === slotId, notSelected: slot.id !== slotId })),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
578
983
|
const id = inputId();
|
|
984
|
+
// A member's tap on a time books it, so pickSlot needs a connection too; a guest's tap only opens
|
|
985
|
+
// the sign-in screen (or the sign-in card), which works offline.
|
|
986
|
+
const defineService = withOnlineWrites(registerService, ["bookSelected", "buyApple", "signIn", "register", "pickSlot"], {
|
|
987
|
+
derive,
|
|
988
|
+
secretFields: ["password"],
|
|
989
|
+
requiresConnectivity: (action, state, arg) => action !== "pickSlot" || (state.isMember
|
|
990
|
+
&& (regularCheckoutAllowed || applePurchase.usesRegularPlatform() || waitlistIds.has(tapped(arg).slotId))),
|
|
991
|
+
});
|
|
579
992
|
const svc = defineService<S, {
|
|
580
993
|
load: (s: S, arg?: unknown) => Promise<S>;
|
|
581
994
|
refreshMember: (s: S, arg?: unknown) => Promise<S>;
|
|
582
|
-
selectDay: (s: S, arg?: unknown) => S
|
|
995
|
+
selectDay: (s: S, arg?: unknown) => Promise<S>;
|
|
583
996
|
selectSlot: (s: S, arg?: unknown) => S;
|
|
997
|
+
pickSlot: (s: S, arg?: unknown) => Promise<S | undefined>;
|
|
998
|
+
openLocations: (s: S) => S;
|
|
999
|
+
closeLocations: (s: S) => S;
|
|
1000
|
+
selectLocation: (s: S, arg?: unknown) => Promise<S>;
|
|
1001
|
+
checkNext: (s: S) => Promise<S>;
|
|
1002
|
+
closeResult: (s: S) => S;
|
|
584
1003
|
setEmail: (s: S, arg?: unknown) => S;
|
|
585
1004
|
setPassword: (s: S, arg?: unknown) => S;
|
|
586
1005
|
setFirstName: (s: S, arg?: unknown) => S;
|
|
@@ -667,6 +1086,28 @@ const svc = defineService<S, {
|
|
|
667
1086
|
bookingInFlight: false,
|
|
668
1087
|
notBooking: true,
|
|
669
1088
|
bookingsScreenId: "himi_bookings",
|
|
1089
|
+
windowStart: "",
|
|
1090
|
+
locationId: "",
|
|
1091
|
+
locationLabel: "All locations",
|
|
1092
|
+
locationA11y: "",
|
|
1093
|
+
locationOptions: [],
|
|
1094
|
+
locationSheetOpen: false,
|
|
1095
|
+
hasLocationMenu: false,
|
|
1096
|
+
noLocationMenu: true,
|
|
1097
|
+
singleLocation: "",
|
|
1098
|
+
hasSingleLocation: false,
|
|
1099
|
+
noSingleLocation: true,
|
|
1100
|
+
groups: [],
|
|
1101
|
+
dayEmpty: false,
|
|
1102
|
+
hasDayTimes: true,
|
|
1103
|
+
noUpcoming: false,
|
|
1104
|
+
hasUpcoming: true,
|
|
1105
|
+
resultText: "",
|
|
1106
|
+
resultSticky: false,
|
|
1107
|
+
resultAuto: false,
|
|
1108
|
+
resultIsBooking: false,
|
|
1109
|
+
resultNotBooking: true,
|
|
1110
|
+
courseMessage: "",
|
|
670
1111
|
}),
|
|
671
1112
|
actions: {
|
|
672
1113
|
iapPublish: (s) => derive(lastReady ?? s),
|
|
@@ -688,7 +1129,12 @@ const svc = defineService<S, {
|
|
|
688
1129
|
try {
|
|
689
1130
|
const now = new Date();
|
|
690
1131
|
const beyond = availabilityBeyond(now, farStart);
|
|
691
|
-
|
|
1132
|
+
// Staff photos need STAFF_MEMBER_DETAILS; a token that may not read them still gets the service.
|
|
1133
|
+
const serviceRes = await getService(s.id, { conditionalFields: ["STAFF_MEMBER_DETAILS"] })
|
|
1134
|
+
.catch(() => getService(s.id))
|
|
1135
|
+
.catch(() => ({ service: undefined }));
|
|
1136
|
+
lastService = serviceRes.service;
|
|
1137
|
+
readStaffPhotos(serviceRes.service);
|
|
692
1138
|
if (courseInput && !serviceRes.service) {
|
|
693
1139
|
slotsById = new Map();
|
|
694
1140
|
allRows = [];
|
|
@@ -739,54 +1185,28 @@ const svc = defineService<S, {
|
|
|
739
1185
|
});
|
|
740
1186
|
}
|
|
741
1187
|
const includeFull = serviceRes.service?.type === "CLASS";
|
|
742
|
-
const [
|
|
743
|
-
queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS *
|
|
1188
|
+
const [first, target] = await Promise.all([
|
|
1189
|
+
queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * DAY_MS)), { bookable: !includeFull }),
|
|
744
1190
|
// A host's start past the window (an Agenda session weeks out): its day alone, so it can
|
|
745
1191
|
// be preselected. Its failure costs only that preselection.
|
|
746
1192
|
beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until), { bookable: !includeFull }).catch(() => ({ availabilityEntries: [] })) : undefined,
|
|
747
1193
|
]);
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
.entries()) {
|
|
758
|
-
const slot = entry.slot as WixBookingSlotFull;
|
|
759
|
-
const key = dayKey(slot.startDate);
|
|
760
|
-
if (!key) continue;
|
|
761
|
-
const slotId = String(slot.sessionId ?? slot.startDate ?? `slot-${index}`);
|
|
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);
|
|
768
|
-
const label = fmtRange(slot.startDate, slot.endDate);
|
|
769
|
-
rows.push({
|
|
770
|
-
id: slotId,
|
|
771
|
-
dayKey: key,
|
|
772
|
-
day: label.day,
|
|
773
|
-
dayChip: dayLabel(slot.startDate, { format: "compact" }),
|
|
774
|
-
title: label.title,
|
|
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,
|
|
784
|
-
selected: false,
|
|
785
|
-
notSelected: true,
|
|
786
|
-
});
|
|
1194
|
+
let built = slotRows([...(first.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])], serviceRes.service);
|
|
1195
|
+
let windowStart = dayKey(now);
|
|
1196
|
+
// Nothing to book in the first two weeks (a class's full sessions do not count), and nothing
|
|
1197
|
+
// at a host's deep-linked start either: look further, two weeks at a time, up to 90 days. The
|
|
1198
|
+
// strip then starts at the first two weeks with a time. Nothing in 90 days → "no upcoming
|
|
1199
|
+
// sessions".
|
|
1200
|
+
if (!built.rows.some((row) => row.selectable)) {
|
|
1201
|
+
const later = await nextWindowWithTimes(s.id, new Date(now.getTime() + WINDOW_DAYS * DAY_MS), now, includeFull, serviceRes.service, "");
|
|
1202
|
+
if (later) { built = later.built; windowStart = later.windowStart; }
|
|
787
1203
|
}
|
|
1204
|
+
slotsById = built.slots;
|
|
1205
|
+
waitlistIds = built.waitlist;
|
|
1206
|
+
const rows = built.rows;
|
|
788
1207
|
allRows = rows;
|
|
789
|
-
|
|
1208
|
+
greyByDay = new Map();
|
|
1209
|
+
const days = daysOf(rows, new Date(`${windowStart}T12:00:00`));
|
|
790
1210
|
const header = serviceHeaderFields(serviceRes.service);
|
|
791
1211
|
regularServicePrice = header.price;
|
|
792
1212
|
regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
|
|
@@ -810,9 +1230,18 @@ const svc = defineService<S, {
|
|
|
810
1230
|
// otherwise open on the first day that has any.
|
|
811
1231
|
const keep = days.find((d) => d.id === chosen.selectedDay && d.hasSlots);
|
|
812
1232
|
const initialDay = startRow?.dayKey ?? startDay?.id ?? picked?.dayKey ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
|
|
1233
|
+
if (!includeFull && initialDay) await loadGreyDay(s.id, initialDay);
|
|
1234
|
+
// A location with no times left in the window goes back to "All locations".
|
|
1235
|
+
// A location with no times left (greyed ones included) goes back to "All locations", and so
|
|
1236
|
+
// does a filter that would hide the time this load restores (a host's start, a guest's pick).
|
|
1237
|
+
const restored = startRow ?? picked;
|
|
1238
|
+
const locationId = windowLocations().some((l) => l.key === chosen.locationId) && (!restored || locationMatches(restored, chosen.locationId))
|
|
1239
|
+
? chosen.locationId : "";
|
|
813
1240
|
return derive(withDay({
|
|
814
1241
|
...next,
|
|
815
1242
|
days,
|
|
1243
|
+
windowStart,
|
|
1244
|
+
locationId,
|
|
816
1245
|
selectedSlotId: startRow ? startRow.id : selectionAlive ? chosen.selectedSlotId : picked?.id ?? "",
|
|
817
1246
|
selectedLine: startRow ? [startRow.day, startRow.title, startRow.subtitle].filter(Boolean).join(" · ")
|
|
818
1247
|
: selectionAlive ? chosen.selectedLine
|
|
@@ -820,14 +1249,17 @@ const svc = defineService<S, {
|
|
|
820
1249
|
status: "ready",
|
|
821
1250
|
errored: false,
|
|
822
1251
|
serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
|
|
823
|
-
serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""
|
|
1252
|
+
serviceLine: [header.duration, regularCheckoutAllowed ? header.price : ""].filter(Boolean).join(" · "),
|
|
824
1253
|
serviceImage: header.image,
|
|
825
1254
|
hasServiceImage: !!header.image,
|
|
826
1255
|
noServiceImage: !header.image,
|
|
827
1256
|
servicePrice: regularCheckoutAllowed ? header.price : "",
|
|
828
1257
|
serviceDuration: header.duration,
|
|
829
1258
|
serviceIsPaid: header.paid,
|
|
830
|
-
|
|
1259
|
+
// A guest's pick restored after sign-in stays picked: the member taps it to book. The
|
|
1260
|
+
// screen never books from a load (two loads can run at once right after a sign-in).
|
|
1261
|
+
// Where nothing can be bought here, a tap says so (pickSlot), not the screen's opening.
|
|
1262
|
+
message: picked ? copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it.") : "",
|
|
831
1263
|
}, initialDay));
|
|
832
1264
|
} catch {
|
|
833
1265
|
slotsById = new Map();
|
|
@@ -840,35 +1272,74 @@ const svc = defineService<S, {
|
|
|
840
1272
|
// selection; the strip is single-select with no "none", so the day stays.
|
|
841
1273
|
// A time picked on another day stays selected (the selected line and the
|
|
842
1274
|
// Book button keep naming it): switching days to compare is not a reset.
|
|
843
|
-
selectDay: (s, arg) => {
|
|
1275
|
+
selectDay: async (s, arg): Promise<S> => {
|
|
844
1276
|
const day = chosenDay(arg);
|
|
845
1277
|
if (!day || !s.days.some((d) => d.id === day)) return derive(s);
|
|
846
1278
|
pendingStart = "";
|
|
847
1279
|
pendingSession = "";
|
|
848
|
-
|
|
1280
|
+
const revision = ++dayRevision;
|
|
1281
|
+
if (lastService?.type !== "CLASS") await loadGreyDay(s.id, day);
|
|
1282
|
+
const current = svc.getState?.() ?? s;
|
|
1283
|
+
return derive(revision === dayRevision ? withDay(current, day) : current);
|
|
849
1284
|
},
|
|
850
1285
|
refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
|
|
851
|
-
selectSlot: (s, arg) =>
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1286
|
+
selectSlot: (s, arg) => selectSlotIn(s, tapped(arg).slotId),
|
|
1287
|
+
// One tap books: the tap does what "Book selected time" did (select, then book). A guest keeps
|
|
1288
|
+
// the pick and signs in first; after the sign-in the time comes back picked, and the member
|
|
1289
|
+
// taps it again. Where the regular checkout is not allowed, the tap selects the time for the
|
|
1290
|
+
// in-app purchase button.
|
|
1291
|
+
pickSlot: async (s, arg): Promise<S | undefined> => {
|
|
1292
|
+
const { slotId, signInScreenId } = tapped(arg);
|
|
1293
|
+
if (bookingLock || s.bookingInFlight || !allRows.some((row) => row.id === slotId && row.selectable)) return derive(s);
|
|
1294
|
+
const selected = selectSlotIn(s, slotId);
|
|
1295
|
+
if (!s.isMember) {
|
|
1296
|
+
// Branded Lite opens its sign-in screen; other apps show the sign-in card on this screen.
|
|
1297
|
+
return signInScreenId ? navigate(selected, { screenId: signInScreenId, args: { mode: "signin", intent: "book" } }) : selected;
|
|
1298
|
+
}
|
|
1299
|
+
const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
|
|
1300
|
+
if (!regular && !waitlistIds.has(slotId)) {
|
|
1301
|
+
// The in-app purchase button takes over for the picked time; with none, say why not.
|
|
1302
|
+
return applePurchase.fields(true, false).iapBuyHidden
|
|
1303
|
+
? derive({ ...selected, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") })
|
|
1304
|
+
: selected;
|
|
1305
|
+
}
|
|
1306
|
+
bookingLock = true;
|
|
1307
|
+
try {
|
|
1308
|
+
return afterBooking(await bookSelectedIn(selected), slotId);
|
|
1309
|
+
} finally {
|
|
1310
|
+
bookingLock = false;
|
|
862
1311
|
}
|
|
863
|
-
return derive({
|
|
864
|
-
...s,
|
|
865
|
-
selectedSlotId: selected ? slotId : s.selectedSlotId,
|
|
866
|
-
selectedLine: selected ? [selected.day, selected.title, selected.subtitle].filter(Boolean).join(" · ") : s.selectedLine,
|
|
867
|
-
message: "",
|
|
868
|
-
successMessage: "",
|
|
869
|
-
slots: s.slots.map((slot) => ({ ...slot, selected: slot.id === slotId, notSelected: slot.id !== slotId })),
|
|
870
|
-
});
|
|
871
1312
|
},
|
|
1313
|
+
openLocations: (s) => derive({ ...s, locationSheetOpen: true }),
|
|
1314
|
+
closeLocations: (s) => derive({ ...s, locationSheetOpen: false }),
|
|
1315
|
+
selectLocation: async (s, arg): Promise<S> => {
|
|
1316
|
+
const raw = (arg ?? {}) as { locationId?: unknown; id?: unknown };
|
|
1317
|
+
const wanted = typeof arg === "string" ? arg : typeof raw.locationId === "string" ? raw.locationId : typeof raw.id === "string" ? raw.id : "";
|
|
1318
|
+
const locationId = windowLocations().some((l) => l.key === wanted) ? wanted : "";
|
|
1319
|
+
const next = visibleSelection({ ...s, locationId, locationSheetOpen: false });
|
|
1320
|
+
// Stay on the day when it has a time at this location; else go to the first day that has one.
|
|
1321
|
+
const keep = allRows.some((row) => row.dayKey === s.selectedDay && row.selectable && locationMatches(row, locationId));
|
|
1322
|
+
const first = allRows.filter((row) => row.selectable && locationMatches(row, locationId)).sort(byDay)[0];
|
|
1323
|
+
const revision = ++dayRevision;
|
|
1324
|
+
if (keep || !first) return derive(next);
|
|
1325
|
+
if (lastService?.type !== "CLASS") await loadGreyDay(s.id, first.dayKey);
|
|
1326
|
+
// A newer day or location choice made during the read wins.
|
|
1327
|
+
if (revision !== dayRevision) return derive(svc.getState?.() ?? next);
|
|
1328
|
+
const current = svc.getState?.() ?? next;
|
|
1329
|
+
return derive(withDay(visibleSelection({ ...current, locationId, locationSheetOpen: false }), first.dayKey));
|
|
1330
|
+
},
|
|
1331
|
+
// The next day after the selected one with a time (at the chosen location): in the loaded
|
|
1332
|
+
// window first, then forward up to 90 days, moving the strip.
|
|
1333
|
+
checkNext: async (s): Promise<S> => {
|
|
1334
|
+
if (checkingNextLock) return derive(s);
|
|
1335
|
+
checkingNextLock = true;
|
|
1336
|
+
try {
|
|
1337
|
+
return await checkNextIn(s);
|
|
1338
|
+
} finally {
|
|
1339
|
+
checkingNextLock = false;
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
closeResult: (s) => derive({ ...s, message: "", successMessage: "" }),
|
|
872
1343
|
setEmail: (s, arg) => derive({ ...s, email: strOf(arg), message: "" }),
|
|
873
1344
|
setPassword: (s, arg) => derive({ ...s, password: strOf(arg), message: "" }),
|
|
874
1345
|
setFirstName: (s, arg) => derive({ ...s, firstName: strOf(arg), message: "" }),
|
|
@@ -889,7 +1360,7 @@ const svc = defineService<S, {
|
|
|
889
1360
|
isRegistering: false,
|
|
890
1361
|
message: s.isCourse
|
|
891
1362
|
? copyParam("time.courseSignedInReady", "Signed in. You can book this course now.")
|
|
892
|
-
: copyParam("time.
|
|
1363
|
+
: copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it."),
|
|
893
1364
|
});
|
|
894
1365
|
} catch {
|
|
895
1366
|
return derive({ ...s, password: "", message: copyParam("time.signInFailed", "Sign in failed. Try again.") });
|
|
@@ -914,225 +1385,273 @@ const svc = defineService<S, {
|
|
|
914
1385
|
isRegistering: false,
|
|
915
1386
|
message: s.isCourse
|
|
916
1387
|
? copyParam("time.courseRegisterReady", "Account created. You can book this course now.")
|
|
917
|
-
: copyParam("time.
|
|
1388
|
+
: copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it."),
|
|
918
1389
|
});
|
|
919
1390
|
} catch {
|
|
920
1391
|
return derive({ ...s, password: "", message: copyParam("time.registerFailed", "Account creation failed. Try another email.") });
|
|
921
1392
|
}
|
|
922
1393
|
},
|
|
1394
|
+
// Kept for the course button and the offline replay (WriteQueue replays this action).
|
|
923
1395
|
bookSelected: async (s) => {
|
|
924
|
-
if (
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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.") });
|
|
1396
|
+
if (bookingLock) return derive(s);
|
|
1397
|
+
bookingLock = true;
|
|
1398
|
+
try {
|
|
1399
|
+
return afterBooking(await bookSelectedIn(s), s.selectedSlotId);
|
|
1400
|
+
} finally {
|
|
1401
|
+
bookingLock = false;
|
|
944
1402
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
/** "Check Next Availability": the next day after the selected one with a time (at the chosen
|
|
1408
|
+
* location), in the loaded window first, then forward up to 90 days, moving the strip. */
|
|
1409
|
+
async function checkNextIn(s: S): Promise<S> {
|
|
1410
|
+
const next = allRows.filter((row) => row.selectable && row.dayKey > s.selectedDay && locationMatches(row, s.locationId))
|
|
1411
|
+
.sort(byDay)[0];
|
|
1412
|
+
if (next) {
|
|
1413
|
+
const revision = ++dayRevision;
|
|
1414
|
+
if (lastService?.type !== "CLASS") await loadGreyDay(s.id, next.dayKey);
|
|
1415
|
+
const current = svc.getState?.() ?? s;
|
|
1416
|
+
return derive(revision === dayRevision ? withDay(current, next.dayKey) : current);
|
|
1417
|
+
}
|
|
1418
|
+
const revision = ++dayRevision;
|
|
1419
|
+
const after = new Date(`${s.selectedDay || s.windowStart}T12:00:00`);
|
|
1420
|
+
const from = new Date(after.getFullYear(), after.getMonth(), after.getDate() + 1);
|
|
1421
|
+
const includeFull = lastService?.type === "CLASS";
|
|
1422
|
+
let later: Awaited<ReturnType<typeof nextWindowWithTimes>>;
|
|
1423
|
+
try {
|
|
1424
|
+
later = Number.isNaN(from.getTime()) ? undefined : await nextWindowWithTimes(s.id, from, new Date(), includeFull, lastService, s.locationId);
|
|
1425
|
+
} catch {
|
|
1426
|
+
return derive({ ...(svc.getState?.() ?? s), message: copyParam("time.errorText", "Times could not be loaded.") });
|
|
1427
|
+
}
|
|
1428
|
+
const current = svc.getState?.() ?? s;
|
|
1429
|
+
// A day or location chosen while the search ran wins: its result is dropped.
|
|
1430
|
+
if (revision !== dayRevision) return derive(current);
|
|
1431
|
+
if (!later) return derive({ ...current, message: copyParam("time.noLaterTimes", "There are no later times in the next 90 days.") });
|
|
1432
|
+
allRows = later.built.rows;
|
|
1433
|
+
slotsById = later.built.slots;
|
|
1434
|
+
waitlistIds = later.built.waitlist;
|
|
1435
|
+
greyByDay = new Map();
|
|
1436
|
+
const day = later.built.rows.filter((row) => row.selectable && locationMatches(row, s.locationId))
|
|
1437
|
+
.sort(byDay)[0]!.dayKey;
|
|
1438
|
+
if (!includeFull) await loadGreyDay(s.id, day);
|
|
1439
|
+
return derive(withDay(visibleSelection({ ...current, windowStart: later.windowStart }), day));
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** The booking itself: a waitlist, a paid or course checkout, or a free booking. */
|
|
1443
|
+
async function bookSelectedIn(s: S): Promise<S> {
|
|
1444
|
+
if (!s.isMember) return derive({ ...s, message: copyParam("time.bookNeedsSignIn", "Sign in before booking.") });
|
|
1445
|
+
if (s.isCourse && !s.scheduleId) return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
1446
|
+
if (!s.isCourse && !s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
|
|
1447
|
+
const joiningWaitlist = !s.isCourse && waitlistIds.has(s.selectedSlotId);
|
|
1448
|
+
const slot = s.isCourse ? undefined : slotsById.get(s.selectedSlotId);
|
|
1449
|
+
if (!s.isCourse && !slot && !joiningWaitlist) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
|
|
1450
|
+
// A booking is paid through the regular checkout only where that is allowed; joining a
|
|
1451
|
+
// waitlist buys nothing, so it skips this check.
|
|
1452
|
+
if (!joiningWaitlist) {
|
|
1453
|
+
try {
|
|
1454
|
+
const current = await getService(s.id);
|
|
1455
|
+
// A course is booked whole, never one of its sessions as a time: a screen opened with no
|
|
1456
|
+
// `course` input whose first service read failed lists the sessions as times.
|
|
1457
|
+
if (!s.isCourse && current.service?.type === "COURSE") return derive({ ...s, message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
1458
|
+
regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
|
|
1459
|
+
if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
1460
|
+
} catch {
|
|
1461
|
+
if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
1462
|
+
}
|
|
1463
|
+
if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
|
|
1464
|
+
}
|
|
1465
|
+
// A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
|
|
1466
|
+
// host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
|
|
1467
|
+
// The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
|
|
1468
|
+
// `emitHostEvent` leaves out rather than let the host read it in its own zone.
|
|
1469
|
+
const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
|
|
1470
|
+
bookingId: booking?.id,
|
|
1471
|
+
serviceId: s.id,
|
|
1472
|
+
serviceName: s.serviceName,
|
|
1473
|
+
start: hostEventInstant(booking?.startDate) || slot?.startDate || s.courseStart,
|
|
1474
|
+
end: hostEventInstant(booking?.endDate) || slot?.endDate || s.courseEnd,
|
|
1475
|
+
});
|
|
1476
|
+
// The member acts on the pick now; a later visit must not restore it.
|
|
1477
|
+
clearPick();
|
|
1478
|
+
if (joiningWaitlist) {
|
|
1479
|
+
const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
|
|
1480
|
+
const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
|
|
1481
|
+
const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
|
|
1482
|
+
const name = contactName(displayName(my) || email);
|
|
1483
|
+
try {
|
|
1484
|
+
await registerToWaitlist(s.selectedSlotId, { email, firstName: name.first, lastName: name.last });
|
|
1485
|
+
return derive({ ...pending, bookingInFlight: false,
|
|
1486
|
+
successMessage: copyParam("time.waitlistJoined", "You're on the waitlist. Check My Bookings for updates."), message: "" });
|
|
1487
|
+
} catch {
|
|
1488
|
+
return derive({ ...s, bookingInFlight: false,
|
|
1489
|
+
message: copyParam("time.waitlistFailed", "Couldn't join the waitlist. Try again.") });
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
// Paid service or any course — create a Wix eCommerce order so a free
|
|
1493
|
+
// course does not remain in CREATED status with no reserved capacity.
|
|
1494
|
+
// FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
|
|
1495
|
+
// hosted-checkout WebView / browser redirect. The chain is
|
|
1496
|
+
// createBooking → ecom.createCheckout(booking line item) → ecom.createOrder
|
|
1497
|
+
// → @himalaya/wix-cashier.proceedToPayment(snapshotId)
|
|
1498
|
+
// where `paymentGatewayOrderId` from createOrder is the cashier order id
|
|
1499
|
+
// (snapshotId) settled on-device via Apple/Google Pay. We do NOT optimistically
|
|
1500
|
+
// write a local booking row (My Bookings revalidates from Wix on success). Any
|
|
1501
|
+
// failure degrades to an inline retry message, never a dead button.
|
|
1502
|
+
if (s.serviceIsPaid || s.isCourse) {
|
|
1503
|
+
const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
|
|
1504
|
+
// Booker identity — same resolution as the free path (never throws).
|
|
1505
|
+
const payerData = await getMyData().catch(() => ({ email: s.email } as WixMyData));
|
|
1506
|
+
const payerEmail = (await signedInEmail()) || payerData.email || payerData.contactEmail || s.email;
|
|
1507
|
+
const payerName = contactName(displayName(payerData) || payerEmail);
|
|
1508
|
+
// Only the same customer's own unfinished booking for this course is reused.
|
|
1509
|
+
const retried = s.isCourse && pendingCourseBooking?.scheduleId === s.scheduleId && pendingCourseBooking.email === payerEmail
|
|
1510
|
+
? pendingCourseBooking : undefined;
|
|
1511
|
+
if (s.isCourse) {
|
|
1512
|
+
try {
|
|
1513
|
+
const latest = (await getService(s.id)).service;
|
|
1514
|
+
const open = latest != null && await courseOpenTo(latest, new Date(), retried?.ordered === true);
|
|
1515
|
+
if (!open) {
|
|
1516
|
+
return derive({ ...pending, bookingInFlight: false, courseAvailable: false,
|
|
1517
|
+
message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
970
1518
|
}
|
|
1519
|
+
} catch {
|
|
1520
|
+
return derive({ ...pending, bookingInFlight: false,
|
|
1521
|
+
message: copyParam("time.courseUnavailable", "Course booking is unavailable right now.") });
|
|
971
1522
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
if (
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
// 1. Create the booking (CREATED status — payment pending).
|
|
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);
|
|
1012
|
-
const bookingId = created.booking?.id;
|
|
1013
|
-
if (!bookingId) throw new Error("booking not created");
|
|
1014
|
-
if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
|
|
1015
|
-
// 2. Wrap the booking as a checkout line item; create the checkout.
|
|
1016
|
-
const checkoutRes = await createCheckout([
|
|
1017
|
-
{ quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
|
|
1018
|
-
]);
|
|
1019
|
-
const checkoutId = checkoutRes.checkout?.id;
|
|
1020
|
-
if (!checkoutId) throw new Error("checkout not created");
|
|
1021
|
-
// 3. Create the Wix-managed order — mints the cashier snapshot when a
|
|
1022
|
-
// balance is due. No paymentGatewayOrderId ⇒ nothing to charge
|
|
1023
|
-
// (covered / zero total) ⇒ the booking is already confirmed.
|
|
1024
|
-
// Hosted (Spaces): the site's own checkout page creates the order (as
|
|
1025
|
-
// RN does) — creating it here first fails on a site with no payment method set up.
|
|
1026
|
-
const hosted = isHostedWorker();
|
|
1027
|
-
const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
|
|
1028
|
-
if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
|
|
1029
|
-
const snapshotId = orderRes.paymentGatewayOrderId ?? "";
|
|
1030
|
-
if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
|
|
1031
|
-
pendingCourseBooking = null;
|
|
1032
|
-
await reportBooked(created.booking);
|
|
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: "" });
|
|
1036
|
-
}
|
|
1037
|
-
// 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
|
|
1038
|
-
// the in-app Wix cashier. Routed through the shared helper so a
|
|
1039
|
-
// wallet-less app can still actually pay: calling proceedToPayment
|
|
1040
|
-
// unconditionally created the booking + order and THEN failed, leaving an
|
|
1041
|
-
// unpaid booking behind (see @wix/himalaya/checkout).
|
|
1042
|
-
const pay = await payCashierOrder({
|
|
1043
|
-
appId: WIX_BOOKINGS_APP_ID,
|
|
1044
|
-
snapshotId,
|
|
1045
|
-
msid: await resolveMsid(),
|
|
1046
|
-
// Hosted (Spaces): the host opens this booking's checkout as the lent member.
|
|
1047
|
-
...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
|
|
1048
|
-
cashierUrl: async () => {
|
|
1049
|
-
const redirect = await createRedirectSession(s.isCourse
|
|
1050
|
-
? { ecomCheckout: { checkoutId } }
|
|
1051
|
-
: { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
|
|
1052
|
-
return redirect.redirectSession?.fullUrl;
|
|
1053
|
-
},
|
|
1054
|
-
});
|
|
1055
|
-
if (pay.kind === "paid") {
|
|
1056
|
-
pendingCourseBooking = null;
|
|
1057
|
-
await reportBooked(created.booking);
|
|
1058
|
-
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
1059
|
-
}
|
|
1060
|
-
if (pay.kind === "handoff") {
|
|
1061
|
-
// The cashier is open in-app; the booking confirms once payment lands, so
|
|
1062
|
-
// promise nothing here — My Bookings revalidates from Wix.
|
|
1063
|
-
return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
|
|
1064
|
-
}
|
|
1065
|
-
if (pay.kind === "cancelled") {
|
|
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.") });
|
|
1069
|
-
}
|
|
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.") });
|
|
1073
|
-
} catch {
|
|
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.") });
|
|
1077
|
-
}
|
|
1523
|
+
}
|
|
1524
|
+
try {
|
|
1525
|
+
// 1. Create the booking (CREATED status — payment pending).
|
|
1526
|
+
// A course retried after a failed checkout reuses the booking it already made.
|
|
1527
|
+
const created = retried ? { booking: retried.booking }
|
|
1528
|
+
: s.isCourse
|
|
1529
|
+
? await bookSchedule(s.scheduleId, payerName.first, payerName.last, payerEmail,
|
|
1530
|
+
s.coursePaymentOption ? { selectedPaymentOption: s.coursePaymentOption } : undefined)
|
|
1531
|
+
: await bookSlot(slot!, payerName.first, payerName.last, payerEmail);
|
|
1532
|
+
const bookingId = created.booking?.id;
|
|
1533
|
+
if (!bookingId) throw new Error("booking not created");
|
|
1534
|
+
if (s.isCourse && !retried) pendingCourseBooking = { scheduleId: s.scheduleId, email: payerEmail, booking: created.booking! };
|
|
1535
|
+
// 2. Wrap the booking as a checkout line item; create the checkout.
|
|
1536
|
+
const checkoutRes = await createCheckout([
|
|
1537
|
+
{ quantity: 1, catalogReference: { appId: WIX_BOOKINGS_APP_ID, catalogItemId: bookingId } },
|
|
1538
|
+
]);
|
|
1539
|
+
const checkoutId = checkoutRes.checkout?.id;
|
|
1540
|
+
if (!checkoutId) throw new Error("checkout not created");
|
|
1541
|
+
// 3. Create the Wix-managed order — mints the cashier snapshot when a
|
|
1542
|
+
// balance is due. No paymentGatewayOrderId ⇒ nothing to charge
|
|
1543
|
+
// (covered / zero total) ⇒ the booking is already confirmed.
|
|
1544
|
+
// Hosted (Spaces): the site's own checkout page creates the order (as
|
|
1545
|
+
// RN does) — creating it here first fails on a site with no payment method set up.
|
|
1546
|
+
const hosted = isHostedWorker();
|
|
1547
|
+
const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
|
|
1548
|
+
if (s.isCourse && !hosted && pendingCourseBooking) pendingCourseBooking.ordered = true;
|
|
1549
|
+
const snapshotId = orderRes.paymentGatewayOrderId ?? "";
|
|
1550
|
+
if ((!snapshotId || (s.isCourse && s.coursePaymentOption === "OFFLINE")) && !hosted) {
|
|
1551
|
+
pendingCourseBooking = null;
|
|
1552
|
+
await reportBooked(created.booking);
|
|
1553
|
+
return derive({ ...pending, bookingInFlight: false, successMessage: s.isCourse
|
|
1554
|
+
? copyParam("time.courseSubmitted", "Course booking submitted. Check My Bookings for confirmation.")
|
|
1555
|
+
: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
1078
1556
|
}
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1557
|
+
// 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
|
|
1558
|
+
// the in-app Wix cashier. Routed through the shared helper so a
|
|
1559
|
+
// wallet-less app can still actually pay: calling proceedToPayment
|
|
1560
|
+
// unconditionally created the booking + order and THEN failed, leaving an
|
|
1561
|
+
// unpaid booking behind (see @wix/himalaya/checkout).
|
|
1562
|
+
const pay = await payCashierOrder({
|
|
1563
|
+
appId: WIX_BOOKINGS_APP_ID,
|
|
1564
|
+
snapshotId,
|
|
1565
|
+
msid: await resolveMsid(),
|
|
1566
|
+
// Hosted (Spaces): the host opens this booking's checkout as the lent member.
|
|
1567
|
+
...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
|
|
1568
|
+
cashierUrl: async () => {
|
|
1569
|
+
const redirect = await createRedirectSession(s.isCourse
|
|
1570
|
+
? { ecomCheckout: { checkoutId } }
|
|
1571
|
+
: { bookingsCheckout: { slotAvailability: { slot: slot as WixSlot } } });
|
|
1572
|
+
return redirect.redirectSession?.fullUrl;
|
|
1573
|
+
},
|
|
1094
1574
|
});
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
try {
|
|
1098
|
-
const created = await bookSlot(slot!, name.first, name.last, email);
|
|
1099
|
-
// Reconcile the optimistic local id with the server-assigned booking id.
|
|
1100
|
-
const realId = created.booking?.id;
|
|
1101
|
-
if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
|
|
1575
|
+
if (pay.kind === "paid") {
|
|
1576
|
+
pendingCourseBooking = null;
|
|
1102
1577
|
await reportBooked(created.booking);
|
|
1103
|
-
return derive({
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// Offline / no bridge: the optimistic cache row already shows in My
|
|
1112
|
-
// Bookings; queue the booking to replay (WriteQueue, idempotent) when
|
|
1113
|
-
// connectivity returns.
|
|
1114
|
-
await queueWrite("tier5.himiBookingTime.bookSelected", {
|
|
1115
|
-
serviceId: s.id,
|
|
1116
|
-
...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
|
|
1117
|
-
email,
|
|
1118
|
-
firstName: name.first,
|
|
1119
|
-
lastName: name.last,
|
|
1120
|
-
...(slot ? { slot: JSON.stringify(slot) } : {}),
|
|
1121
|
-
});
|
|
1122
|
-
return derive({
|
|
1123
|
-
...pending,
|
|
1124
|
-
bookingInFlight: false,
|
|
1125
|
-
successMessage: "Booked offline — will sync when you reconnect.",
|
|
1126
|
-
message: "",
|
|
1127
|
-
});
|
|
1128
|
-
}
|
|
1578
|
+
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
1579
|
+
}
|
|
1580
|
+
if (pay.kind === "handoff") {
|
|
1581
|
+
// The cashier is open in-app; the booking confirms once payment lands, so
|
|
1582
|
+
// promise nothing here — My Bookings revalidates from Wix.
|
|
1583
|
+
return derive({ ...pending, bookingInFlight: false, message: copyParam("time.checkoutOpened", "Complete your payment in the checkout to confirm this booking.") });
|
|
1584
|
+
}
|
|
1585
|
+
if (pay.kind === "cancelled") {
|
|
1129
1586
|
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1130
|
-
? copyParam("time.
|
|
1131
|
-
: copyParam("time.
|
|
1587
|
+
? copyParam("time.coursePaymentCancelled", "Payment cancelled — your course booking wasn't confirmed. Try again.")
|
|
1588
|
+
: copyParam("time.paymentCancelled", "Payment cancelled — your booking wasn't confirmed. Pick a time to try again.") });
|
|
1132
1589
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
});
|
|
1590
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1591
|
+
? copyParam("time.coursePaymentFailed", "Payment couldn't be completed. Try again.")
|
|
1592
|
+
: copyParam("time.paymentFailed", "Payment couldn't be completed. Pick another time or try again.") });
|
|
1593
|
+
} catch {
|
|
1594
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1595
|
+
? copyParam("time.courseCheckoutFailed", "Checkout could not start. Try again.")
|
|
1596
|
+
: copyParam("time.checkoutFailed", "Checkout could not start. Pick another time or try again.") });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
// FREE appointment/class — the original member bookSlot rung (offline-safe below).
|
|
1600
|
+
// getMyData/signedInEmail carry their own fallbacks (never throw), so the
|
|
1601
|
+
// booker identity is resolved up front — needed for both the optimistic
|
|
1602
|
+
// cache row and the offline replay params.
|
|
1603
|
+
const my = await getMyData().catch(() => ({ email: s.email } as WixMyData));
|
|
1604
|
+
const email = (await signedInEmail()) || my.email || my.contactEmail || s.email;
|
|
1605
|
+
const name = contactName(displayName(my) || email);
|
|
1606
|
+
// Optimistic local write — My Bookings repaints from cache immediately (offline or not).
|
|
1607
|
+
const selectedSlot = allRows.find((r) => r.id === s.selectedSlotId);
|
|
1608
|
+
const localId = `local-${Date.now()}`;
|
|
1609
|
+
const localRow = (id: string): DbEntity => ({
|
|
1610
|
+
id,
|
|
1611
|
+
title: s.serviceName || "Booking",
|
|
1612
|
+
subtitle: s.isCourse ? (s.selectedLine || "Full course") : (selectedSlot?.title || "Booked time"),
|
|
1613
|
+
status: "Booked",
|
|
1614
|
+
});
|
|
1615
|
+
if (!offlineWritesEnabled) await cachePut(localRow(localId));
|
|
1616
|
+
const pending = derive({ ...s, bookingInFlight: true, message: "", successMessage: "" });
|
|
1617
|
+
try {
|
|
1618
|
+
const created = await bookSlot(slot!, name.first, name.last, email);
|
|
1619
|
+
// Reconcile the optimistic local id with the server-assigned booking id.
|
|
1620
|
+
const realId = created.booking?.id;
|
|
1621
|
+
if (offlineWritesEnabled) await cachePut(localRow(realId || localId));
|
|
1622
|
+
else if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
|
|
1623
|
+
await reportBooked(created.booking);
|
|
1624
|
+
return derive({
|
|
1625
|
+
...pending,
|
|
1626
|
+
bookingInFlight: false,
|
|
1627
|
+
successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."),
|
|
1628
|
+
message: "",
|
|
1629
|
+
});
|
|
1630
|
+
} catch (e) {
|
|
1631
|
+
if (!offlineWritesEnabled && isBridgeUnavailable(e)) {
|
|
1632
|
+
// Offline / no bridge: the optimistic cache row already shows in My
|
|
1633
|
+
// Bookings; queue the booking to replay (WriteQueue, idempotent) when
|
|
1634
|
+
// connectivity returns.
|
|
1635
|
+
await queueWrite("tier5.himiBookingTime.bookSelected", {
|
|
1636
|
+
serviceId: s.id,
|
|
1637
|
+
...(s.isCourse ? { scheduleId: s.scheduleId } : { slotId: s.selectedSlotId }),
|
|
1638
|
+
email,
|
|
1639
|
+
firstName: name.first,
|
|
1640
|
+
lastName: name.last,
|
|
1641
|
+
...(slot ? { slot: JSON.stringify(slot) } : {}),
|
|
1642
|
+
});
|
|
1643
|
+
return derive({
|
|
1644
|
+
...pending,
|
|
1645
|
+
bookingInFlight: false,
|
|
1646
|
+
successMessage: "Booked offline — will sync when you reconnect.",
|
|
1647
|
+
message: "",
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
return derive({ ...s, bookingInFlight: false, message: s.isCourse
|
|
1651
|
+
? copyParam("time.courseBookFailed", "Course booking failed. Try again.")
|
|
1652
|
+
: copyParam("time.bookFailed", "Booking failed. Pick another time or try again.") });
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1136
1655
|
resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), ...ATTENDANCE_INITIAL, email: "", password: "", firstName: "", lastName: "", isMember: false, canBook: false, cannotBook: true }, [...ATTENDANCE_NOT_HYDRATED, "email", "password", "firstName", "lastName", "isMember", "canBook", "cannotBook"]);
|
|
1137
1656
|
svc.load();
|
|
1138
1657
|
export { svc };
|