@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,544 @@
|
|
|
1
|
+
// Tier-5 bundle: himi-salon-home — the salon-spa template's Home, Treatments and About screens.
|
|
2
|
+
//
|
|
3
|
+
// One read of the site's Wix Bookings services feeds all three: the Home "Signature treatments"
|
|
4
|
+
// rail, the treatment-category tiles (Home and Categories) and the Categories search. Categories
|
|
5
|
+
// are DERIVED from the live services (name, count, the photo of its first pictured service), so
|
|
6
|
+
// a customer's own catalog shapes the screen with no configuration; a tile opens the shared
|
|
7
|
+
// Treatments list at its category (the list's `category` nav param). Prices and durations use
|
|
8
|
+
// the booking flow's own wording (_shared/himi-bookings.ts), so a treatment reads the same on
|
|
9
|
+
// every screen. The member's first name, for the greeting, comes from the same profile read the
|
|
10
|
+
// account screen uses.
|
|
11
|
+
//
|
|
12
|
+
// A failed read is an error with Retry and an empty catalog is an empty state — never sample
|
|
13
|
+
// rows. The greeting is read at load time only: the release build hashes the boot state, so a
|
|
14
|
+
// clock read at boot would make every build differ.
|
|
15
|
+
import { defineService, navigate } from "@himalaya/state";
|
|
16
|
+
import { queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
17
|
+
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
18
|
+
import { open as openUrl } from "@himalaya/open-url";
|
|
19
|
+
import { categoryName, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
|
|
20
|
+
import { loadMemberProfile, signedInEmail } from "../_shared/himi-account.js";
|
|
21
|
+
import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
22
|
+
|
|
23
|
+
export interface TreatmentRow {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
tagLine: string;
|
|
27
|
+
category: string;
|
|
28
|
+
/** "$250", "From $120", "Free" — as the Treatments list shows it; "" when there is none. */
|
|
29
|
+
price: string;
|
|
30
|
+
duration: string;
|
|
31
|
+
image: string;
|
|
32
|
+
hasImage: boolean;
|
|
33
|
+
noImage: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface CategoryRow {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
count: number;
|
|
39
|
+
countLabel: string;
|
|
40
|
+
image: string;
|
|
41
|
+
hasImage: boolean;
|
|
42
|
+
noImage: boolean;
|
|
43
|
+
/** The category's identity for the Treatments list's `category` nav param: its Wix id, else
|
|
44
|
+
* `name:<name>` (the key the list builds its category titles from). */
|
|
45
|
+
key: string;
|
|
46
|
+
}
|
|
47
|
+
export interface TeamRow {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
role: string;
|
|
51
|
+
image: string;
|
|
52
|
+
hasImage: boolean;
|
|
53
|
+
noImage: boolean;
|
|
54
|
+
initials: string;
|
|
55
|
+
}
|
|
56
|
+
interface S {
|
|
57
|
+
status: "loading" | "ready" | "error";
|
|
58
|
+
greeting: string;
|
|
59
|
+
firstName: string;
|
|
60
|
+
hasName: boolean;
|
|
61
|
+
noName: boolean;
|
|
62
|
+
isMember: boolean;
|
|
63
|
+
notMember: boolean;
|
|
64
|
+
memberInitials: string;
|
|
65
|
+
memberImage: string;
|
|
66
|
+
hasMemberImage: boolean;
|
|
67
|
+
noMemberImage: boolean;
|
|
68
|
+
popular: TreatmentRow[];
|
|
69
|
+
categories: CategoryRow[];
|
|
70
|
+
team: TeamRow[];
|
|
71
|
+
/** The staff read failed — the strip stays hidden (it is decoration), the catalog is unaffected. */
|
|
72
|
+
teamFailed: boolean;
|
|
73
|
+
noTeam: boolean;
|
|
74
|
+
query: string;
|
|
75
|
+
matches: TreatmentRow[];
|
|
76
|
+
/** The query `matches` answers. */
|
|
77
|
+
matchesFor: string;
|
|
78
|
+
noMatches: boolean;
|
|
79
|
+
notSearching: boolean;
|
|
80
|
+
searching: boolean;
|
|
81
|
+
matchesLabel: string;
|
|
82
|
+
noPopular: boolean;
|
|
83
|
+
noCategories: boolean;
|
|
84
|
+
total: number;
|
|
85
|
+
totalLabel: string;
|
|
86
|
+
emptyText: string;
|
|
87
|
+
errorText: string;
|
|
88
|
+
/** The outcome line of the last link action ("" when it opened). */
|
|
89
|
+
linkMessage: string;
|
|
90
|
+
isLoading: boolean;
|
|
91
|
+
notLoading: boolean;
|
|
92
|
+
errored: boolean;
|
|
93
|
+
notErrored: boolean;
|
|
94
|
+
noEmpty: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const POPULAR_MAX = 8;
|
|
98
|
+
const TEAM_MAX = 8;
|
|
99
|
+
|
|
100
|
+
type WixApi = { call: (method: string, path: string, body?: unknown) => Promise<unknown> };
|
|
101
|
+
function wixApi(): WixApi | null {
|
|
102
|
+
return (globalThis as { himalaya?: { wixApi?: WixApi } }).himalaya?.wixApi ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mediaUrl(value: unknown): string {
|
|
106
|
+
if (typeof value === "string") return wixMediaUrl(value);
|
|
107
|
+
if (!value || typeof value !== "object") return "";
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
for (const item of value) {
|
|
110
|
+
const url = mediaUrl(item);
|
|
111
|
+
if (url) return url;
|
|
112
|
+
}
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
const node = value as Record<string, unknown>;
|
|
116
|
+
for (const direct of [node.url, node.src, node.imageUrl]) {
|
|
117
|
+
const url = wixMediaUrl(direct);
|
|
118
|
+
if (url) return url;
|
|
119
|
+
}
|
|
120
|
+
for (const nested of [node.image, node.mainMedia, node.media, node.items, node.coverMedia]) {
|
|
121
|
+
const url = mediaUrl(nested);
|
|
122
|
+
if (url) return url;
|
|
123
|
+
}
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const clean = (value: unknown): string => (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "");
|
|
128
|
+
|
|
129
|
+
/** The booking flow's price and duration words, read from this screen's `treatment.` copy. */
|
|
130
|
+
const label: BookingLabel = (key, fallback, values) =>
|
|
131
|
+
values ? copyTemplate(`treatment.${key}`, fallback, values) : copyParam(`treatment.${key}`, fallback);
|
|
132
|
+
|
|
133
|
+
export function treatmentRow(service: WixService): TreatmentRow {
|
|
134
|
+
const name = clean(service.name) || copyParam("treatment.fallbackName", "Treatment");
|
|
135
|
+
const price = servicePriceLine(service, label);
|
|
136
|
+
const image = mediaUrl((service as { media?: unknown }).media);
|
|
137
|
+
return {
|
|
138
|
+
id: String(service.id ?? ""),
|
|
139
|
+
name,
|
|
140
|
+
tagLine: clean((service as { tagLine?: unknown }).tagLine),
|
|
141
|
+
category: categoryName(service.category),
|
|
142
|
+
// A custom price is a sentence ("Priced on consultation"): the detail says it, a tile does not.
|
|
143
|
+
price: price.isText ? "" : price.text,
|
|
144
|
+
duration: serviceDurationText(service, label),
|
|
145
|
+
image,
|
|
146
|
+
hasImage: !!image,
|
|
147
|
+
noImage: !image,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function countLabel(n: number): string {
|
|
152
|
+
return n === 1 ? copyParam("treatments.countOne", "1 treatment") : copyTemplate("treatments.countMany", "{count} treatments", { count: n });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One tile per category, in the catalog's own category order (sortOrder, then first seen). */
|
|
156
|
+
export function categoriesOf(services: WixService[]): CategoryRow[] {
|
|
157
|
+
const byName = new Map<string, { name: string; key: string; order: number; seen: number; rows: TreatmentRow[] }>();
|
|
158
|
+
services.forEach((service, index) => {
|
|
159
|
+
const name = categoryName(service.category);
|
|
160
|
+
if (!name) return;
|
|
161
|
+
const category = service.category as { id?: unknown; sortOrder?: unknown } | undefined;
|
|
162
|
+
const sortOrder = Number(category?.sortOrder);
|
|
163
|
+
const key = typeof category?.id === "string" && category.id ? category.id : `name:${name}`;
|
|
164
|
+
const entry = byName.get(name) ?? { name, key, order: Number.isFinite(sortOrder) ? sortOrder : Number.MAX_SAFE_INTEGER, seen: index, rows: [] };
|
|
165
|
+
entry.rows.push(treatmentRow(service));
|
|
166
|
+
byName.set(name, entry);
|
|
167
|
+
});
|
|
168
|
+
// A stable, id-safe key: component ids and web list keys are built from it, and a name may
|
|
169
|
+
// hold spaces. Two names can slug alike ("A/B", "A B"): a later one gets "-2", "-3", in
|
|
170
|
+
// catalog order, so no two tiles share an identity.
|
|
171
|
+
const used = new Set<string>();
|
|
172
|
+
const uniqueId = (name: string, seen: number): string => {
|
|
173
|
+
const base = `category-${name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "") || seen}`;
|
|
174
|
+
let id = base;
|
|
175
|
+
for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
|
|
176
|
+
used.add(id);
|
|
177
|
+
return id;
|
|
178
|
+
};
|
|
179
|
+
return [...byName.values()]
|
|
180
|
+
.sort((a, b) => a.order - b.order || a.seen - b.seen)
|
|
181
|
+
.map((entry) => {
|
|
182
|
+
const pictured = entry.rows.find((row) => row.hasImage);
|
|
183
|
+
return {
|
|
184
|
+
id: uniqueId(entry.name, entry.seen),
|
|
185
|
+
name: entry.name,
|
|
186
|
+
count: entry.rows.length,
|
|
187
|
+
countLabel: countLabel(entry.rows.length),
|
|
188
|
+
image: pictured?.image ?? "",
|
|
189
|
+
hasImage: !!pictured,
|
|
190
|
+
noImage: !pictured,
|
|
191
|
+
key: entry.key,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Pictured treatments first (a rail of monograms reads as unfinished), catalog order within. */
|
|
197
|
+
export function popularOf(services: WixService[]): TreatmentRow[] {
|
|
198
|
+
const rows = services.map(treatmentRow).filter((row) => row.id);
|
|
199
|
+
return [...rows.filter((row) => row.hasImage), ...rows.filter((row) => !row.hasImage)].slice(0, POPULAR_MAX);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function matchesOf(all: TreatmentRow[], query: string): TreatmentRow[] {
|
|
203
|
+
const q = query.trim().toLocaleLowerCase();
|
|
204
|
+
if (!q) return [];
|
|
205
|
+
return all.filter((row) => [row.name, row.category, row.tagLine].some((field) => field.toLocaleLowerCase().includes(q)));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function greetingNow(): string {
|
|
209
|
+
const hour = new Date().getHours();
|
|
210
|
+
if (hour < 12) return copyParam("home.greetingMorning", "Good morning");
|
|
211
|
+
if (hour < 18) return copyParam("home.greetingAfternoon", "Good afternoon");
|
|
212
|
+
return copyParam("home.greetingEvening", "Good evening");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The name to greet: the first word of a real name, never an email address. */
|
|
216
|
+
export function firstNameOf(memberName: string): string {
|
|
217
|
+
const name = memberName.trim();
|
|
218
|
+
if (!name || name.includes("@")) return "";
|
|
219
|
+
return name.split(/\s+/)[0] ?? "";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface RawStaff { id?: unknown; name?: unknown; description?: unknown; mainMedia?: unknown }
|
|
223
|
+
|
|
224
|
+
/** "Lead aesthetician" from "Lead aesthetician. Ten years of…": the description's first sentence. */
|
|
225
|
+
function roleOf(description: unknown): string {
|
|
226
|
+
const text = clean(description);
|
|
227
|
+
if (!text) return "";
|
|
228
|
+
const first = text.split(/(?<=[.!?])\s/)[0] ?? text;
|
|
229
|
+
return first.replace(/[.!?]$/, "").slice(0, 60);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function teamRow(raw: RawStaff): TeamRow {
|
|
233
|
+
const name = clean(raw.name);
|
|
234
|
+
const image = mediaUrl(raw.mainMedia);
|
|
235
|
+
const words = name.split(/\s+/).filter(Boolean);
|
|
236
|
+
return {
|
|
237
|
+
id: String(raw.id ?? ""),
|
|
238
|
+
name,
|
|
239
|
+
role: roleOf(raw.description),
|
|
240
|
+
image,
|
|
241
|
+
hasImage: !!image,
|
|
242
|
+
noImage: !image,
|
|
243
|
+
initials: ((words[0]?.[0] ?? "") + (words.length > 1 ? words[words.length - 1]?.[0] ?? "" : "")).toUpperCase(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The Bookings staff with a real name ("Staff Member #1" is Wix's unnamed default), pictured first.
|
|
248
|
+
* The team strip is decoration on Home: a failed read is reported (`failed`) and hides the strip,
|
|
249
|
+
* and it never fails the catalog. */
|
|
250
|
+
interface TeamRead { rows: TeamRow[]; failed: boolean }
|
|
251
|
+
async function loadTeam(): Promise<TeamRead> {
|
|
252
|
+
const api = wixApi();
|
|
253
|
+
if (!api) return { rows: [], failed: true };
|
|
254
|
+
try {
|
|
255
|
+
const res = await api.call("POST", "/bookings/v1/staff-members/query", { query: { cursorPaging: { limit: 50 } } }) as { staffMembers?: RawStaff[] } | null;
|
|
256
|
+
const rows = (res?.staffMembers ?? []).map(teamRow).filter((row) => row.id && row.name && !/^staff member\b/i.test(row.name));
|
|
257
|
+
return { rows: [...rows.filter((row) => row.hasImage), ...rows.filter((row) => !row.hasImage)].slice(0, TEAM_MAX), failed: false };
|
|
258
|
+
} catch {
|
|
259
|
+
return { rows: [], failed: true };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let allRows: TreatmentRow[] = [];
|
|
264
|
+
// The catalog read in this run. A restored (hydrated) screen paints before it lands: until then
|
|
265
|
+
// the search has nothing to match against, so a restored query keeps its restored results, a new
|
|
266
|
+
// one waits for the read, and neither shows "no matches".
|
|
267
|
+
let catalogRead = false;
|
|
268
|
+
|
|
269
|
+
function derive(s: S): S {
|
|
270
|
+
const loading = s.status === "loading";
|
|
271
|
+
const errored = s.status === "error";
|
|
272
|
+
const searching = !!s.query.trim();
|
|
273
|
+
const restored = !catalogRead && s.query === s.matchesFor;
|
|
274
|
+
const matches = catalogRead ? matchesOf(allRows, s.query) : restored ? s.matches : [];
|
|
275
|
+
return {
|
|
276
|
+
...s,
|
|
277
|
+
hasName: !!s.firstName,
|
|
278
|
+
noName: !s.firstName,
|
|
279
|
+
notMember: !s.isMember,
|
|
280
|
+
hasMemberImage: !!s.memberImage,
|
|
281
|
+
noMemberImage: !s.memberImage,
|
|
282
|
+
matches,
|
|
283
|
+
matchesFor: catalogRead || restored ? s.query : "",
|
|
284
|
+
searching,
|
|
285
|
+
notSearching: !searching,
|
|
286
|
+
noMatches: !searching || matches.length > 0 || !catalogRead,
|
|
287
|
+
matchesLabel: searching && (catalogRead || matches.length > 0) ? countLabel(matches.length) : "",
|
|
288
|
+
noPopular: s.popular.length === 0,
|
|
289
|
+
noTeam: s.team.length === 0,
|
|
290
|
+
noCategories: s.categories.length === 0,
|
|
291
|
+
totalLabel: s.total ? countLabel(s.total) : "",
|
|
292
|
+
isLoading: loading,
|
|
293
|
+
notLoading: !loading,
|
|
294
|
+
errored,
|
|
295
|
+
notErrored: !errored,
|
|
296
|
+
noEmpty: loading || errored || s.total > 0,
|
|
297
|
+
emptyText: copyParam("home.emptyText", "Treatments will appear here once the salon adds them."),
|
|
298
|
+
errorText: copyParam("home.errorText", "Treatments could not be loaded."),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const BOOT: S = derive({
|
|
303
|
+
status: "loading",
|
|
304
|
+
greeting: copyParam("home.greetingNeutral", "Welcome"),
|
|
305
|
+
firstName: "",
|
|
306
|
+
hasName: false,
|
|
307
|
+
noName: true,
|
|
308
|
+
isMember: false,
|
|
309
|
+
notMember: true,
|
|
310
|
+
memberInitials: "",
|
|
311
|
+
memberImage: "",
|
|
312
|
+
hasMemberImage: false,
|
|
313
|
+
noMemberImage: true,
|
|
314
|
+
popular: [],
|
|
315
|
+
categories: [],
|
|
316
|
+
team: [],
|
|
317
|
+
teamFailed: false,
|
|
318
|
+
noTeam: true,
|
|
319
|
+
query: "",
|
|
320
|
+
matches: [],
|
|
321
|
+
matchesFor: "",
|
|
322
|
+
noMatches: true,
|
|
323
|
+
notSearching: true,
|
|
324
|
+
searching: false,
|
|
325
|
+
matchesLabel: "",
|
|
326
|
+
noPopular: true,
|
|
327
|
+
noCategories: true,
|
|
328
|
+
total: 0,
|
|
329
|
+
totalLabel: "",
|
|
330
|
+
emptyText: "",
|
|
331
|
+
errorText: "",
|
|
332
|
+
linkMessage: "",
|
|
333
|
+
isLoading: true,
|
|
334
|
+
notLoading: false,
|
|
335
|
+
errored: false,
|
|
336
|
+
notErrored: true,
|
|
337
|
+
noEmpty: true,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const GUEST_PART: Pick<S, "isMember" | "firstName" | "memberInitials" | "memberImage"> = { isMember: false, firstName: "", memberInitials: "", memberImage: "" };
|
|
341
|
+
const MEMBER_BUDGET_MS = 6000;
|
|
342
|
+
|
|
343
|
+
async function memberPart(): Promise<Pick<S, "isMember" | "firstName" | "memberInitials" | "memberImage">> {
|
|
344
|
+
try {
|
|
345
|
+
const profile = await loadMemberProfile("");
|
|
346
|
+
return {
|
|
347
|
+
isMember: profile.isMember,
|
|
348
|
+
firstName: profile.isMember ? firstNameOf(profile.memberName) : "",
|
|
349
|
+
memberInitials: profile.isMember ? profile.memberInitials : "",
|
|
350
|
+
memberImage: profile.isMember ? profile.memberImage : "",
|
|
351
|
+
};
|
|
352
|
+
} catch {
|
|
353
|
+
// An unreadable profile greets a visitor; it never blocks the catalog.
|
|
354
|
+
return GUEST_PART;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The query as typed NOW. `load` resolves seconds after it started (three reads in parallel), and
|
|
359
|
+
// the search field is live while it runs: the snapshot `s` it was handed holds the query from
|
|
360
|
+
// before, so the answer must take this one, or "fac" typed meanwhile is wiped by the load.
|
|
361
|
+
// `null` until something is typed in this run: then the query on screen stands, which may be one
|
|
362
|
+
// a host restored (hydrated) with the screen.
|
|
363
|
+
let liveQuery: string | null = null;
|
|
364
|
+
|
|
365
|
+
// The whole menu, page by page (Bookings answers at most 100 per page): categories, counts and
|
|
366
|
+
// the Treatments search are derived from it, so a site with 101 services must not lose one.
|
|
367
|
+
// Capped so a runaway catalog cannot stall Home; 500 is far past any salon's menu.
|
|
368
|
+
const CATALOG_PAGE = 100, CATALOG_MAX = 500;
|
|
369
|
+
async function readCatalog(): Promise<{ ok: true; services: WixService[] } | { ok: false; services: WixService[] }> {
|
|
370
|
+
const services: WixService[] = [];
|
|
371
|
+
try {
|
|
372
|
+
for (let offset = 0; offset < CATALOG_MAX; offset += CATALOG_PAGE) {
|
|
373
|
+
const res = await queryServices({ query: { filter: { hidden: { $ne: true } }, paging: { limit: CATALOG_PAGE, offset } } });
|
|
374
|
+
const page = res.services ?? [];
|
|
375
|
+
services.push(...page.filter((service) => !!service.id));
|
|
376
|
+
if (page.length < CATALOG_PAGE) break;
|
|
377
|
+
}
|
|
378
|
+
return { ok: true, services };
|
|
379
|
+
} catch {
|
|
380
|
+
// A failed read is the error state (status "error" below), never a partial menu.
|
|
381
|
+
return { ok: false, services: [] };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Each load's number: a pooled screen re-entered while its first load still runs starts a second
|
|
386
|
+
// one, and only the newest may commit. An older one that lands later answers with the state as it
|
|
387
|
+
// is now (`now()`).
|
|
388
|
+
let loadSeq = 0;
|
|
389
|
+
// Each link tap's number, taken when the tap starts, and the tap whose answer set `linkMessage`.
|
|
390
|
+
// A failed link's message belongs to the visit it was tapped on: the next load clears it, unless
|
|
391
|
+
// the tap was made while that load's reads ran (an earlier visit's tap may answer during them).
|
|
392
|
+
let linkTaps = 0;
|
|
393
|
+
let messageTap = 0;
|
|
394
|
+
// Taps up to this number belong to a visit a finished load has replaced: their late answers are dropped.
|
|
395
|
+
let obsoleteTaps = 0;
|
|
396
|
+
// Each member-screen tap (openForMember): only the newest one navigates after its session read.
|
|
397
|
+
let memberTaps = 0;
|
|
398
|
+
|
|
399
|
+
async function load(s: S): Promise<S> {
|
|
400
|
+
const mine = ++loadSeq;
|
|
401
|
+
const tapsAtStart = linkTaps;
|
|
402
|
+
const [member, team, catalog] = await Promise.all([
|
|
403
|
+
// Bounded: a session read that never answers greets a visitor, never holds the catalog. The
|
|
404
|
+
// budget covers the profile reads after the session's, so a slow member is still named.
|
|
405
|
+
within(memberPart(), MEMBER_BUDGET_MS, GUEST_PART),
|
|
406
|
+
loadTeam(),
|
|
407
|
+
readCatalog(),
|
|
408
|
+
]);
|
|
409
|
+
if (mine !== loadSeq) return now();
|
|
410
|
+
obsoleteTaps = Math.max(obsoleteTaps, tapsAtStart);
|
|
411
|
+
// A link tapped during the reads keeps its message (About's links stay live meanwhile); one
|
|
412
|
+
// tapped before this load started is cleared, even if it answered during the reads.
|
|
413
|
+
const linkMessage = messageTap > tapsAtStart ? now().linkMessage : "";
|
|
414
|
+
const query = liveQuery ?? now().query;
|
|
415
|
+
if (!catalog.ok) return derive({ ...s, ...member, team: team.rows, teamFailed: team.failed, query, linkMessage, greeting: greetingNow(), status: "error" });
|
|
416
|
+
allRows = catalog.services.map(treatmentRow);
|
|
417
|
+
catalogRead = true;
|
|
418
|
+
return derive({
|
|
419
|
+
...s,
|
|
420
|
+
...member,
|
|
421
|
+
query,
|
|
422
|
+
linkMessage,
|
|
423
|
+
greeting: greetingNow(),
|
|
424
|
+
status: "ready",
|
|
425
|
+
popular: popularOf(catalog.services),
|
|
426
|
+
categories: categoriesOf(catalog.services),
|
|
427
|
+
team: team.rows,
|
|
428
|
+
teamFailed: team.failed,
|
|
429
|
+
total: allRows.length,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Directions, a call or an email: only these schemes leave the app, and a map link only to a
|
|
434
|
+
* maps host. A link the descriptor did not mean to send is refused, never opened. */
|
|
435
|
+
export function allowedLink(raw: string): string {
|
|
436
|
+
const url = raw.trim();
|
|
437
|
+
if (/^(tel|mailto):[^\s]+$/i.test(url)) return url;
|
|
438
|
+
// No `URL`: a bare iOS JSContext (the worker engine floor) has none, and `new URL` would throw
|
|
439
|
+
// there on every link. The host is read by hand — https only, no `user@` part (in
|
|
440
|
+
// "https://google.com@evil.example" the host is evil.example), then an optional port and a path.
|
|
441
|
+
// No backslash in the host either: a URL parser reads "\" as "/", so in
|
|
442
|
+
// "https://evil.example\.google.com" the host is evil.example.
|
|
443
|
+
const match = /^https:\/\/([^/?#:@\s\\]+)(?::\d+)?(?:[/?#]\S*)?$/i.exec(url);
|
|
444
|
+
if (match && /(^|\.)(google\.com|apple\.com|wix\.com|wixsite\.com|wixstudio\.com)$/i.test(match[1])) return url;
|
|
445
|
+
return "";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The newest state an action returned — what a slow action answers from after its await.
|
|
449
|
+
let latest: S = BOOT;
|
|
450
|
+
const commit = (next: S): S => (latest = next);
|
|
451
|
+
/** The state as it is now: the runtime's, which also holds a state a host restored (hydrated)
|
|
452
|
+
* without any action here, where `latest` would still be BOOT. */
|
|
453
|
+
const now = (): S => (svc.getState?.() as S | undefined) ?? latest;
|
|
454
|
+
|
|
455
|
+
/** Each category-tile tap is a new visit to the Treatments list, even to the category it showed
|
|
456
|
+
* last: the web host keeps one list worker and hands it the same `category` again. A fresh nonce
|
|
457
|
+
* (`categoryActivation`) lets it tell a new tap from a return (Branded Lite's shop does the same). */
|
|
458
|
+
let categoryTaps = 0;
|
|
459
|
+
|
|
460
|
+
const valueOf = (arg: unknown): string => (typeof arg === "string" ? arg : String((arg as { value?: unknown } | undefined)?.value ?? ""));
|
|
461
|
+
|
|
462
|
+
/** `read`, or `fallback` once `ms` pass, where the engine has a timer (the web and WebView hosts,
|
|
463
|
+
* where a `himi.wix.memberIdentity` that never answers has been seen). A bare JSContext has no
|
|
464
|
+
* timer; there it waits, as every read does. `read` must not reject. */
|
|
465
|
+
function within<T>(read: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
466
|
+
const host = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (timer: unknown) => void };
|
|
467
|
+
if (typeof host.setTimeout !== "function") return read;
|
|
468
|
+
return new Promise<T>((resolve) => {
|
|
469
|
+
const timer = host.setTimeout!(() => resolve(fallback), ms);
|
|
470
|
+
void read.then((value) => { host.clearTimeout?.(timer); resolve(value); });
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A tap's session read: one that never answers counts as a guest, so the tap opens the sign-in
|
|
475
|
+
* rather than nothing. */
|
|
476
|
+
const sessionEmail = (ms = 1500): Promise<string> => within(signedInEmail(), ms, "");
|
|
477
|
+
|
|
478
|
+
const svc = defineService<S, {
|
|
479
|
+
load: (s: S, arg?: unknown) => Promise<S>;
|
|
480
|
+
refresh: (s: S, arg?: unknown) => Promise<S>;
|
|
481
|
+
search: (s: S, arg?: unknown) => S;
|
|
482
|
+
clearSearch: (s: S) => S;
|
|
483
|
+
openLink: (s: S, arg?: unknown) => Promise<S | undefined>;
|
|
484
|
+
openCategory: (s: S, arg?: unknown) => S;
|
|
485
|
+
openForMember: (s: S, arg?: unknown) => Promise<S>;
|
|
486
|
+
}>("himiSalonHome", {
|
|
487
|
+
// A failed link's message belongs to that visit: a restored screen must not show it again.
|
|
488
|
+
notHydrated: ["linkMessage"],
|
|
489
|
+
state: BOOT,
|
|
490
|
+
actions: {
|
|
491
|
+
load: (s, arg) => { stashCopy(arg); return load(s).then(commit); },
|
|
492
|
+
refresh: (s, arg) => { stashCopy(arg); return load(s.status === "error" ? { ...s, status: "loading" } : s).then(commit); },
|
|
493
|
+
search: (s, arg) => { liveQuery = valueOf(arg).slice(0, 120); return commit(derive({ ...s, query: liveQuery })); },
|
|
494
|
+
clearSearch: (s) => { liveQuery = ""; return commit(derive({ ...s, query: "" })); },
|
|
495
|
+
openCategory: (s, arg) => {
|
|
496
|
+
const key = String((arg as { key?: unknown } | undefined)?.key ?? "").trim();
|
|
497
|
+
if (!key) return s;
|
|
498
|
+
return navigate(s, { screenId: "himi_services", args: { category: key, categoryActivation: `${Date.now().toString(36)}-${++categoryTaps}` } });
|
|
499
|
+
},
|
|
500
|
+
// A member screen (the chat): a member goes there; a guest gets the sign-in first, never the
|
|
501
|
+
// screen's own guest prompt. The session is asked at the tap, not read from the last load.
|
|
502
|
+
// With a presentation the sign-in is a modal that, once signed in, closes and opens the screen
|
|
503
|
+
// on `returnTab`; pushed, the sign-in pushes the screen itself (himi-store-account).
|
|
504
|
+
openForMember: async (_s, arg) => {
|
|
505
|
+
const field = (key: string): string => {
|
|
506
|
+
const value = (arg as Record<string, unknown> | undefined)?.[key];
|
|
507
|
+
return typeof value === "string" ? value : "";
|
|
508
|
+
};
|
|
509
|
+
const screenId = field("screenId"), signInScreenId = field("signInScreenId");
|
|
510
|
+
const presentation = field("signInPresentation") === "full_screen" ? "full_screen" : field("signInPresentation") === "sheet" ? "sheet" : "";
|
|
511
|
+
if (!screenId) return now();
|
|
512
|
+
const mine = ++memberTaps;
|
|
513
|
+
const email = signInScreenId ? await sessionEmail() : "";
|
|
514
|
+
// A newer tap is on its way: only it navigates (no second sheet, no sheet over the chat).
|
|
515
|
+
if (mine !== memberTaps) return now();
|
|
516
|
+
if (!signInScreenId || email) return navigate(now(), { screenId });
|
|
517
|
+
const args: Record<string, string> = { mode: "signin", returnScreenId: screenId };
|
|
518
|
+
if (!presentation) return navigate(now(), { screenId: signInScreenId, args });
|
|
519
|
+
if (field("returnTab")) args.returnTab = field("returnTab");
|
|
520
|
+
return navigate(now(), { screenId: signInScreenId, presentation, args: { ...args, modal: "1" } });
|
|
521
|
+
},
|
|
522
|
+
// After the await, `s` is the state from BEFORE the hand-off — a load may have finished
|
|
523
|
+
// meanwhile. Answer from the state as it is now (`now()`), or change nothing.
|
|
524
|
+
openLink: async (s, arg) => {
|
|
525
|
+
stashCopy(arg);
|
|
526
|
+
const url = allowedLink(String((arg as { url?: unknown } | undefined)?.url ?? ""));
|
|
527
|
+
const failed = copyParam("about.linkFailed", "This link could not be opened.");
|
|
528
|
+
const tap = ++linkTaps;
|
|
529
|
+
if (!url) { messageTap = tap; return commit(derive({ ...s, linkMessage: failed })); }
|
|
530
|
+
let opened = false;
|
|
531
|
+
try { opened = (await openUrl(url)).opened === true; } catch { opened = false; }
|
|
532
|
+
// A newer tap has answered, or a load has started a new visit since this tap: its answer
|
|
533
|
+
// would overwrite a newer result or show an old failure.
|
|
534
|
+
if (tap < messageTap || tap <= obsoleteTaps) return undefined;
|
|
535
|
+
messageTap = tap;
|
|
536
|
+
const current = now();
|
|
537
|
+
if (opened && !current.linkMessage) return undefined;
|
|
538
|
+
return commit(derive({ ...current, linkMessage: opened ? "" : failed }));
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
// No load at boot: Home, Treatments and About each load on appear, with their copy. A boot load
|
|
543
|
+
// ran the member, team and catalog reads a second time on every first open.
|
|
544
|
+
export { svc };
|