@wix/himalaya-cli 0.1614.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/cli.mjs +1 -0
- 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/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/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/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/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/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/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/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/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- 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/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/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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Saved (favorite) booking services use the host's durable, app-scoped KV, shared by every
|
|
2
|
+
// screen worker — the service detail toggles a row (himi-booking-service `toggleSaved`), the
|
|
3
|
+
// Favorites list reads them (himi-booking-saved). Same model as _shared/himi-store-saved.ts: a
|
|
4
|
+
// failed write must never claim that a save succeeded.
|
|
5
|
+
export interface SavedService {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
/** The price line as the detail showed it ("$250"), kept so the list paints offline. */
|
|
9
|
+
price: string;
|
|
10
|
+
/** The duration line ("1 hr"). */
|
|
11
|
+
duration: string;
|
|
12
|
+
image: string;
|
|
13
|
+
category: string;
|
|
14
|
+
/** A course: the list's Book passes `course` to the time screen, which then refuses a course
|
|
15
|
+
* whose record failed to load instead of offering its sessions one by one. */
|
|
16
|
+
course?: boolean;
|
|
17
|
+
}
|
|
18
|
+
type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
|
|
19
|
+
type Host = { himalaya?: { storage?: StorageLike; input?: Record<string, unknown> }; localStorage?: StorageLike };
|
|
20
|
+
const BASE = "himi-booking:saved-services:v1";
|
|
21
|
+
function storage(): StorageLike {
|
|
22
|
+
const host = globalThis as Host;
|
|
23
|
+
const kv = host.himalaya?.storage ?? host.localStorage;
|
|
24
|
+
if (!kv) throw new Error("Saved-service storage is unavailable");
|
|
25
|
+
return kv;
|
|
26
|
+
}
|
|
27
|
+
const validPart = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && value !== "default";
|
|
28
|
+
/** Saves belong to whoever made them. A signed-in member's live under the host's authenticated
|
|
29
|
+
* account + business — the partition collection preferences use, so a second member on the same
|
|
30
|
+
* device never sees them; a visitor's live on the device. A member's partition, read for the
|
|
31
|
+
* first time, takes the visitor's saves on this device (signing in carries them, like a cart),
|
|
32
|
+
* once — the device list is then emptied. */
|
|
33
|
+
function storeKey(kv: StorageLike): string {
|
|
34
|
+
const input = (globalThis as Host).himalaya?.input;
|
|
35
|
+
const account = input?.__himiPreferenceAccount, business = input?.__himiPreferenceBusiness;
|
|
36
|
+
if (!validPart(account) || !validPart(business)) return BASE;
|
|
37
|
+
const key = `${BASE}.${account}.${encodeURIComponent(business)}`;
|
|
38
|
+
if (kv.getItem(key) === null) {
|
|
39
|
+
const visitor = kv.getItem(BASE);
|
|
40
|
+
if (visitor !== null) {
|
|
41
|
+
kv.setItem(key, visitor);
|
|
42
|
+
// Empty the device list only once the member's copy is confirmed: a web host may swallow a
|
|
43
|
+
// failed write, and clearing then would lose the visitor's saves.
|
|
44
|
+
if (kv.getItem(key) === visitor) kv.setItem(BASE, "[]");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
50
|
+
export function readSavedServices(): SavedService[] {
|
|
51
|
+
const kv = storage();
|
|
52
|
+
const raw = kv.getItem(storeKey(kv));
|
|
53
|
+
if (!raw) return [];
|
|
54
|
+
const parsed: unknown = JSON.parse(raw);
|
|
55
|
+
if (!Array.isArray(parsed)) throw new Error("Invalid saved-service data");
|
|
56
|
+
const rows = new Map<string, SavedService>();
|
|
57
|
+
for (const value of parsed) {
|
|
58
|
+
if (!value || typeof value !== "object") continue;
|
|
59
|
+
const row = value as Record<string, unknown>;
|
|
60
|
+
if (typeof row.id !== "string" || !row.id.trim() || typeof row.name !== "string") continue;
|
|
61
|
+
rows.set(row.id, { id: row.id, name: row.name, price: str(row.price), duration: str(row.duration), image: str(row.image), category: str(row.category), ...(row.course === true ? { course: true } : {}) });
|
|
62
|
+
}
|
|
63
|
+
return [...rows.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isServiceSaved(id: string): boolean {
|
|
67
|
+
return readSavedServices().some(row => row.id === id);
|
|
68
|
+
}
|
|
69
|
+
export function writeSavedServices(rows: SavedService[]): void {
|
|
70
|
+
const kv = storage();
|
|
71
|
+
const key = storeKey(kv);
|
|
72
|
+
const value = JSON.stringify(rows);
|
|
73
|
+
kv.setItem(key, value);
|
|
74
|
+
// Web hosts may swallow quota/security errors. Confirm the synchronous write
|
|
75
|
+
// before displaying success; a missing/mismatched read is a failed save.
|
|
76
|
+
if (kv.getItem(key) !== value) throw new Error("Saved-service write did not persist");
|
|
77
|
+
}
|
|
78
|
+
/** Save or unsave; returns the new saved state. The newest save is listed first. */
|
|
79
|
+
export function toggleSavedService(service: SavedService): boolean {
|
|
80
|
+
if (!service.id.trim() || !service.name.trim()) throw new Error("No service to save");
|
|
81
|
+
const rows = readSavedServices();
|
|
82
|
+
const saved = !rows.some(row => row.id === service.id);
|
|
83
|
+
writeSavedServices(saved ? [service, ...rows] : rows.filter(row => row.id !== service.id));
|
|
84
|
+
return saved;
|
|
85
|
+
}
|
|
86
|
+
export function removeSavedService(id: string): void {
|
|
87
|
+
writeSavedServices(readSavedServices().filter(row => row.id !== id));
|
|
88
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Shared Wix Bookings shaping for himi workers (branded-lite `bookings`
|
|
2
|
+
// vertical): defensive extraction where the LIVE services payload drifts from
|
|
3
|
+
// the generated client's types. Used by BOTH the services-list worker
|
|
4
|
+
// (himi_services rows) and the service-detail worker so the two screens agree
|
|
5
|
+
// on one shape (the himi-restaurants.ts pattern).
|
|
6
|
+
|
|
7
|
+
/** Human-readable category name. The live services payload carries `category`
|
|
8
|
+
* as a STRUCTURED object (`{ id, name, sortOrder }`) while the generated
|
|
9
|
+
* client types it as a string — joined raw it rendered
|
|
10
|
+
* "[object Object] · 1 hr · $111" in the services rows (production sweep R3).
|
|
11
|
+
* Extract the name member; an id-only/unrecognizable shape yields "" so the
|
|
12
|
+
* caller omits the segment instead of stringifying the object. */
|
|
13
|
+
export function categoryName(value: unknown): string {
|
|
14
|
+
if (typeof value === "string") return value.trim();
|
|
15
|
+
if (!value || typeof value !== "object") return "";
|
|
16
|
+
const name = (value as { name?: unknown }).name;
|
|
17
|
+
return typeof name === "string" ? name.trim() : "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
import { businessTimeZone } from "./app-locale.js";
|
|
21
|
+
import { formatDateMedium, formatMonthDay, zoneOffsetAt } from "./engine-intl.js";
|
|
22
|
+
import { formatServiceMoney, formatServicePrice, serviceCurrency } from "./wix-service-price.js";
|
|
23
|
+
|
|
24
|
+
const HAS_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
|
25
|
+
const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
|
|
26
|
+
|
|
27
|
+
/** The instant (ms) a slot starts. Wix may list a slot's `startDate` as a LOCAL wall time with its
|
|
28
|
+
* `timezone` beside it ("2026-11-02T18:30:00" in America/New_York is 23:30Z); a bare `Date.parse`
|
|
29
|
+
* would read that in the device's zone. So a local time is placed in the slot's own zone when this
|
|
30
|
+
* engine has Intl and knows the zone, and read in the device's zone only when it cannot be. */
|
|
31
|
+
export function slotStartInstant(slot: { startDate?: string; timezone?: unknown }): number {
|
|
32
|
+
const start = slot.startDate ?? "";
|
|
33
|
+
if (!start) return NaN;
|
|
34
|
+
const local = LOCAL_TIME.exec(start);
|
|
35
|
+
if (HAS_OFFSET.test(start) || !local || typeof slot.timezone !== "string" || !slot.timezone) return Date.parse(start);
|
|
36
|
+
const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
|
|
37
|
+
const offset = zoneOffsetAt(wall, slot.timezone);
|
|
38
|
+
if (!Number.isFinite(offset)) return Date.parse(start);
|
|
39
|
+
// Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
|
|
40
|
+
const again = zoneOffsetAt(wall - offset, slot.timezone);
|
|
41
|
+
return wall - (Number.isFinite(again) ? again : offset);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `himi_time` reads the next two weeks. A host's `start` past them (an Agenda session weeks
|
|
45
|
+
* ahead) is read on its own — that session's local day only, never everything up to it — so it
|
|
46
|
+
* can be preselected while each read stays bounded. `undefined` when the start is inside the two
|
|
47
|
+
* weeks. */
|
|
48
|
+
export function availabilityBeyond(now: Date, start = ""): { from: Date; until: Date } | undefined {
|
|
49
|
+
const end = now.getTime() + 14 * 86_400_000;
|
|
50
|
+
const at = start ? Date.parse(start) : NaN;
|
|
51
|
+
if (!Number.isFinite(at) || at < end) return undefined;
|
|
52
|
+
const d = new Date(at);
|
|
53
|
+
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
54
|
+
// Never back into the two weeks already read: a slot in both would be listed twice.
|
|
55
|
+
return { from: new Date(Math.max(dayStart.getTime(), end)), until: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The slot a host asked to open at (`himi_time` input `start`, an ISO 8601 instant): the id of the
|
|
59
|
+
* slot that starts at that same instant, or "" when none does (taken since, or out of the window).
|
|
60
|
+
* Compared as instants, so `…T16:30:00Z` finds a slot listed as `…T18:30:00+02:00`, or as a local
|
|
61
|
+
* `…T18:30:00` beside `timezone: "Asia/Jerusalem"`. */
|
|
62
|
+
export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone?: unknown }]>, start: string): string {
|
|
63
|
+
const at = start ? Date.parse(start) : NaN;
|
|
64
|
+
if (!Number.isFinite(at)) return "";
|
|
65
|
+
for (const [id, slot] of slots) {
|
|
66
|
+
if (slotStartInstant(slot) === at) return id;
|
|
67
|
+
}
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- Courses ----------------------------------------------------------------------
|
|
72
|
+
// One answer for the service screen's course button and the time screen's course booking, so
|
|
73
|
+
// the button never offers a course that the booking step then refuses.
|
|
74
|
+
|
|
75
|
+
type CoursePolicyShape = {
|
|
76
|
+
/** `timeZone`: the business's IANA zone, for a session time Wix lists as a bare local wall time. */
|
|
77
|
+
schedule?: { id?: string; firstSessionStart?: string; lastSessionEnd?: string; timeZone?: string };
|
|
78
|
+
bookingPolicy?: {
|
|
79
|
+
bookAfterStartPolicy?: { enabled?: boolean };
|
|
80
|
+
limitEarlyBookingPolicy?: { enabled?: boolean; earliestBookingInMinutes?: number };
|
|
81
|
+
limitLateBookingPolicy?: { enabled?: boolean; latestBookingInMinutes?: number };
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** A bare local wall time placed in `timeZone`, as an instant (ms). NaN when the value is not a bare local
|
|
86
|
+
* time, has an impossible calendar day, or the engine does not know the zone. */
|
|
87
|
+
function placedInstant(value: string, timeZone: string | undefined): number {
|
|
88
|
+
const local = LOCAL_TIME.exec(value);
|
|
89
|
+
if (HAS_OFFSET.test(value) || !local || !timeZone || !validDate(value)) return NaN;
|
|
90
|
+
const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
|
|
91
|
+
const offset = zoneOffsetAt(wall, timeZone);
|
|
92
|
+
if (!Number.isFinite(offset)) return NaN;
|
|
93
|
+
// Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
|
|
94
|
+
const again = zoneOffsetAt(wall - offset, timeZone);
|
|
95
|
+
return wall - (Number.isFinite(again) ? again : offset);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The zone that places a course's bare local session times: the schedule's own, else the app's
|
|
99
|
+
* provisioned business zone. "" when there is none: a zone is never guessed. */
|
|
100
|
+
const courseZone = (schedule: { timeZone?: string } | undefined): string => schedule?.timeZone || businessTimeZone();
|
|
101
|
+
|
|
102
|
+
/** The instant (ms) of a course session time, for the "Ended" label. A time with an offset is exact. A
|
|
103
|
+
* bare local wall time is the BUSINESS's wall time, so it is placed in its zone — never in the device's,
|
|
104
|
+
* which would present another zone's clock as a confirmed instant. No zone, a date-only value, an
|
|
105
|
+
* impossible calendar day or an engine that does not know the zone: unknown (NaN). */
|
|
106
|
+
function sessionInstant(value: string, timeZone: string | undefined): number {
|
|
107
|
+
return HAS_OFFSET.test(value) ? validDate(value)?.getTime() ?? NaN : placedInstant(value, timeZone);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The same instant for the booking rules (window, read range): a bare local time the business zone can
|
|
111
|
+
* place is placed there, so the list label, the service screen and the time screen agree. Everything else
|
|
112
|
+
* keeps the read they always had (`Date.parse`), so an app with no provisioned zone is unchanged. */
|
|
113
|
+
function courseInstant(value: string | undefined, timeZone: string | undefined): number {
|
|
114
|
+
const text = value ?? "";
|
|
115
|
+
const placed = placedInstant(text, timeZone);
|
|
116
|
+
return Number.isFinite(placed) ? placed : Date.parse(text);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A completed course, independent of whether bookings are currently allowed. Missing,
|
|
120
|
+
* invalid or reversed dates are unknown, never evidence that a course has ended. A session time
|
|
121
|
+
* without an offset counts only when the schedule names the business's `timeZone`. */
|
|
122
|
+
export function courseHasEnded(service: CoursePolicyShape, now: number): boolean {
|
|
123
|
+
const zone = courseZone(service.schedule);
|
|
124
|
+
const first = sessionInstant(service.schedule?.firstSessionStart ?? "", zone);
|
|
125
|
+
const last = sessionInstant(service.schedule?.lastSessionEnd ?? "", zone);
|
|
126
|
+
return Number.isFinite(now) && Number.isFinite(first) && Number.isFinite(last) && first <= last && last <= now;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Does this course schedule hold a session time that is a bare local wall time, which only the
|
|
130
|
+
* business's zone can place? A caller reads the zone only when this is true. */
|
|
131
|
+
export function courseNeedsZone(schedule: { firstSessionStart?: string; lastSessionEnd?: string } | undefined): boolean {
|
|
132
|
+
return [schedule?.firstSessionStart, schedule?.lastSessionEnd].some((value) => !!value && !HAS_OFFSET.test(value) && LOCAL_TIME.test(value));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
|
|
136
|
+
* started (unless the policy lets people join late), and `now` is inside its booking window —
|
|
137
|
+
* which the early and late limits count from the first session. */
|
|
138
|
+
export function courseWindowOpen(service: CoursePolicyShape, now: number): boolean {
|
|
139
|
+
const zone = courseZone(service.schedule);
|
|
140
|
+
const first = courseInstant(service.schedule?.firstSessionStart, zone);
|
|
141
|
+
const last = courseInstant(service.schedule?.lastSessionEnd, zone);
|
|
142
|
+
if (!Number.isFinite(first) || !Number.isFinite(last) || last <= now) return false;
|
|
143
|
+
const policy = service.bookingPolicy;
|
|
144
|
+
if (first < now && policy?.bookAfterStartPolicy?.enabled !== true) return false;
|
|
145
|
+
const early = policy?.limitEarlyBookingPolicy;
|
|
146
|
+
if (early?.enabled && typeof early.earliestBookingInMinutes === "number" && now < first - early.earliestBookingInMinutes * 60_000) return false;
|
|
147
|
+
const late = policy?.limitLateBookingPolicy;
|
|
148
|
+
if (late?.enabled && typeof late.latestBookingInMinutes === "number" && now > first - late.latestBookingInMinutes * 60_000) return false;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** How far ahead a course is read: the whole remaining course, up to a year. Both screens read the
|
|
153
|
+
* same sessions — from the later of its first session and now — so the service screen never
|
|
154
|
+
* offers a course that the booking step then refuses on a session the first did not read. */
|
|
155
|
+
export const COURSE_READ_DAYS = 366;
|
|
156
|
+
const DAY = 86_400_000;
|
|
157
|
+
|
|
158
|
+
/** The 14-day availability windows covering a course's remaining sessions, and whether they reach
|
|
159
|
+
* its last session (`complete`) and start at its first (`fromFirst`, so a count is the whole
|
|
160
|
+
* course). Empty when the course has no usable dates or has ended. */
|
|
161
|
+
export function courseReadWindows(service: CoursePolicyShape, now: number, chunkDays = 14): { windows: Array<[number, number]>; complete: boolean; fromFirst: boolean } {
|
|
162
|
+
const zone = courseZone(service.schedule);
|
|
163
|
+
const first = courseInstant(service.schedule?.firstSessionStart, zone);
|
|
164
|
+
const last = courseInstant(service.schedule?.lastSessionEnd, zone);
|
|
165
|
+
if (!Number.isFinite(first) || !Number.isFinite(last)) return { windows: [], complete: false, fromFirst: false };
|
|
166
|
+
const from = Math.max(first, now);
|
|
167
|
+
const courseEnd = last + DAY;
|
|
168
|
+
const until = Math.min(courseEnd, from + COURSE_READ_DAYS * DAY);
|
|
169
|
+
const windows: Array<[number, number]> = [];
|
|
170
|
+
for (let at = from; at < until; at += chunkDays * DAY) windows.push([at, Math.min(until, at + chunkDays * DAY)]);
|
|
171
|
+
return { windows, complete: courseEnd <= until, fromFirst: from === first };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type CourseEntryShape = { slot?: { startDate?: string; timezone?: unknown }; bookable?: boolean; openSpots?: number };
|
|
175
|
+
|
|
176
|
+
/** A course across EVERY upcoming session read: a customer books all of them at once, so the
|
|
177
|
+
* smallest open count is the course's, and one session that cannot be booked closes it. A start
|
|
178
|
+
* is read in the session's own zone (`slotStartInstant`). `found` is false when no upcoming
|
|
179
|
+
* session came back: both screens then treat the course as not bookable. So is a course whose
|
|
180
|
+
* read did not reach its last session (`readComplete`, from `courseReadWindows`): a session
|
|
181
|
+
* past the year read may be full, and the course is booked whole. */
|
|
182
|
+
export function courseSessionsAvailability(entries: readonly CourseEntryShape[], now: number, readComplete = true): { found: boolean; spots: number | undefined; bookable: boolean } {
|
|
183
|
+
const upcoming = entries.filter((entry) => {
|
|
184
|
+
const start = entry.slot ? slotStartInstant(entry.slot as { startDate?: string; timezone?: unknown }) : NaN;
|
|
185
|
+
return Number.isFinite(start) && start >= now;
|
|
186
|
+
});
|
|
187
|
+
if (!upcoming.length) return { found: false, spots: undefined, bookable: false };
|
|
188
|
+
const counts = upcoming.map((entry) => entry.openSpots).filter((n): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0);
|
|
189
|
+
const spots = counts.length ? Math.min(...counts) : undefined;
|
|
190
|
+
return { found: true, spots, bookable: readComplete && upcoming.every((entry) => entry.bookable !== false) && spots !== 0 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- Price, duration and course dates ------------------------------------------------
|
|
194
|
+
// One answer for the services list and the service screen, so a row and the screen it opens
|
|
195
|
+
// say the same price and the same length. Each screen reads its own copy keys: the caller
|
|
196
|
+
// passes a `BookingLabel` reader bound to its prefix ("service." on the service screen,
|
|
197
|
+
// "services." on the list).
|
|
198
|
+
|
|
199
|
+
/** Reads one copy line by its key suffix ("priceFree", "days"). `values` fills {placeholders}. */
|
|
200
|
+
export type BookingLabel = (key: string, fallback: string, values?: Record<string, string | number>) => string;
|
|
201
|
+
|
|
202
|
+
type PricedServiceShape = {
|
|
203
|
+
type?: string;
|
|
204
|
+
duration?: unknown;
|
|
205
|
+
payment?: {
|
|
206
|
+
rateType?: unknown;
|
|
207
|
+
fixed?: { price?: unknown };
|
|
208
|
+
varied?: { minPrice?: unknown; defaultPrice?: unknown };
|
|
209
|
+
custom?: { description?: unknown };
|
|
210
|
+
};
|
|
211
|
+
schedule?: {
|
|
212
|
+
firstSessionStart?: string;
|
|
213
|
+
lastSessionEnd?: string;
|
|
214
|
+
availabilityConstraints?: {
|
|
215
|
+
sessionDurations?: unknown[];
|
|
216
|
+
durations?: Array<{ minutes?: unknown } | undefined>;
|
|
217
|
+
durationRange?: {
|
|
218
|
+
hourOptions?: { minDurationInMinutes?: unknown; maxDurationInMinutes?: unknown };
|
|
219
|
+
dayOptions?: { minDurationInDays?: unknown; maxDurationInDays?: unknown };
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function trimmed(value: unknown): string {
|
|
226
|
+
return typeof value === "string" ? value.trim() : "";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
230
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
231
|
+
if (typeof value === "string" && value.trim()) {
|
|
232
|
+
const n = Number(value);
|
|
233
|
+
return Number.isNaN(n) ? undefined : n;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface ServicePriceLine { text: string; unit: string; isText: boolean }
|
|
239
|
+
|
|
240
|
+
/** The price line by rate type. FIXED → the price, no cents on a whole amount; VARIED →
|
|
241
|
+
* "From {price}"; CUSTOM → the owner's description, no unit; NO_FEE → "Free", no unit. */
|
|
242
|
+
export function servicePriceLine(service: PricedServiceShape, label: BookingLabel): ServicePriceLine {
|
|
243
|
+
const payment = service.payment ?? {};
|
|
244
|
+
const rate = trimmed(payment.rateType).toUpperCase();
|
|
245
|
+
const currency = serviceCurrency(service);
|
|
246
|
+
const unit = service.type === "COURSE"
|
|
247
|
+
? label("unitParticipant", "per participant")
|
|
248
|
+
: label("unitSession", "per session");
|
|
249
|
+
if (rate === "NO_FEE") return { text: label("priceFree", "Free"), unit: "", isText: false };
|
|
250
|
+
if (rate === "CUSTOM") return { text: trimmed(payment.custom?.description), unit: "", isText: true };
|
|
251
|
+
if (rate === "VARIED") {
|
|
252
|
+
const from = formatServiceMoney(payment.varied?.minPrice, currency) || formatServiceMoney(payment.varied?.defaultPrice, currency);
|
|
253
|
+
return { text: from ? label("priceFrom", "From {price}", { price: from }) : "", unit: from ? unit : "", isText: false };
|
|
254
|
+
}
|
|
255
|
+
// FIXED, or a legacy payload with no rate type.
|
|
256
|
+
const text = formatServiceMoney(payment.fixed?.price, currency) || formatServicePrice(service);
|
|
257
|
+
return { text, unit: text ? unit : "", isText: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function minutesText(minutes: number): string {
|
|
261
|
+
if (minutes < 60) return `${minutes} min`;
|
|
262
|
+
const hours = Math.floor(minutes / 60);
|
|
263
|
+
const rest = minutes % 60;
|
|
264
|
+
return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** "45 min", "1 hr 30 min"; a service with several lengths, or a flexible range the
|
|
268
|
+
* customer picks from: "30 min - 1 hr", "1 day - 3 days". "" when the record has none. */
|
|
269
|
+
export function serviceDurationText(service: PricedServiceShape, label: BookingLabel): string {
|
|
270
|
+
const constraints = service.schedule?.availabilityConstraints;
|
|
271
|
+
const positive = (values: unknown[]) => values.map(finiteNumber).filter((n): n is number => n != null && n > 0);
|
|
272
|
+
const listed = positive([
|
|
273
|
+
...(constraints?.sessionDurations ?? []),
|
|
274
|
+
...(constraints?.durations ?? []).map((d) => d?.minutes),
|
|
275
|
+
]);
|
|
276
|
+
const hours = constraints?.durationRange?.hourOptions;
|
|
277
|
+
const range = positive([hours?.minDurationInMinutes, hours?.maxDurationInMinutes]);
|
|
278
|
+
const days = constraints?.durationRange?.dayOptions;
|
|
279
|
+
const dayRange = positive([days?.minDurationInDays, days?.maxDurationInDays]);
|
|
280
|
+
if (!listed.length && !range.length && dayRange.length) {
|
|
281
|
+
const dayText = (n: number) => (n === 1 ? label("day", "1 day") : label("days", "{n} days", { n }));
|
|
282
|
+
const first = Math.min(...dayRange);
|
|
283
|
+
const last = Math.max(...dayRange);
|
|
284
|
+
return first === last ? dayText(first) : `${dayText(first)} - ${dayText(last)}`;
|
|
285
|
+
}
|
|
286
|
+
const minutes = listed.length ? listed : range.length ? range : positive([service.duration]);
|
|
287
|
+
if (!minutes.length) return "";
|
|
288
|
+
const min = Math.min(...minutes);
|
|
289
|
+
const max = Math.max(...minutes);
|
|
290
|
+
return min === max ? minutesText(min) : `${minutesText(min)} - ${minutesText(max)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function validDate(value: string | undefined): Date | undefined {
|
|
294
|
+
if (!value) return undefined;
|
|
295
|
+
const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/i.exec(value);
|
|
296
|
+
if (!parts) return undefined;
|
|
297
|
+
const year = Number(parts[1]), month = Number(parts[2]) - 1, day = Number(parts[3]);
|
|
298
|
+
// Date accepts February 30 by rolling into March. Check the stated calendar day first;
|
|
299
|
+
// setUTCFullYear also preserves years 0–99, which Date.UTC would reinterpret as 1900–1999.
|
|
300
|
+
const calendar = new Date(0);
|
|
301
|
+
calendar.setUTCFullYear(year, month, day);
|
|
302
|
+
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month || calendar.getUTCDate() !== day) return undefined;
|
|
303
|
+
const date = new Date(value);
|
|
304
|
+
return Number.isFinite(date.getTime()) ? date : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function shortDate(date: Date, year: boolean): string {
|
|
308
|
+
return year ? formatDateMedium(date, "en") : formatMonthDay(date, "en");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A course's dates, "Nov 3 – Dec 15, 2026" (the year on the first date only when the two
|
|
312
|
+
* differ). "" when the record has no first or last session. */
|
|
313
|
+
export function courseDatesText(service: PricedServiceShape): string {
|
|
314
|
+
const first = validDate(service.schedule?.firstSessionStart);
|
|
315
|
+
const last = validDate(service.schedule?.lastSessionEnd);
|
|
316
|
+
if (!first || !last) return "";
|
|
317
|
+
return `${shortDate(first, first.getFullYear() !== last.getFullYear())} – ${shortDate(last, true)}`;
|
|
318
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
|
|
2
|
+
// §"Copy is configuration"): a worker never hardcodes user-facing copy — the
|
|
3
|
+
// descriptor bakes the provisioned copy-pack overrides relevant to this worker
|
|
4
|
+
// into its onAppear action params as a `copy` JSON object (customer strings or
|
|
5
|
+
// source-matched stock translations, computed at descriptor-build time), and the
|
|
6
|
+
// worker reads every user-facing string through `copyParam(key, fallback)`.
|
|
7
|
+
//
|
|
8
|
+
// STASHED (the stashed appear-params idiom, himi-branded-lite-home): the
|
|
9
|
+
// param-carrying onAppear dispatch updates the stash; later arg-less calls
|
|
10
|
+
// (self-kick refreshes, retry buttons, pull-to-refresh) fall back to it, so
|
|
11
|
+
// resolution order can never drop the pack. With no pack the param is absent
|
|
12
|
+
// and every read returns `fallback` verbatim — routing a worker string through
|
|
13
|
+
// copyParam is byte-for-byte behavior-preserving.
|
|
14
|
+
//
|
|
15
|
+
// Pure + Hermes-safe (no host globals, no Node APIs; its only import is the pure `app-locale` sibling): bundled into
|
|
16
|
+
// every worker that emits user-facing copy, and copied verbatim into the
|
|
17
|
+
// generated templates' tier5-src/_shared/.
|
|
18
|
+
|
|
19
|
+
import { isSingular, setAppLocale, setBusinessTimeZone } from "./app-locale.js";
|
|
20
|
+
|
|
21
|
+
/** Customer strings override every read; translated stock is valid only for its
|
|
22
|
+
* original English fallback. Some existing keys serve more than one message. */
|
|
23
|
+
export type CopyPackValue = string | { source: string; translation: string };
|
|
24
|
+
|
|
25
|
+
let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
|
|
26
|
+
let copyReceived = false;
|
|
27
|
+
|
|
28
|
+
/** Absorb the `copy` onAppear param (customer strings or stock translation records)
|
|
29
|
+
* from an action arg. Call first in every action that can receive onAppear
|
|
30
|
+
* params (load/refresh). Non-copy args and malformed packs are ignored — the
|
|
31
|
+
* stock fallbacks keep rendering. */
|
|
32
|
+
export function stashCopy(arg: unknown): void {
|
|
33
|
+
if (!arg || typeof arg !== "object") return;
|
|
34
|
+
// A param-carrying dispatch names the app language and business zone too (absent: the English house
|
|
35
|
+
// pattern, and no zone).
|
|
36
|
+
if ("copy" in arg || "locale" in arg || "businessTimeZone" in arg) {
|
|
37
|
+
setAppLocale((arg as Record<string, unknown>).locale);
|
|
38
|
+
setBusinessTimeZone((arg as Record<string, unknown>).businessTimeZone);
|
|
39
|
+
}
|
|
40
|
+
const raw = (arg as Record<string, unknown>).copy;
|
|
41
|
+
if (typeof raw !== "string" || !raw) return;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
44
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
45
|
+
const next: Record<string, CopyPackValue> = Object.create(null);
|
|
46
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
47
|
+
if (typeof value === "string") next[key] = value;
|
|
48
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
49
|
+
const row = value as Record<string, unknown>;
|
|
50
|
+
if (typeof row.source === "string" && typeof row.translation === "string") {
|
|
51
|
+
next[key] = { source: row.source, translation: row.translation };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
stashedCopy = next;
|
|
56
|
+
copyReceived = true;
|
|
57
|
+
} catch {
|
|
58
|
+
// Malformed pack param — keep the stock copy rather than dying mid-action.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The provisioned override for `key`, or `fallback` (the stock copy). An optional
|
|
63
|
+
* restored label survives only until a current copy pack arrives. */
|
|
64
|
+
export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
|
|
65
|
+
const value = stashedCopy[key];
|
|
66
|
+
// A disk-restored row may paint before onAppear supplies its copy pack. Keep its
|
|
67
|
+
// last label only before a pack arrives; even an empty current pack selects stock copy.
|
|
68
|
+
if (value === undefined) return copyReceived ? fallback : restoredFallback;
|
|
69
|
+
if (typeof value === "string") return value !== "" ? value : fallback;
|
|
70
|
+
return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Interpolate `{name}` placeholders in a (possibly overridden) template:
|
|
74
|
+
* `copyTemplate("plans.joinOnSite", "…join {name}.", { name })`. Unknown
|
|
75
|
+
* placeholders resolve to "" so a typo'd override degrades, never crashes. */
|
|
76
|
+
export function copyTemplate(key: string, fallback: string, values: Record<string, string | number>): string {
|
|
77
|
+
return copyParam(key, fallback).replace(/\{([A-Za-z0-9_]+)\}/g, (_all, name: string) =>
|
|
78
|
+
name in values ? String(values[name]) : "",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The small ICU plural form used by Bookings spot counts. The provisioned copy may
|
|
83
|
+
* translate both branches without adding a second key for the singular. */
|
|
84
|
+
export function copyPlural(key: string, fallback: string, count: number): string {
|
|
85
|
+
const pattern = copyParam(key, fallback);
|
|
86
|
+
const match = /^\{n, plural, one \{([^{}]*)\} other \{([^{}]*)\}\}$/.exec(pattern);
|
|
87
|
+
if (!match) return copyTemplate(key, fallback, { n: count });
|
|
88
|
+
return (isSingular(count) ? match[1] : match[2])!.replace(/#/g, String(count));
|
|
89
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Graduated to the SDK (SP-3): the store display helpers + v3 product query now live in
|
|
2
|
+
// @himalaya/wix-stores. This thin re-export keeps existing bundle imports working; new
|
|
3
|
+
// code should import from "@himalaya/wix-stores" directly.
|
|
4
|
+
// Relative so node (tests importing worker sources) resolves it too — the bare
|
|
5
|
+
// "@himalaya/wix-stores" name exists only in the esbuild alias map / the off-repo
|
|
6
|
+
// umbrella. gen-templates.mts rewrites this back to @himalaya/wix-stores off-repo.
|
|
7
|
+
export * from "@himalaya/wix-stores";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared display-text helpers for himi cards: trim catalog artifacts from titles and
|
|
2
|
+
// derive up-to-two-letter placeholder initials so same-first-letter lists still vary.
|
|
3
|
+
|
|
4
|
+
/** Trim trailing artifact markers (stray asterisks / footnote daggers) and whitespace
|
|
5
|
+
* that leak from test/seed catalogs, without touching interior punctuation. Falls back
|
|
6
|
+
* to the original when trimming would empty the string. */
|
|
7
|
+
export function cleanTitle(value?: string): string {
|
|
8
|
+
const raw = (value ?? "").trim();
|
|
9
|
+
const cleaned = raw.replace(/[\s*†‡]+$/u, "").trim();
|
|
10
|
+
return cleaned || raw;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Up to two significant initials for a placeholder avatar. Two-plus words → first
|
|
14
|
+
* letter of the first two; a single word → its first two alphanumerics. Keeps a list
|
|
15
|
+
* of similar names (e.g. many "Booking…") from collapsing to one identical glyph. */
|
|
16
|
+
export function initials(value?: string): string {
|
|
17
|
+
const words = (value ?? "").trim().split(/\s+/).filter(Boolean);
|
|
18
|
+
if (words.length === 0) return "#";
|
|
19
|
+
if (words.length === 1) {
|
|
20
|
+
const alnum = words[0].replace(/[^\p{L}\p{N}]/gu, "");
|
|
21
|
+
return (alnum.slice(0, 2) || "#").toUpperCase();
|
|
22
|
+
}
|
|
23
|
+
return ((words[0][0] ?? "") + (words[1][0] ?? "")).toUpperCase();
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Host events — what a member screen tells the app hosting it about something the member did
|
|
2
|
+
// (a booking confirmed or cancelled), so a host such as Symphony can say so in its chat and refresh
|
|
3
|
+
// what it shows.
|
|
4
|
+
//
|
|
5
|
+
// A worker dispatches `host.event` with `{ type, ...fields }` (string values only: a dispatch carries
|
|
6
|
+
// one string params map). An embed (`HimalayaEmbed`) hands it to the host's `onAction` as an action
|
|
7
|
+
// with that id; anywhere else nothing handles it and it is dropped. It is a notice, never a request:
|
|
8
|
+
// the host owns what happens next, and the worker never waits on it or depends on its answer.
|
|
9
|
+
//
|
|
10
|
+
// The contract (hosts parse exactly this — Symphony's `PlaceHostEvent`):
|
|
11
|
+
// type booking.created | booking.cancelled | plan.used
|
|
12
|
+
// serviceName the class / service title (bookings) planName the plan's name (plan.used)
|
|
13
|
+
// start, end ISO 8601 instants WITH an offset (`Z` or `+02:00`); a local time is left out
|
|
14
|
+
// remaining sessions left on the plan, when known (a whole number)
|
|
15
|
+
// bookingId, serviceId, planId when known
|
|
16
|
+
|
|
17
|
+
import { dispatch } from "@himalaya/dispatch";
|
|
18
|
+
|
|
19
|
+
export const HOST_EVENT_ACTION_ID = "host.event";
|
|
20
|
+
|
|
21
|
+
/** The events a member screen reports. */
|
|
22
|
+
export type HostEventType = "booking.created" | "booking.cancelled" | "plan.used";
|
|
23
|
+
|
|
24
|
+
export interface HostEventFields {
|
|
25
|
+
bookingId?: string;
|
|
26
|
+
serviceId?: string;
|
|
27
|
+
serviceName?: string;
|
|
28
|
+
planId?: string;
|
|
29
|
+
planName?: string;
|
|
30
|
+
start?: string;
|
|
31
|
+
end?: string;
|
|
32
|
+
remaining?: number | string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
|
|
36
|
+
|
|
37
|
+
/** [value] when it is an ISO 8601 instant that names its offset, else "". A slot's LOCAL wall time
|
|
38
|
+
* ("2026-11-02T18:30:00") would be read in the host's zone, i.e. a wrong hour, so it is left out. */
|
|
39
|
+
export function hostEventInstant(value: unknown): string {
|
|
40
|
+
return typeof value === "string" && WITH_OFFSET.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The `host.event` params for [type] and [fields]: unset and empty fields are left out, times
|
|
44
|
+
* without an offset are left out, and `remaining` is a whole number ≥ 0 or nothing. */
|
|
45
|
+
export function hostEventParams(type: HostEventType, fields: HostEventFields = {}): Record<string, string> {
|
|
46
|
+
const params: Record<string, string> = { type };
|
|
47
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
48
|
+
let out = "";
|
|
49
|
+
if (key === "start" || key === "end") out = hostEventInstant(value);
|
|
50
|
+
else if (key === "remaining") {
|
|
51
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
52
|
+
out = Number.isInteger(n) && n >= 0 ? String(n) : "";
|
|
53
|
+
} else if (typeof value === "string") out = value.trim();
|
|
54
|
+
if (out) params[key] = out;
|
|
55
|
+
}
|
|
56
|
+
return params;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tell the host about [type]. Never throws: a host that does not listen must not break the screen. */
|
|
60
|
+
export async function emitHostEvent(type: HostEventType, fields: HostEventFields = {}): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
await dispatch(HOST_EVENT_ACTION_ID, hostEventParams(type, fields));
|
|
63
|
+
} catch {
|
|
64
|
+
// Not embedded, or the host has no handler: nothing to tell.
|
|
65
|
+
}
|
|
66
|
+
}
|