@wix/himalaya-cli 0.1613.0 → 0.1615.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/sdk/ui/src/index.d.ts +53 -13
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
- package/dist/cli.mjs +1638 -1330
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +4 -0
- package/guides/component-reference.json +1 -1
- package/guides/component-reference.md +3 -0
- package/guides/designing-himi-screens.md +4 -1
- package/guides/hhp.md +2 -2
- package/guides/sdk-reference.json +28 -4
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/branded-lite/dev/index.ts +192 -0
- package/templates/branded-lite/l10n/en.json +1 -1
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/dev/index.ts +192 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
// Tier-5 bundle: himi-booking — single service detail.
|
|
2
|
+
//
|
|
3
|
+
// Opened from an Agenda row (`sessionId` + `start` in the input), the screen is about THAT
|
|
4
|
+
// session: its date and time show under the name and in the facts, and the footer offers what
|
|
5
|
+
// the Agenda row offers, from the same session state (`_shared/booking-session.ts`): Book (Choose
|
|
6
|
+
// Time opens with the session picked), Join Waitlist (joined from here), a disabled Book with the
|
|
7
|
+
// reason, or the member's own place (Booked / On waitlist). A course keeps its course footer.
|
|
8
|
+
// Opened without a session (the Services list), the footer is "Choose a Time", as before.
|
|
9
|
+
import { defineService, navigate } from "@himalaya/state";
|
|
10
|
+
import { share as shareSheet } from "@himalaya/share";
|
|
11
|
+
import { open, defaultMode } from "@himalaya/open-url";
|
|
12
|
+
import { addDays, dayKey, dayLabel, timeLabel } from "@himalaya/time";
|
|
13
|
+
import { getService, queryServiceAvailability, registerToWaitlist, type WixAvailabilityEntry, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
|
|
14
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
15
|
+
import { memberAccess, type MemberAccess } from "../_shared/member-access.js";
|
|
16
|
+
import { placesIn, sessionControl, sessionOf, type MemberPlace, type Session, type SessionControl } from "../_shared/booking-session.js";
|
|
17
|
+
import { categoryName, courseDatesText, courseReadWindows, courseSessionsAvailability, courseWindowOpen, serviceDurationText, servicePriceLine, slotStartInstant, type BookingLabel, type ServicePriceLine } from "../_shared/himi-bookings.js";
|
|
18
|
+
import { formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
|
|
19
|
+
import { stashCopy, copyParam, copyTemplate, copyPlural } from "../_shared/himi-copy.js";
|
|
20
|
+
import { get, type DbEntity } from "@himalaya/local-db";
|
|
21
|
+
import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
|
|
22
|
+
import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
|
|
23
|
+
import { descriptionBody } from "../_shared/rich-body.js";
|
|
24
|
+
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
25
|
+
import { isServiceSaved, toggleSavedService } from "../_shared/himi-booking-saved.js";
|
|
26
|
+
|
|
27
|
+
// Offline-first (data-offline): the service detail is a public read-through. On a
|
|
28
|
+
// fetch failure we fall back to the last-synced row for this service written by
|
|
29
|
+
// the services LIST worker (shared `services` entity). DEGRADES GRACEFULLY: the
|
|
30
|
+
// local-db read is wrapped so a bundle that ships in an app which hasn't granted
|
|
31
|
+
// `local_database` behaves exactly as before (online-only, error on failure).
|
|
32
|
+
const ENTITY = "services";
|
|
33
|
+
async function readCached(id: string): Promise<DbEntity | null> {
|
|
34
|
+
try { return await get(ENTITY, id); } catch { /* capability not granted → online-only */ return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Staff names come back only on request (Wix "Get Service" `conditionalFields`). */
|
|
38
|
+
const SERVICE_FIELDS = { conditionalFields: ["STAFF_MEMBER_DETAILS"] };
|
|
39
|
+
/** At most this many location rows, in record order. */
|
|
40
|
+
const MAX_LOCATIONS = 3;
|
|
41
|
+
|
|
42
|
+
interface ServiceImage { url: string; alt: string }
|
|
43
|
+
interface LocationRow {
|
|
44
|
+
index: number;
|
|
45
|
+
title: string;
|
|
46
|
+
/** A maps search for the address; "" for the client's place. */
|
|
47
|
+
directionsUrl: string;
|
|
48
|
+
tappable: boolean;
|
|
49
|
+
notTappable: boolean;
|
|
50
|
+
a11y: string;
|
|
51
|
+
}
|
|
52
|
+
interface PolicyLine { text: string; owner: boolean; notOwner: boolean }
|
|
53
|
+
interface CourseSession { date: string; time: string }
|
|
54
|
+
|
|
55
|
+
interface S {
|
|
56
|
+
status: "loading" | "ready";
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
/** The category name, shown as the small label above the name. */
|
|
60
|
+
category: string;
|
|
61
|
+
noCategory: boolean;
|
|
62
|
+
typeTag: string;
|
|
63
|
+
noTypeTag: boolean;
|
|
64
|
+
isClass: boolean;
|
|
65
|
+
isCourse: boolean;
|
|
66
|
+
noCourse: boolean;
|
|
67
|
+
tagline: string;
|
|
68
|
+
noTagline: boolean;
|
|
69
|
+
/** The price line: "₪90" + "per session", "From ₪60", "Free", or a CUSTOM description. */
|
|
70
|
+
priceText: string;
|
|
71
|
+
priceUnit: string;
|
|
72
|
+
/** A CUSTOM rate: the owner's own words stand in for the price, with no unit. */
|
|
73
|
+
priceIsText: boolean;
|
|
74
|
+
noPrice: boolean;
|
|
75
|
+
noPriceUnit: boolean;
|
|
76
|
+
/** Hide the amount row (no price, or a CUSTOM text price). */
|
|
77
|
+
noPriceAmount: boolean;
|
|
78
|
+
/** Hide the CUSTOM text price (any other rate). */
|
|
79
|
+
noPriceDescription: boolean;
|
|
80
|
+
priceA11y: string;
|
|
81
|
+
images: ServiceImage[];
|
|
82
|
+
/** The first image, for the one-image case. */
|
|
83
|
+
image: string;
|
|
84
|
+
hasGallery: boolean;
|
|
85
|
+
noGallery: boolean;
|
|
86
|
+
hasOneImage: boolean;
|
|
87
|
+
noOneImage: boolean;
|
|
88
|
+
hasImage: boolean;
|
|
89
|
+
noImage: boolean;
|
|
90
|
+
durationText: string;
|
|
91
|
+
noDuration: boolean;
|
|
92
|
+
courseDates: string;
|
|
93
|
+
courseSessionCount: string;
|
|
94
|
+
noCourseDates: boolean;
|
|
95
|
+
spotsText: string;
|
|
96
|
+
noSpots: boolean;
|
|
97
|
+
spotsWarning: boolean;
|
|
98
|
+
spotsNeutral: boolean;
|
|
99
|
+
hideSpotsNeutral: boolean;
|
|
100
|
+
courseSessions: CourseSession[];
|
|
101
|
+
noCourseSessions: boolean;
|
|
102
|
+
courseMoreSessions: CourseSession[];
|
|
103
|
+
noCourseMoreSessions: boolean;
|
|
104
|
+
scheduleExpanded: boolean;
|
|
105
|
+
scheduleCollapsed: boolean;
|
|
106
|
+
courseCtaBook: boolean;
|
|
107
|
+
courseCtaRequest: boolean;
|
|
108
|
+
courseCtaFull: boolean;
|
|
109
|
+
courseCtaClosed: boolean;
|
|
110
|
+
hideChooseTime: boolean;
|
|
111
|
+
hideRequestTime: boolean;
|
|
112
|
+
noCourseCtaBook: boolean;
|
|
113
|
+
noCourseCtaRequest: boolean;
|
|
114
|
+
noCourseCtaFull: boolean;
|
|
115
|
+
noCourseCtaClosed: boolean;
|
|
116
|
+
locations: LocationRow[];
|
|
117
|
+
noLocations: boolean;
|
|
118
|
+
online: boolean;
|
|
119
|
+
noOnline: boolean;
|
|
120
|
+
staffText: string;
|
|
121
|
+
staffImage: string;
|
|
122
|
+
staffInitials: string;
|
|
123
|
+
hasStaffImage: boolean;
|
|
124
|
+
noStaffImage: boolean;
|
|
125
|
+
noStaff: boolean;
|
|
126
|
+
payText: string;
|
|
127
|
+
paySub: string;
|
|
128
|
+
noPay: boolean;
|
|
129
|
+
noPaySub: boolean;
|
|
130
|
+
/** The business confirms each booking: the button says "Request to Book". */
|
|
131
|
+
requestToBook: boolean;
|
|
132
|
+
notRequestToBook: boolean;
|
|
133
|
+
bookableOnline: boolean;
|
|
134
|
+
notBookableOnline: boolean;
|
|
135
|
+
about: string;
|
|
136
|
+
aboutJson: string;
|
|
137
|
+
hasAboutJson: boolean;
|
|
138
|
+
noAbout: boolean;
|
|
139
|
+
policyLines: PolicyLine[];
|
|
140
|
+
noPolicy: boolean;
|
|
141
|
+
policyExpanded: boolean;
|
|
142
|
+
policyCollapsed: boolean;
|
|
143
|
+
/** No fact row has data: the facts section and its divider hide. */
|
|
144
|
+
noFacts: boolean;
|
|
145
|
+
/** No price, duration or category: Branded Web's summary grid hides. */
|
|
146
|
+
noKeyFacts: boolean;
|
|
147
|
+
/** Loading, error or empty: the service content hides. */
|
|
148
|
+
hideContent: boolean;
|
|
149
|
+
hideFooter: boolean;
|
|
150
|
+
/** Share payload — the service page URL when the payload carries one. */
|
|
151
|
+
shareUrl: string;
|
|
152
|
+
timeScreenId: string;
|
|
153
|
+
emptyText: string;
|
|
154
|
+
errored: boolean;
|
|
155
|
+
notErrored: boolean;
|
|
156
|
+
isLoading: boolean;
|
|
157
|
+
notLoading: boolean;
|
|
158
|
+
hasService: boolean;
|
|
159
|
+
noEmpty: boolean;
|
|
160
|
+
// ---- The session an Agenda row opened this screen on (all "" without one) ----
|
|
161
|
+
/** Its Wix session id ("" when it has none) and start instant: Book opens Choose Time on it. */
|
|
162
|
+
sessionId: string;
|
|
163
|
+
sessionStart: string;
|
|
164
|
+
/** "Tue, Nov 5 · 6:00 PM" under the name; the date and the time range apart for the fact row. */
|
|
165
|
+
sessionWhen: string;
|
|
166
|
+
sessionDate: string;
|
|
167
|
+
sessionTime: string;
|
|
168
|
+
noSession: boolean;
|
|
169
|
+
/** The control the Agenda row shows for this session (`sessionControl`), "" without a session. */
|
|
170
|
+
sessionControl: "" | SessionControl;
|
|
171
|
+
/** Why a closed session cannot be booked: "Full", "Not open yet" or "Closed". */
|
|
172
|
+
closedLabel: string;
|
|
173
|
+
noSessionBook: boolean;
|
|
174
|
+
noSessionRequest: boolean;
|
|
175
|
+
noSessionWaitlist: boolean;
|
|
176
|
+
noSessionClosed: boolean;
|
|
177
|
+
noSessionBooked: boolean;
|
|
178
|
+
noSessionWaitlisted: boolean;
|
|
179
|
+
/** "Change time" shows while the member can still choose another session. */
|
|
180
|
+
noChangeTime: boolean;
|
|
181
|
+
/** The outcome of a waitlist join, above the footer's control: it went through (a check) or not. */
|
|
182
|
+
sessionMessage: string;
|
|
183
|
+
sessionMessageOk: boolean;
|
|
184
|
+
sessionMessageFailed: boolean;
|
|
185
|
+
noSessionMessage: boolean;
|
|
186
|
+
// ---- Favorites (the heart an app adds with withServiceSaveToggle; _shared/himi-booking-saved.ts) ----
|
|
187
|
+
saved: boolean;
|
|
188
|
+
notSaved: boolean;
|
|
189
|
+
/** The heart's spoken label: what a tap does now. */
|
|
190
|
+
saveLabel: string;
|
|
191
|
+
/** "" unless the last save or unsave failed. */
|
|
192
|
+
saveMessage: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function inputId(): string {
|
|
196
|
+
// WS-C — `himalaya.input.id` is the PRIMARY contract: hosts thread the row's
|
|
197
|
+
// `id` nav param (himi.open { screenId: "himi_service", id }) into worker input.
|
|
198
|
+
// The screenId-slice below is #570 back-compat for pre-params releases with
|
|
199
|
+
// baked `himi_service_<id>` screenIds — do not author against this.
|
|
200
|
+
const input = (globalThis as { himalaya?: { input?: { id?: unknown; screenId?: unknown } } }).himalaya?.input;
|
|
201
|
+
if (typeof input?.id === "string" && input.id) return input.id;
|
|
202
|
+
const screenId = typeof input?.screenId === "string" ? input.screenId : "";
|
|
203
|
+
return screenId.startsWith("himi_service_") ? screenId.slice("himi_service_".length) : "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The session an Agenda row opened this screen on, from the same input as `id`: `sessionId`
|
|
207
|
+
* (the Wix session id, "" for a session without one) and `start` (its ISO instant). No valid
|
|
208
|
+
* `start` — opened from the Services list — and the screen has no session. */
|
|
209
|
+
function inputSession(): { sessionId: string; start: string } {
|
|
210
|
+
const input = (globalThis as { himalaya?: { input?: { sessionId?: unknown; start?: unknown } } }).himalaya?.input;
|
|
211
|
+
const start = typeof input?.start === "string" && Number.isFinite(Date.parse(input.start)) ? input.start : "";
|
|
212
|
+
return { sessionId: start && typeof input?.sessionId === "string" ? input.sessionId.trim() : "", start };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stringOf(value: unknown): string {
|
|
216
|
+
return typeof value === "string" ? value.trim() : "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function numberOf(value: unknown): number | undefined {
|
|
220
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
221
|
+
if (typeof value === "string" && value.trim()) {
|
|
222
|
+
const n = Number(value);
|
|
223
|
+
return Number.isNaN(n) ? undefined : n;
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- Price -----------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** The service screen's copy keys for the shared price, duration and course-date lines. */
|
|
231
|
+
const serviceLabel: BookingLabel = (key, fallback, values) =>
|
|
232
|
+
values ? copyTemplate(`service.${key}`, fallback, values) : copyParam(`service.${key}`, fallback);
|
|
233
|
+
|
|
234
|
+
function priceLine(service: WixService): ServicePriceLine {
|
|
235
|
+
return servicePriceLine(service, serviceLabel);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- Media -----------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/** Every distinct image of the service: `media.items`, then `mainMedia`, then `coverMedia`. */
|
|
241
|
+
function serviceImages(service: WixService, name: string): ServiceImage[] {
|
|
242
|
+
const media = service.media ?? {};
|
|
243
|
+
const out: ServiceImage[] = [];
|
|
244
|
+
for (const item of [...(media.items ?? []), media.mainMedia, media.coverMedia]) {
|
|
245
|
+
const url = wixMediaUrl(item?.image?.url, WIX_DETAIL_IMAGE);
|
|
246
|
+
if (url && !out.some((image) => image.url === url)) out.push({ url, alt: stringOf(item?.image?.altText) || name });
|
|
247
|
+
}
|
|
248
|
+
if (!out.length) {
|
|
249
|
+
// Older payloads carry one image in another shape.
|
|
250
|
+
const legacy = legacyImageUrl(service);
|
|
251
|
+
if (legacy) out.push({ url: legacy, alt: name });
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function legacyImageUrl(value: unknown): string {
|
|
257
|
+
if (typeof value === "string") return wixMediaUrl(value, WIX_DETAIL_IMAGE);
|
|
258
|
+
if (!value || typeof value !== "object") return "";
|
|
259
|
+
if (Array.isArray(value)) {
|
|
260
|
+
for (const item of value) {
|
|
261
|
+
const url = legacyImageUrl(item);
|
|
262
|
+
if (url) return url;
|
|
263
|
+
}
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
const node = value as Record<string, unknown>;
|
|
267
|
+
for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
|
|
268
|
+
const url = wixMediaUrl(direct, WIX_DETAIL_IMAGE);
|
|
269
|
+
if (url) return url;
|
|
270
|
+
}
|
|
271
|
+
for (const key of ["image", "thumbnail", "mediaItem", "mainMedia", "main", "media", "items", "photos", "gallery", "coverImage"]) {
|
|
272
|
+
const url = legacyImageUrl(node[key]);
|
|
273
|
+
if (url) return url;
|
|
274
|
+
}
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- Duration --------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function durationText(service: WixService): string {
|
|
281
|
+
return serviceDurationText(service, serviceLabel);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- Locations -------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** The address as one line, or "" when it names no place. A country alone (the Wix default
|
|
287
|
+
* "Location 1") is not an address. */
|
|
288
|
+
function addressText(address: WixServiceAddress | undefined): string {
|
|
289
|
+
if (!address) return "";
|
|
290
|
+
const formatted = stringOf(address.formattedAddress);
|
|
291
|
+
if (formatted) return formatted;
|
|
292
|
+
const street = [stringOf(address.streetAddress?.number), stringOf(address.streetAddress?.name)].filter(Boolean).join(" ");
|
|
293
|
+
const line = [street, stringOf(address.streetAddress?.apt)].filter(Boolean).join(" ") || stringOf(address.addressLine);
|
|
294
|
+
const city = stringOf(address.city);
|
|
295
|
+
if (!line && !city) return "";
|
|
296
|
+
return [line, city, stringOf(address.subdivision), stringOf(address.postalCode), stringOf(address.country)].filter(Boolean).join(", ");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function directionsUrl(address: string): string {
|
|
300
|
+
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One row per location the customer can act on: a BUSINESS or CUSTOM location with an
|
|
304
|
+
* address (tap → maps), or the client's place. At most {@link MAX_LOCATIONS}. */
|
|
305
|
+
function locationRows(service: WixService): LocationRow[] {
|
|
306
|
+
const rows: LocationRow[] = [];
|
|
307
|
+
for (const location of service.locations ?? []) {
|
|
308
|
+
if (rows.length >= MAX_LOCATIONS) break;
|
|
309
|
+
const type = stringOf(location?.type).toUpperCase();
|
|
310
|
+
if (type === "CUSTOMER") {
|
|
311
|
+
const title = copyParam("service.clientPlace", "Client's place");
|
|
312
|
+
rows.push({ index: rows.length, title, directionsUrl: "", tappable: false, notTappable: true, a11y: title });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const place = type === "BUSINESS" ? location.business : type === "CUSTOM" ? location.custom : undefined;
|
|
316
|
+
if (!place) continue;
|
|
317
|
+
const address = addressText(place.address) || addressText(location.calculatedAddress);
|
|
318
|
+
if (!address) continue;
|
|
319
|
+
const title = (type === "BUSINESS" ? stringOf(location.business?.name) : "") || address;
|
|
320
|
+
rows.push({
|
|
321
|
+
index: rows.length,
|
|
322
|
+
title,
|
|
323
|
+
directionsUrl: directionsUrl(address),
|
|
324
|
+
tappable: true,
|
|
325
|
+
notTappable: false,
|
|
326
|
+
a11y: copyTemplate("service.directionsLabel", "Get directions to {place}", { place: title }),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return rows;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- Staff -----------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
function initials(name: string): string {
|
|
335
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]!.toUpperCase()).join("");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function firstName(name: string): string {
|
|
339
|
+
return name.split(/\s+/)[0] ?? name;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** "With Noa Levi", "With Noa or Dana", "With Noa, Dana and 2 more". Empty when the
|
|
343
|
+
* record carries no staff details (a visitor may not be allowed to read them). */
|
|
344
|
+
function staffLine(service: WixService): { text: string; image: string; initials: string } {
|
|
345
|
+
const staff = (service.staffMemberDetails?.staffMembers ?? []).filter((member) => stringOf(member?.name));
|
|
346
|
+
if (!staff.length) return { text: "", image: "", initials: "" };
|
|
347
|
+
const names = staff.map((member) => stringOf(member.name));
|
|
348
|
+
const text = names.length === 1
|
|
349
|
+
? copyTemplate("service.staffOne", "With {name}", { name: names[0]! })
|
|
350
|
+
: names.length === 2
|
|
351
|
+
? copyTemplate("service.staffTwo", "With {a} or {b}", { a: firstName(names[0]!), b: firstName(names[1]!) })
|
|
352
|
+
: copyTemplate("service.staffMany", "With {a}, {b} and {count} more", { a: firstName(names[0]!), b: firstName(names[1]!), count: names.length - 2 });
|
|
353
|
+
return { text, image: wixMediaUrl(staff[0]!.mainMedia?.image?.url, WIX_LIST_IMAGE), initials: initials(names[0]!) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- How to pay ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/** The payment row, by the first rule that matches. Never mentions pricing plans. */
|
|
359
|
+
function payLine(service: WixService): { text: string; sub: string } {
|
|
360
|
+
const payment = service.payment ?? {};
|
|
361
|
+
const rate = stringOf(payment.rateType).toUpperCase();
|
|
362
|
+
if (rate === "NO_FEE") return { text: "", sub: "" };
|
|
363
|
+
const options = payment.options ?? {};
|
|
364
|
+
const currency = serviceCurrency(service);
|
|
365
|
+
const depositMoney = payment.fixed?.deposit ?? payment.varied?.deposit;
|
|
366
|
+
const deposit = numberOf(depositMoney?.value);
|
|
367
|
+
if (options.deposit && deposit != null && deposit > 0) {
|
|
368
|
+
const price = rate === "VARIED" ? undefined : numberOf(payment.fixed?.price?.value);
|
|
369
|
+
const remaining = price != null && price > deposit ? price - deposit : undefined;
|
|
370
|
+
const depositCurrency = stringOf(depositMoney?.currency) || currency;
|
|
371
|
+
return {
|
|
372
|
+
text: copyTemplate("service.payDeposit", "{amount} deposit", { amount: formatServiceMoney(depositMoney, currency) }),
|
|
373
|
+
sub: remaining != null
|
|
374
|
+
? copyTemplate("service.payDueLater", "{amount} due later", { amount: formatServiceMoney({ value: String(remaining), currency: depositCurrency }, currency) })
|
|
375
|
+
: "",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (options.online && options.inPerson) return { text: copyParam("service.payBoth", "Pay now or in person"), sub: "" };
|
|
379
|
+
if (options.online) return { text: copyParam("service.payOnline", "Pay now"), sub: "" };
|
|
380
|
+
if (options.inPerson) return { text: copyParam("service.payInPerson", "Pay in person"), sub: "" };
|
|
381
|
+
return { text: "", sub: "" };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- Our policy ------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/** Minutes as words: under 60 → minutes; under 2 days, or not whole days → hours; else days. */
|
|
387
|
+
function windowText(minutes: number): string {
|
|
388
|
+
if (minutes < 60) {
|
|
389
|
+
return minutes === 1 ? copyParam("service.minute", "1 minute") : copyTemplate("service.minutes", "{n} minutes", { n: minutes });
|
|
390
|
+
}
|
|
391
|
+
if (minutes < 2880 || minutes % 1440 !== 0) {
|
|
392
|
+
const hours = Math.round((minutes / 60) * 10) / 10;
|
|
393
|
+
return hours === 1 ? copyParam("service.hour", "1 hour") : copyTemplate("service.hours", "{n} hours", { n: hours });
|
|
394
|
+
}
|
|
395
|
+
const days = minutes / 1440;
|
|
396
|
+
return days === 1 ? copyParam("service.day", "1 day") : copyTemplate("service.days", "{n} days", { n: days });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
type Change = { kind: "none" } | { kind: "anytime" } | { kind: "until"; minutes: number } | { kind: "off" };
|
|
400
|
+
|
|
401
|
+
function changeRule(enabled: boolean | undefined, limited: boolean | undefined, minutes: number | undefined): Change {
|
|
402
|
+
if (enabled === undefined) return { kind: "none" };
|
|
403
|
+
if (!enabled) return { kind: "off" };
|
|
404
|
+
return limited && minutes != null && minutes > 0 ? { kind: "until", minutes } : { kind: "anytime" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Strip HTML from the owner's policy text but keep its line breaks. */
|
|
408
|
+
function policyText(text: string): string {
|
|
409
|
+
return text.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]*>/g, "").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The lines of "Our policy", from the business's own booking policy. The app never
|
|
413
|
+
* invents policy text: each line exists only when the policy states it. */
|
|
414
|
+
function policyLines(policy: WixServiceBookingPolicy | undefined, type: string): PolicyLine[] {
|
|
415
|
+
if (!policy) return [];
|
|
416
|
+
const lines: PolicyLine[] = [];
|
|
417
|
+
const add = (text: string, owner = false) => { if (text) lines.push({ text, owner, notOwner: !owner }); };
|
|
418
|
+
const templated = (key: string, fallback: string, values: Record<string, string | number>) =>
|
|
419
|
+
type === "COURSE" && fallback.includes("session starts")
|
|
420
|
+
? copyTemplate(`${key}Course`, fallback.replace("session starts", "course starts"), values)
|
|
421
|
+
: copyTemplate(key, fallback, values);
|
|
422
|
+
|
|
423
|
+
const custom = policy.customPolicyDescription;
|
|
424
|
+
if (custom?.enabled) add(policyText(stringOf(custom.description)), true);
|
|
425
|
+
|
|
426
|
+
const cancel = changeRule(policy.cancellationPolicy?.enabled, policy.cancellationPolicy?.limitLatestCancellation, numberOf(policy.cancellationPolicy?.latestCancellationInMinutes));
|
|
427
|
+
const reschedule = changeRule(policy.reschedulePolicy?.enabled, policy.reschedulePolicy?.limitLatestReschedule, numberOf(policy.reschedulePolicy?.latestRescheduleInMinutes));
|
|
428
|
+
if (cancel.kind === "until" && reschedule.kind === "until" && cancel.minutes === reschedule.minutes) {
|
|
429
|
+
add(templated("service.changeUntil", "Cancel or reschedule online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
|
|
430
|
+
} else if (cancel.kind === "anytime" && reschedule.kind === "anytime") {
|
|
431
|
+
add(copyParam("service.changeAnytime", "You can cancel or reschedule online at any time."));
|
|
432
|
+
} else {
|
|
433
|
+
if (cancel.kind === "until") add(templated("service.cancelUntil", "Cancel online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
|
|
434
|
+
if (cancel.kind === "anytime") add(copyParam("service.cancelAnytime", "You can cancel online at any time."));
|
|
435
|
+
if (cancel.kind === "off") add(copyParam("service.cancelNotOnline", "You can't cancel online. Contact us to cancel."));
|
|
436
|
+
if (reschedule.kind === "until") add(templated("service.rescheduleUntil", "Reschedule online up to {window} before the session starts.", { window: windowText(reschedule.minutes) }));
|
|
437
|
+
if (reschedule.kind === "anytime") add(copyParam("service.rescheduleAnytime", "You can reschedule online at any time."));
|
|
438
|
+
if (reschedule.kind === "off") add(copyParam("service.rescheduleNotOnline", "You can't reschedule online."));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const fees = policy.cancellationFeePolicy;
|
|
442
|
+
if (fees?.enabled) {
|
|
443
|
+
for (const window of fees.cancellationWindows ?? []) {
|
|
444
|
+
const percentage = stringOf(window?.percentage);
|
|
445
|
+
const fee = formatServiceMoney(window?.amount) || (percentage ? `${percentage}%` : "");
|
|
446
|
+
if (!fee) continue;
|
|
447
|
+
const start = numberOf(window?.startInMinutes);
|
|
448
|
+
if (start == null || start <= 0) add(copyTemplate("service.feeLate", "Late cancellations have a {fee} fee.", { fee }));
|
|
449
|
+
else if (cancel.kind === "until" && cancel.minutes === start) add(copyTemplate("service.feeAfterThat", "Cancellations after that have a {fee} fee.", { fee }));
|
|
450
|
+
else add(templated("service.feeWindow", "Cancellations less than {window} before the session starts have a {fee} fee.", { window: windowText(start), fee }));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const early = policy.limitEarlyBookingPolicy;
|
|
455
|
+
const earliest = numberOf(early?.earliestBookingInMinutes);
|
|
456
|
+
if (early?.enabled && earliest != null && earliest > 0) add(templated("service.bookingsOpen", "Bookings open {window} before the session starts.", { window: windowText(earliest) }));
|
|
457
|
+
const late = policy.limitLateBookingPolicy;
|
|
458
|
+
const latest = numberOf(late?.latestBookingInMinutes);
|
|
459
|
+
if (late?.enabled && latest != null && latest > 0) add(templated("service.bookingsClose", "Bookings close {window} before the session starts.", { window: windowText(latest) }));
|
|
460
|
+
|
|
461
|
+
// Live payloads also carry `participantsPolicy.enabled` (not in the V2 types); a disabled
|
|
462
|
+
// policy keeps its configured maximum, which is not a rule the customer is held to.
|
|
463
|
+
const participants = policy.participantsPolicy as { enabled?: boolean; maxParticipantsPerBooking?: number } | undefined;
|
|
464
|
+
const max = numberOf(participants?.maxParticipantsPerBooking);
|
|
465
|
+
if (participants?.enabled !== false && max != null && max > 1) add(copyTemplate("service.maxParticipants", "Book for up to {n} participants.", { n: max }));
|
|
466
|
+
if (type === "CLASS" && policy.waitlistPolicy?.enabled) add(copyParam("service.waitlistRule", "When a class is full, you can join the waitlist."));
|
|
467
|
+
if (type === "COURSE" && policy.bookAfterStartPolicy?.enabled) add(copyParam("service.joinAfterStart", "You can join after the course starts."));
|
|
468
|
+
return lines;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---- Share -----------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
/** The service's public page URL when the payload carries one ("" otherwise). Bookings
|
|
474
|
+
* V2 puts it at `urls.servicePage.url`; the other shapes are older payloads. */
|
|
475
|
+
function serviceShareUrl(service: WixService): string {
|
|
476
|
+
const page = stringOf(service.urls?.servicePage?.url);
|
|
477
|
+
if (/^https?:\/\//i.test(page)) return page;
|
|
478
|
+
const raw = service as unknown as {
|
|
479
|
+
url?: unknown;
|
|
480
|
+
servicePageUrl?: { base?: string; path?: string; relativePath?: string } | string;
|
|
481
|
+
};
|
|
482
|
+
if (typeof raw.url === "string" && /^https?:\/\//i.test(raw.url)) return raw.url;
|
|
483
|
+
const legacy = raw.servicePageUrl;
|
|
484
|
+
if (typeof legacy === "string" && /^https?:\/\//i.test(legacy)) return legacy;
|
|
485
|
+
if (legacy && typeof legacy === "object" && typeof legacy.base === "string" && legacy.base) {
|
|
486
|
+
const path = typeof legacy.path === "string" ? legacy.path : typeof legacy.relativePath === "string" ? legacy.relativePath : "";
|
|
487
|
+
return `${legacy.base.replace(/\/$/, "")}${path && !path.startsWith("/") ? "/" : ""}${path}`;
|
|
488
|
+
}
|
|
489
|
+
return "";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---- Class and course ------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
const DAY_MS = 86_400_000;
|
|
495
|
+
const GROUP_WINDOW_DAYS = 14;
|
|
496
|
+
|
|
497
|
+
function validDate(value: string | undefined): Date | undefined {
|
|
498
|
+
if (!value) return undefined;
|
|
499
|
+
const date = new Date(value);
|
|
500
|
+
return Number.isFinite(date.getTime()) ? date : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function shortDate(date: Date, year: boolean): string {
|
|
504
|
+
return year ? formatDateMedium(date, "en") : formatMonthDay(date, "en");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function courseDates(service: WixService): string {
|
|
508
|
+
return courseDatesText(service);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function spotCount(entry: WixAvailabilityEntry | undefined): number | undefined {
|
|
512
|
+
const count = numberOf(entry?.openSpots);
|
|
513
|
+
return count != null && count >= 0 ? count : undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function spotText(count: number): string {
|
|
517
|
+
return copyPlural("service.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", count);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function sessionTime(start: string, end: string | undefined): string {
|
|
521
|
+
const from = validDate(start);
|
|
522
|
+
const to = validDate(end);
|
|
523
|
+
if (!from) return "";
|
|
524
|
+
return to ? `${formatClock(from, "en")} – ${formatClock(to, "en")}` : formatClock(from, "en");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** V1 availability is the same visitor-readable call used by the time screen. Ask
|
|
528
|
+
* for full entries too, since `bookable: true` hides the full sessions whose
|
|
529
|
+
* zero capacity and waitlist state the design needs. Bound every request to 14
|
|
530
|
+
* days; a course reads at most 84 days and never calls its partial count exact. */
|
|
531
|
+
async function groupAvailability(service: WixService, now: Date): Promise<{ entries: WixAvailabilityEntry[]; complete: boolean; readToEnd: boolean }> {
|
|
532
|
+
if (service.type === "CLASS") {
|
|
533
|
+
const res = await queryServiceAvailability(service.id, now.toISOString(), new Date(now.getTime() + GROUP_WINDOW_DAYS * DAY_MS).toISOString(), { bookable: false });
|
|
534
|
+
return { entries: res.availabilityEntries ?? [], complete: true, readToEnd: true };
|
|
535
|
+
}
|
|
536
|
+
if (service.type !== "COURSE") return { entries: [], complete: true, readToEnd: true };
|
|
537
|
+
// The same sessions the time screen checks before it books (_shared/himi-bookings.ts). The
|
|
538
|
+
// session count is the whole course only when the read starts at its first session.
|
|
539
|
+
const read = courseReadWindows(service, now.getTime(), GROUP_WINDOW_DAYS);
|
|
540
|
+
const replies = await Promise.all(read.windows.map(([from, to]) =>
|
|
541
|
+
queryServiceAvailability(service.id, new Date(from).toISOString(), new Date(to).toISOString(), { bookable: false })));
|
|
542
|
+
return { entries: replies.flatMap((res) => res.availabilityEntries ?? []), complete: read.complete && read.fromFirst, readToEnd: read.complete };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** When a session starts, read in its own zone (a local wall time beside `timezone`). */
|
|
546
|
+
function sessionStart(entry: WixAvailabilityEntry): number {
|
|
547
|
+
return entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function withGroupAvailability(s: S, service: WixService): Promise<S> {
|
|
551
|
+
if (service.type !== "CLASS" && service.type !== "COURSE") return s;
|
|
552
|
+
const now = new Date();
|
|
553
|
+
try {
|
|
554
|
+
const result = await groupAvailability(service, now);
|
|
555
|
+
const seen = new Set<string>();
|
|
556
|
+
const entries = result.entries.filter((entry) => {
|
|
557
|
+
const slot = entry.slot;
|
|
558
|
+
if (!slot?.startDate) return false;
|
|
559
|
+
const key = String(slot.sessionId ?? slot.startDate);
|
|
560
|
+
if (seen.has(key)) return false;
|
|
561
|
+
seen.add(key);
|
|
562
|
+
return true;
|
|
563
|
+
}).sort((a, b) => sessionStart(a) - sessionStart(b));
|
|
564
|
+
const next = entries.find((entry) => sessionStart(entry) >= now.getTime());
|
|
565
|
+
// A course is booked whole, so its count is the smallest across every upcoming session, and
|
|
566
|
+
// one session that cannot be booked closes it (the rule the time screen books by).
|
|
567
|
+
const course = service.type === "COURSE" ? courseSessionsAvailability(entries, now.getTime(), result.readToEnd) : undefined;
|
|
568
|
+
const count = course ? course.spots : spotCount(next);
|
|
569
|
+
// No upcoming session read back: the time screen refuses it, so the button is closed too.
|
|
570
|
+
const courseClosed = s.courseCtaClosed || (course != null && (!course.found || (!course.bookable && count !== 0)));
|
|
571
|
+
const waitlist = service.type === "CLASS" && service.bookingPolicy?.waitlistPolicy?.enabled === true &&
|
|
572
|
+
(numberOf(next?.waitingList?.openSpots) ?? 0) > 0;
|
|
573
|
+
const spotsText = count == null ? "" : count > 0 ? spotText(count)
|
|
574
|
+
: service.type === "CLASS"
|
|
575
|
+
? waitlist
|
|
576
|
+
? copyParam("service.classFullWaitlist", "The class is fully booked. You can join the waitlist.")
|
|
577
|
+
: copyParam("service.classFull", "The class is fully booked.")
|
|
578
|
+
: "";
|
|
579
|
+
const courseSessions = service.type === "COURSE" ? entries.map((entry) => ({
|
|
580
|
+
date: shortDate(validDate(entry.slot!.startDate)!, true),
|
|
581
|
+
time: sessionTime(entry.slot!.startDate!, entry.slot!.endDate),
|
|
582
|
+
})) : [];
|
|
583
|
+
return derive({
|
|
584
|
+
...s,
|
|
585
|
+
spotsText,
|
|
586
|
+
spotsWarning: count != null && count > 0 && count <= 3,
|
|
587
|
+
courseCtaClosed: courseClosed,
|
|
588
|
+
courseCtaFull: service.type === "COURSE" && count === 0 && !courseClosed,
|
|
589
|
+
courseCtaBook: service.type === "COURSE" && count !== 0 && !s.requestToBook && !courseClosed,
|
|
590
|
+
courseCtaRequest: service.type === "COURSE" && count !== 0 && s.requestToBook && !courseClosed,
|
|
591
|
+
courseSessionCount: service.type === "COURSE" && result.complete && courseSessions.length > 0
|
|
592
|
+
? copyPlural("service.sessionsCount", "{n, plural, one {# session} other {# sessions}}", courseSessions.length) : "",
|
|
593
|
+
courseSessions: courseSessions.slice(0, 3),
|
|
594
|
+
courseMoreSessions: courseSessions.slice(3),
|
|
595
|
+
});
|
|
596
|
+
} catch {
|
|
597
|
+
// The record still loads if the availability endpoint is unavailable. Unknown
|
|
598
|
+
// capacity stays unknown; the customer can inspect availability at booking.
|
|
599
|
+
return s;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---- The session from the Agenda -------------------------------------------------
|
|
604
|
+
|
|
605
|
+
/** The session as last read, with the member its place was read for: a join checks against them. */
|
|
606
|
+
interface Picked { session: Session; place: MemberPlace | undefined; who: MemberAccess | undefined }
|
|
607
|
+
|
|
608
|
+
const NO_SESSION = {
|
|
609
|
+
sessionId: "", sessionStart: "", sessionWhen: "", sessionDate: "", sessionTime: "", sessionControl: "" as const, closedLabel: "",
|
|
610
|
+
};
|
|
611
|
+
const NO_SESSION_MESSAGE = { sessionMessage: "", sessionMessageOk: false, sessionMessageFailed: false };
|
|
612
|
+
|
|
613
|
+
let picked: Picked | undefined;
|
|
614
|
+
/** The record the last load showed: a join that finds another member re-reads its session. */
|
|
615
|
+
let loadedService: WixService | undefined;
|
|
616
|
+
/** A waitlist join is in flight: a second tap registers nothing. */
|
|
617
|
+
let joining = false;
|
|
618
|
+
|
|
619
|
+
/** The input session as the Agenda read it: the service's availability on that LOCAL day (the
|
|
620
|
+
* Agenda reads whole local days), the entry with the session id — or, for a session without
|
|
621
|
+
* one, the same start instant — and the signed-in member's place in it. `undefined` when the
|
|
622
|
+
* screen has no session, the service is not a class or course, the session is no longer listed,
|
|
623
|
+
* or a read fails: the screen then offers "Choose a Time", as from the Services list. */
|
|
624
|
+
async function readSession(service: WixService): Promise<Picked | undefined> {
|
|
625
|
+
const want = inputSession();
|
|
626
|
+
const type = stringOf(service.type).toUpperCase();
|
|
627
|
+
if (!want.start || (type !== "CLASS" && type !== "COURSE")) return undefined;
|
|
628
|
+
const day = dayKey(want.start);
|
|
629
|
+
try {
|
|
630
|
+
const from = new Date(`${day}T00:00:00`).toISOString();
|
|
631
|
+
const to = new Date(`${dayKey(addDays(day, 1))}T00:00:00`).toISOString();
|
|
632
|
+
const res = await queryServiceAvailability(service.id, from, to, { bookable: false });
|
|
633
|
+
const sessions = (res.availabilityEntries ?? []).map((entry) => sessionOf(entry, service)).filter((x): x is Session => !!x);
|
|
634
|
+
const at = Date.parse(want.start);
|
|
635
|
+
// Two sessions of a class can start at the same instant: the session id decides when there is one.
|
|
636
|
+
const session = want.sessionId
|
|
637
|
+
? sessions.find((x) => x.sessionId === want.sessionId)
|
|
638
|
+
: sessions.find((x) => !x.sessionId && Date.parse(x.start) === at) ?? sessions.find((x) => Date.parse(x.start) === at);
|
|
639
|
+
if (!session) return undefined;
|
|
640
|
+
// A failed bookings read is not "no place": Book on a session the member holds invites a
|
|
641
|
+
// duplicate, so the screen falls back to Choose a Time instead (placesIn throws).
|
|
642
|
+
const { who, found } = await placesIn(day, day, [session]);
|
|
643
|
+
return { session, place: found.get(session.key), who };
|
|
644
|
+
} catch (err) {
|
|
645
|
+
// Not silent: the screen still offers "Choose a Time", and the host log says why it did not
|
|
646
|
+
// offer the session's own control.
|
|
647
|
+
console.warn("himi_service: the Agenda session could not be read; offering Choose a Time", err);
|
|
648
|
+
return undefined;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** How long the session read may take before the screen paints without it. The read asks the host
|
|
653
|
+
* for the member's identity, and a host whose identity dispatch never settles must not pin the
|
|
654
|
+
* skeleton (the time screen's D5 case). */
|
|
655
|
+
const SESSION_READ_BUDGET_MS = 10_000;
|
|
656
|
+
|
|
657
|
+
/** `readSession`, or `undefined` once the budget is spent. A worker engine with no timer waits
|
|
658
|
+
* for the read itself. */
|
|
659
|
+
function readSessionBounded(service: WixService): Promise<Picked | undefined> {
|
|
660
|
+
const host = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (timer: unknown) => void };
|
|
661
|
+
const read = readSession(service);
|
|
662
|
+
if (typeof host.setTimeout !== "function") return read;
|
|
663
|
+
return new Promise((resolve) => {
|
|
664
|
+
const timer = host.setTimeout!(() => resolve(undefined), SESSION_READ_BUDGET_MS);
|
|
665
|
+
void read.then((sel) => {
|
|
666
|
+
try { host.clearTimeout?.(timer); } catch { /* best-effort */ }
|
|
667
|
+
resolve(sel);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** A class session's own spots: "4 spots left", or full — with "You can join the waitlist" only
|
|
673
|
+
* when Join Waitlist is offered. The service-wide line describes the NEXT session, which may be
|
|
674
|
+
* another one. Unknown capacity (the site reports none) shows no line, as on the Agenda row. */
|
|
675
|
+
function sessionSpots(session: Session, control: SessionControl): Pick<S, "spotsText" | "spotsWarning"> {
|
|
676
|
+
if (session.total <= 0) return { spotsText: "", spotsWarning: false };
|
|
677
|
+
if (session.open > 0) return { spotsText: spotText(session.open), spotsWarning: session.open <= 3 };
|
|
678
|
+
return {
|
|
679
|
+
spotsText: control === "waitlist"
|
|
680
|
+
? copyParam("service.classFullWaitlist", "The class is fully booked. You can join the waitlist.")
|
|
681
|
+
: copyParam("service.classFull", "The class is fully booked."),
|
|
682
|
+
spotsWarning: false,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** `s` showing `sel` — or no session. Records `sel` as the session a join acts on. */
|
|
687
|
+
function withSession(s: S, sel: Picked | undefined): S {
|
|
688
|
+
picked = sel;
|
|
689
|
+
if (!sel) return derive({ ...s, ...NO_SESSION });
|
|
690
|
+
const { session, place } = sel;
|
|
691
|
+
const { control, closedLabel } = sessionControl(session, place);
|
|
692
|
+
const date = dayLabel(session.start, { absolute: true });
|
|
693
|
+
const time = Date.parse(session.end) > Date.parse(session.start)
|
|
694
|
+
? `${timeLabel(session.start)} – ${timeLabel(session.end)}`
|
|
695
|
+
: timeLabel(session.start);
|
|
696
|
+
return derive({
|
|
697
|
+
...s,
|
|
698
|
+
...(session.kind === "class" ? sessionSpots(session, control) : {}),
|
|
699
|
+
sessionId: session.sessionId,
|
|
700
|
+
sessionStart: session.start,
|
|
701
|
+
sessionDate: date,
|
|
702
|
+
sessionTime: time,
|
|
703
|
+
// The start only: beside "Change Time" a full range broke mid-range on a phone. The fact row
|
|
704
|
+
// below carries the range.
|
|
705
|
+
sessionWhen: [date, timeLabel(session.start)].filter(Boolean).join(" · "),
|
|
706
|
+
sessionControl: control,
|
|
707
|
+
closedLabel,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The member's name for a waitlist registration, when their member record has one (the Agenda's
|
|
712
|
+
* rule). The email alone registers; a failed profile read registers by email alone. */
|
|
713
|
+
async function memberName(): Promise<{ firstName?: string; lastName?: string }> {
|
|
714
|
+
const res = await getMyMember({ fieldsets: ["FULL"] }).catch(() => undefined);
|
|
715
|
+
const contact = res?.member?.contact as { firstName?: string; lastName?: string } | undefined;
|
|
716
|
+
return { firstName: contact?.firstName || undefined, lastName: contact?.lastName || undefined };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const sameMember = (a: MemberAccess | undefined, b: MemberAccess | undefined): boolean =>
|
|
720
|
+
!!a?.email && a.email === b?.email && a.scope === b?.scope;
|
|
721
|
+
|
|
722
|
+
/** Where a visitor's Join Waitlist signs in: the app's sign-in screen and how it opens, from the
|
|
723
|
+
* appear (or tap) params — Branded Lite passes `himi_sign_in` as a sheet. */
|
|
724
|
+
let signInScreenId = "";
|
|
725
|
+
let signInPresentation: "" | "sheet" | "full_screen" = "";
|
|
726
|
+
function rememberSignIn(arg: unknown): void {
|
|
727
|
+
const a = (arg ?? {}) as { signInScreenId?: unknown; signInPresentation?: unknown };
|
|
728
|
+
if (typeof a.signInScreenId === "string" && a.signInScreenId.trim()) signInScreenId = a.signInScreenId.trim();
|
|
729
|
+
if (a.signInPresentation === "sheet" || a.signInPresentation === "full_screen") signInPresentation = a.signInPresentation;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ---- State -----------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
function derive(s: S): S {
|
|
735
|
+
const isLoading = s.status === "loading";
|
|
736
|
+
const hasService = !!s.name;
|
|
737
|
+
const hasImage = s.images.length > 0;
|
|
738
|
+
const control = s.sessionControl;
|
|
739
|
+
// The session's own control replaces the service-wide one; a course session not yet booked
|
|
740
|
+
// keeps the course footer ("course"), as on the Agenda, where it opens this screen.
|
|
741
|
+
const sessionCta = control !== "" && control !== "course";
|
|
742
|
+
const ownPlace = control === "booked" || control === "waitlisted";
|
|
743
|
+
return {
|
|
744
|
+
...s,
|
|
745
|
+
isLoading,
|
|
746
|
+
notLoading: !isLoading,
|
|
747
|
+
notErrored: !s.errored,
|
|
748
|
+
hasService,
|
|
749
|
+
noEmpty: isLoading || s.errored || hasService,
|
|
750
|
+
noCategory: !s.category,
|
|
751
|
+
noTypeTag: !s.typeTag,
|
|
752
|
+
noCourse: !s.isCourse,
|
|
753
|
+
noTagline: !s.tagline,
|
|
754
|
+
noPrice: !s.priceText,
|
|
755
|
+
noPriceUnit: !s.priceUnit,
|
|
756
|
+
noPriceAmount: !s.priceText || s.priceIsText,
|
|
757
|
+
noPriceDescription: !s.priceText || !s.priceIsText,
|
|
758
|
+
priceA11y: s.priceText && !s.priceIsText
|
|
759
|
+
? copyTemplate("service.priceLabel", "Price, {price}", { price: [s.priceText, s.priceUnit].filter(Boolean).join(" ") })
|
|
760
|
+
: "",
|
|
761
|
+
image: s.images[0]?.url ?? "",
|
|
762
|
+
hasGallery: s.images.length > 1,
|
|
763
|
+
noGallery: s.images.length < 2,
|
|
764
|
+
hasOneImage: s.images.length === 1,
|
|
765
|
+
noOneImage: s.images.length !== 1,
|
|
766
|
+
hasImage,
|
|
767
|
+
noImage: !hasImage,
|
|
768
|
+
noDuration: !s.durationText,
|
|
769
|
+
noCourseDates: !s.courseDates,
|
|
770
|
+
noSpots: !s.spotsText,
|
|
771
|
+
spotsNeutral: !s.spotsWarning,
|
|
772
|
+
hideSpotsNeutral: !s.spotsText || s.spotsWarning,
|
|
773
|
+
noCourseMoreSessions: s.courseMoreSessions.length === 0,
|
|
774
|
+
noCourseSessions: s.courseSessions.length === 0,
|
|
775
|
+
scheduleCollapsed: !s.scheduleExpanded,
|
|
776
|
+
noLocations: s.locations.length === 0,
|
|
777
|
+
noOnline: !s.online,
|
|
778
|
+
noStaff: !s.staffText,
|
|
779
|
+
hasStaffImage: !!s.staffImage,
|
|
780
|
+
noStaffImage: !s.staffImage,
|
|
781
|
+
noPay: !s.payText,
|
|
782
|
+
noPaySub: !s.paySub,
|
|
783
|
+
notRequestToBook: !s.requestToBook,
|
|
784
|
+
hideChooseTime: s.isCourse || s.requestToBook || sessionCta,
|
|
785
|
+
hideRequestTime: s.isCourse || !s.requestToBook || sessionCta,
|
|
786
|
+
noCourseCtaBook: !s.courseCtaBook || sessionCta,
|
|
787
|
+
noCourseCtaRequest: !s.courseCtaRequest || sessionCta,
|
|
788
|
+
noCourseCtaFull: !s.courseCtaFull || sessionCta,
|
|
789
|
+
noCourseCtaClosed: !s.courseCtaClosed || sessionCta,
|
|
790
|
+
noSession: !s.sessionWhen,
|
|
791
|
+
// Book where the Agenda row says Book; "Request to Book" when the business confirms each one.
|
|
792
|
+
noSessionBook: control !== "book" || s.requestToBook,
|
|
793
|
+
noSessionRequest: control !== "book" || !s.requestToBook,
|
|
794
|
+
noSessionWaitlist: control !== "waitlist",
|
|
795
|
+
noSessionClosed: control !== "closed",
|
|
796
|
+
noSessionBooked: control !== "booked",
|
|
797
|
+
noSessionWaitlisted: control !== "waitlisted",
|
|
798
|
+
// Change Time opens the time screen, which cannot book a service closed to online booking.
|
|
799
|
+
noChangeTime: (control !== "book" && control !== "waitlist" && control !== "closed") || !s.bookableOnline,
|
|
800
|
+
noSessionMessage: !s.sessionMessage,
|
|
801
|
+
notSaved: !s.saved,
|
|
802
|
+
saveLabel: s.saved
|
|
803
|
+
? copyParam("service.unsaveLabel", "Remove from favorites")
|
|
804
|
+
: copyParam("service.saveLabel", "Save to favorites"),
|
|
805
|
+
notBookableOnline: !s.bookableOnline,
|
|
806
|
+
noAbout: !s.about && !s.aboutJson,
|
|
807
|
+
noPolicy: s.policyLines.length === 0,
|
|
808
|
+
policyCollapsed: !s.policyExpanded,
|
|
809
|
+
noFacts: !s.sessionWhen && !s.courseDates && !s.durationText && !s.spotsText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
|
|
810
|
+
noKeyFacts: !s.priceText && !s.durationText && !s.category,
|
|
811
|
+
hideContent: isLoading || s.errored || !hasService,
|
|
812
|
+
// The session's own status still shows when the service has since closed online booking: the
|
|
813
|
+
// member's place, or the Agenda row's disabled Book with its reason.
|
|
814
|
+
hideFooter: isLoading || s.errored || !hasService || (!s.bookableOnline && !ownPlace && control !== "closed"),
|
|
815
|
+
emptyText: s.errored
|
|
816
|
+
? copyParam("service.errorText", "Service details could not be loaded.")
|
|
817
|
+
: copyParam("service.emptyText", "This service is not available."),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function withService(s: S, service: WixService): S {
|
|
822
|
+
const body = descriptionBody(service.description);
|
|
823
|
+
const serviceId = stringOf(service.id) || s.id;
|
|
824
|
+
const name = stringOf(service.name) || "Service";
|
|
825
|
+
const price = priceLine(service);
|
|
826
|
+
const staff = staffLine(service);
|
|
827
|
+
const pay = payLine(service);
|
|
828
|
+
const isCourse = service.type === "COURSE";
|
|
829
|
+
const isClass = service.type === "CLASS";
|
|
830
|
+
// Ended, started without late joining, or outside the booking window: the same rule the time
|
|
831
|
+
// screen applies before it books (_shared/himi-bookings.ts).
|
|
832
|
+
const courseCtaClosed = isCourse && !courseWindowOpen(service, Date.now());
|
|
833
|
+
return derive({
|
|
834
|
+
...s,
|
|
835
|
+
status: "ready",
|
|
836
|
+
errored: false,
|
|
837
|
+
id: serviceId,
|
|
838
|
+
name,
|
|
839
|
+
// Live payloads carry `category` as a structured object — extract the name
|
|
840
|
+
// (the list worker joins the same field; see _shared/himi-bookings.ts).
|
|
841
|
+
category: categoryName(service.category),
|
|
842
|
+
typeTag: isClass ? copyParam("service.tagClass", "Class") : isCourse ? copyParam("service.tagCourse", "Course") : "",
|
|
843
|
+
isClass,
|
|
844
|
+
isCourse,
|
|
845
|
+
tagline: stringOf(service.tagLine),
|
|
846
|
+
priceText: price.text,
|
|
847
|
+
priceUnit: price.unit,
|
|
848
|
+
priceIsText: price.isText,
|
|
849
|
+
images: serviceImages(service, name),
|
|
850
|
+
durationText: isCourse && durationText(service)
|
|
851
|
+
? copyTemplate("service.durationEach", "{duration} each", { duration: durationText(service) })
|
|
852
|
+
: durationText(service),
|
|
853
|
+
courseDates: isCourse ? courseDates(service) : "",
|
|
854
|
+
courseSessionCount: "",
|
|
855
|
+
courseSessions: [],
|
|
856
|
+
courseMoreSessions: [],
|
|
857
|
+
spotsText: "",
|
|
858
|
+
spotsWarning: false,
|
|
859
|
+
scheduleExpanded: false,
|
|
860
|
+
courseCtaClosed,
|
|
861
|
+
courseCtaFull: false,
|
|
862
|
+
courseCtaBook: isCourse && !courseCtaClosed && service.onlineBooking?.requireManualApproval !== true,
|
|
863
|
+
courseCtaRequest: isCourse && !courseCtaClosed && service.onlineBooking?.requireManualApproval === true,
|
|
864
|
+
locations: locationRows(service),
|
|
865
|
+
online: service.conferencing?.enabled === true,
|
|
866
|
+
staffText: staff.text,
|
|
867
|
+
staffImage: staff.image,
|
|
868
|
+
staffInitials: staff.initials,
|
|
869
|
+
payText: pay.text,
|
|
870
|
+
paySub: pay.sub,
|
|
871
|
+
requestToBook: service.onlineBooking?.requireManualApproval === true,
|
|
872
|
+
bookableOnline: service.onlineBooking?.enabled !== false,
|
|
873
|
+
about: body.bodyText,
|
|
874
|
+
aboutJson: body.bodyJson,
|
|
875
|
+
hasAboutJson: body.bodyJson !== "",
|
|
876
|
+
policyLines: policyLines(service.bookingPolicy, stringOf(service.type).toUpperCase()),
|
|
877
|
+
shareUrl: serviceShareUrl(service),
|
|
878
|
+
// WS-C — canonical param-less target; the descriptor's choose-time CTA
|
|
879
|
+
// binds the separate `id` state field as a first-class nav param.
|
|
880
|
+
timeScreenId: "himi_time",
|
|
881
|
+
saved: savedNow(serviceId),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Whether the service is a favorite. Unreadable storage reads as not saved: the heart is a
|
|
886
|
+
* convenience and never holds the screen back. */
|
|
887
|
+
function savedNow(id: string): boolean {
|
|
888
|
+
try { return !!id && isServiceSaved(id); } catch { return false; }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** The service record: with staff details when this token may read them, else without. */
|
|
892
|
+
async function fetchService(id: string): Promise<WixService | undefined> {
|
|
893
|
+
try {
|
|
894
|
+
return (await getService(id, SERVICE_FIELDS)).service;
|
|
895
|
+
} catch {
|
|
896
|
+
// A visitor token that may not read STAFF_MEMBER_DETAILS must not lose the whole screen.
|
|
897
|
+
return (await getService(id)).service;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** `{ index }` from a row tap, as a number (`$item.index` may arrive as a string). */
|
|
902
|
+
function tappedIndex(arg: unknown): number {
|
|
903
|
+
const a = arg as { index?: unknown; args?: { index?: unknown } } | null | undefined;
|
|
904
|
+
return numberOf(a?.index ?? a?.args?.index) ?? -1;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const id = inputId();
|
|
908
|
+
const svc = defineService<S, {
|
|
909
|
+
load: (s: S, arg?: unknown) => Promise<S>;
|
|
910
|
+
share: (s: S) => Promise<S>;
|
|
911
|
+
openLink: (s: S, arg?: unknown) => Promise<undefined>;
|
|
912
|
+
togglePolicy: (s: S) => S;
|
|
913
|
+
toggleSchedule: (s: S) => S;
|
|
914
|
+
openDirections: (s: S, arg?: unknown) => Promise<S>;
|
|
915
|
+
chooseTime: (s: S) => S;
|
|
916
|
+
book: (s: S) => S;
|
|
917
|
+
changeTime: (s: S) => S;
|
|
918
|
+
joinWaitlist: (s: S, arg?: unknown) => Promise<S>;
|
|
919
|
+
toggleSaved: (s: S) => S;
|
|
920
|
+
}>("himiBookingService", {
|
|
921
|
+
// A join's outcome belongs to that visit: a cold start must not replay it from saved state.
|
|
922
|
+
notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage", "saveMessage"],
|
|
923
|
+
state: derive({
|
|
924
|
+
status: "loading",
|
|
925
|
+
id,
|
|
926
|
+
name: "",
|
|
927
|
+
category: "",
|
|
928
|
+
noCategory: true,
|
|
929
|
+
typeTag: "",
|
|
930
|
+
noTypeTag: true,
|
|
931
|
+
isClass: false,
|
|
932
|
+
isCourse: false,
|
|
933
|
+
noCourse: true,
|
|
934
|
+
tagline: "",
|
|
935
|
+
noTagline: true,
|
|
936
|
+
priceText: "",
|
|
937
|
+
priceUnit: "",
|
|
938
|
+
priceIsText: false,
|
|
939
|
+
noPrice: true,
|
|
940
|
+
noPriceUnit: true,
|
|
941
|
+
noPriceAmount: true,
|
|
942
|
+
noPriceDescription: true,
|
|
943
|
+
priceA11y: "",
|
|
944
|
+
images: [],
|
|
945
|
+
image: "",
|
|
946
|
+
hasGallery: false,
|
|
947
|
+
noGallery: true,
|
|
948
|
+
hasOneImage: false,
|
|
949
|
+
noOneImage: true,
|
|
950
|
+
hasImage: false,
|
|
951
|
+
noImage: true,
|
|
952
|
+
durationText: "",
|
|
953
|
+
noDuration: true,
|
|
954
|
+
courseDates: "",
|
|
955
|
+
courseSessionCount: "",
|
|
956
|
+
noCourseDates: true,
|
|
957
|
+
spotsText: "",
|
|
958
|
+
noSpots: true,
|
|
959
|
+
spotsWarning: false,
|
|
960
|
+
spotsNeutral: true,
|
|
961
|
+
hideSpotsNeutral: true,
|
|
962
|
+
courseSessions: [],
|
|
963
|
+
noCourseSessions: true,
|
|
964
|
+
courseMoreSessions: [],
|
|
965
|
+
noCourseMoreSessions: true,
|
|
966
|
+
scheduleExpanded: false,
|
|
967
|
+
scheduleCollapsed: true,
|
|
968
|
+
courseCtaBook: false,
|
|
969
|
+
courseCtaRequest: false,
|
|
970
|
+
courseCtaFull: false,
|
|
971
|
+
courseCtaClosed: false,
|
|
972
|
+
hideChooseTime: false,
|
|
973
|
+
hideRequestTime: true,
|
|
974
|
+
noCourseCtaBook: true,
|
|
975
|
+
noCourseCtaRequest: true,
|
|
976
|
+
noCourseCtaFull: true,
|
|
977
|
+
noCourseCtaClosed: true,
|
|
978
|
+
locations: [],
|
|
979
|
+
noLocations: true,
|
|
980
|
+
online: false,
|
|
981
|
+
noOnline: true,
|
|
982
|
+
staffText: "",
|
|
983
|
+
staffImage: "",
|
|
984
|
+
staffInitials: "",
|
|
985
|
+
hasStaffImage: false,
|
|
986
|
+
noStaffImage: true,
|
|
987
|
+
noStaff: true,
|
|
988
|
+
payText: "",
|
|
989
|
+
paySub: "",
|
|
990
|
+
noPay: true,
|
|
991
|
+
noPaySub: true,
|
|
992
|
+
requestToBook: false,
|
|
993
|
+
notRequestToBook: true,
|
|
994
|
+
bookableOnline: true,
|
|
995
|
+
notBookableOnline: false,
|
|
996
|
+
about: "",
|
|
997
|
+
aboutJson: "",
|
|
998
|
+
hasAboutJson: false,
|
|
999
|
+
noAbout: true,
|
|
1000
|
+
policyLines: [],
|
|
1001
|
+
noPolicy: true,
|
|
1002
|
+
policyExpanded: false,
|
|
1003
|
+
policyCollapsed: true,
|
|
1004
|
+
noFacts: true,
|
|
1005
|
+
noKeyFacts: true,
|
|
1006
|
+
hideContent: true,
|
|
1007
|
+
hideFooter: true,
|
|
1008
|
+
shareUrl: "",
|
|
1009
|
+
timeScreenId: id ? "himi_time" : "himi_services", // WS-C canonical + `id` nav param
|
|
1010
|
+
emptyText: "",
|
|
1011
|
+
errored: false,
|
|
1012
|
+
notErrored: true,
|
|
1013
|
+
isLoading: true,
|
|
1014
|
+
notLoading: false,
|
|
1015
|
+
hasService: false,
|
|
1016
|
+
noEmpty: true,
|
|
1017
|
+
...NO_SESSION,
|
|
1018
|
+
noSession: true,
|
|
1019
|
+
noSessionBook: true,
|
|
1020
|
+
noSessionRequest: true,
|
|
1021
|
+
noSessionWaitlist: true,
|
|
1022
|
+
noSessionClosed: true,
|
|
1023
|
+
noSessionBooked: true,
|
|
1024
|
+
noSessionWaitlisted: true,
|
|
1025
|
+
noChangeTime: true,
|
|
1026
|
+
...NO_SESSION_MESSAGE,
|
|
1027
|
+
noSessionMessage: true,
|
|
1028
|
+
saved: false,
|
|
1029
|
+
notSaved: true,
|
|
1030
|
+
saveLabel: "",
|
|
1031
|
+
saveMessage: "",
|
|
1032
|
+
}),
|
|
1033
|
+
actions: {
|
|
1034
|
+
load: async (s, arg) => {
|
|
1035
|
+
stashCopy(arg);
|
|
1036
|
+
rememberSignIn(arg);
|
|
1037
|
+
if (!s.id) return derive({ ...s, status: "ready", errored: true });
|
|
1038
|
+
// A new view: a join's outcome from before is not news when the screen shows again.
|
|
1039
|
+
const start = { ...s, policyExpanded: false, scheduleExpanded: false, saveMessage: "", ...NO_SESSION_MESSAGE };
|
|
1040
|
+
try {
|
|
1041
|
+
const service = await fetchService(s.id);
|
|
1042
|
+
if (!service) return withSession(derive({ ...start, status: "ready" }), undefined);
|
|
1043
|
+
loadedService = service;
|
|
1044
|
+
// The session the Agenda row opened on, read beside the service-wide availability.
|
|
1045
|
+
const [shown, sel] = await Promise.all([withGroupAvailability(withService(start, service), service), readSessionBounded(service)]);
|
|
1046
|
+
// The heart is read again after the waits (the session read takes up to 10 s): a toggle
|
|
1047
|
+
// tapped meanwhile wrote storage, and the snapshot taken before would undo it.
|
|
1048
|
+
return withSession({ ...shown, saved: savedNow(s.id) }, sel);
|
|
1049
|
+
} catch {
|
|
1050
|
+
// Offline: paint from the last-synced services cache (written by the list
|
|
1051
|
+
// worker) before falling back to the error state. The cache holds the name,
|
|
1052
|
+
// the category and one image; every other row stays hidden.
|
|
1053
|
+
const cached = await readCached(s.id);
|
|
1054
|
+
// No session state offline: the member's place in it cannot be read.
|
|
1055
|
+
if (cached) {
|
|
1056
|
+
const name = String(cached.name ?? "") || "Service";
|
|
1057
|
+
const image = String(cached.image ?? "");
|
|
1058
|
+
return withSession({
|
|
1059
|
+
...start,
|
|
1060
|
+
status: "ready",
|
|
1061
|
+
errored: false,
|
|
1062
|
+
id: s.id,
|
|
1063
|
+
name,
|
|
1064
|
+
category: String(cached.category ?? ""),
|
|
1065
|
+
images: image ? [{ url: image, alt: name }] : [],
|
|
1066
|
+
timeScreenId: `himi_time_${s.id}`,
|
|
1067
|
+
saved: savedNow(s.id),
|
|
1068
|
+
}, undefined);
|
|
1069
|
+
}
|
|
1070
|
+
return withSession({ ...start, status: "ready", errored: true, name: "" }, undefined);
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
openLink: async (_state, arg) => { await openRichLink(arg); return undefined; },
|
|
1074
|
+
share: async (s) => {
|
|
1075
|
+
if (!s.name) return s;
|
|
1076
|
+
const text = [s.name, s.tagline].filter(Boolean).join(" — ");
|
|
1077
|
+
try {
|
|
1078
|
+
await shareSheet({ subject: s.name, text, ...(s.shareUrl ? { url: s.shareUrl } : {}) });
|
|
1079
|
+
return s;
|
|
1080
|
+
} catch {
|
|
1081
|
+
return s;
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
|
|
1085
|
+
toggleSchedule: (s) => derive({ ...s, scheduleExpanded: !s.scheduleExpanded }),
|
|
1086
|
+
// The heart: save or unsave this service on the device (the member's own list once signed
|
|
1087
|
+
// in). The row keeps what the Favorites list shows offline; a failed write says so and never
|
|
1088
|
+
// shows the heart as saved.
|
|
1089
|
+
toggleSaved: (s) => {
|
|
1090
|
+
if (!s.id || !s.hasService || s.errored) return s;
|
|
1091
|
+
try {
|
|
1092
|
+
const saved = toggleSavedService({
|
|
1093
|
+
id: s.id,
|
|
1094
|
+
name: s.name,
|
|
1095
|
+
price: s.priceIsText ? "" : s.priceText,
|
|
1096
|
+
duration: s.durationText,
|
|
1097
|
+
image: s.image,
|
|
1098
|
+
category: s.category,
|
|
1099
|
+
...(s.isCourse ? { course: true } : {}),
|
|
1100
|
+
});
|
|
1101
|
+
return derive({ ...s, saved, saveMessage: "" });
|
|
1102
|
+
} catch {
|
|
1103
|
+
return derive({ ...s, saved: savedNow(s.id), saveMessage: copyParam("service.saveError", "Could not update your favorites. Try again.") });
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
// The footer button opens the time screen for the loaded service. A worker directive,
|
|
1107
|
+
// not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
|
|
1108
|
+
// dispatch their taps without resolving `$service:` params, so the id never arrived.
|
|
1109
|
+
chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id, ...(s.isCourse ? { course: "true" } : {}) } }) : s),
|
|
1110
|
+
// Book the session the screen opened on: Choose Time opens with it picked — the Agenda row's
|
|
1111
|
+
// own Book (`{ id, start, sessionId }`), so the member does not choose the time again.
|
|
1112
|
+
book: (s) => (s.id && s.hasService && s.sessionControl === "book"
|
|
1113
|
+
? navigate(s, { screenId: "himi_time", args: { id: s.id, start: s.sessionStart, sessionId: s.sessionId } })
|
|
1114
|
+
: s),
|
|
1115
|
+
// "Change time": Choose Time for the service, with nothing picked.
|
|
1116
|
+
changeTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id } }) : s),
|
|
1117
|
+
// Join the session's waitlist from here — the Agenda row's join: a visitor signs in first; the
|
|
1118
|
+
// member's email and name are read, and the registration goes out only while the member is the
|
|
1119
|
+
// one the session's place was read for. The screen then shows "On waitlist".
|
|
1120
|
+
joinWaitlist: async (s, arg): Promise<S> => {
|
|
1121
|
+
rememberSignIn(arg);
|
|
1122
|
+
const sel = picked;
|
|
1123
|
+
if (!sel || joining || s.sessionControl !== "waitlist" || !sel.session.sessionId) return s;
|
|
1124
|
+
joining = true;
|
|
1125
|
+
try {
|
|
1126
|
+
const before = await memberAccess().catch(() => undefined);
|
|
1127
|
+
if (!before?.email) {
|
|
1128
|
+
joining = false;
|
|
1129
|
+
// Without a sign-in screen, Choose Time opens on the session: it signs a guest in and
|
|
1130
|
+
// joins the waitlist itself. A sign-in sheet only closes, and this screen loads again.
|
|
1131
|
+
if (!signInScreenId) return navigate(s, { screenId: "himi_time", args: { id: s.id, start: s.sessionStart, sessionId: s.sessionId } });
|
|
1132
|
+
return navigate(s, signInPresentation
|
|
1133
|
+
? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", modal: "1" } }
|
|
1134
|
+
: { screenId: signInScreenId, args: { mode: "signin" } });
|
|
1135
|
+
}
|
|
1136
|
+
const name = await memberName();
|
|
1137
|
+
const current = await memberAccess().catch(() => undefined);
|
|
1138
|
+
if (!sameMember(before, current) || !sameMember(sel.who, current)) {
|
|
1139
|
+
// Another member since the load — or the load was a visitor's, whose place was never
|
|
1140
|
+
// read: read the session again for whoever is signed in now; nothing is registered.
|
|
1141
|
+
const reread = loadedService ? await readSession(loadedService) : undefined;
|
|
1142
|
+
joining = false;
|
|
1143
|
+
return withSession(svc.getState() as S, reread);
|
|
1144
|
+
}
|
|
1145
|
+
const res = await registerToWaitlist(sel.session.sessionId, { email: current!.email, ...name });
|
|
1146
|
+
const place: MemberPlace = { bookingId: res?.booking?.id ?? "", revision: "", place: "waitlisted", registrationId: res?.registrationId, confirmed: false };
|
|
1147
|
+
joining = false;
|
|
1148
|
+
// Against the state as it is NOW; a load that landed meanwhile read this session again.
|
|
1149
|
+
const now = svc.getState() as S;
|
|
1150
|
+
return withSession({ ...now, sessionMessage: copyParam("agenda.joinedWaitlist", "You're on the waitlist."), sessionMessageOk: true, sessionMessageFailed: false },
|
|
1151
|
+
{ ...(picked?.session.key === sel.session.key ? picked : sel), place });
|
|
1152
|
+
} catch {
|
|
1153
|
+
joining = false;
|
|
1154
|
+
return derive({ ...(svc.getState() as S), sessionMessage: copyParam("agenda.waitlistFailed", "Couldn't join the waitlist. Try again."), sessionMessageOk: false, sessionMessageFailed: true });
|
|
1155
|
+
}
|
|
1156
|
+
},
|
|
1157
|
+
openDirections: async (s, arg) => {
|
|
1158
|
+
const url = s.locations[tappedIndex(arg)]?.directionsUrl ?? "";
|
|
1159
|
+
if (!url) return s;
|
|
1160
|
+
try {
|
|
1161
|
+
await open(url, defaultMode(url));
|
|
1162
|
+
} catch {
|
|
1163
|
+
// Nothing took the link; the screen stays as it was.
|
|
1164
|
+
}
|
|
1165
|
+
return s;
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
});
|
|
1169
|
+
svc.load();
|
|
1170
|
+
export { svc };
|