@wix/himalaya-cli 0.1521.0 → 0.1523.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +18 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +13 -0
- package/dist/cli.mjs +2 -3
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +862 -35
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/branded-lite/dev/index.ts +862 -35
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/brandy/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/community-club/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/gym-fitness/dev/index.ts +862 -35
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/restaurant/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/retail-store/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/yoga-studio/dev/index.ts +857 -30
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +69 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +9 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +237 -17
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +277 -55
- package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +9 -0
|
@@ -65,3 +65,72 @@ export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone
|
|
|
65
65
|
}
|
|
66
66
|
return "";
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// ---- Courses ----------------------------------------------------------------------
|
|
70
|
+
// One answer for the service screen's course button and the time screen's course booking, so
|
|
71
|
+
// the button never offers a course that the booking step then refuses.
|
|
72
|
+
|
|
73
|
+
type CoursePolicyShape = {
|
|
74
|
+
schedule?: { id?: string; firstSessionStart?: string; lastSessionEnd?: string };
|
|
75
|
+
bookingPolicy?: {
|
|
76
|
+
bookAfterStartPolicy?: { enabled?: boolean };
|
|
77
|
+
limitEarlyBookingPolicy?: { enabled?: boolean; earliestBookingInMinutes?: number };
|
|
78
|
+
limitLateBookingPolicy?: { enabled?: boolean; latestBookingInMinutes?: number };
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
|
|
83
|
+
* started (unless the policy lets people join late), and `now` is inside its booking window —
|
|
84
|
+
* which the early and late limits count from the first session. */
|
|
85
|
+
export function courseWindowOpen(service: CoursePolicyShape, now: number): boolean {
|
|
86
|
+
const first = Date.parse(service.schedule?.firstSessionStart ?? "");
|
|
87
|
+
const last = Date.parse(service.schedule?.lastSessionEnd ?? "");
|
|
88
|
+
if (!Number.isFinite(first) || !Number.isFinite(last) || last <= now) return false;
|
|
89
|
+
const policy = service.bookingPolicy;
|
|
90
|
+
if (first < now && policy?.bookAfterStartPolicy?.enabled !== true) return false;
|
|
91
|
+
const early = policy?.limitEarlyBookingPolicy;
|
|
92
|
+
if (early?.enabled && typeof early.earliestBookingInMinutes === "number" && now < first - early.earliestBookingInMinutes * 60_000) return false;
|
|
93
|
+
const late = policy?.limitLateBookingPolicy;
|
|
94
|
+
if (late?.enabled && typeof late.latestBookingInMinutes === "number" && now > first - late.latestBookingInMinutes * 60_000) return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** How far ahead a course is read: the whole remaining course, up to a year. Both screens read the
|
|
99
|
+
* same sessions — from the later of its first session and now — so the service screen never
|
|
100
|
+
* offers a course that the booking step then refuses on a session the first did not read. */
|
|
101
|
+
export const COURSE_READ_DAYS = 366;
|
|
102
|
+
const DAY = 86_400_000;
|
|
103
|
+
|
|
104
|
+
/** The 14-day availability windows covering a course's remaining sessions, and whether they reach
|
|
105
|
+
* its last session (`complete`) and start at its first (`fromFirst`, so a count is the whole
|
|
106
|
+
* course). Empty when the course has no usable dates or has ended. */
|
|
107
|
+
export function courseReadWindows(service: CoursePolicyShape, now: number, chunkDays = 14): { windows: Array<[number, number]>; complete: boolean; fromFirst: boolean } {
|
|
108
|
+
const first = Date.parse(service.schedule?.firstSessionStart ?? "");
|
|
109
|
+
const last = Date.parse(service.schedule?.lastSessionEnd ?? "");
|
|
110
|
+
if (!Number.isFinite(first) || !Number.isFinite(last)) return { windows: [], complete: false, fromFirst: false };
|
|
111
|
+
const from = Math.max(first, now);
|
|
112
|
+
const courseEnd = last + DAY;
|
|
113
|
+
const until = Math.min(courseEnd, from + COURSE_READ_DAYS * DAY);
|
|
114
|
+
const windows: Array<[number, number]> = [];
|
|
115
|
+
for (let at = from; at < until; at += chunkDays * DAY) windows.push([at, Math.min(until, at + chunkDays * DAY)]);
|
|
116
|
+
return { windows, complete: courseEnd <= until, fromFirst: from === first };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type CourseEntryShape = { slot?: { startDate?: string; timezone?: unknown }; bookable?: boolean; openSpots?: number };
|
|
120
|
+
|
|
121
|
+
/** A course across EVERY upcoming session read: a customer books all of them at once, so the
|
|
122
|
+
* smallest open count is the course's, and one session that cannot be booked closes it. A start
|
|
123
|
+
* is read in the session's own zone (`slotStartInstant`). `found` is false when no upcoming
|
|
124
|
+
* session came back: both screens then treat the course as not bookable. So is a course whose
|
|
125
|
+
* read did not reach its last session (`readComplete`, from `courseReadWindows`): a session
|
|
126
|
+
* past the year read may be full, and the course is booked whole. */
|
|
127
|
+
export function courseSessionsAvailability(entries: readonly CourseEntryShape[], now: number, readComplete = true): { found: boolean; spots: number | undefined; bookable: boolean } {
|
|
128
|
+
const upcoming = entries.filter((entry) => {
|
|
129
|
+
const start = entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
|
|
130
|
+
return Number.isFinite(start) && start >= now;
|
|
131
|
+
});
|
|
132
|
+
if (!upcoming.length) return { found: false, spots: undefined, bookable: false };
|
|
133
|
+
const counts = upcoming.map((entry) => entry.openSpots).filter((n): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0);
|
|
134
|
+
const spots = counts.length ? Math.min(...counts) : undefined;
|
|
135
|
+
return { found: true, spots, bookable: readComplete && upcoming.every((entry) => entry.bookable !== false) && spots !== 0 };
|
|
136
|
+
}
|
|
@@ -54,3 +54,12 @@ export function copyTemplate(key: string, fallback: string, values: Record<strin
|
|
|
54
54
|
name in values ? String(values[name]) : "",
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/** The small ICU plural form used by Bookings spot counts. The provisioned copy may
|
|
59
|
+
* translate both branches without adding a second key for the singular. */
|
|
60
|
+
export function copyPlural(key: string, fallback: string, count: number): string {
|
|
61
|
+
const pattern = copyParam(key, fallback);
|
|
62
|
+
const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
|
|
63
|
+
if (!match) return copyTemplate(key, fallback, { n: count });
|
|
64
|
+
return (count === 1 ? match[1] : match[2])!.replace(/#/g, String(count));
|
|
65
|
+
}
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import { defineService, navigate } from "@himalaya/state";
|
|
3
3
|
import { share as shareSheet } from "@himalaya/share";
|
|
4
4
|
import { open, defaultMode } from "@himalaya/open-url";
|
|
5
|
-
import { getService, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
|
|
6
|
-
import { categoryName } from "../_shared/himi-bookings.js";
|
|
5
|
+
import { getService, queryServiceAvailability, type WixAvailabilityEntry, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
|
|
6
|
+
import { categoryName, courseReadWindows, courseSessionsAvailability, courseWindowOpen, slotStartInstant } from "../_shared/himi-bookings.js";
|
|
7
7
|
import { formatServicePrice, formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
|
|
8
|
-
import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
8
|
+
import { stashCopy, copyParam, copyTemplate, copyPlural } from "../_shared/himi-copy.js";
|
|
9
9
|
import { get, type DbEntity } from "@himalaya/local-db";
|
|
10
10
|
import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
|
|
11
|
+
import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
|
|
11
12
|
|
|
12
13
|
// Offline-first (data-offline): the service detail is a public read-through. On a
|
|
13
14
|
// fetch failure we fall back to the last-synced row for this service written by
|
|
@@ -35,6 +36,7 @@ interface LocationRow {
|
|
|
35
36
|
a11y: string;
|
|
36
37
|
}
|
|
37
38
|
interface PolicyLine { text: string; owner: boolean; notOwner: boolean }
|
|
39
|
+
interface CourseSession { date: string; time: string }
|
|
38
40
|
|
|
39
41
|
interface S {
|
|
40
42
|
status: "loading" | "ready";
|
|
@@ -43,6 +45,11 @@ interface S {
|
|
|
43
45
|
/** The category name, shown as the small label above the name. */
|
|
44
46
|
category: string;
|
|
45
47
|
noCategory: boolean;
|
|
48
|
+
typeTag: string;
|
|
49
|
+
noTypeTag: boolean;
|
|
50
|
+
isClass: boolean;
|
|
51
|
+
isCourse: boolean;
|
|
52
|
+
noCourse: boolean;
|
|
46
53
|
tagline: string;
|
|
47
54
|
noTagline: boolean;
|
|
48
55
|
/** The price line: "₪90" + "per session", "From ₪60", "Free", or a CUSTOM description. */
|
|
@@ -68,6 +75,30 @@ interface S {
|
|
|
68
75
|
noImage: boolean;
|
|
69
76
|
durationText: string;
|
|
70
77
|
noDuration: boolean;
|
|
78
|
+
courseDates: string;
|
|
79
|
+
courseSessionCount: string;
|
|
80
|
+
noCourseDates: boolean;
|
|
81
|
+
spotsText: string;
|
|
82
|
+
noSpots: boolean;
|
|
83
|
+
spotsWarning: boolean;
|
|
84
|
+
spotsNeutral: boolean;
|
|
85
|
+
hideSpotsNeutral: boolean;
|
|
86
|
+
courseSessions: CourseSession[];
|
|
87
|
+
noCourseSessions: boolean;
|
|
88
|
+
courseMoreSessions: CourseSession[];
|
|
89
|
+
noCourseMoreSessions: boolean;
|
|
90
|
+
scheduleExpanded: boolean;
|
|
91
|
+
scheduleCollapsed: boolean;
|
|
92
|
+
courseCtaBook: boolean;
|
|
93
|
+
courseCtaRequest: boolean;
|
|
94
|
+
courseCtaFull: boolean;
|
|
95
|
+
courseCtaClosed: boolean;
|
|
96
|
+
hideChooseTime: boolean;
|
|
97
|
+
hideRequestTime: boolean;
|
|
98
|
+
noCourseCtaBook: boolean;
|
|
99
|
+
noCourseCtaRequest: boolean;
|
|
100
|
+
noCourseCtaFull: boolean;
|
|
101
|
+
noCourseCtaClosed: boolean;
|
|
71
102
|
locations: LocationRow[];
|
|
72
103
|
noLocations: boolean;
|
|
73
104
|
online: boolean;
|
|
@@ -146,7 +177,9 @@ function priceLine(service: WixService): PriceLine {
|
|
|
146
177
|
const payment = service.payment ?? {};
|
|
147
178
|
const rate = stringOf(payment.rateType).toUpperCase();
|
|
148
179
|
const currency = serviceCurrency(service);
|
|
149
|
-
const unit =
|
|
180
|
+
const unit = service.type === "COURSE"
|
|
181
|
+
? copyParam("service.unitParticipant", "per participant")
|
|
182
|
+
: copyParam("service.unitSession", "per session");
|
|
150
183
|
if (rate === "NO_FEE") return { text: copyParam("service.priceFree", "Free"), unit: "", isText: false };
|
|
151
184
|
if (rate === "CUSTOM") return { text: stringOf(payment.custom?.description), unit: "", isText: true };
|
|
152
185
|
if (rate === "VARIED") {
|
|
@@ -363,10 +396,14 @@ function policyText(text: string): string {
|
|
|
363
396
|
|
|
364
397
|
/** The lines of "Our policy", from the business's own booking policy. The app never
|
|
365
398
|
* invents policy text: each line exists only when the policy states it. */
|
|
366
|
-
function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[] {
|
|
399
|
+
function policyLines(policy: WixServiceBookingPolicy | undefined, type: string): PolicyLine[] {
|
|
367
400
|
if (!policy) return [];
|
|
368
401
|
const lines: PolicyLine[] = [];
|
|
369
402
|
const add = (text: string, owner = false) => { if (text) lines.push({ text, owner, notOwner: !owner }); };
|
|
403
|
+
const templated = (key: string, fallback: string, values: Record<string, string | number>) =>
|
|
404
|
+
type === "COURSE" && fallback.includes("session starts")
|
|
405
|
+
? copyTemplate(`${key}Course`, fallback.replace("session starts", "course starts"), values)
|
|
406
|
+
: copyTemplate(key, fallback, values);
|
|
370
407
|
|
|
371
408
|
const custom = policy.customPolicyDescription;
|
|
372
409
|
if (custom?.enabled) add(policyText(stringOf(custom.description)), true);
|
|
@@ -374,14 +411,14 @@ function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[]
|
|
|
374
411
|
const cancel = changeRule(policy.cancellationPolicy?.enabled, policy.cancellationPolicy?.limitLatestCancellation, numberOf(policy.cancellationPolicy?.latestCancellationInMinutes));
|
|
375
412
|
const reschedule = changeRule(policy.reschedulePolicy?.enabled, policy.reschedulePolicy?.limitLatestReschedule, numberOf(policy.reschedulePolicy?.latestRescheduleInMinutes));
|
|
376
413
|
if (cancel.kind === "until" && reschedule.kind === "until" && cancel.minutes === reschedule.minutes) {
|
|
377
|
-
add(
|
|
414
|
+
add(templated("service.changeUntil", "Cancel or reschedule online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
|
|
378
415
|
} else if (cancel.kind === "anytime" && reschedule.kind === "anytime") {
|
|
379
416
|
add(copyParam("service.changeAnytime", "You can cancel or reschedule online at any time."));
|
|
380
417
|
} else {
|
|
381
|
-
if (cancel.kind === "until") add(
|
|
418
|
+
if (cancel.kind === "until") add(templated("service.cancelUntil", "Cancel online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
|
|
382
419
|
if (cancel.kind === "anytime") add(copyParam("service.cancelAnytime", "You can cancel online at any time."));
|
|
383
420
|
if (cancel.kind === "off") add(copyParam("service.cancelNotOnline", "You can't cancel online. Contact us to cancel."));
|
|
384
|
-
if (reschedule.kind === "until") add(
|
|
421
|
+
if (reschedule.kind === "until") add(templated("service.rescheduleUntil", "Reschedule online up to {window} before the session starts.", { window: windowText(reschedule.minutes) }));
|
|
385
422
|
if (reschedule.kind === "anytime") add(copyParam("service.rescheduleAnytime", "You can reschedule online at any time."));
|
|
386
423
|
if (reschedule.kind === "off") add(copyParam("service.rescheduleNotOnline", "You can't reschedule online."));
|
|
387
424
|
}
|
|
@@ -395,22 +432,24 @@ function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[]
|
|
|
395
432
|
const start = numberOf(window?.startInMinutes);
|
|
396
433
|
if (start == null || start <= 0) add(copyTemplate("service.feeLate", "Late cancellations have a {fee} fee.", { fee }));
|
|
397
434
|
else if (cancel.kind === "until" && cancel.minutes === start) add(copyTemplate("service.feeAfterThat", "Cancellations after that have a {fee} fee.", { fee }));
|
|
398
|
-
else add(
|
|
435
|
+
else add(templated("service.feeWindow", "Cancellations less than {window} before the session starts have a {fee} fee.", { window: windowText(start), fee }));
|
|
399
436
|
}
|
|
400
437
|
}
|
|
401
438
|
|
|
402
439
|
const early = policy.limitEarlyBookingPolicy;
|
|
403
440
|
const earliest = numberOf(early?.earliestBookingInMinutes);
|
|
404
|
-
if (early?.enabled && earliest != null && earliest > 0) add(
|
|
441
|
+
if (early?.enabled && earliest != null && earliest > 0) add(templated("service.bookingsOpen", "Bookings open {window} before the session starts.", { window: windowText(earliest) }));
|
|
405
442
|
const late = policy.limitLateBookingPolicy;
|
|
406
443
|
const latest = numberOf(late?.latestBookingInMinutes);
|
|
407
|
-
if (late?.enabled && latest != null && latest > 0) add(
|
|
444
|
+
if (late?.enabled && latest != null && latest > 0) add(templated("service.bookingsClose", "Bookings close {window} before the session starts.", { window: windowText(latest) }));
|
|
408
445
|
|
|
409
446
|
// Live payloads also carry `participantsPolicy.enabled` (not in the V2 types); a disabled
|
|
410
447
|
// policy keeps its configured maximum, which is not a rule the customer is held to.
|
|
411
448
|
const participants = policy.participantsPolicy as { enabled?: boolean; maxParticipantsPerBooking?: number } | undefined;
|
|
412
449
|
const max = numberOf(participants?.maxParticipantsPerBooking);
|
|
413
450
|
if (participants?.enabled !== false && max != null && max > 1) add(copyTemplate("service.maxParticipants", "Book for up to {n} participants.", { n: max }));
|
|
451
|
+
if (type === "CLASS" && policy.waitlistPolicy?.enabled) add(copyParam("service.waitlistRule", "When a class is full, you can join the waitlist."));
|
|
452
|
+
if (type === "COURSE" && policy.bookAfterStartPolicy?.enabled) add(copyParam("service.joinAfterStart", "You can join after the course starts."));
|
|
414
453
|
return lines;
|
|
415
454
|
}
|
|
416
455
|
|
|
@@ -442,6 +481,120 @@ function plain(text: string | undefined): string {
|
|
|
442
481
|
.trim();
|
|
443
482
|
}
|
|
444
483
|
|
|
484
|
+
// ---- Class and course ------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
const DAY_MS = 86_400_000;
|
|
487
|
+
const GROUP_WINDOW_DAYS = 14;
|
|
488
|
+
|
|
489
|
+
function validDate(value: string | undefined): Date | undefined {
|
|
490
|
+
if (!value) return undefined;
|
|
491
|
+
const date = new Date(value);
|
|
492
|
+
return Number.isFinite(date.getTime()) ? date : undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function shortDate(date: Date, year: boolean): string {
|
|
496
|
+
return year ? formatDateMedium(date, "en") : formatMonthDay(date, "en");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function courseDates(service: WixService): string {
|
|
500
|
+
const first = validDate(service.schedule?.firstSessionStart);
|
|
501
|
+
const last = validDate(service.schedule?.lastSessionEnd);
|
|
502
|
+
if (!first || !last) return "";
|
|
503
|
+
return `${shortDate(first, first.getFullYear() !== last.getFullYear())} – ${shortDate(last, true)}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function spotCount(entry: WixAvailabilityEntry | undefined): number | undefined {
|
|
507
|
+
const count = numberOf(entry?.openSpots);
|
|
508
|
+
return count != null && count >= 0 ? count : undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function spotText(count: number): string {
|
|
512
|
+
return copyPlural("service.spotsLeft", "{n, plural, one {# spot left} other {# spots left}}", count);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function sessionTime(start: string, end: string | undefined): string {
|
|
516
|
+
const from = validDate(start);
|
|
517
|
+
const to = validDate(end);
|
|
518
|
+
if (!from) return "";
|
|
519
|
+
return to ? `${formatClock(from, "en")} – ${formatClock(to, "en")}` : formatClock(from, "en");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** V1 availability is the same visitor-readable call used by the time screen. Ask
|
|
523
|
+
* for full entries too, since `bookable: true` hides the full sessions whose
|
|
524
|
+
* zero capacity and waitlist state the design needs. Bound every request to 14
|
|
525
|
+
* days; a course reads at most 84 days and never calls its partial count exact. */
|
|
526
|
+
async function groupAvailability(service: WixService, now: Date): Promise<{ entries: WixAvailabilityEntry[]; complete: boolean; readToEnd: boolean }> {
|
|
527
|
+
if (service.type === "CLASS") {
|
|
528
|
+
const res = await queryServiceAvailability(service.id, now.toISOString(), new Date(now.getTime() + GROUP_WINDOW_DAYS * DAY_MS).toISOString(), { bookable: false });
|
|
529
|
+
return { entries: res.availabilityEntries ?? [], complete: true, readToEnd: true };
|
|
530
|
+
}
|
|
531
|
+
if (service.type !== "COURSE") return { entries: [], complete: true, readToEnd: true };
|
|
532
|
+
// The same sessions the time screen checks before it books (_shared/himi-bookings.ts). The
|
|
533
|
+
// session count is the whole course only when the read starts at its first session.
|
|
534
|
+
const read = courseReadWindows(service, now.getTime(), GROUP_WINDOW_DAYS);
|
|
535
|
+
const replies = await Promise.all(read.windows.map(([from, to]) =>
|
|
536
|
+
queryServiceAvailability(service.id, new Date(from).toISOString(), new Date(to).toISOString(), { bookable: false })));
|
|
537
|
+
return { entries: replies.flatMap((res) => res.availabilityEntries ?? []), complete: read.complete && read.fromFirst, readToEnd: read.complete };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** When a session starts, read in its own zone (a local wall time beside `timezone`). */
|
|
541
|
+
function sessionStart(entry: WixAvailabilityEntry): number {
|
|
542
|
+
return entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function withGroupAvailability(s: S, service: WixService): Promise<S> {
|
|
546
|
+
if (service.type !== "CLASS" && service.type !== "COURSE") return s;
|
|
547
|
+
const now = new Date();
|
|
548
|
+
try {
|
|
549
|
+
const result = await groupAvailability(service, now);
|
|
550
|
+
const seen = new Set<string>();
|
|
551
|
+
const entries = result.entries.filter((entry) => {
|
|
552
|
+
const slot = entry.slot;
|
|
553
|
+
if (!slot?.startDate) return false;
|
|
554
|
+
const key = String(slot.sessionId ?? slot.startDate);
|
|
555
|
+
if (seen.has(key)) return false;
|
|
556
|
+
seen.add(key);
|
|
557
|
+
return true;
|
|
558
|
+
}).sort((a, b) => sessionStart(a) - sessionStart(b));
|
|
559
|
+
const next = entries.find((entry) => sessionStart(entry) >= now.getTime());
|
|
560
|
+
// A course is booked whole, so its count is the smallest across every upcoming session, and
|
|
561
|
+
// one session that cannot be booked closes it (the rule the time screen books by).
|
|
562
|
+
const course = service.type === "COURSE" ? courseSessionsAvailability(entries, now.getTime(), result.readToEnd) : undefined;
|
|
563
|
+
const count = course ? course.spots : spotCount(next);
|
|
564
|
+
// No upcoming session read back: the time screen refuses it, so the button is closed too.
|
|
565
|
+
const courseClosed = s.courseCtaClosed || (course != null && (!course.found || (!course.bookable && count !== 0)));
|
|
566
|
+
const waitlist = service.type === "CLASS" && service.bookingPolicy?.waitlistPolicy?.enabled === true &&
|
|
567
|
+
(numberOf(next?.waitingList?.openSpots) ?? 0) > 0;
|
|
568
|
+
const spotsText = count == null ? "" : count > 0 ? spotText(count)
|
|
569
|
+
: service.type === "CLASS"
|
|
570
|
+
? waitlist
|
|
571
|
+
? copyParam("service.classFullWaitlist", "The class is fully booked. You can join the waitlist.")
|
|
572
|
+
: copyParam("service.classFull", "The class is fully booked.")
|
|
573
|
+
: "";
|
|
574
|
+
const courseSessions = service.type === "COURSE" ? entries.map((entry) => ({
|
|
575
|
+
date: shortDate(validDate(entry.slot!.startDate)!, true),
|
|
576
|
+
time: sessionTime(entry.slot!.startDate!, entry.slot!.endDate),
|
|
577
|
+
})) : [];
|
|
578
|
+
return derive({
|
|
579
|
+
...s,
|
|
580
|
+
spotsText,
|
|
581
|
+
spotsWarning: count != null && count > 0 && count <= 3,
|
|
582
|
+
courseCtaClosed: courseClosed,
|
|
583
|
+
courseCtaFull: service.type === "COURSE" && count === 0 && !courseClosed,
|
|
584
|
+
courseCtaBook: service.type === "COURSE" && count !== 0 && !s.requestToBook && !courseClosed,
|
|
585
|
+
courseCtaRequest: service.type === "COURSE" && count !== 0 && s.requestToBook && !courseClosed,
|
|
586
|
+
courseSessionCount: service.type === "COURSE" && result.complete && courseSessions.length > 0
|
|
587
|
+
? copyPlural("service.sessionsCount", "{n, plural, one {# session} other {# sessions}}", courseSessions.length) : "",
|
|
588
|
+
courseSessions: courseSessions.slice(0, 3),
|
|
589
|
+
courseMoreSessions: courseSessions.slice(3),
|
|
590
|
+
});
|
|
591
|
+
} catch {
|
|
592
|
+
// The record still loads if the availability endpoint is unavailable. Unknown
|
|
593
|
+
// capacity stays unknown; the customer can inspect availability at booking.
|
|
594
|
+
return s;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
445
598
|
// ---- State -----------------------------------------------------------------------
|
|
446
599
|
|
|
447
600
|
function derive(s: S): S {
|
|
@@ -456,6 +609,8 @@ function derive(s: S): S {
|
|
|
456
609
|
hasService,
|
|
457
610
|
noEmpty: isLoading || s.errored || hasService,
|
|
458
611
|
noCategory: !s.category,
|
|
612
|
+
noTypeTag: !s.typeTag,
|
|
613
|
+
noCourse: !s.isCourse,
|
|
459
614
|
noTagline: !s.tagline,
|
|
460
615
|
noPrice: !s.priceText,
|
|
461
616
|
noPriceUnit: !s.priceUnit,
|
|
@@ -472,6 +627,13 @@ function derive(s: S): S {
|
|
|
472
627
|
hasImage,
|
|
473
628
|
noImage: !hasImage,
|
|
474
629
|
noDuration: !s.durationText,
|
|
630
|
+
noCourseDates: !s.courseDates,
|
|
631
|
+
noSpots: !s.spotsText,
|
|
632
|
+
spotsNeutral: !s.spotsWarning,
|
|
633
|
+
hideSpotsNeutral: !s.spotsText || s.spotsWarning,
|
|
634
|
+
noCourseMoreSessions: s.courseMoreSessions.length === 0,
|
|
635
|
+
noCourseSessions: s.courseSessions.length === 0,
|
|
636
|
+
scheduleCollapsed: !s.scheduleExpanded,
|
|
475
637
|
noLocations: s.locations.length === 0,
|
|
476
638
|
noOnline: !s.online,
|
|
477
639
|
noStaff: !s.staffText,
|
|
@@ -480,11 +642,17 @@ function derive(s: S): S {
|
|
|
480
642
|
noPay: !s.payText,
|
|
481
643
|
noPaySub: !s.paySub,
|
|
482
644
|
notRequestToBook: !s.requestToBook,
|
|
645
|
+
hideChooseTime: s.isCourse || s.requestToBook,
|
|
646
|
+
hideRequestTime: s.isCourse || !s.requestToBook,
|
|
647
|
+
noCourseCtaBook: !s.courseCtaBook,
|
|
648
|
+
noCourseCtaRequest: !s.courseCtaRequest,
|
|
649
|
+
noCourseCtaFull: !s.courseCtaFull,
|
|
650
|
+
noCourseCtaClosed: !s.courseCtaClosed,
|
|
483
651
|
notBookableOnline: !s.bookableOnline,
|
|
484
652
|
noAbout: !s.about,
|
|
485
653
|
noPolicy: s.policyLines.length === 0,
|
|
486
654
|
policyCollapsed: !s.policyExpanded,
|
|
487
|
-
noFacts: !s.durationText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
|
|
655
|
+
noFacts: !s.courseDates && !s.durationText && !s.spotsText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
|
|
488
656
|
noKeyFacts: !s.priceText && !s.durationText && !s.category,
|
|
489
657
|
hideContent: isLoading || s.errored || !hasService,
|
|
490
658
|
hideFooter: isLoading || s.errored || !hasService || !s.bookableOnline,
|
|
@@ -500,6 +668,11 @@ function withService(s: S, service: WixService): S {
|
|
|
500
668
|
const price = priceLine(service);
|
|
501
669
|
const staff = staffLine(service);
|
|
502
670
|
const pay = payLine(service);
|
|
671
|
+
const isCourse = service.type === "COURSE";
|
|
672
|
+
const isClass = service.type === "CLASS";
|
|
673
|
+
// Ended, started without late joining, or outside the booking window: the same rule the time
|
|
674
|
+
// screen applies before it books (_shared/himi-bookings.ts).
|
|
675
|
+
const courseCtaClosed = isCourse && !courseWindowOpen(service, Date.now());
|
|
503
676
|
return derive({
|
|
504
677
|
...s,
|
|
505
678
|
status: "ready",
|
|
@@ -509,12 +682,28 @@ function withService(s: S, service: WixService): S {
|
|
|
509
682
|
// Live payloads carry `category` as a structured object — extract the name
|
|
510
683
|
// (the list worker joins the same field; see _shared/himi-bookings.ts).
|
|
511
684
|
category: categoryName(service.category),
|
|
685
|
+
typeTag: isClass ? copyParam("service.tagClass", "Class") : isCourse ? copyParam("service.tagCourse", "Course") : "",
|
|
686
|
+
isClass,
|
|
687
|
+
isCourse,
|
|
512
688
|
tagline: stringOf(service.tagLine),
|
|
513
689
|
priceText: price.text,
|
|
514
690
|
priceUnit: price.unit,
|
|
515
691
|
priceIsText: price.isText,
|
|
516
692
|
images: serviceImages(service, name),
|
|
517
|
-
durationText: durationText(service)
|
|
693
|
+
durationText: isCourse && durationText(service)
|
|
694
|
+
? copyTemplate("service.durationEach", "{duration} each", { duration: durationText(service) })
|
|
695
|
+
: durationText(service),
|
|
696
|
+
courseDates: isCourse ? courseDates(service) : "",
|
|
697
|
+
courseSessionCount: "",
|
|
698
|
+
courseSessions: [],
|
|
699
|
+
courseMoreSessions: [],
|
|
700
|
+
spotsText: "",
|
|
701
|
+
spotsWarning: false,
|
|
702
|
+
scheduleExpanded: false,
|
|
703
|
+
courseCtaClosed,
|
|
704
|
+
courseCtaFull: false,
|
|
705
|
+
courseCtaBook: isCourse && !courseCtaClosed && service.onlineBooking?.requireManualApproval !== true,
|
|
706
|
+
courseCtaRequest: isCourse && !courseCtaClosed && service.onlineBooking?.requireManualApproval === true,
|
|
518
707
|
locations: locationRows(service),
|
|
519
708
|
online: service.conferencing?.enabled === true,
|
|
520
709
|
staffText: staff.text,
|
|
@@ -525,7 +714,7 @@ function withService(s: S, service: WixService): S {
|
|
|
525
714
|
requestToBook: service.onlineBooking?.requireManualApproval === true,
|
|
526
715
|
bookableOnline: service.onlineBooking?.enabled !== false,
|
|
527
716
|
about: plain(service.description),
|
|
528
|
-
policyLines: policyLines(service.bookingPolicy),
|
|
717
|
+
policyLines: policyLines(service.bookingPolicy, stringOf(service.type).toUpperCase()),
|
|
529
718
|
shareUrl: serviceShareUrl(service),
|
|
530
719
|
// WS-C — canonical param-less target; the descriptor's choose-time CTA
|
|
531
720
|
// binds the separate `id` state field as a first-class nav param.
|
|
@@ -554,6 +743,7 @@ const svc = defineService<S, {
|
|
|
554
743
|
load: (s: S, arg?: unknown) => Promise<S>;
|
|
555
744
|
share: (s: S) => Promise<S>;
|
|
556
745
|
togglePolicy: (s: S) => S;
|
|
746
|
+
toggleSchedule: (s: S) => S;
|
|
557
747
|
openDirections: (s: S, arg?: unknown) => Promise<S>;
|
|
558
748
|
chooseTime: (s: S) => S;
|
|
559
749
|
}>("himiBookingService", {
|
|
@@ -563,6 +753,11 @@ const svc = defineService<S, {
|
|
|
563
753
|
name: "",
|
|
564
754
|
category: "",
|
|
565
755
|
noCategory: true,
|
|
756
|
+
typeTag: "",
|
|
757
|
+
noTypeTag: true,
|
|
758
|
+
isClass: false,
|
|
759
|
+
isCourse: false,
|
|
760
|
+
noCourse: true,
|
|
566
761
|
tagline: "",
|
|
567
762
|
noTagline: true,
|
|
568
763
|
priceText: "",
|
|
@@ -583,6 +778,30 @@ const svc = defineService<S, {
|
|
|
583
778
|
noImage: true,
|
|
584
779
|
durationText: "",
|
|
585
780
|
noDuration: true,
|
|
781
|
+
courseDates: "",
|
|
782
|
+
courseSessionCount: "",
|
|
783
|
+
noCourseDates: true,
|
|
784
|
+
spotsText: "",
|
|
785
|
+
noSpots: true,
|
|
786
|
+
spotsWarning: false,
|
|
787
|
+
spotsNeutral: true,
|
|
788
|
+
hideSpotsNeutral: true,
|
|
789
|
+
courseSessions: [],
|
|
790
|
+
noCourseSessions: true,
|
|
791
|
+
courseMoreSessions: [],
|
|
792
|
+
noCourseMoreSessions: true,
|
|
793
|
+
scheduleExpanded: false,
|
|
794
|
+
scheduleCollapsed: true,
|
|
795
|
+
courseCtaBook: false,
|
|
796
|
+
courseCtaRequest: false,
|
|
797
|
+
courseCtaFull: false,
|
|
798
|
+
courseCtaClosed: false,
|
|
799
|
+
hideChooseTime: false,
|
|
800
|
+
hideRequestTime: true,
|
|
801
|
+
noCourseCtaBook: true,
|
|
802
|
+
noCourseCtaRequest: true,
|
|
803
|
+
noCourseCtaFull: true,
|
|
804
|
+
noCourseCtaClosed: true,
|
|
586
805
|
locations: [],
|
|
587
806
|
noLocations: true,
|
|
588
807
|
online: false,
|
|
@@ -625,10 +844,10 @@ const svc = defineService<S, {
|
|
|
625
844
|
load: async (s, arg) => {
|
|
626
845
|
stashCopy(arg);
|
|
627
846
|
if (!s.id) return derive({ ...s, status: "ready", errored: true });
|
|
628
|
-
const start = { ...s, policyExpanded: false };
|
|
847
|
+
const start = { ...s, policyExpanded: false, scheduleExpanded: false };
|
|
629
848
|
try {
|
|
630
849
|
const service = await fetchService(s.id);
|
|
631
|
-
return service ? withService(start, service) : derive({ ...start, status: "ready" });
|
|
850
|
+
return service ? withGroupAvailability(withService(start, service), service) : derive({ ...start, status: "ready" });
|
|
632
851
|
} catch {
|
|
633
852
|
// Offline: paint from the last-synced services cache (written by the list
|
|
634
853
|
// worker) before falling back to the error state. The cache holds the name,
|
|
@@ -662,10 +881,11 @@ const svc = defineService<S, {
|
|
|
662
881
|
}
|
|
663
882
|
},
|
|
664
883
|
togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
|
|
884
|
+
toggleSchedule: (s) => derive({ ...s, scheduleExpanded: !s.scheduleExpanded }),
|
|
665
885
|
// The footer button opens the time screen for the loaded service. A worker directive,
|
|
666
886
|
// not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
|
|
667
887
|
// dispatch their taps without resolving `$service:` params, so the id never arrived.
|
|
668
|
-
chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id } }) : s),
|
|
888
|
+
chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id, ...(s.isCourse ? { course: "true" } : {}) } }) : s),
|
|
669
889
|
openDirections: async (s, arg) => {
|
|
670
890
|
const url = s.locations[tappedIndex(arg)]?.directionsUrl ?? "";
|
|
671
891
|
if (!url) return s;
|