@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,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
|
+
}
|
|
@@ -22,6 +22,7 @@ import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-med
|
|
|
22
22
|
import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
|
|
23
23
|
import { descriptionBody } from "../_shared/rich-body.js";
|
|
24
24
|
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
25
|
+
import { isServiceSaved, toggleSavedService } from "../_shared/himi-booking-saved.js";
|
|
25
26
|
|
|
26
27
|
// Offline-first (data-offline): the service detail is a public read-through. On a
|
|
27
28
|
// fetch failure we fall back to the last-synced row for this service written by
|
|
@@ -182,6 +183,13 @@ interface S {
|
|
|
182
183
|
sessionMessageOk: boolean;
|
|
183
184
|
sessionMessageFailed: boolean;
|
|
184
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;
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
function inputId(): string {
|
|
@@ -790,6 +798,10 @@ function derive(s: S): S {
|
|
|
790
798
|
// Change Time opens the time screen, which cannot book a service closed to online booking.
|
|
791
799
|
noChangeTime: (control !== "book" && control !== "waitlist" && control !== "closed") || !s.bookableOnline,
|
|
792
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"),
|
|
793
805
|
notBookableOnline: !s.bookableOnline,
|
|
794
806
|
noAbout: !s.about && !s.aboutJson,
|
|
795
807
|
noPolicy: s.policyLines.length === 0,
|
|
@@ -866,9 +878,16 @@ function withService(s: S, service: WixService): S {
|
|
|
866
878
|
// WS-C — canonical param-less target; the descriptor's choose-time CTA
|
|
867
879
|
// binds the separate `id` state field as a first-class nav param.
|
|
868
880
|
timeScreenId: "himi_time",
|
|
881
|
+
saved: savedNow(serviceId),
|
|
869
882
|
});
|
|
870
883
|
}
|
|
871
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
|
+
|
|
872
891
|
/** The service record: with staff details when this token may read them, else without. */
|
|
873
892
|
async function fetchService(id: string): Promise<WixService | undefined> {
|
|
874
893
|
try {
|
|
@@ -897,9 +916,10 @@ const svc = defineService<S, {
|
|
|
897
916
|
book: (s: S) => S;
|
|
898
917
|
changeTime: (s: S) => S;
|
|
899
918
|
joinWaitlist: (s: S, arg?: unknown) => Promise<S>;
|
|
919
|
+
toggleSaved: (s: S) => S;
|
|
900
920
|
}>("himiBookingService", {
|
|
901
921
|
// A join's outcome belongs to that visit: a cold start must not replay it from saved state.
|
|
902
|
-
notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage"],
|
|
922
|
+
notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage", "saveMessage"],
|
|
903
923
|
state: derive({
|
|
904
924
|
status: "loading",
|
|
905
925
|
id,
|
|
@@ -1005,6 +1025,10 @@ const svc = defineService<S, {
|
|
|
1005
1025
|
noChangeTime: true,
|
|
1006
1026
|
...NO_SESSION_MESSAGE,
|
|
1007
1027
|
noSessionMessage: true,
|
|
1028
|
+
saved: false,
|
|
1029
|
+
notSaved: true,
|
|
1030
|
+
saveLabel: "",
|
|
1031
|
+
saveMessage: "",
|
|
1008
1032
|
}),
|
|
1009
1033
|
actions: {
|
|
1010
1034
|
load: async (s, arg) => {
|
|
@@ -1012,14 +1036,16 @@ const svc = defineService<S, {
|
|
|
1012
1036
|
rememberSignIn(arg);
|
|
1013
1037
|
if (!s.id) return derive({ ...s, status: "ready", errored: true });
|
|
1014
1038
|
// A new view: a join's outcome from before is not news when the screen shows again.
|
|
1015
|
-
const start = { ...s, policyExpanded: false, scheduleExpanded: false, ...NO_SESSION_MESSAGE };
|
|
1039
|
+
const start = { ...s, policyExpanded: false, scheduleExpanded: false, saveMessage: "", ...NO_SESSION_MESSAGE };
|
|
1016
1040
|
try {
|
|
1017
1041
|
const service = await fetchService(s.id);
|
|
1018
1042
|
if (!service) return withSession(derive({ ...start, status: "ready" }), undefined);
|
|
1019
1043
|
loadedService = service;
|
|
1020
1044
|
// The session the Agenda row opened on, read beside the service-wide availability.
|
|
1021
1045
|
const [shown, sel] = await Promise.all([withGroupAvailability(withService(start, service), service), readSessionBounded(service)]);
|
|
1022
|
-
|
|
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);
|
|
1023
1049
|
} catch {
|
|
1024
1050
|
// Offline: paint from the last-synced services cache (written by the list
|
|
1025
1051
|
// worker) before falling back to the error state. The cache holds the name,
|
|
@@ -1038,6 +1064,7 @@ const svc = defineService<S, {
|
|
|
1038
1064
|
category: String(cached.category ?? ""),
|
|
1039
1065
|
images: image ? [{ url: image, alt: name }] : [],
|
|
1040
1066
|
timeScreenId: `himi_time_${s.id}`,
|
|
1067
|
+
saved: savedNow(s.id),
|
|
1041
1068
|
}, undefined);
|
|
1042
1069
|
}
|
|
1043
1070
|
return withSession({ ...start, status: "ready", errored: true, name: "" }, undefined);
|
|
@@ -1056,6 +1083,26 @@ const svc = defineService<S, {
|
|
|
1056
1083
|
},
|
|
1057
1084
|
togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
|
|
1058
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
|
+
},
|
|
1059
1106
|
// The footer button opens the time screen for the loaded service. A worker directive,
|
|
1060
1107
|
// not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
|
|
1061
1108
|
// dispatch their taps without resolving `$service:` params, so the id never arrived.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Visitors browse every Wix Bookings service of the site, grouped by category under a small
|
|
4
4
|
// title, in the business's own order (the order of the site's Book Online page). The category
|
|
5
5
|
// chips are anchors, not filters: a tap scrolls the list to its category's title, and the chip
|
|
6
|
-
// of the category at the top of the list lights up while the member scrolls.
|
|
6
|
+
// of the category at the top of the list lights up while the member scrolls. Opened with a
|
|
7
|
+
// `category` nav param (a category tile), the list starts at that category. A name search
|
|
7
8
|
// keeps the grouping. With two or more locations a pin limits the list to the services offered
|
|
8
9
|
// at one location; the choice is shared with the Agenda and Choose Time screens. Each row
|
|
9
10
|
// carries what the service record has: photo, name, tagline, a Class or Course tag, and
|
|
@@ -449,10 +450,57 @@ let activeCategory = "";
|
|
|
449
450
|
let activeChosen = false;
|
|
450
451
|
let scrollTarget = "";
|
|
451
452
|
let jumps = 0;
|
|
453
|
+
/** This worker run's own mark on its jump values. A renderer remembers the value it last jumped
|
|
454
|
+
* for, and a screen restored from an earlier visit can bring that memory back: a value that
|
|
455
|
+
* repeats across runs ("cat:x|1") would then be taken as done. Not in the state, so the boot
|
|
456
|
+
* state (which a release hashes) does not change with the clock. */
|
|
457
|
+
const RUN = Date.now().toString(36);
|
|
452
458
|
let quietUntil = 0;
|
|
453
459
|
/** How long a jump the worker asked for takes to settle: a top-row report inside it is the jump's
|
|
454
460
|
* own (the renderers suppress those, this is the belt to their braces). */
|
|
455
461
|
const JUMP_SETTLE_MS = 900;
|
|
462
|
+
/** The category this list was opened on (the `category` nav param: a category's Wix id, its
|
|
463
|
+
* `name:<name>` key, or its name), e.g. from a category tile. Its chip lights and the list jumps
|
|
464
|
+
* to its title once that group shows, and once more when this run's live page lands. After that
|
|
465
|
+
* — or as soon as the member scrolls or taps a chip — the member leads.
|
|
466
|
+
*
|
|
467
|
+
* Armed by the screen's own `load` (its onAppear), never by the boot request: the iOS and
|
|
468
|
+
* Android lists act on a jump that CHANGES while they are on screen (a chip tap), and drop one
|
|
469
|
+
* that is already in the state when they mount. The second jump is for a screen restored from
|
|
470
|
+
* its last visit: the host puts back that visit's scroll position after the first one. */
|
|
471
|
+
let pendingCategory = "";
|
|
472
|
+
let bootLoad = true;
|
|
473
|
+
/** This run's own live read has landed (a restored snapshot's rows do not count). */
|
|
474
|
+
let liveLanded = false;
|
|
475
|
+
let jumpedFromCache = false;
|
|
476
|
+
/** The `category` param the list last started at, and that visit's `categoryActivation` (a
|
|
477
|
+
* nonce a category tap sends, so a second tap on the SAME category is a new visit too). */
|
|
478
|
+
let openedFor = "";
|
|
479
|
+
let openedActivation = "";
|
|
480
|
+
function inputParam(key: "category" | "categoryActivation"): string {
|
|
481
|
+
const value = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.[key];
|
|
482
|
+
return typeof value === "string" ? value.trim().slice(0, 200) : "";
|
|
483
|
+
}
|
|
484
|
+
const inputCategory = (): string => inputParam("category");
|
|
485
|
+
/** Read on every `load`, not once at boot: a pooled worker is handed the next screen's input just
|
|
486
|
+
* before its `load` runs (the web host keeps one services worker for every push). The same param
|
|
487
|
+
* again is the screen coming back (a pop, a refresh), which keeps the member's place. */
|
|
488
|
+
function armCategory(): boolean {
|
|
489
|
+
const next = inputCategory();
|
|
490
|
+
const activation = inputParam("categoryActivation");
|
|
491
|
+
if (next === openedFor && activation === openedActivation) return false;
|
|
492
|
+
openedFor = next;
|
|
493
|
+
openedActivation = activation;
|
|
494
|
+
pendingCategory = next;
|
|
495
|
+
jumpedFromCache = false;
|
|
496
|
+
// A new visit is a new run for a pooled worker: the last visit's live page is not this one's,
|
|
497
|
+
// so the jump that waits for "this run's live read" waits for the read this load starts.
|
|
498
|
+
liveLanded = false;
|
|
499
|
+
// An open with no category ("See all") starts at the top: the last visit's lit chip and its
|
|
500
|
+
// jump do not carry over to it.
|
|
501
|
+
if (!next) { activeCategory = ""; activeChosen = false; scrollTarget = ""; }
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
456
504
|
|
|
457
505
|
// ---- Location ------------------------------------------------------------------------
|
|
458
506
|
/** The member's choice (shared with Agenda and Choose Time through the on-device store). */
|
|
@@ -500,6 +548,17 @@ function display(s: S): S {
|
|
|
500
548
|
...section.rows.map((row) => ({ ...row, isSection: false, notSection: true, sectionTitle: "" })),
|
|
501
549
|
]);
|
|
502
550
|
const chips = titled && sections.length >= 2 ? sections.map((section) => ({ id: section.id, label: section.title })) : [];
|
|
551
|
+
const live = liveLanded && !loading;
|
|
552
|
+
if (pendingCategory && chips.length && (live || !jumpedFromCache)) {
|
|
553
|
+
const opened = chips.find((chip) => chip.id === sectionIdOf(pendingCategory) || chip.label === pendingCategory);
|
|
554
|
+
if (opened) {
|
|
555
|
+
activeCategory = opened.id;
|
|
556
|
+
activeChosen = true;
|
|
557
|
+
scrollTarget = `${opened.id}|${RUN}.${++jumps}`;
|
|
558
|
+
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
559
|
+
if (live) pendingCategory = ""; else jumpedFromCache = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
503
562
|
// No chips (a reload in flight, a single group): the lit chip waits for the list to come back.
|
|
504
563
|
if (chips.length && (!activeChosen || !chips.some((chip) => chip.id === activeCategory))) activeCategory = chips[0]!.id;
|
|
505
564
|
const target = scrollTarget.split("|")[0];
|
|
@@ -553,9 +612,15 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
553
612
|
register: registerServices,
|
|
554
613
|
restorePagesOnLoad: true,
|
|
555
614
|
onLoad() {
|
|
615
|
+
let reset: { query: string } | undefined;
|
|
616
|
+
if (bootLoad) bootLoad = false;
|
|
617
|
+
// A new visit at a category drops a search the member left on this list: a saved "massage"
|
|
618
|
+
// would hide the category the tile opened, and its jump would never land.
|
|
619
|
+
else if (armCategory() && pendingCategory) reset = { query: "" };
|
|
556
620
|
// Copy is stashed before this hook. Repaint restored labels immediately, even
|
|
557
621
|
// when the background query holds the old page through a slow or failed read.
|
|
558
622
|
(svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
|
|
623
|
+
return reset;
|
|
559
624
|
},
|
|
560
625
|
state: { status: "loading", query: "", all: [], results: [], listRows: [], emptyText: "", countLabel: "", errored: false,
|
|
561
626
|
notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
|
|
@@ -639,9 +704,10 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
639
704
|
?? ((p as { args?: { selectedIds?: unknown } } | undefined)?.args?.selectedIds);
|
|
640
705
|
const picked = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : activeCategory;
|
|
641
706
|
if (!(s.categoryChips ?? []).some((chip) => chip.id === picked)) return s;
|
|
707
|
+
pendingCategory = "";
|
|
642
708
|
activeCategory = picked;
|
|
643
709
|
activeChosen = true;
|
|
644
|
-
scrollTarget = `${picked}|${++jumps}`;
|
|
710
|
+
scrollTarget = `${picked}|${RUN}.${++jumps}`;
|
|
645
711
|
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
646
712
|
return display(s);
|
|
647
713
|
},
|
|
@@ -653,6 +719,7 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
653
719
|
if (!rows.some((row) => !row.isSection)) return s;
|
|
654
720
|
const section = sectionOfRow(rows, argOf(p, "id"));
|
|
655
721
|
if (!section || !(s.categoryChips ?? []).some((chip) => chip.id === section)) return s;
|
|
722
|
+
pendingCategory = "";
|
|
656
723
|
activeCategory = section;
|
|
657
724
|
activeChosen = true;
|
|
658
725
|
// The member has moved on from the last jump: a re-render never scrolls back to it.
|
|
@@ -688,7 +755,7 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
688
755
|
// Ended label the restored row for the same course showed, until a current pack replaces it.
|
|
689
756
|
const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
|
|
690
757
|
const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
|
|
691
|
-
if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
|
|
758
|
+
if (page.metadata?.fromCache === false && !loading && !errored) { restoredEndedBridge = false; liveLanded = true; }
|
|
692
759
|
const filtered = page.criteria.filter.startsWith("value:");
|
|
693
760
|
const hasQuery = !!page.criteria.query.trim();
|
|
694
761
|
// The menu offers every location of the whole catalog: a settled live read replaces it, the
|
|
@@ -326,6 +326,9 @@ function inputId(): string {
|
|
|
326
326
|
* not just the first, because the boot load and the screen's own `load` can overlap. */
|
|
327
327
|
/** Counts the customer's picks. A load that sees it move ran while they picked a time. */
|
|
328
328
|
let selectionRevision = 0;
|
|
329
|
+
// Each guest tap that asks the session first: only the newest answers, so two quick taps whose
|
|
330
|
+
// reads come back out of order never pick the older time or open a second sign-in.
|
|
331
|
+
let sessionTaps = 0;
|
|
329
332
|
|
|
330
333
|
let pendingStart = (() => {
|
|
331
334
|
const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
|
|
@@ -958,12 +961,13 @@ let dayRevision = 0;
|
|
|
958
961
|
/** True while "Check Next Availability" searches; a second tap waits for the first. */
|
|
959
962
|
let checkingNextLock = false;
|
|
960
963
|
|
|
961
|
-
/** `{ slotId, signInScreenId }` from a pill tap (or a bare slot id). */
|
|
962
|
-
function tapped(arg: unknown): { slotId: string; signInScreenId: string } {
|
|
963
|
-
if (typeof arg === "string") return { slotId: arg, signInScreenId: "" };
|
|
964
|
-
const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown };
|
|
964
|
+
/** `{ slotId, signInScreenId, signInPresentation }` from a pill tap (or a bare slot id). */
|
|
965
|
+
function tapped(arg: unknown): { slotId: string; signInScreenId: string; signInPresentation: "" | "sheet" | "full_screen" } {
|
|
966
|
+
if (typeof arg === "string") return { slotId: arg, signInScreenId: "", signInPresentation: "" };
|
|
967
|
+
const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown; signInPresentation?: unknown };
|
|
965
968
|
const slotId = typeof a.slotId === "string" ? a.slotId : typeof a.id === "string" ? a.id : "";
|
|
966
|
-
|
|
969
|
+
const presentation = a.signInPresentation === "sheet" || a.signInPresentation === "full_screen" ? a.signInPresentation : "";
|
|
970
|
+
return { slotId, signInScreenId: typeof a.signInScreenId === "string" ? a.signInScreenId : "", signInPresentation: presentation };
|
|
967
971
|
}
|
|
968
972
|
|
|
969
973
|
/** Select a bookable time, as a tap does: the pick-storage rules and the selection revision. */
|
|
@@ -1304,12 +1308,40 @@ const svc = defineService<S, {
|
|
|
1304
1308
|
// taps it again. Where the regular checkout is not allowed, the tap selects the time for the
|
|
1305
1309
|
// in-app purchase button.
|
|
1306
1310
|
pickSlot: async (s, arg): Promise<S | undefined> => {
|
|
1307
|
-
const { slotId, signInScreenId } = tapped(arg);
|
|
1311
|
+
const { slotId, signInScreenId, signInPresentation } = tapped(arg);
|
|
1308
1312
|
if (bookingLock || s.bookingInFlight || !allRows.some((row) => row.id === slotId && row.selectable)) return derive(s);
|
|
1309
1313
|
const selected = selectSlotIn(s, slotId);
|
|
1310
|
-
|
|
1314
|
+
// A modal sign-in leaves this screen in place: a host need not run its onAppear when the
|
|
1315
|
+
// sheet closes, and the reload that refreshes `isMember` can still be running. So ask the
|
|
1316
|
+
// session before opening the sheet again, or a member's tap would only reopen it. Only the
|
|
1317
|
+
// session's own identity counts here, never getMyData: that can still answer with a stale
|
|
1318
|
+
// site or owner profile for a visitor. A member found here gets the pick and the "tap again"
|
|
1319
|
+
// note, as after a reload, and books on the next tap: that tap passes the booking lock and
|
|
1320
|
+
// the offline guard with the member state, which this await would let a second tap skip.
|
|
1321
|
+
if (!selected.isMember && signInScreenId && signInPresentation) {
|
|
1322
|
+
const mine = ++sessionTaps;
|
|
1323
|
+
const email = await signedInEmail();
|
|
1324
|
+
if (mine !== sessionTaps) return undefined;
|
|
1325
|
+
// From the state as it is NOW: a reload may have landed during the await. The pick
|
|
1326
|
+
// stays only while that time is still bookable.
|
|
1327
|
+
const now = svc.getState?.() ?? s;
|
|
1328
|
+
const open = allRows.some((row) => row.id === slotId && row.selectable);
|
|
1329
|
+
if (email) {
|
|
1330
|
+
return derive({ ...(open ? selectSlotIn(now, slotId) : now), isMember: true, identityLine: email, email,
|
|
1331
|
+
message: open ? copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it.") : "" });
|
|
1332
|
+
}
|
|
1333
|
+
// Still a guest: the sign-in opens over the newest state; a time gone meanwhile opens nothing.
|
|
1334
|
+
if (!open) return derive(now);
|
|
1335
|
+
return navigate(selectSlotIn(now, slotId), { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", intent: "book", modal: "1" } });
|
|
1336
|
+
}
|
|
1337
|
+
if (!selected.isMember) {
|
|
1311
1338
|
// Branded Lite opens its sign-in screen; other apps show the sign-in card on this screen.
|
|
1312
|
-
|
|
1339
|
+
// With a presentation it opens as a modal (`modal`: the sign-in worker closes it after a
|
|
1340
|
+
// sign-in), and this screen loads again under it with the guest's pick kept.
|
|
1341
|
+
if (!signInScreenId) return selected;
|
|
1342
|
+
return navigate(selected, signInPresentation
|
|
1343
|
+
? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", intent: "book", modal: "1" } }
|
|
1344
|
+
: { screenId: signInScreenId, args: { mode: "signin", intent: "book" } });
|
|
1313
1345
|
}
|
|
1314
1346
|
const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
|
|
1315
1347
|
if (!regular && !waitlistIds.has(slotId)) {
|
|
@@ -650,6 +650,9 @@ async function settled(next: S | undefined): Promise<S | undefined> {
|
|
|
650
650
|
|
|
651
651
|
/** How the app opens its sign-in screen, from the appear params ("" = push). */
|
|
652
652
|
let signInPresentation: "" | "sheet" | "full_screen" = "";
|
|
653
|
+
/** The app's sign-in screen (`signInScreenId` appear param): Branded Lite's `himi_sign_in` by
|
|
654
|
+
* default; an app with no such screen names its own (salon-spa: the Profile tab, `himi_account`). */
|
|
655
|
+
let signInScreenId = "himi_sign_in";
|
|
653
656
|
|
|
654
657
|
const svc = defineService<S, {
|
|
655
658
|
load: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
@@ -678,6 +681,8 @@ const svc = defineService<S, {
|
|
|
678
681
|
stashCopy(p);
|
|
679
682
|
const presentation = argOf(p, "signInPresentation");
|
|
680
683
|
if (presentation === "sheet" || presentation === "full_screen") signInPresentation = presentation;
|
|
684
|
+
const signInScreen = argOf(p, "signInScreenId");
|
|
685
|
+
if (/^[a-z][a-z0-9_]{0,63}$/.test(signInScreen)) signInScreenId = signInScreen;
|
|
681
686
|
const fw = Number(argOf(p, "firstWeekday") || s.firstWeekday);
|
|
682
687
|
const today = dayKey(new Date());
|
|
683
688
|
// First appearance: open on this week. A re-appearance keeps the member's place and
|
|
@@ -878,8 +883,8 @@ const svc = defineService<S, {
|
|
|
878
883
|
busy = false;
|
|
879
884
|
// As a modal it only closes, and this screen loads again (its re-appearance reload).
|
|
880
885
|
return navigate(s, signInPresentation
|
|
881
|
-
? { screenId:
|
|
882
|
-
: { screenId:
|
|
886
|
+
? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", modal: "1" } }
|
|
887
|
+
: { screenId: signInScreenId, args: { mode: "signin", returnScreenId: "himi_agenda" } });
|
|
883
888
|
}
|
|
884
889
|
const name = await memberName();
|
|
885
890
|
const current = await memberAccess().catch(() => undefined);
|