@wix/himalaya-cli 0.1500.0 → 0.1502.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +5 -4
- package/guides/splash-screens.md +56 -2
- package/package.json +2 -2
- package/templates/branded-lite/dev/index.ts +112 -25
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +24 -4
- package/templates/branded-lite/tier5-src/business-chat/index.ts +68 -20
package/dist/cli.mjs
CHANGED
|
@@ -2576,10 +2576,11 @@ __export(splash_exports, {
|
|
|
2576
2576
|
import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "node:fs";
|
|
2577
2577
|
import { dirname as dirname4, join as join5 } from "node:path";
|
|
2578
2578
|
function parseSplashConfig(configTs) {
|
|
2579
|
-
const block = /splash
|
|
2579
|
+
const block = /["']?splash["']?\s*[:=]\s*\{([^}]*)\}/.exec(configTs);
|
|
2580
2580
|
if (!block) return {};
|
|
2581
|
-
const backgroundColor = /backgroundColor
|
|
2582
|
-
|
|
2581
|
+
const backgroundColor = /["']?backgroundColor["']?\s*:\s*["']([^"']+)["']/.exec(block[1])?.[1];
|
|
2582
|
+
const logoAsset = /["']?logoAsset["']?\s*:\s*["']([^"']*)["']/.exec(block[1])?.[1];
|
|
2583
|
+
return { ...backgroundColor ? { backgroundColor } : {}, ...logoAsset !== void 0 ? { logoAsset } : {} };
|
|
2583
2584
|
}
|
|
2584
2585
|
function nativeConfigInputTokens(configTs) {
|
|
2585
2586
|
const declared = configTs ? parseSplashConfig(configTs).backgroundColor : void 0;
|
|
@@ -147009,7 +147010,7 @@ var init_template_trim = __esm({
|
|
|
147009
147010
|
return Array.isArray(items) ? items : void 0;
|
|
147010
147011
|
};
|
|
147011
147012
|
NAV_SCREEN_KEYS = /* @__PURE__ */ new Set(["screenId", "parentScreenId", "contentScreenId", "badgeScreenId"]);
|
|
147012
|
-
WORKER_SCREEN_PARAMS = /* @__PURE__ */ new Set(["signInScreen"]);
|
|
147013
|
+
WORKER_SCREEN_PARAMS = /* @__PURE__ */ new Set(["signInScreen", "productScreen", "serviceScreen"]);
|
|
147013
147014
|
sorted = (xs2) => [...new Set(xs2)].sort();
|
|
147014
147015
|
refuse3 = (e2) => ({ code: 2, result: { ok: false, error: e2.message, ...e2.detail } });
|
|
147015
147016
|
plural = (n2, word) => `${n2} ${word}${n2 === 1 ? "" : "s"}`;
|
package/guides/splash-screens.md
CHANGED
|
@@ -199,8 +199,8 @@ holding the readiness wait there would defeat the point of making this layer OTA
|
|
|
199
199
|
Two seams, and only one of them needs work:
|
|
200
200
|
|
|
201
201
|
- **OS splash → in-app surface.** The background is **already continuous** — both layers
|
|
202
|
-
resolve the same colour token —
|
|
203
|
-
|
|
202
|
+
resolve the same colour token — Android fades a declared gradient over this opaque base in 400 ms.
|
|
203
|
+
The *content* also eases in, so `HimalayaSplashView` / `HimalayaSplash` ease the
|
|
204
204
|
logo and caption in over `contentFadeInMs`.
|
|
205
205
|
- **In-app surface → real content.** Fades out over a fixed `exitFadeMs` (350 ms), via
|
|
206
206
|
`.splashExitAnimation(ready:)` on iOS and `HimalayaSplashHost` on Android.
|
|
@@ -340,3 +340,57 @@ Note that the `splash` accessibility id / `testTag` on some apps today belongs t
|
|
|
340
340
|
- [designing-himi-screens.md](designing-himi-screens.md) — composing the launch moment as design.
|
|
341
341
|
- [RFC 0012](../core/schema/RFC/0012-screen-rollout-and-authoring.md) — the binary/OTA seam.
|
|
342
342
|
- [RFC 0021](../core/schema/RFC/0021-design-kits.md) — why the background is a token.
|
|
343
|
+
|
|
344
|
+
### Branded-lite logo and handoff
|
|
345
|
+
|
|
346
|
+
Use the baked branded-lite app icon as the in-app logo:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
splash: { enabled: true, logoAsset: "branded_lite_app_icon", logoWidth: 160 }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Hide the logo on both Android splash layers with an **explicit empty string**:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
splash: { enabled: true, logoAsset: "" }
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`logoWidth` is the square in-app logo size in logical points/dp (default 96 for absent or non-positive values). It does not resize Android's OS-controlled icon slot.
|
|
359
|
+
Omitting `logoAsset` retains existing behavior: no in-app logo; the OS uses the
|
|
360
|
+
launcher icon. An empty value also produces no in-app logo on iOS, macOS and web.
|
|
361
|
+
Android 12+ always reserves an icon slot: the build emits a transparent splash
|
|
362
|
+
icon when hidden, while preserving the actual launcher icon. This native change
|
|
363
|
+
requires a new binary; changing the served block alone only changes the in-app layer.
|
|
364
|
+
|
|
365
|
+
A declared `splash.backgroundColor` now overrides branded-lite's provisioning
|
|
366
|
+
primary color for the native launch background, in both light and dark appearance.
|
|
367
|
+
Without it the provisioning color remains the default. For a gradient, set this
|
|
368
|
+
token to its first/top color. Android and iOS/macOS paint that solid color first and fade the
|
|
369
|
+
gradient over it in 400 ms (immediate with reduce motion or Determinism).
|
|
370
|
+
The Android branded-lite shell mounts its first screen beneath the splash after
|
|
371
|
+
seeding and dismisses after that screen draws a descriptor (including a worker's
|
|
372
|
+
initial loading state) or an error. Explicit `readySignal: "first_content"`
|
|
373
|
+
retains the WebView page-loaded signal. `maxHoldMs` remains the hard deadline;
|
|
374
|
+
auth gates also count as first content. The iOS branded-lite shell likewise mounts its
|
|
375
|
+
content beneath the overlay and waits for loaded content or an error to appear,
|
|
376
|
+
within the same deadline. Once dismissed, the iOS splash stays dismissed even if
|
|
377
|
+
OTA timing settings change. Branded-lite Android image splashes use translucent dark system-bar
|
|
378
|
+
scrims with light icons for legibility; color and gradient splashes remain full-bleed.
|
|
379
|
+
Web retains its current transitions and readiness.
|
|
380
|
+
The standalone branded-lite macOS shell currently does not mount a boot splash
|
|
381
|
+
overlay; `splash.enabled` does not enable one there. The shared SwiftUI background
|
|
382
|
+
renderer is available on macOS, but this host handoff integration covers Android
|
|
383
|
+
and iOS. macOS shell integration requires a separate change.
|
|
384
|
+
|
|
385
|
+
Branded-lite builds expose the launcher artwork as the regular iOS image asset
|
|
386
|
+
`branded_lite_app_icon`, so the same logo configuration works on Android and iOS.
|
|
387
|
+
|
|
388
|
+
Native iOS hosts that use `HimalayaScreenHost` can opt into
|
|
389
|
+
`.onHimalayaScreenContent { ... }` to observe the appearance of loaded content or
|
|
390
|
+
an error. The callback ignores the host loader and does not replace a WebView's
|
|
391
|
+
page-loaded readiness signal. Existing hosts behave as before unless they opt in.
|
|
392
|
+
|
|
393
|
+
If cached splash configuration references missing or invalid colors after a binary
|
|
394
|
+
update, native splash stops fall back to the flat background, then `accentPrimary`,
|
|
395
|
+
then opaque white. Branded-lite also shows a shell loader if `maxHoldMs` expires
|
|
396
|
+
before seed prefill completes; the deadline remains unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1502.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -71,5 +71,5 @@
|
|
|
71
71
|
"artifactId": "himalaya-cli"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
|
-
"falconPackageHash": "
|
|
74
|
+
"falconPackageHash": "82f6fd0420b1bcd4738afd24ff1b18fa4a0df4fdf4813e7596851c23"
|
|
75
75
|
}
|
|
@@ -89216,24 +89216,65 @@ const SCREENS = runtimeSetting({
|
|
|
89216
89216
|
"itemsPath": "$item.articleActions",
|
|
89217
89217
|
"items": [
|
|
89218
89218
|
{
|
|
89219
|
-
"id": "himi_business_chat.
|
|
89219
|
+
"id": "himi_business_chat.article_action.slot.$item.id",
|
|
89220
89220
|
"body": {
|
|
89221
89221
|
"package": "himalaya",
|
|
89222
|
-
"type": "
|
|
89222
|
+
"type": "VStack",
|
|
89223
89223
|
"props": {
|
|
89224
|
-
"
|
|
89225
|
-
|
|
89226
|
-
|
|
89227
|
-
|
|
89228
|
-
|
|
89229
|
-
|
|
89230
|
-
|
|
89231
|
-
|
|
89224
|
+
"children": [
|
|
89225
|
+
{
|
|
89226
|
+
"id": "himi_business_chat.article_action.web.$item.id",
|
|
89227
|
+
"body": {
|
|
89228
|
+
"package": "himalaya",
|
|
89229
|
+
"type": "Button",
|
|
89230
|
+
"props": {
|
|
89231
|
+
"title": "$item.label",
|
|
89232
|
+
"style": "secondary",
|
|
89233
|
+
"size": "sm",
|
|
89234
|
+
"fullWidth": false,
|
|
89235
|
+
"action": {
|
|
89236
|
+
"actionId": "capability.open_url.open",
|
|
89237
|
+
"params": {
|
|
89238
|
+
"url": "$item.url"
|
|
89239
|
+
}
|
|
89240
|
+
}
|
|
89241
|
+
}
|
|
89242
|
+
},
|
|
89243
|
+
"modifiers": {
|
|
89244
|
+
"hiddenPath": "$item.inApp"
|
|
89245
|
+
}
|
|
89246
|
+
},
|
|
89247
|
+
{
|
|
89248
|
+
"id": "himi_business_chat.article_action.app.$item.id",
|
|
89249
|
+
"body": {
|
|
89250
|
+
"package": "himalaya",
|
|
89251
|
+
"type": "Button",
|
|
89252
|
+
"props": {
|
|
89253
|
+
"title": "$item.label",
|
|
89254
|
+
"style": "secondary",
|
|
89255
|
+
"size": "sm",
|
|
89256
|
+
"fullWidth": false,
|
|
89257
|
+
"action": {
|
|
89258
|
+
"actionId": "himi.open",
|
|
89259
|
+
"params": {
|
|
89260
|
+
"screenId": "$item.screenId",
|
|
89261
|
+
"id": "$item.targetId"
|
|
89262
|
+
}
|
|
89263
|
+
}
|
|
89264
|
+
}
|
|
89265
|
+
},
|
|
89266
|
+
"modifiers": {
|
|
89267
|
+
"hiddenPath": "$item.web"
|
|
89268
|
+
}
|
|
89232
89269
|
}
|
|
89233
|
-
|
|
89270
|
+
],
|
|
89271
|
+
"spacing": 0,
|
|
89272
|
+
"alignment": "leading"
|
|
89234
89273
|
}
|
|
89235
89274
|
},
|
|
89236
|
-
"modifiers": {
|
|
89275
|
+
"modifiers": {
|
|
89276
|
+
"fillMaxWidth": true
|
|
89277
|
+
}
|
|
89237
89278
|
}
|
|
89238
89279
|
],
|
|
89239
89280
|
"spacing": 6
|
|
@@ -89607,24 +89648,65 @@ const SCREENS = runtimeSetting({
|
|
|
89607
89648
|
"itemsPath": "$item.articleActions",
|
|
89608
89649
|
"items": [
|
|
89609
89650
|
{
|
|
89610
|
-
"id": "himi_business_chat.
|
|
89651
|
+
"id": "himi_business_chat.card_action.slot.$item.id",
|
|
89611
89652
|
"body": {
|
|
89612
89653
|
"package": "himalaya",
|
|
89613
|
-
"type": "
|
|
89654
|
+
"type": "VStack",
|
|
89614
89655
|
"props": {
|
|
89615
|
-
"
|
|
89616
|
-
|
|
89617
|
-
|
|
89618
|
-
|
|
89619
|
-
|
|
89620
|
-
|
|
89621
|
-
|
|
89622
|
-
|
|
89656
|
+
"children": [
|
|
89657
|
+
{
|
|
89658
|
+
"id": "himi_business_chat.card_action.web.$item.id",
|
|
89659
|
+
"body": {
|
|
89660
|
+
"package": "himalaya",
|
|
89661
|
+
"type": "Button",
|
|
89662
|
+
"props": {
|
|
89663
|
+
"title": "$item.label",
|
|
89664
|
+
"style": "secondary",
|
|
89665
|
+
"size": "md",
|
|
89666
|
+
"fullWidth": true,
|
|
89667
|
+
"action": {
|
|
89668
|
+
"actionId": "capability.open_url.open",
|
|
89669
|
+
"params": {
|
|
89670
|
+
"url": "$item.url"
|
|
89671
|
+
}
|
|
89672
|
+
}
|
|
89673
|
+
}
|
|
89674
|
+
},
|
|
89675
|
+
"modifiers": {
|
|
89676
|
+
"hiddenPath": "$item.inApp"
|
|
89677
|
+
}
|
|
89678
|
+
},
|
|
89679
|
+
{
|
|
89680
|
+
"id": "himi_business_chat.card_action.app.$item.id",
|
|
89681
|
+
"body": {
|
|
89682
|
+
"package": "himalaya",
|
|
89683
|
+
"type": "Button",
|
|
89684
|
+
"props": {
|
|
89685
|
+
"title": "$item.label",
|
|
89686
|
+
"style": "secondary",
|
|
89687
|
+
"size": "md",
|
|
89688
|
+
"fullWidth": true,
|
|
89689
|
+
"action": {
|
|
89690
|
+
"actionId": "himi.open",
|
|
89691
|
+
"params": {
|
|
89692
|
+
"screenId": "$item.screenId",
|
|
89693
|
+
"id": "$item.targetId"
|
|
89694
|
+
}
|
|
89695
|
+
}
|
|
89696
|
+
}
|
|
89697
|
+
},
|
|
89698
|
+
"modifiers": {
|
|
89699
|
+
"hiddenPath": "$item.web"
|
|
89700
|
+
}
|
|
89623
89701
|
}
|
|
89624
|
-
|
|
89702
|
+
],
|
|
89703
|
+
"spacing": 0,
|
|
89704
|
+
"alignment": "leading"
|
|
89625
89705
|
}
|
|
89626
89706
|
},
|
|
89627
|
-
"modifiers": {
|
|
89707
|
+
"modifiers": {
|
|
89708
|
+
"fillMaxWidth": true
|
|
89709
|
+
}
|
|
89628
89710
|
}
|
|
89629
89711
|
],
|
|
89630
89712
|
"spacing": 6
|
|
@@ -89990,7 +90072,12 @@ const SCREENS = runtimeSetting({
|
|
|
89990
90072
|
"token": "surfaceSecondary"
|
|
89991
90073
|
},
|
|
89992
90074
|
"onAppear": {
|
|
89993
|
-
"actionId": "tier5.businessChat.init"
|
|
90075
|
+
"actionId": "tier5.businessChat.init",
|
|
90076
|
+
"params": {
|
|
90077
|
+
"siteUrl": "__HIMI_MEMBER_SITE_URL__",
|
|
90078
|
+
"productScreen": "himi_product",
|
|
90079
|
+
"serviceScreen": "himi_service"
|
|
90080
|
+
}
|
|
89994
90081
|
}
|
|
89995
90082
|
}
|
|
89996
90083
|
},
|
|
@@ -14,7 +14,26 @@ export type BusinessMessagePart = {
|
|
|
14
14
|
|
|
15
15
|
export type BusinessTextRun = { text: string; marks?: string[] };
|
|
16
16
|
export type BusinessSuggestion = { id: string; messageId: string; label: string; kind: "postback" | "visitor_option"; appId?: string; interactionId?: string; enabled: boolean; disabled: boolean };
|
|
17
|
-
|
|
17
|
+
/** `inApp` buttons open `screenId` with `targetId` inside the app; the rest open `url` in the browser. Only a
|
|
18
|
+
* `routable` one (a link in the text or a template button, never a file) may be opened in the app. */
|
|
19
|
+
export type BusinessArticleAction = { id: string; label: string; url: string; style: "primary" | "secondary"; screenId: string; targetId: string; inApp: boolean; web: boolean; routable: boolean };
|
|
20
|
+
/** The app's own screen for a site link, named by the slug in the link's path. */
|
|
21
|
+
export type BusinessInAppRoute = { kind: "product" | "service"; slug: string };
|
|
22
|
+
|
|
23
|
+
/** A store product page or a bookings service (its page or its booking calendar) on this app's own site has a screen
|
|
24
|
+
* in the app; another site's page (even one on the same host, as Wix sites share one), a pay link or a file has
|
|
25
|
+
* none, and with no site configured nothing is routed. The slug only names the item: the worker still has to find it. */
|
|
26
|
+
export function businessInAppRoute(url: string, siteUrl: string): BusinessInAppRoute | undefined {
|
|
27
|
+
const site = /^https:\/\/[^/?#\s]+(?:\/[^?#\s]*)?/i.exec(siteUrl.trim())?.[0].replace(/\/+$/, "") ?? "";
|
|
28
|
+
if (!site || !url.toLowerCase().startsWith(`${site.toLowerCase()}/`)) return undefined;
|
|
29
|
+
const match = /^\/(product-page|service-page|booking-calendar)\/([^/?#]+)\/?(?:[?#].*)?$/i.exec(url.slice(site.length));
|
|
30
|
+
if (!match) return undefined;
|
|
31
|
+
let slug: string;
|
|
32
|
+
// A malformed escape ("50%-off") names nothing the app can look up: the link stays a web link.
|
|
33
|
+
try { slug = decodeURIComponent(match[2]!); } catch { return undefined; }
|
|
34
|
+
return slug ? { kind: match[1]!.toLowerCase() === "product-page" ? "product" : "service", slug } : undefined;
|
|
35
|
+
}
|
|
36
|
+
const webAction = { screenId: "", targetId: "", inApp: false, web: true, routable: true };
|
|
18
37
|
export type BusinessVisitorOptions = { answerTo: string; labels: string[] };
|
|
19
38
|
|
|
20
39
|
export type BusinessMessage = {
|
|
@@ -274,7 +293,7 @@ function articleActions(message: RecordValue, messageId: string): BusinessArticl
|
|
|
274
293
|
if (!label || !rawUrl || button.payload != null) return [];
|
|
275
294
|
// No `URL` global in a worker: accept only a plain https URL whose authority carries no credentials.
|
|
276
295
|
if (!isSafeLink(rawUrl)) return [];
|
|
277
|
-
return [{ id: `${messageId}:article-${index}`, label, url: rawUrl, style: "secondary" as const }];
|
|
296
|
+
return [{ id: `${messageId}:article-${index}`, label, url: rawUrl, style: "secondary" as const, ...webAction }];
|
|
278
297
|
});
|
|
279
298
|
}
|
|
280
299
|
|
|
@@ -471,7 +490,8 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
471
490
|
const productPage = cardLike && !isAiGenerated ? [...extras.links, ...articleActions(message, row.id)].map((link) => /^https:\/\/[^/?#]+(?:\/[^/?#]+)*?\/product-page\/([^/?#]+)/i.exec(link.url)?.[1]).find(Boolean) : undefined;
|
|
472
491
|
// A file, recording or video has no inline player here: it gets a button that opens it.
|
|
473
492
|
const attachmentLinks = row.parts.filter((part) => (part.kind === "file" || part.kind === "audio" || part.kind === "video") && !!part.url)
|
|
474
|
-
|
|
493
|
+
// A file, recording or video always opens as itself in the browser, wherever its URL lives.
|
|
494
|
+
.map((part, at) => ({ id: `${row.id}:attachment-${at}`, label: (part.fileName || part.caption || `Open ${part.kind}`).slice(0, 40), url: part.url as string, style: "secondary" as const, ...webAction, routable: false }));
|
|
475
495
|
// The card look (a framed box with a centred title) is for what an owner tool sends: a product, coupon, scheduling
|
|
476
496
|
// or pay link, an event, or a file. A coupon's title is its code, which the coupon block already shows.
|
|
477
497
|
// An AI answer is text with link pills, as on the site's Smart Chat, even when it is shaped like a card.
|
|
@@ -481,7 +501,7 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
481
501
|
// Inbox adds to the text is left out of it.
|
|
482
502
|
const cardLines = cardTitle ? lines.slice(1) : titled.split("\n");
|
|
483
503
|
const cardDetail = (productPage ? cardLines.filter((line) => !/^\s*\d+\s+variants?\b|\|\s*(?:in|out of)\s+stock\s*$/i.test(line)) : cardLines).join("\n");
|
|
484
|
-
const articles = [...articleActions(message, row.id), ...attachmentLinks, ...extras.links.map((link, at) => ({ id: `${row.id}:link-${at}`, label: link.label, url: link.url, style: /\b(view|buy|shop|book|pay|order|checkout|reserve|schedule)\b/i.test(link.label) ? "primary" as const : "secondary" as const }))]
|
|
504
|
+
const articles = [...articleActions(message, row.id), ...attachmentLinks, ...extras.links.map((link, at) => ({ id: `${row.id}:link-${at}`, label: link.label, url: link.url, style: /\b(view|buy|shop|book|pay|order|checkout|reserve|schedule)\b/i.test(link.label) ? "primary" as const : "secondary" as const, ...webAction }))]
|
|
485
505
|
.filter((action, at, all) => all.findIndex((other) => other.url === action.url) === at);
|
|
486
506
|
|
|
487
507
|
return {
|
|
@@ -4,16 +4,17 @@ import type { ChatIdentity } from '../_shared/business-chat-transport.js';
|
|
|
4
4
|
import { type MessageRow } from '../_shared/business-chat-message-model.js';
|
|
5
5
|
import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
|
|
6
6
|
import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
|
|
7
|
-
import { dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
7
|
+
import { businessInAppRoute, type BusinessArticleAction, dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
|
|
8
8
|
import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
|
|
9
9
|
import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
|
|
10
10
|
import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
|
|
11
|
+
import { queryServices } from '@himalaya/wix-bookings';
|
|
11
12
|
|
|
12
13
|
type PendingIntent = BusinessChatIntent;
|
|
13
14
|
// Only what the screen reads. This state crosses into the worker on every keystroke, so a row carries no raw
|
|
14
15
|
// parts, plain text or text runs: they would be re-sent each time for nothing.
|
|
15
16
|
type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mine'> & {
|
|
16
|
-
senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; suggestions:BusinessSuggestion[]; articleActions:
|
|
17
|
+
senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; suggestions:BusinessSuggestion[]; articleActions:BusinessArticleAction[]; couponCode:string; noCoupon:boolean; notMine:boolean;
|
|
17
18
|
isCard:boolean; notCard:boolean; noSuggestions:boolean; noActions:boolean; cardTitle:string; cardDetail:string; noCardText:boolean;
|
|
18
19
|
dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
|
|
19
20
|
/** Only the member's own row that the server has not returned yet. */
|
|
@@ -82,6 +83,7 @@ function safeLoadFailure(error:unknown):string{
|
|
|
82
83
|
return `${loadStage}:${/^(TypeError|SyntaxError|ProtocolError|Unsupported|Error)$/.test(name)?name:'OtherError'}${safeCode}`;
|
|
83
84
|
}
|
|
84
85
|
function value(arg:unknown):string{if(typeof arg==='string')return arg;const a=arg as Record<string,unknown>|null;return typeof a?.value==='string'?a.value:'';}
|
|
86
|
+
function hasParam(arg:unknown,key:string):boolean{const a=arg as Record<string,unknown>|null;const p=a?.params as Record<string,unknown>|undefined;return typeof a?.[key]==='string'||typeof p?.[key]==='string';}
|
|
85
87
|
function param(arg:unknown,key:string):string{const a=arg as Record<string,unknown>|null;const p=a?.params as Record<string,unknown>|undefined;return typeof a?.[key]==='string'?a[key] as string:typeof p?.[key]==='string'?p[key] as string:'';}
|
|
86
88
|
// A private attachment's download URL costs a round trip and stays valid for minutes, so a refresh reuses it.
|
|
87
89
|
const attachmentUrls=new Map<string,{url:string;at:number}>();
|
|
@@ -94,21 +96,26 @@ async function privateAttachmentUrl(part:{mediaId?:string;mediaProvider?:string;
|
|
|
94
96
|
if(url)attachmentUrls.set(key,{url,at:Date.now()});
|
|
95
97
|
return url;
|
|
96
98
|
}
|
|
97
|
-
// A product card's picture comes from the store: the message carries only the product page link. A found
|
|
98
|
-
// reused for ten minutes and a miss for two, so a refresh never repeats the lookup.
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// A product card's picture comes from the store: the message carries only the product page link. A found product is
|
|
100
|
+
// reused for ten minutes and a miss for two, so a refresh never repeats the lookup. Its id opens it in the app.
|
|
101
|
+
/** `id` is the product the link names (same slug) and opens it in the app; the picture may also come from a product of
|
|
102
|
+
* the same name, which never stands in for the link's own product. */
|
|
103
|
+
type FoundProduct={url:string;id:string};
|
|
104
|
+
const productImages=new Map<string,FoundProduct&{at:number}>();
|
|
105
|
+
const productImageLookups=new Map<string,Promise<FoundProduct>>();
|
|
101
106
|
const PRODUCT_IMAGE_TTL_MS=10*60*1000;
|
|
102
107
|
const PRODUCT_IMAGE_MISS_TTL_MS=2*60*1000;
|
|
103
|
-
function productImageFor(slug:string,name:string):Promise<
|
|
108
|
+
function productImageFor(slug:string,name:string):Promise<FoundProduct>{
|
|
104
109
|
// Keyed by both: the search runs on the name, so a miss for one name must not hide a hit for another.
|
|
105
110
|
const key=JSON.stringify([slug,name]);
|
|
106
111
|
const hit=productImages.get(key);
|
|
107
|
-
if(hit&&Date.now()-hit.at<(hit.
|
|
112
|
+
if(hit&&Date.now()-hit.at<(hit.id?PRODUCT_IMAGE_TTL_MS:PRODUCT_IMAGE_MISS_TTL_MS))return Promise.resolve({url:hit.url,id:hit.id});
|
|
108
113
|
const running=productImageLookups.get(key);
|
|
109
114
|
if(running)return running;
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
// A reset (sign-out, another member or business) bumps `epoch`: a lookup it overtook must not fill the new cache.
|
|
116
|
+
const startEpoch=epoch;
|
|
117
|
+
const lookup:Promise<FoundProduct>=(async()=>{
|
|
118
|
+
let url='';let id='';
|
|
112
119
|
try{
|
|
113
120
|
// The name first; then the link's own words, for a product renamed since the message was written. The slug is the
|
|
114
121
|
// identity, so a same-named product with another slug is used only if neither search finds the slug.
|
|
@@ -123,13 +130,48 @@ function productImageFor(slug:string,name:string):Promise<string>{
|
|
|
123
130
|
}
|
|
124
131
|
const match=bySlug??sameName;
|
|
125
132
|
if(match)url=productImage(match);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
if(bySlug)id=typeof bySlug.id==='string'?bySlug.id:'';
|
|
134
|
+
}catch{url='';id='';}
|
|
135
|
+
if(startEpoch===epoch)productImages.set(key,{url,id,at:Date.now()});
|
|
136
|
+
return {url,id};
|
|
137
|
+
})().finally(()=>{if(productImageLookups.get(key)===lookup)productImageLookups.delete(key);});
|
|
130
138
|
productImageLookups.set(key,lookup);
|
|
131
139
|
return lookup;
|
|
132
140
|
}
|
|
141
|
+
// A bookings service named by a link's slug, cached like a product (a miss for less time). The first history load
|
|
142
|
+
// routes every row at once, so links to the same service share one lookup while it is running.
|
|
143
|
+
const serviceIds=new Map<string,{id:string;at:number}>();
|
|
144
|
+
const serviceIdLookups=new Map<string,Promise<string>>();
|
|
145
|
+
function serviceIdFor(slug:string):Promise<string>{
|
|
146
|
+
const hit=serviceIds.get(slug);
|
|
147
|
+
if(hit&&Date.now()-hit.at<(hit.id?PRODUCT_IMAGE_TTL_MS:PRODUCT_IMAGE_MISS_TTL_MS))return Promise.resolve(hit.id);
|
|
148
|
+
const running=serviceIdLookups.get(slug);
|
|
149
|
+
if(running)return running;
|
|
150
|
+
const startEpoch=epoch;
|
|
151
|
+
const lookup:Promise<string>=(async()=>{
|
|
152
|
+
let id='';
|
|
153
|
+
try{
|
|
154
|
+
const found=(await queryServices({query:{filter:{'mainSlug.name':{$eq:slug}},paging:{limit:1}}})).services??[];
|
|
155
|
+
id=typeof found[0]?.id==='string'?found[0].id:'';
|
|
156
|
+
}catch{id='';}
|
|
157
|
+
if(startEpoch===epoch)serviceIds.set(slug,{id,at:Date.now()});
|
|
158
|
+
return id;
|
|
159
|
+
})().finally(()=>{if(serviceIdLookups.get(slug)===lookup)serviceIdLookups.delete(slug);});
|
|
160
|
+
serviceIdLookups.set(slug,lookup);
|
|
161
|
+
return lookup;
|
|
162
|
+
}
|
|
163
|
+
/** A button whose link the app has a screen for opens that screen; a link it cannot place stays a web link. */
|
|
164
|
+
// This app's own site and the screens it has for products and services, as the screen passes them to `init`: only
|
|
165
|
+
// its own site's pages, and only to a screen the app registers, are opened in the app.
|
|
166
|
+
let ownSiteUrl='';
|
|
167
|
+
const ownScreens={product:'',service:''};
|
|
168
|
+
async function routeAction(action:BusinessArticleAction,productName:string):Promise<BusinessArticleAction>{
|
|
169
|
+
const route=action.routable?businessInAppRoute(action.url,ownSiteUrl):undefined;
|
|
170
|
+
const screenId=route?ownScreens[route.kind]:'';
|
|
171
|
+
if(!route||!screenId)return action;
|
|
172
|
+
const id=route.kind==='product'?(await productImageFor(route.slug,productName)).id:await serviceIdFor(route.slug);
|
|
173
|
+
return id?{...action,screenId,targetId:id,inApp:true,web:false}:action;
|
|
174
|
+
}
|
|
133
175
|
function slimRow(row:Record<string,unknown>):BusinessMessageRow{
|
|
134
176
|
const pick=(...keys:string[])=>Object.fromEntries(keys.map(key=>[key,row[key]]));
|
|
135
177
|
return pick('id','sender','when','createdDate','mine','senderAvatar','senderName','senderInitials','senderBadge','imageUrl','imageDescription','noImage','bodyText','bodyMarkdown','suggestions','articleActions','couponCode','noCoupon','notMine','isCard','notCard','noSuggestions','noActions','cardTitle','cardDetail','noCardText','dayLabel','dayHidden','startsDay','scrollAnchor') as unknown as BusinessMessageRow;
|
|
@@ -149,9 +191,12 @@ async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:str
|
|
|
149
191
|
const presented=presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as Record<string,unknown>[];
|
|
150
192
|
await Promise.all(presented.map(async row=>{
|
|
151
193
|
const slug=typeof row.productSlug==='string'?row.productSlug:'';
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
194
|
+
const name=String(row.productName||'');
|
|
195
|
+
if(slug&&!row.imageUrl){
|
|
196
|
+
const {url}=await productImageFor(slug,name);
|
|
197
|
+
if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:name||'Product image'});
|
|
198
|
+
}
|
|
199
|
+
row.articleActions=await Promise.all((row.articleActions as BusinessArticleAction[]).map(action=>routeAction(action,name)));
|
|
155
200
|
}));
|
|
156
201
|
return presented.map(slimRow);
|
|
157
202
|
}
|
|
@@ -178,7 +223,7 @@ function safeError(error:unknown,operation:'load'|'send'|'init'):string{
|
|
|
178
223
|
}
|
|
179
224
|
async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
|
|
180
225
|
function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
|
|
181
|
-
attachmentUrls.clear();productImages.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
|
|
226
|
+
attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
|
|
182
227
|
return shape({...initial,phase,error:phase==='guest'?'Sign in to send a message.':s.error});
|
|
183
228
|
}
|
|
184
229
|
let refreshTail:Promise<unknown>=Promise.resolve();
|
|
@@ -259,8 +304,11 @@ async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
|
|
|
259
304
|
const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
|
|
260
305
|
state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
|
|
261
306
|
actions:{
|
|
262
|
-
init:async
|
|
307
|
+
init:async(s,a)=>{
|
|
263
308
|
if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
|
|
309
|
+
// A retry passes no site: the one the screen gave first stays. A site the screen passes, even an empty one (a
|
|
310
|
+
// business with no site), replaces it, so an earlier business's links are not routed to this one's screens.
|
|
311
|
+
if(hasParam(a,'siteUrl')){ownSiteUrl=param(a,'siteUrl');ownScreens.product=param(a,'productScreen');ownScreens.service=param(a,'serviceScreen');}
|
|
264
312
|
const oldPrincipal=s.principal;
|
|
265
313
|
const initRevision=draftRevision;
|
|
266
314
|
// Text typed at any point of the load, subscribe step included, outlives the snapshot this started from. So does the
|
|
@@ -418,7 +466,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
|
|
|
418
466
|
}
|
|
419
467
|
catch(error){if(startEpoch!==epoch)return undefined;const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));return lost?clearPrivate(next,'guest'):shape({...withLiveDraft(next),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});}
|
|
420
468
|
},
|
|
421
|
-
dispose:async s=>{epoch++;attachmentUrls.clear();productImages.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
|
|
469
|
+
dispose:async s=>{epoch++;attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
|
|
422
470
|
}
|
|
423
471
|
});
|
|
424
472
|
readServiceState=()=>svc.getState();
|