@wix/himalaya-cli 0.1493.0 → 0.1494.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/dev-server/src/builder.d.ts +2 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +52 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/index.d.ts +180 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +4 -2
- package/dist/cli.mjs +30 -29
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/qa-builds.md +2 -2
- package/guides/sdk-reference.json +256 -11
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +1624 -270
- package/templates/beauty-salon/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +514 -117
- package/templates/beauty-salon/tokens.json +6 -0
- package/templates/branded-lite/dev/index.ts +1621 -260
- package/templates/branded-lite/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +514 -117
- package/templates/branded-lite/tokens.json +1 -0
- package/templates/community-club/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/community-club/tokens.json +6 -0
- package/templates/events-meetups/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/events-meetups/tokens.json +6 -0
- package/templates/gym-fitness/dev/index.ts +1623 -269
- package/templates/gym-fitness/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +514 -117
- package/templates/gym-fitness/tokens.json +6 -0
- package/templates/nonprofit-cause/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/nonprofit-cause/tokens.json +6 -0
- package/templates/podcast-creator/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/podcast-creator/tokens.json +6 -0
- package/templates/portfolio-creative/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/portfolio-creative/tokens.json +6 -0
- package/templates/restaurant/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/restaurant/tokens.json +6 -0
- package/templates/retail-store/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/retail-store/tokens.json +6 -0
- package/templates/yoga-studio/dev/index.ts +1617 -263
- package/templates/yoga-studio/tier5-src/_shared/wix-service-price.ts +18 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +514 -117
- package/templates/yoga-studio/tokens.json +6 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Tier-5 bundle: himi-booking — single service detail.
|
|
2
|
-
import { defineService } from "@himalaya/state";
|
|
2
|
+
import { defineService, navigate } from "@himalaya/state";
|
|
3
3
|
import { share as shareSheet } from "@himalaya/share";
|
|
4
|
-
import {
|
|
4
|
+
import { open, defaultMode } from "@himalaya/open-url";
|
|
5
|
+
import { getService, type WixService, type WixServiceAddress, type WixServiceBookingPolicy } from "@himalaya/wix-bookings";
|
|
5
6
|
import { categoryName } from "../_shared/himi-bookings.js";
|
|
6
|
-
import { formatServicePrice } from "../_shared/wix-service-price.js";
|
|
7
|
-
import { stashCopy, copyParam } from "../_shared/himi-copy.js";
|
|
7
|
+
import { formatServicePrice, formatServiceMoney, serviceCurrency } from "../_shared/wix-service-price.js";
|
|
8
|
+
import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
8
9
|
import { get, type DbEntity } from "@himalaya/local-db";
|
|
9
|
-
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
10
|
+
import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
|
|
10
11
|
|
|
11
12
|
// Offline-first (data-offline): the service detail is a public read-through. On a
|
|
12
13
|
// fetch failure we fall back to the last-synced row for this service written by
|
|
@@ -18,26 +19,87 @@ async function readCached(id: string): Promise<DbEntity | null> {
|
|
|
18
19
|
try { return await get(ENTITY, id); } catch { /* capability not granted → online-only */ return null; }
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Staff names come back only on request (Wix "Get Service" `conditionalFields`). */
|
|
23
|
+
const SERVICE_FIELDS = { conditionalFields: ["STAFF_MEMBER_DETAILS"] };
|
|
24
|
+
/** At most this many location rows, in record order. */
|
|
25
|
+
const MAX_LOCATIONS = 3;
|
|
26
|
+
|
|
27
|
+
interface ServiceImage { url: string; alt: string }
|
|
28
|
+
interface LocationRow {
|
|
29
|
+
index: number;
|
|
30
|
+
title: string;
|
|
31
|
+
/** A maps search for the address; "" for the client's place. */
|
|
32
|
+
directionsUrl: string;
|
|
33
|
+
tappable: boolean;
|
|
34
|
+
notTappable: boolean;
|
|
35
|
+
a11y: string;
|
|
36
|
+
}
|
|
37
|
+
interface PolicyLine { text: string; owner: boolean; notOwner: boolean }
|
|
38
|
+
|
|
21
39
|
interface S {
|
|
22
40
|
status: "loading" | "ready";
|
|
23
41
|
id: string;
|
|
24
42
|
name: string;
|
|
25
|
-
|
|
26
|
-
description: string;
|
|
27
|
-
/** Labeled facts (price / duration / category) — previously collapsed into
|
|
28
|
-
* the one footnote `subtitle`; each collapses when the catalog carries none. */
|
|
29
|
-
price: string;
|
|
30
|
-
duration: string;
|
|
43
|
+
/** The category name, shown as the small label above the name. */
|
|
31
44
|
category: string;
|
|
32
|
-
hasFacts: boolean;
|
|
33
|
-
noFacts: boolean;
|
|
34
|
-
noPrice: boolean;
|
|
35
|
-
noDuration: boolean;
|
|
36
45
|
noCategory: boolean;
|
|
37
|
-
|
|
46
|
+
tagline: string;
|
|
47
|
+
noTagline: boolean;
|
|
48
|
+
/** The price line: "₪90" + "per session", "From ₪60", "Free", or a CUSTOM description. */
|
|
49
|
+
priceText: string;
|
|
50
|
+
priceUnit: string;
|
|
51
|
+
/** A CUSTOM rate: the owner's own words stand in for the price, with no unit. */
|
|
52
|
+
priceIsText: boolean;
|
|
53
|
+
noPrice: boolean;
|
|
54
|
+
noPriceUnit: boolean;
|
|
55
|
+
/** Hide the amount row (no price, or a CUSTOM text price). */
|
|
56
|
+
noPriceAmount: boolean;
|
|
57
|
+
/** Hide the CUSTOM text price (any other rate). */
|
|
58
|
+
noPriceDescription: boolean;
|
|
59
|
+
priceA11y: string;
|
|
60
|
+
images: ServiceImage[];
|
|
61
|
+
/** The first image, for the one-image case. */
|
|
38
62
|
image: string;
|
|
63
|
+
hasGallery: boolean;
|
|
64
|
+
noGallery: boolean;
|
|
65
|
+
hasOneImage: boolean;
|
|
66
|
+
noOneImage: boolean;
|
|
39
67
|
hasImage: boolean;
|
|
40
68
|
noImage: boolean;
|
|
69
|
+
durationText: string;
|
|
70
|
+
noDuration: boolean;
|
|
71
|
+
locations: LocationRow[];
|
|
72
|
+
noLocations: boolean;
|
|
73
|
+
online: boolean;
|
|
74
|
+
noOnline: boolean;
|
|
75
|
+
staffText: string;
|
|
76
|
+
staffImage: string;
|
|
77
|
+
staffInitials: string;
|
|
78
|
+
hasStaffImage: boolean;
|
|
79
|
+
noStaffImage: boolean;
|
|
80
|
+
noStaff: boolean;
|
|
81
|
+
payText: string;
|
|
82
|
+
paySub: string;
|
|
83
|
+
noPay: boolean;
|
|
84
|
+
noPaySub: boolean;
|
|
85
|
+
/** The business confirms each booking: the button says "Request to Book". */
|
|
86
|
+
requestToBook: boolean;
|
|
87
|
+
notRequestToBook: boolean;
|
|
88
|
+
bookableOnline: boolean;
|
|
89
|
+
notBookableOnline: boolean;
|
|
90
|
+
about: string;
|
|
91
|
+
noAbout: boolean;
|
|
92
|
+
policyLines: PolicyLine[];
|
|
93
|
+
noPolicy: boolean;
|
|
94
|
+
policyExpanded: boolean;
|
|
95
|
+
policyCollapsed: boolean;
|
|
96
|
+
/** No fact row has data: the facts section and its divider hide. */
|
|
97
|
+
noFacts: boolean;
|
|
98
|
+
/** No price, duration or category: Branded Web's summary grid hides. */
|
|
99
|
+
noKeyFacts: boolean;
|
|
100
|
+
/** Loading, error or empty: the service content hides. */
|
|
101
|
+
hideContent: boolean;
|
|
102
|
+
hideFooter: boolean;
|
|
41
103
|
/** Share payload — the service page URL when the payload carries one. */
|
|
42
104
|
shareUrl: string;
|
|
43
105
|
timeScreenId: string;
|
|
@@ -62,69 +124,315 @@ function inputId(): string {
|
|
|
62
124
|
}
|
|
63
125
|
|
|
64
126
|
function stringOf(value: unknown): string {
|
|
65
|
-
return typeof value === "string" ? value : "";
|
|
127
|
+
return typeof value === "string" ? value.trim() : "";
|
|
66
128
|
}
|
|
67
129
|
|
|
68
130
|
function numberOf(value: unknown): number | undefined {
|
|
69
|
-
if (typeof value === "number") return value;
|
|
70
|
-
if (typeof value === "string" && value.trim())
|
|
131
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
132
|
+
if (typeof value === "string" && value.trim()) {
|
|
133
|
+
const n = Number(value);
|
|
134
|
+
return Number.isNaN(n) ? undefined : n;
|
|
135
|
+
}
|
|
71
136
|
return undefined;
|
|
72
137
|
}
|
|
73
138
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
139
|
+
// ---- Price -----------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
interface PriceLine { text: string; unit: string; isText: boolean }
|
|
142
|
+
|
|
143
|
+
/** The price line by rate type. FIXED → the price; VARIED → "From {price}"; CUSTOM → the
|
|
144
|
+
* owner's description, no unit; NO_FEE → "Free", no unit. */
|
|
145
|
+
function priceLine(service: WixService): PriceLine {
|
|
146
|
+
const payment = service.payment ?? {};
|
|
147
|
+
const rate = stringOf(payment.rateType).toUpperCase();
|
|
148
|
+
const currency = serviceCurrency(service);
|
|
149
|
+
const unit = copyParam("service.unitSession", "per session");
|
|
150
|
+
if (rate === "NO_FEE") return { text: copyParam("service.priceFree", "Free"), unit: "", isText: false };
|
|
151
|
+
if (rate === "CUSTOM") return { text: stringOf(payment.custom?.description), unit: "", isText: true };
|
|
152
|
+
if (rate === "VARIED") {
|
|
153
|
+
const from = formatServiceMoney(payment.varied?.minPrice, currency) || formatServiceMoney(payment.varied?.defaultPrice, currency);
|
|
154
|
+
return { text: from ? copyTemplate("service.priceFrom", "From {price}", { price: from }) : "", unit: from ? unit : "", isText: false };
|
|
155
|
+
}
|
|
156
|
+
// FIXED, or a legacy payload with no rate type.
|
|
157
|
+
const text = formatServiceMoney(payment.fixed?.price, currency) || formatServicePrice(service);
|
|
158
|
+
return { text, unit: text ? unit : "", isText: false };
|
|
78
159
|
}
|
|
79
160
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
161
|
+
// ---- Media -----------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Every distinct image of the service: `media.items`, then `mainMedia`, then `coverMedia`. */
|
|
164
|
+
function serviceImages(service: WixService, name: string): ServiceImage[] {
|
|
165
|
+
const media = service.media ?? {};
|
|
166
|
+
const out: ServiceImage[] = [];
|
|
167
|
+
for (const item of [...(media.items ?? []), media.mainMedia, media.coverMedia]) {
|
|
168
|
+
const url = wixMediaUrl(item?.image?.url, WIX_DETAIL_IMAGE);
|
|
169
|
+
if (url && !out.some((image) => image.url === url)) out.push({ url, alt: stringOf(item?.image?.altText) || name });
|
|
170
|
+
}
|
|
171
|
+
if (!out.length) {
|
|
172
|
+
// Older payloads carry one image in another shape.
|
|
173
|
+
const legacy = legacyImageUrl(service);
|
|
174
|
+
if (legacy) out.push({ url: legacy, alt: name });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
87
177
|
}
|
|
88
178
|
|
|
89
|
-
function
|
|
179
|
+
function legacyImageUrl(value: unknown): string {
|
|
90
180
|
if (typeof value === "string") return wixMediaUrl(value, WIX_DETAIL_IMAGE);
|
|
91
181
|
if (!value || typeof value !== "object") return "";
|
|
92
182
|
if (Array.isArray(value)) {
|
|
93
183
|
for (const item of value) {
|
|
94
|
-
const url =
|
|
184
|
+
const url = legacyImageUrl(item);
|
|
95
185
|
if (url) return url;
|
|
96
186
|
}
|
|
97
187
|
return "";
|
|
98
188
|
}
|
|
99
|
-
const node = value as
|
|
100
|
-
url?: unknown;
|
|
101
|
-
src?: unknown;
|
|
102
|
-
imageUrl?: unknown;
|
|
103
|
-
imageSrc?: unknown;
|
|
104
|
-
image?: unknown;
|
|
105
|
-
thumbnail?: unknown;
|
|
106
|
-
mediaItem?: unknown;
|
|
107
|
-
mainMedia?: unknown;
|
|
108
|
-
main?: unknown;
|
|
109
|
-
media?: unknown;
|
|
110
|
-
items?: unknown;
|
|
111
|
-
photos?: unknown;
|
|
112
|
-
gallery?: unknown;
|
|
113
|
-
coverImage?: unknown;
|
|
114
|
-
};
|
|
189
|
+
const node = value as Record<string, unknown>;
|
|
115
190
|
for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
|
|
116
191
|
const url = wixMediaUrl(direct, WIX_DETAIL_IMAGE);
|
|
117
192
|
if (url) return url;
|
|
118
193
|
}
|
|
119
|
-
for (const
|
|
120
|
-
const url =
|
|
194
|
+
for (const key of ["image", "thumbnail", "mediaItem", "mainMedia", "main", "media", "items", "photos", "gallery", "coverImage"]) {
|
|
195
|
+
const url = legacyImageUrl(node[key]);
|
|
121
196
|
if (url) return url;
|
|
122
197
|
}
|
|
123
198
|
return "";
|
|
124
199
|
}
|
|
125
200
|
|
|
126
|
-
|
|
127
|
-
|
|
201
|
+
// ---- Duration --------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
function minutesText(minutes: number): string {
|
|
204
|
+
if (minutes < 60) return `${minutes} min`;
|
|
205
|
+
const hours = Math.floor(minutes / 60);
|
|
206
|
+
const rest = minutes % 60;
|
|
207
|
+
return rest ? `${hours} hr ${rest} min` : `${hours} hr`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** "45 min", "1 hr 30 min"; a service with several lengths, or a flexible range the
|
|
211
|
+
* customer picks from: "30 min - 1 hr", "1 day - 3 days". */
|
|
212
|
+
function durationText(service: WixService): string {
|
|
213
|
+
const constraints = service.schedule?.availabilityConstraints;
|
|
214
|
+
const positive = (values: unknown[]) => values.map(numberOf).filter((n): n is number => n != null && n > 0);
|
|
215
|
+
const listed = positive([
|
|
216
|
+
...(constraints?.sessionDurations ?? []),
|
|
217
|
+
...(constraints?.durations ?? []).map((d) => d?.minutes),
|
|
218
|
+
]);
|
|
219
|
+
const hours = constraints?.durationRange?.hourOptions;
|
|
220
|
+
const range = positive([hours?.minDurationInMinutes, hours?.maxDurationInMinutes]);
|
|
221
|
+
const days = constraints?.durationRange?.dayOptions;
|
|
222
|
+
const dayRange = positive([days?.minDurationInDays, days?.maxDurationInDays]);
|
|
223
|
+
if (!listed.length && !range.length && dayRange.length) {
|
|
224
|
+
const dayText = (n: number) => (n === 1 ? copyParam("service.day", "1 day") : copyTemplate("service.days", "{n} days", { n }));
|
|
225
|
+
const first = Math.min(...dayRange);
|
|
226
|
+
const last = Math.max(...dayRange);
|
|
227
|
+
return first === last ? dayText(first) : `${dayText(first)} - ${dayText(last)}`;
|
|
228
|
+
}
|
|
229
|
+
const minutes = listed.length ? listed : range.length ? range : positive([service.duration]);
|
|
230
|
+
if (!minutes.length) return "";
|
|
231
|
+
const min = Math.min(...minutes);
|
|
232
|
+
const max = Math.max(...minutes);
|
|
233
|
+
return min === max ? minutesText(min) : `${minutesText(min)} - ${minutesText(max)}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- Locations -------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/** The address as one line, or "" when it names no place. A country alone (the Wix default
|
|
239
|
+
* "Location 1") is not an address. */
|
|
240
|
+
function addressText(address: WixServiceAddress | undefined): string {
|
|
241
|
+
if (!address) return "";
|
|
242
|
+
const formatted = stringOf(address.formattedAddress);
|
|
243
|
+
if (formatted) return formatted;
|
|
244
|
+
const street = [stringOf(address.streetAddress?.number), stringOf(address.streetAddress?.name)].filter(Boolean).join(" ");
|
|
245
|
+
const line = [street, stringOf(address.streetAddress?.apt)].filter(Boolean).join(" ") || stringOf(address.addressLine);
|
|
246
|
+
const city = stringOf(address.city);
|
|
247
|
+
if (!line && !city) return "";
|
|
248
|
+
return [line, city, stringOf(address.subdivision), stringOf(address.postalCode), stringOf(address.country)].filter(Boolean).join(", ");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function directionsUrl(address: string): string {
|
|
252
|
+
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** One row per location the customer can act on: a BUSINESS or CUSTOM location with an
|
|
256
|
+
* address (tap → maps), or the client's place. At most {@link MAX_LOCATIONS}. */
|
|
257
|
+
function locationRows(service: WixService): LocationRow[] {
|
|
258
|
+
const rows: LocationRow[] = [];
|
|
259
|
+
for (const location of service.locations ?? []) {
|
|
260
|
+
if (rows.length >= MAX_LOCATIONS) break;
|
|
261
|
+
const type = stringOf(location?.type).toUpperCase();
|
|
262
|
+
if (type === "CUSTOMER") {
|
|
263
|
+
const title = copyParam("service.clientPlace", "Client's place");
|
|
264
|
+
rows.push({ index: rows.length, title, directionsUrl: "", tappable: false, notTappable: true, a11y: title });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const place = type === "BUSINESS" ? location.business : type === "CUSTOM" ? location.custom : undefined;
|
|
268
|
+
if (!place) continue;
|
|
269
|
+
const address = addressText(place.address) || addressText(location.calculatedAddress);
|
|
270
|
+
if (!address) continue;
|
|
271
|
+
const title = (type === "BUSINESS" ? stringOf(location.business?.name) : "") || address;
|
|
272
|
+
rows.push({
|
|
273
|
+
index: rows.length,
|
|
274
|
+
title,
|
|
275
|
+
directionsUrl: directionsUrl(address),
|
|
276
|
+
tappable: true,
|
|
277
|
+
notTappable: false,
|
|
278
|
+
a11y: copyTemplate("service.directionsLabel", "Get directions to {place}", { place: title }),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return rows;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- Staff -----------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
function initials(name: string): string {
|
|
287
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]!.toUpperCase()).join("");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function firstName(name: string): string {
|
|
291
|
+
return name.split(/\s+/)[0] ?? name;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** "With Noa Levi", "With Noa or Dana", "With Noa, Dana and 2 more". Empty when the
|
|
295
|
+
* record carries no staff details (a visitor may not be allowed to read them). */
|
|
296
|
+
function staffLine(service: WixService): { text: string; image: string; initials: string } {
|
|
297
|
+
const staff = (service.staffMemberDetails?.staffMembers ?? []).filter((member) => stringOf(member?.name));
|
|
298
|
+
if (!staff.length) return { text: "", image: "", initials: "" };
|
|
299
|
+
const names = staff.map((member) => stringOf(member.name));
|
|
300
|
+
const text = names.length === 1
|
|
301
|
+
? copyTemplate("service.staffOne", "With {name}", { name: names[0]! })
|
|
302
|
+
: names.length === 2
|
|
303
|
+
? copyTemplate("service.staffTwo", "With {a} or {b}", { a: firstName(names[0]!), b: firstName(names[1]!) })
|
|
304
|
+
: copyTemplate("service.staffMany", "With {a}, {b} and {count} more", { a: firstName(names[0]!), b: firstName(names[1]!), count: names.length - 2 });
|
|
305
|
+
return { text, image: wixMediaUrl(staff[0]!.mainMedia?.image?.url, WIX_LIST_IMAGE), initials: initials(names[0]!) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- How to pay ------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/** The payment row, by the first rule that matches. Never mentions pricing plans. */
|
|
311
|
+
function payLine(service: WixService): { text: string; sub: string } {
|
|
312
|
+
const payment = service.payment ?? {};
|
|
313
|
+
const rate = stringOf(payment.rateType).toUpperCase();
|
|
314
|
+
if (rate === "NO_FEE") return { text: "", sub: "" };
|
|
315
|
+
const options = payment.options ?? {};
|
|
316
|
+
const currency = serviceCurrency(service);
|
|
317
|
+
const depositMoney = payment.fixed?.deposit ?? payment.varied?.deposit;
|
|
318
|
+
const deposit = numberOf(depositMoney?.value);
|
|
319
|
+
if (options.deposit && deposit != null && deposit > 0) {
|
|
320
|
+
const price = rate === "VARIED" ? undefined : numberOf(payment.fixed?.price?.value);
|
|
321
|
+
const remaining = price != null && price > deposit ? price - deposit : undefined;
|
|
322
|
+
const depositCurrency = stringOf(depositMoney?.currency) || currency;
|
|
323
|
+
return {
|
|
324
|
+
text: copyTemplate("service.payDeposit", "{amount} deposit", { amount: formatServiceMoney(depositMoney, currency) }),
|
|
325
|
+
sub: remaining != null
|
|
326
|
+
? copyTemplate("service.payDueLater", "{amount} due later", { amount: formatServiceMoney({ value: String(remaining), currency: depositCurrency }, currency) })
|
|
327
|
+
: "",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (options.online && options.inPerson) return { text: copyParam("service.payBoth", "Pay now or in person"), sub: "" };
|
|
331
|
+
if (options.online) return { text: copyParam("service.payOnline", "Pay now"), sub: "" };
|
|
332
|
+
if (options.inPerson) return { text: copyParam("service.payInPerson", "Pay in person"), sub: "" };
|
|
333
|
+
return { text: "", sub: "" };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- Our policy ------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/** Minutes as words: under 60 → minutes; under 2 days, or not whole days → hours; else days. */
|
|
339
|
+
function windowText(minutes: number): string {
|
|
340
|
+
if (minutes < 60) {
|
|
341
|
+
return minutes === 1 ? copyParam("service.minute", "1 minute") : copyTemplate("service.minutes", "{n} minutes", { n: minutes });
|
|
342
|
+
}
|
|
343
|
+
if (minutes < 2880 || minutes % 1440 !== 0) {
|
|
344
|
+
const hours = Math.round((minutes / 60) * 10) / 10;
|
|
345
|
+
return hours === 1 ? copyParam("service.hour", "1 hour") : copyTemplate("service.hours", "{n} hours", { n: hours });
|
|
346
|
+
}
|
|
347
|
+
const days = minutes / 1440;
|
|
348
|
+
return days === 1 ? copyParam("service.day", "1 day") : copyTemplate("service.days", "{n} days", { n: days });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
type Change = { kind: "none" } | { kind: "anytime" } | { kind: "until"; minutes: number } | { kind: "off" };
|
|
352
|
+
|
|
353
|
+
function changeRule(enabled: boolean | undefined, limited: boolean | undefined, minutes: number | undefined): Change {
|
|
354
|
+
if (enabled === undefined) return { kind: "none" };
|
|
355
|
+
if (!enabled) return { kind: "off" };
|
|
356
|
+
return limited && minutes != null && minutes > 0 ? { kind: "until", minutes } : { kind: "anytime" };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Strip HTML from the owner's policy text but keep its line breaks. */
|
|
360
|
+
function policyText(text: string): string {
|
|
361
|
+
return text.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]*>/g, "").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The lines of "Our policy", from the business's own booking policy. The app never
|
|
365
|
+
* invents policy text: each line exists only when the policy states it. */
|
|
366
|
+
function policyLines(policy: WixServiceBookingPolicy | undefined): PolicyLine[] {
|
|
367
|
+
if (!policy) return [];
|
|
368
|
+
const lines: PolicyLine[] = [];
|
|
369
|
+
const add = (text: string, owner = false) => { if (text) lines.push({ text, owner, notOwner: !owner }); };
|
|
370
|
+
|
|
371
|
+
const custom = policy.customPolicyDescription;
|
|
372
|
+
if (custom?.enabled) add(policyText(stringOf(custom.description)), true);
|
|
373
|
+
|
|
374
|
+
const cancel = changeRule(policy.cancellationPolicy?.enabled, policy.cancellationPolicy?.limitLatestCancellation, numberOf(policy.cancellationPolicy?.latestCancellationInMinutes));
|
|
375
|
+
const reschedule = changeRule(policy.reschedulePolicy?.enabled, policy.reschedulePolicy?.limitLatestReschedule, numberOf(policy.reschedulePolicy?.latestRescheduleInMinutes));
|
|
376
|
+
if (cancel.kind === "until" && reschedule.kind === "until" && cancel.minutes === reschedule.minutes) {
|
|
377
|
+
add(copyTemplate("service.changeUntil", "Cancel or reschedule online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
|
|
378
|
+
} else if (cancel.kind === "anytime" && reschedule.kind === "anytime") {
|
|
379
|
+
add(copyParam("service.changeAnytime", "You can cancel or reschedule online at any time."));
|
|
380
|
+
} else {
|
|
381
|
+
if (cancel.kind === "until") add(copyTemplate("service.cancelUntil", "Cancel online up to {window} before the session starts.", { window: windowText(cancel.minutes) }));
|
|
382
|
+
if (cancel.kind === "anytime") add(copyParam("service.cancelAnytime", "You can cancel online at any time."));
|
|
383
|
+
if (cancel.kind === "off") add(copyParam("service.cancelNotOnline", "You can't cancel online. Contact us to cancel."));
|
|
384
|
+
if (reschedule.kind === "until") add(copyTemplate("service.rescheduleUntil", "Reschedule online up to {window} before the session starts.", { window: windowText(reschedule.minutes) }));
|
|
385
|
+
if (reschedule.kind === "anytime") add(copyParam("service.rescheduleAnytime", "You can reschedule online at any time."));
|
|
386
|
+
if (reschedule.kind === "off") add(copyParam("service.rescheduleNotOnline", "You can't reschedule online."));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const fees = policy.cancellationFeePolicy;
|
|
390
|
+
if (fees?.enabled) {
|
|
391
|
+
for (const window of fees.cancellationWindows ?? []) {
|
|
392
|
+
const percentage = stringOf(window?.percentage);
|
|
393
|
+
const fee = formatServiceMoney(window?.amount) || (percentage ? `${percentage}%` : "");
|
|
394
|
+
if (!fee) continue;
|
|
395
|
+
const start = numberOf(window?.startInMinutes);
|
|
396
|
+
if (start == null || start <= 0) add(copyTemplate("service.feeLate", "Late cancellations have a {fee} fee.", { fee }));
|
|
397
|
+
else if (cancel.kind === "until" && cancel.minutes === start) add(copyTemplate("service.feeAfterThat", "Cancellations after that have a {fee} fee.", { fee }));
|
|
398
|
+
else add(copyTemplate("service.feeWindow", "Cancellations less than {window} before the session starts have a {fee} fee.", { window: windowText(start), fee }));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const early = policy.limitEarlyBookingPolicy;
|
|
403
|
+
const earliest = numberOf(early?.earliestBookingInMinutes);
|
|
404
|
+
if (early?.enabled && earliest != null && earliest > 0) add(copyTemplate("service.bookingsOpen", "Bookings open {window} before the session starts.", { window: windowText(earliest) }));
|
|
405
|
+
const late = policy.limitLateBookingPolicy;
|
|
406
|
+
const latest = numberOf(late?.latestBookingInMinutes);
|
|
407
|
+
if (late?.enabled && latest != null && latest > 0) add(copyTemplate("service.bookingsClose", "Bookings close {window} before the session starts.", { window: windowText(latest) }));
|
|
408
|
+
|
|
409
|
+
// Live payloads also carry `participantsPolicy.enabled` (not in the V2 types); a disabled
|
|
410
|
+
// policy keeps its configured maximum, which is not a rule the customer is held to.
|
|
411
|
+
const participants = policy.participantsPolicy as { enabled?: boolean; maxParticipantsPerBooking?: number } | undefined;
|
|
412
|
+
const max = numberOf(participants?.maxParticipantsPerBooking);
|
|
413
|
+
if (participants?.enabled !== false && max != null && max > 1) add(copyTemplate("service.maxParticipants", "Book for up to {n} participants.", { n: max }));
|
|
414
|
+
return lines;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---- Share -----------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
/** The service's public page URL when the payload carries one ("" otherwise). Bookings
|
|
420
|
+
* V2 puts it at `urls.servicePage.url`; the other shapes are older payloads. */
|
|
421
|
+
function serviceShareUrl(service: WixService): string {
|
|
422
|
+
const page = stringOf(service.urls?.servicePage?.url);
|
|
423
|
+
if (/^https?:\/\//i.test(page)) return page;
|
|
424
|
+
const raw = service as unknown as {
|
|
425
|
+
url?: unknown;
|
|
426
|
+
servicePageUrl?: { base?: string; path?: string; relativePath?: string } | string;
|
|
427
|
+
};
|
|
428
|
+
if (typeof raw.url === "string" && /^https?:\/\//i.test(raw.url)) return raw.url;
|
|
429
|
+
const legacy = raw.servicePageUrl;
|
|
430
|
+
if (typeof legacy === "string" && /^https?:\/\//i.test(legacy)) return legacy;
|
|
431
|
+
if (legacy && typeof legacy === "object" && typeof legacy.base === "string" && legacy.base) {
|
|
432
|
+
const path = typeof legacy.path === "string" ? legacy.path : typeof legacy.relativePath === "string" ? legacy.relativePath : "";
|
|
433
|
+
return `${legacy.base.replace(/\/$/, "")}${path && !path.startsWith("/") ? "/" : ""}${path}`;
|
|
434
|
+
}
|
|
435
|
+
return "";
|
|
128
436
|
}
|
|
129
437
|
|
|
130
438
|
function plain(text: string | undefined): string {
|
|
@@ -134,68 +442,90 @@ function plain(text: string | undefined): string {
|
|
|
134
442
|
.trim();
|
|
135
443
|
}
|
|
136
444
|
|
|
445
|
+
// ---- State -----------------------------------------------------------------------
|
|
446
|
+
|
|
137
447
|
function derive(s: S): S {
|
|
138
|
-
const
|
|
448
|
+
const isLoading = s.status === "loading";
|
|
449
|
+
const hasService = !!s.name;
|
|
450
|
+
const hasImage = s.images.length > 0;
|
|
139
451
|
return {
|
|
140
452
|
...s,
|
|
141
|
-
isLoading
|
|
142
|
-
notLoading:
|
|
453
|
+
isLoading,
|
|
454
|
+
notLoading: !isLoading,
|
|
143
455
|
notErrored: !s.errored,
|
|
144
|
-
hasService
|
|
145
|
-
|
|
146
|
-
noFacts: !hasFacts,
|
|
147
|
-
noPrice: !s.price,
|
|
148
|
-
noDuration: !s.duration,
|
|
456
|
+
hasService,
|
|
457
|
+
noEmpty: isLoading || s.errored || hasService,
|
|
149
458
|
noCategory: !s.category,
|
|
150
|
-
|
|
459
|
+
noTagline: !s.tagline,
|
|
460
|
+
noPrice: !s.priceText,
|
|
461
|
+
noPriceUnit: !s.priceUnit,
|
|
462
|
+
noPriceAmount: !s.priceText || s.priceIsText,
|
|
463
|
+
noPriceDescription: !s.priceText || !s.priceIsText,
|
|
464
|
+
priceA11y: s.priceText && !s.priceIsText
|
|
465
|
+
? copyTemplate("service.priceLabel", "Price, {price}", { price: [s.priceText, s.priceUnit].filter(Boolean).join(" ") })
|
|
466
|
+
: "",
|
|
467
|
+
image: s.images[0]?.url ?? "",
|
|
468
|
+
hasGallery: s.images.length > 1,
|
|
469
|
+
noGallery: s.images.length < 2,
|
|
470
|
+
hasOneImage: s.images.length === 1,
|
|
471
|
+
noOneImage: s.images.length !== 1,
|
|
472
|
+
hasImage,
|
|
473
|
+
noImage: !hasImage,
|
|
474
|
+
noDuration: !s.durationText,
|
|
475
|
+
noLocations: s.locations.length === 0,
|
|
476
|
+
noOnline: !s.online,
|
|
477
|
+
noStaff: !s.staffText,
|
|
478
|
+
hasStaffImage: !!s.staffImage,
|
|
479
|
+
noStaffImage: !s.staffImage,
|
|
480
|
+
noPay: !s.payText,
|
|
481
|
+
noPaySub: !s.paySub,
|
|
482
|
+
notRequestToBook: !s.requestToBook,
|
|
483
|
+
notBookableOnline: !s.bookableOnline,
|
|
484
|
+
noAbout: !s.about,
|
|
485
|
+
noPolicy: s.policyLines.length === 0,
|
|
486
|
+
policyCollapsed: !s.policyExpanded,
|
|
487
|
+
noFacts: !s.durationText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
|
|
488
|
+
noKeyFacts: !s.priceText && !s.durationText && !s.category,
|
|
489
|
+
hideContent: isLoading || s.errored || !hasService,
|
|
490
|
+
hideFooter: isLoading || s.errored || !hasService || !s.bookableOnline,
|
|
151
491
|
emptyText: s.errored
|
|
152
492
|
? copyParam("service.errorText", "Service details could not be loaded.")
|
|
153
493
|
: copyParam("service.emptyText", "This service is not available."),
|
|
154
494
|
};
|
|
155
495
|
}
|
|
156
496
|
|
|
157
|
-
/** The service's public page URL when the payload carries one ("" otherwise). */
|
|
158
|
-
function serviceShareUrl(service: WixService): string {
|
|
159
|
-
const raw = service as unknown as {
|
|
160
|
-
url?: unknown;
|
|
161
|
-
servicePageUrl?: { base?: string; path?: string; relativePath?: string } | string;
|
|
162
|
-
};
|
|
163
|
-
if (typeof raw.url === "string" && /^https?:\/\//i.test(raw.url)) return raw.url;
|
|
164
|
-
const page = raw.servicePageUrl;
|
|
165
|
-
if (typeof page === "string" && /^https?:\/\//i.test(page)) return page;
|
|
166
|
-
if (page && typeof page === "object" && typeof page.base === "string" && page.base) {
|
|
167
|
-
const path = typeof page.path === "string" ? page.path : typeof page.relativePath === "string" ? page.relativePath : "";
|
|
168
|
-
return `${page.base.replace(/\/$/, "")}${path && !path.startsWith("/") ? "/" : ""}${path}`;
|
|
169
|
-
}
|
|
170
|
-
return "";
|
|
171
|
-
}
|
|
172
|
-
|
|
173
497
|
function withService(s: S, service: WixService): S {
|
|
174
498
|
const serviceId = stringOf(service.id) || s.id;
|
|
175
499
|
const name = stringOf(service.name) || "Service";
|
|
176
|
-
const price =
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
// (the list worker joins the same field; see _shared/himi-bookings.ts).
|
|
180
|
-
const category = categoryName(service.category);
|
|
181
|
-
const bits = [category, duration, price].filter(Boolean);
|
|
182
|
-
const image = imageUrl(service);
|
|
500
|
+
const price = priceLine(service);
|
|
501
|
+
const staff = staffLine(service);
|
|
502
|
+
const pay = payLine(service);
|
|
183
503
|
return derive({
|
|
184
504
|
...s,
|
|
185
505
|
status: "ready",
|
|
186
506
|
errored: false,
|
|
187
|
-
notErrored: true,
|
|
188
507
|
id: serviceId,
|
|
189
508
|
name,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
509
|
+
// Live payloads carry `category` as a structured object — extract the name
|
|
510
|
+
// (the list worker joins the same field; see _shared/himi-bookings.ts).
|
|
511
|
+
category: categoryName(service.category),
|
|
512
|
+
tagline: stringOf(service.tagLine),
|
|
513
|
+
priceText: price.text,
|
|
514
|
+
priceUnit: price.unit,
|
|
515
|
+
priceIsText: price.isText,
|
|
516
|
+
images: serviceImages(service, name),
|
|
517
|
+
durationText: durationText(service),
|
|
518
|
+
locations: locationRows(service),
|
|
519
|
+
online: service.conferencing?.enabled === true,
|
|
520
|
+
staffText: staff.text,
|
|
521
|
+
staffImage: staff.image,
|
|
522
|
+
staffInitials: staff.initials,
|
|
523
|
+
payText: pay.text,
|
|
524
|
+
paySub: pay.sub,
|
|
525
|
+
requestToBook: service.onlineBooking?.requireManualApproval === true,
|
|
526
|
+
bookableOnline: service.onlineBooking?.enabled !== false,
|
|
527
|
+
about: plain(service.description),
|
|
528
|
+
policyLines: policyLines(service.bookingPolicy),
|
|
199
529
|
shareUrl: serviceShareUrl(service),
|
|
200
530
|
// WS-C — canonical param-less target; the descriptor's choose-time CTA
|
|
201
531
|
// binds the separate `id` state field as a first-class nav param.
|
|
@@ -203,29 +533,84 @@ function withService(s: S, service: WixService): S {
|
|
|
203
533
|
});
|
|
204
534
|
}
|
|
205
535
|
|
|
536
|
+
/** The service record: with staff details when this token may read them, else without. */
|
|
537
|
+
async function fetchService(id: string): Promise<WixService | undefined> {
|
|
538
|
+
try {
|
|
539
|
+
return (await getService(id, SERVICE_FIELDS)).service;
|
|
540
|
+
} catch {
|
|
541
|
+
// A visitor token that may not read STAFF_MEMBER_DETAILS must not lose the whole screen.
|
|
542
|
+
return (await getService(id)).service;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** `{ index }` from a row tap, as a number (`$item.index` may arrive as a string). */
|
|
547
|
+
function tappedIndex(arg: unknown): number {
|
|
548
|
+
const a = arg as { index?: unknown; args?: { index?: unknown } } | null | undefined;
|
|
549
|
+
return numberOf(a?.index ?? a?.args?.index) ?? -1;
|
|
550
|
+
}
|
|
551
|
+
|
|
206
552
|
const id = inputId();
|
|
207
553
|
const svc = defineService<S, {
|
|
208
554
|
load: (s: S, arg?: unknown) => Promise<S>;
|
|
209
555
|
share: (s: S) => Promise<S>;
|
|
556
|
+
togglePolicy: (s: S) => S;
|
|
557
|
+
openDirections: (s: S, arg?: unknown) => Promise<S>;
|
|
558
|
+
chooseTime: (s: S) => S;
|
|
210
559
|
}>("himiBookingService", {
|
|
211
560
|
state: derive({
|
|
212
561
|
status: "loading",
|
|
213
562
|
id,
|
|
214
563
|
name: "",
|
|
215
|
-
subtitle: "",
|
|
216
|
-
description: "",
|
|
217
|
-
price: "",
|
|
218
|
-
duration: "",
|
|
219
564
|
category: "",
|
|
220
|
-
hasFacts: false,
|
|
221
|
-
noFacts: true,
|
|
222
|
-
noPrice: true,
|
|
223
|
-
noDuration: true,
|
|
224
565
|
noCategory: true,
|
|
225
|
-
|
|
566
|
+
tagline: "",
|
|
567
|
+
noTagline: true,
|
|
568
|
+
priceText: "",
|
|
569
|
+
priceUnit: "",
|
|
570
|
+
priceIsText: false,
|
|
571
|
+
noPrice: true,
|
|
572
|
+
noPriceUnit: true,
|
|
573
|
+
noPriceAmount: true,
|
|
574
|
+
noPriceDescription: true,
|
|
575
|
+
priceA11y: "",
|
|
576
|
+
images: [],
|
|
226
577
|
image: "",
|
|
578
|
+
hasGallery: false,
|
|
579
|
+
noGallery: true,
|
|
580
|
+
hasOneImage: false,
|
|
581
|
+
noOneImage: true,
|
|
227
582
|
hasImage: false,
|
|
228
583
|
noImage: true,
|
|
584
|
+
durationText: "",
|
|
585
|
+
noDuration: true,
|
|
586
|
+
locations: [],
|
|
587
|
+
noLocations: true,
|
|
588
|
+
online: false,
|
|
589
|
+
noOnline: true,
|
|
590
|
+
staffText: "",
|
|
591
|
+
staffImage: "",
|
|
592
|
+
staffInitials: "",
|
|
593
|
+
hasStaffImage: false,
|
|
594
|
+
noStaffImage: true,
|
|
595
|
+
noStaff: true,
|
|
596
|
+
payText: "",
|
|
597
|
+
paySub: "",
|
|
598
|
+
noPay: true,
|
|
599
|
+
noPaySub: true,
|
|
600
|
+
requestToBook: false,
|
|
601
|
+
notRequestToBook: true,
|
|
602
|
+
bookableOnline: true,
|
|
603
|
+
notBookableOnline: false,
|
|
604
|
+
about: "",
|
|
605
|
+
noAbout: true,
|
|
606
|
+
policyLines: [],
|
|
607
|
+
noPolicy: true,
|
|
608
|
+
policyExpanded: false,
|
|
609
|
+
policyCollapsed: true,
|
|
610
|
+
noFacts: true,
|
|
611
|
+
noKeyFacts: true,
|
|
612
|
+
hideContent: true,
|
|
613
|
+
hideFooter: true,
|
|
229
614
|
shareUrl: "",
|
|
230
615
|
timeScreenId: id ? "himi_time" : "himi_services", // WS-C canonical + `id` nav param
|
|
231
616
|
emptyText: "",
|
|
@@ -239,39 +624,36 @@ const svc = defineService<S, {
|
|
|
239
624
|
actions: {
|
|
240
625
|
load: async (s, arg) => {
|
|
241
626
|
stashCopy(arg);
|
|
242
|
-
if (!s.id) return derive({ ...s, status: "ready", errored: true
|
|
627
|
+
if (!s.id) return derive({ ...s, status: "ready", errored: true });
|
|
628
|
+
const start = { ...s, policyExpanded: false };
|
|
243
629
|
try {
|
|
244
|
-
const
|
|
245
|
-
return
|
|
630
|
+
const service = await fetchService(s.id);
|
|
631
|
+
return service ? withService(start, service) : derive({ ...start, status: "ready" });
|
|
246
632
|
} catch {
|
|
247
633
|
// Offline: paint from the last-synced services cache (written by the list
|
|
248
|
-
// worker) before falling back to the error state.
|
|
634
|
+
// worker) before falling back to the error state. The cache holds the name,
|
|
635
|
+
// the category and one image; every other row stays hidden.
|
|
249
636
|
const cached = await readCached(s.id);
|
|
250
637
|
if (cached) {
|
|
251
638
|
const name = String(cached.name ?? "") || "Service";
|
|
252
639
|
const image = String(cached.image ?? "");
|
|
253
640
|
return derive({
|
|
254
|
-
...
|
|
641
|
+
...start,
|
|
255
642
|
status: "ready",
|
|
256
643
|
errored: false,
|
|
257
|
-
notErrored: true,
|
|
258
644
|
id: s.id,
|
|
259
645
|
name,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
initial: String(cached.initial ?? "") || (name.trim()[0] || "S").toUpperCase(),
|
|
263
|
-
image,
|
|
264
|
-
hasImage: !!image,
|
|
265
|
-
noImage: !image,
|
|
646
|
+
category: String(cached.category ?? ""),
|
|
647
|
+
images: image ? [{ url: image, alt: name }] : [],
|
|
266
648
|
timeScreenId: `himi_time_${s.id}`,
|
|
267
649
|
});
|
|
268
650
|
}
|
|
269
|
-
return derive({ ...
|
|
651
|
+
return derive({ ...start, status: "ready", errored: true, name: "" });
|
|
270
652
|
}
|
|
271
653
|
},
|
|
272
654
|
share: async (s) => {
|
|
273
655
|
if (!s.name) return s;
|
|
274
|
-
const text = [s.name, s.
|
|
656
|
+
const text = [s.name, s.tagline].filter(Boolean).join(" — ");
|
|
275
657
|
try {
|
|
276
658
|
await shareSheet({ subject: s.name, text, ...(s.shareUrl ? { url: s.shareUrl } : {}) });
|
|
277
659
|
return s;
|
|
@@ -279,6 +661,21 @@ const svc = defineService<S, {
|
|
|
279
661
|
return s;
|
|
280
662
|
}
|
|
281
663
|
},
|
|
664
|
+
togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
|
|
665
|
+
// The footer button opens the time screen for the loaded service. A worker directive,
|
|
666
|
+
// not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
|
|
667
|
+
// dispatch their taps without resolving `$service:` params, so the id never arrived.
|
|
668
|
+
chooseTime: (s) => (s.id && s.hasService ? navigate(s, { screenId: "himi_time", args: { id: s.id } }) : s),
|
|
669
|
+
openDirections: async (s, arg) => {
|
|
670
|
+
const url = s.locations[tappedIndex(arg)]?.directionsUrl ?? "";
|
|
671
|
+
if (!url) return s;
|
|
672
|
+
try {
|
|
673
|
+
await open(url, defaultMode(url));
|
|
674
|
+
} catch {
|
|
675
|
+
// Nothing took the link; the screen stays as it was.
|
|
676
|
+
}
|
|
677
|
+
return s;
|
|
678
|
+
},
|
|
282
679
|
},
|
|
283
680
|
});
|
|
284
681
|
svc.load();
|