@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 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\s*[:=]\s*\{([^}]*)\}/.exec(configTs);
2579
+ const block = /["']?splash["']?\s*[:=]\s*\{([^}]*)\}/.exec(configTs);
2580
2580
  if (!block) return {};
2581
- const backgroundColor = /backgroundColor\s*:\s*["']([^"']+)["']/.exec(block[1])?.[1];
2582
- return backgroundColor ? { backgroundColor } : {};
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"}`;
@@ -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 — so there is nothing to cross-fade there. What would
203
- otherwise pop is the *content*, so `HimalayaSplashView` / `HimalayaSplash` ease the
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.1500.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": "e222405d52f4c02637f42bee8bea3261dd918b3661be44c4db36b236"
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.article_action_$item.id",
89219
+ "id": "himi_business_chat.article_action.slot.$item.id",
89220
89220
  "body": {
89221
89221
  "package": "himalaya",
89222
- "type": "Button",
89222
+ "type": "VStack",
89223
89223
  "props": {
89224
- "title": "$item.label",
89225
- "style": "secondary",
89226
- "size": "sm",
89227
- "fullWidth": false,
89228
- "action": {
89229
- "actionId": "capability.open_url.open",
89230
- "params": {
89231
- "url": "$item.url"
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.card_action_$item.id",
89651
+ "id": "himi_business_chat.card_action.slot.$item.id",
89611
89652
  "body": {
89612
89653
  "package": "himalaya",
89613
- "type": "Button",
89654
+ "type": "VStack",
89614
89655
  "props": {
89615
- "title": "$item.label",
89616
- "style": "secondary",
89617
- "size": "md",
89618
- "fullWidth": true,
89619
- "action": {
89620
- "actionId": "capability.open_url.open",
89621
- "params": {
89622
- "url": "$item.url"
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
- export type BusinessArticleAction = { id: string; label: string; url: string; style: "primary" | "secondary" };
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
- .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 }));
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:{id:string;label:string;url:string;style:'primary'|'secondary'}[]; couponCode:string; noCoupon:boolean; notMine:boolean;
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 picture is
98
- // reused for ten minutes and a miss for two, so a refresh never repeats the lookup.
99
- const productImages=new Map<string,{url:string;at:number}>();
100
- const productImageLookups=new Map<string,Promise<string>>();
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<string>{
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.url?PRODUCT_IMAGE_TTL_MS:PRODUCT_IMAGE_MISS_TTL_MS))return Promise.resolve(hit.url);
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
- const lookup=(async()=>{
111
- let url='';
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
- }catch{url='';}
127
- productImages.set(key,{url,at:Date.now()});
128
- return url;
129
- })().finally(()=>{productImageLookups.delete(key);});
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
- if(!slug||row.imageUrl)return;
153
- const url=await productImageFor(slug,String(row.productName||''));
154
- if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:String(row.productName||'Product image')});
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 s=>{
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();