@wix/himalaya-cli 0.1614.0 → 0.1615.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -0,0 +1,669 @@
1
+ import { stashCopy } from "../_shared/himi-copy.js";
2
+ import { businessChatCopy, BUSINESS_CHAT_COPY } from "../_shared/business-chat-copy.js";
3
+ import { defineService } from '@himalaya/state';
4
+ import * as chat from '../_shared/business-chat-transport.js';
5
+ import type { ChatIdentity } from '../_shared/business-chat-transport.js';
6
+ import { type MessageRow } from '../_shared/business-chat-message-model.js';
7
+ import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
8
+ import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
9
+ import { businessInAppRoute, type BusinessArticleAction, dispatchBusinessSuggestion, findBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
10
+ import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
11
+ import { smartChatHistory } from '../_shared/business-chat-history.js';
12
+ import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
13
+ import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
14
+ import { queryServices } from '@himalaya/wix-bookings';
15
+
16
+ type PendingIntent = BusinessChatIntent;
17
+ // Only what the screen reads. This state crosses into the worker on every keystroke, so a row carries no raw
18
+ // parts, plain text or text runs: they would be re-sent each time for nothing.
19
+ type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mine'> & {
20
+ 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;
21
+ isCard:boolean; notCard:boolean; noSuggestions:boolean; noActions:boolean; cardTitle:string; cardDetail:string; noCardText:boolean;
22
+ dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
23
+ /** Only the member's own row that the server has not returned yet. */
24
+ pending?:boolean;
25
+ /** Confirmed local delivery, retained until the authoritative history returns it. */
26
+ acceptedIntent?:BusinessChatIntent;
27
+ };
28
+ type State = {
29
+ phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
30
+ roomId:string; principal:string; scope:string; messages:BusinessMessageRow[]; draft:string; busy:boolean;
31
+ historySource:'members'|'smart-chat'; historyRevision:number; smartChatEmpty:boolean;
32
+ /** Actual row namespace, independent of the transport followed after acceptance. */
33
+ transcriptSource:'members'|'smart-chat';
34
+ liveState:string; liveReason:string; realtimeEventCount:number; lastRealtimeEventType:string; pendingIntent:PendingIntent|null; scrollAnchor:string; scrollSend:number; usedSuggestionIds:string[]; visitorOptions:BusinessVisitorOptions|null; quickReplies:BusinessSuggestion[]; hasQuickReplies:boolean; noQuickReplies:boolean;
35
+ liveLabel:string; hasActiveRoom:boolean; noActiveRoom:boolean; isGuest:boolean; notGuest:boolean; realtimeDiagnostic:string; noError:boolean; noNotice:boolean; noMessages:boolean; hasMessages:boolean; noEmpty:boolean; notLoading:boolean;
36
+ hostedName:string; hasHostedName:boolean; noHostedName:boolean;
37
+ /** Who signs business messages: the hosted site, else the name the screen's `init` passes. */
38
+ businessName:string; businessInitials:string;
39
+ sendDisabled:boolean; sendEnabled:boolean; noLive:boolean; loadingHidden:boolean; errorText:string; loadFailure:string;
40
+ };
41
+ // A hosted build serves many businesses: the title is the site the host opened, not the app's own name.
42
+ const hostedName=hostSiteFromInput((globalThis as {himalaya?:{input?:unknown}}).himalaya?.input)?.name??'';
43
+ /** One or two capitals for a name, as an avatar shows it ("Hush Studio" → "HS"). */
44
+ // What a reader sees as one character: an emoji joined from several code points (👩‍🍳, a flag) stays whole. Code
45
+ // points are the fallback where the engine has no Intl.Segmenter.
46
+ function graphemes(text:string):string[]{
47
+ // The wrist's engine has no Intl at all, so it is only read behind this check.
48
+ const Segmenter=typeof Intl!=='undefined'?(Intl as unknown as {Segmenter?:new(locale?:string,options?:{granularity:'grapheme'})=>{segment(input:string):Iterable<{segment:string}>}}).Segmenter:undefined;
49
+ return Segmenter?Array.from(new Segmenter(undefined,{granularity:'grapheme'}).segment(text),(part)=>part.segment):[...text];
50
+ }
51
+ function initialsOf(name:string):string{
52
+ const first=(word:string)=>graphemes(word)[0]??'';
53
+ const words=name.trim().split(/\s+/).filter(Boolean);
54
+ return (words.length>1?`${first(words[0]!)}${first(words[words.length-1]!)}`:first(words[0]??'')).toUpperCase();
55
+ }
56
+ const initial:State={hostedName,businessName:hostedName,businessInitials:initialsOf(hostedName),hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,historySource:'members',historyRevision:0,smartChatEmpty:false,transcriptSource:'members',
57
+ liveState:'unavailable',liveReason:'',realtimeEventCount:0,lastRealtimeEventType:'',liveLabel:'',hasActiveRoom:false,noActiveRoom:true,isGuest:false,notGuest:true,realtimeDiagnostic:'',pendingIntent:null,scrollAnchor:'',scrollSend:0,usedSuggestionIds:[],visitorOptions:null,quickReplies:[],hasQuickReplies:false,noQuickReplies:true,noError:true,noNotice:true,noMessages:true,hasMessages:false,noEmpty:true,notLoading:false,sendDisabled:true,sendEnabled:false,noLive:true,loadingHidden:false,errorText:'',loadFailure:''};
58
+ let currentIdentity:ChatIdentity|null=null;
59
+ let subscriptionId='';
60
+ let initFlight=false;
61
+ let suggestionFlight=false;
62
+ let refreshFlight=false;
63
+ let refreshQueued=false;
64
+ let epoch=0;
65
+ // Acceptance can precede the async action's state commit. Realtime callbacks in
66
+ // that window must already follow this session's identity-scoped Smart Chat.
67
+ let smartChatAcceptedEpoch=-1;
68
+ let statusSeq=0;
69
+ let eventDuringInit=false;
70
+ let statusDuringInit:{state:string;reason:string}|null=null;
71
+ // Read through functions: a plain read after init's own assignment is narrowed by the compiler to its initial value.
72
+ function takeEventDuringInit():boolean{const seen=eventDuringInit;eventDuringInit=false;return seen;}
73
+ function takeStatusDuringInit():{state:string;reason:string}|null{const seen=statusDuringInit;statusDuringInit=null;return seen;}
74
+ let draftRevision=0;
75
+ let historyRevision=0;
76
+ let businessParticipantId='';
77
+ let loadStage:'room_selection'|'history_request'|'history_normalization'|'ai_enrichment'='room_selection';
78
+ let readServiceState:()=>State=()=>shape(initial);
79
+
80
+ /** Follow the identity-scoped history cursor: the first page need not contain the latest reply.
81
+ * Refuse an incomplete transcript rather than silently returning another conversation. */
82
+ async function loadSmartChatMessages():Promise<unknown[]>{
83
+ const messages:unknown[]=[];
84
+ const seen=new Set<string>();
85
+ let cursor:string|undefined;
86
+ const incomplete=()=>Object.assign(new Error('smart_chat_history_incomplete'),{code:'smart_chat_history_incomplete'});
87
+ for(let page=0;page<20;page++){
88
+ let response:Awaited<ReturnType<typeof listMessages>>;
89
+ try{response=await chat.fence(me(),()=>listMessages({cursorPaging:{limit:100,...(cursor?{cursor}:{})}}));}
90
+ catch(error){
91
+ if(/member_sign_in_required|MemberAccessChanged|\b401\b/i.test(String(error))||(page>0&&/\b403\b/.test(String(error))))throw Object.assign(new Error('member_sign_in_required'),{code:'smart_chat_history_incomplete'});
92
+ // Only an explicit unavailable feature permits the legacy fallback. A timeout or
93
+ // 5xx says nothing about whether a Smart Chat conversation exists.
94
+ if(!page&&/\b403\b|\b404\b/.test(String(error)))throw error;
95
+ throw incomplete();
96
+ }
97
+ if(!Array.isArray(response?.messages))throw incomplete();
98
+ messages.push(...response.messages);
99
+ const paging=response.pagingMetadata;
100
+ const next=paging?.cursors?.next;
101
+ if(paging?.hasNext===false||(!paging?.hasNext&&!next))return messages;
102
+ if(typeof next!=='string'||!next.trim()||seen.has(next))throw incomplete();
103
+ seen.add(next);cursor=next;
104
+ }
105
+ throw incomplete();
106
+ }
107
+
108
+ /** Submit a displayed quick reply through the AI endpoint; a label counts only while it is still a current option. */
109
+ async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|undefined,label:string):Promise<boolean>{
110
+ const request=businessVisitorOptionRequest(options,label,newBusinessSelectionId());
111
+ if(!request)return false;
112
+ await chat.fence(me(),()=>streamMessages(request));
113
+ return true;
114
+ }
115
+ function liveDescription(state:string,reason:string):string{return businessRealtimeStatusLabel(state,reason);}
116
+ /** The member's own message as it shows the instant they tap Send, before the server has answered. It carries the
117
+ * id sent to the server, so the real message replaces it once history returns it. */
118
+ function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
119
+ const when=intent.delivery==='sending'?businessChatCopy('sending'):intent.delivery==='unconfirmed'?businessChatCopy('unconfirmed'):messageTimeLabel(intent.createdDate);
120
+ return {id:intent.id,sender:'',when,createdDate:intent.createdDate,mine:true,
121
+ senderAvatar:'',senderName:intent.senderName??'',senderInitials:'',senderBadge:'',imageUrl:'',imageDescription:'',noImage:true,bodyText:intent.text,bodyMarkdown:intent.text,
122
+ suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,isCard:false,notCard:true,noSuggestions:true,noActions:true,cardTitle:'',cardDetail:'',noCardText:true,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true,acceptedIntent:intent.delivery==='sent'?intent:undefined};
123
+ }
124
+ function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,noSuggestions:s.phase!=='ready'||!(row.suggestions||[]).length,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=s.phase==='ready'&&quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
125
+ notLoading:s.phase!=='loading',sendDisabled:!canSend(s,s.draft),sendEnabled:canSend(s,s.draft),noLive:!s.roomId||s.liveState==='connected'};}
126
+ /** Smart Chat resolves the conversation from the member identity, including the first message.
127
+ * History must finish loading before Send is offered so delivery has a reliable anchor. */
128
+ function canSend(s:State,draft:string):boolean{return !!s.member&&s.phase==='ready'&&!s.busy&&!!draft.trim()&&!s.pendingIntent;}
129
+ function safeLoadFailure(error:unknown):string{
130
+ const name=error instanceof Error?error.name:'UnknownError';
131
+ const code=(error as {code?:unknown}|null)?.code;
132
+ const safeCode=typeof code==='string'&&/^[a-z0-9_]{1,48}$/i.test(code)?`:${code}`:'';
133
+ return `${loadStage}:${/^(TypeError|SyntaxError|ProtocolError|Unsupported|Error)$/.test(name)?name:'OtherError'}${safeCode}`;
134
+ }
135
+ 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:'';}
136
+ 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';}
137
+ 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:'';}
138
+ // A private attachment's download URL costs a round trip and stays valid for minutes, so a refresh reuses it.
139
+ const attachmentUrls=new Map<string,{url:string;at:number}>();
140
+ const ATTACHMENT_URL_TTL_MS=4*60*1000;
141
+ async function privateAttachmentUrl(part:{mediaId?:string;mediaProvider?:string;url?:string;fileName?:string},identity:ChatIdentity,startEpoch:number):Promise<string>{
142
+ if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
143
+ const key=`${identity.email}|${identity.scope}|${part.mediaId??''}`;
144
+ const hit=attachmentUrls.get(key);
145
+ if(hit&&Date.now()-hit.at<ATTACHMENT_URL_TTL_MS)return hit.url;
146
+ const url=await chat.attachmentUrl(identity,part);
147
+ if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
148
+ if(url)attachmentUrls.set(key,{url,at:Date.now()});
149
+ return url;
150
+ }
151
+ // A product card's picture comes from the store: the message carries only the product page link. A found product is
152
+ // reused for ten minutes and a miss for two, so a refresh never repeats the lookup. Its id opens it in the app.
153
+ /** `id` is the product the link names (same slug) and opens it in the app; the picture may also come from a product of
154
+ * the same name, which never stands in for the link's own product. */
155
+ type FoundProduct={url:string;id:string};
156
+ const productImages=new Map<string,FoundProduct&{at:number}>();
157
+ const productImageLookups=new Map<string,Promise<FoundProduct>>();
158
+ const PRODUCT_IMAGE_TTL_MS=10*60*1000;
159
+ const PRODUCT_IMAGE_MISS_TTL_MS=2*60*1000;
160
+ function productImageFor(slug:string,name:string):Promise<FoundProduct>{
161
+ // Keyed by both: the search runs on the name, so a miss for one name must not hide a hit for another.
162
+ const key=JSON.stringify([slug,name]);
163
+ const hit=productImages.get(key);
164
+ 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});
165
+ const running=productImageLookups.get(key);
166
+ if(running)return running;
167
+ // A reset (sign-out, another member or business) bumps `epoch`: a lookup it overtook must not fill the new cache.
168
+ const startEpoch=epoch;
169
+ const lookup:Promise<FoundProduct>=(async()=>{
170
+ let url='';let id='';
171
+ try{
172
+ // The name first; then the link's own words, for a product renamed since the message was written. The slug is the
173
+ // identity, so a same-named product with another slug is used only if neither search finds the slug.
174
+ let sameName:Parameters<typeof productImage>[0]|undefined;
175
+ let bySlug:Parameters<typeof productImage>[0]|undefined;
176
+ for(const query of [name,slug.replace(/-/g,' ')]){
177
+ if(!query.trim())continue;
178
+ const found=(await searchStoreProducts({query,limit:10})).products??[];
179
+ bySlug=found.find(product=>product.slug===slug);
180
+ if(bySlug)break;
181
+ if(query===name&&!sameName)sameName=found.find(product=>product.name===name);
182
+ }
183
+ const match=bySlug??sameName;
184
+ if(match)url=productImage(match);
185
+ if(bySlug)id=typeof bySlug.id==='string'?bySlug.id:'';
186
+ }catch{url='';id='';}
187
+ if(startEpoch===epoch)productImages.set(key,{url,id,at:Date.now()});
188
+ return {url,id};
189
+ })().finally(()=>{if(productImageLookups.get(key)===lookup)productImageLookups.delete(key);});
190
+ productImageLookups.set(key,lookup);
191
+ return lookup;
192
+ }
193
+ // A bookings service named by a link's slug, cached like a product (a miss for less time). The first history load
194
+ // routes every row at once, so links to the same service share one lookup while it is running.
195
+ const serviceIds=new Map<string,{id:string;at:number}>();
196
+ const serviceIdLookups=new Map<string,Promise<string>>();
197
+ function serviceIdFor(slug:string):Promise<string>{
198
+ const hit=serviceIds.get(slug);
199
+ if(hit&&Date.now()-hit.at<(hit.id?PRODUCT_IMAGE_TTL_MS:PRODUCT_IMAGE_MISS_TTL_MS))return Promise.resolve(hit.id);
200
+ const running=serviceIdLookups.get(slug);
201
+ if(running)return running;
202
+ const startEpoch=epoch;
203
+ const lookup:Promise<string>=(async()=>{
204
+ let id='';
205
+ try{
206
+ const found=(await queryServices({query:{filter:{'mainSlug.name':{$eq:slug}},paging:{limit:1}}})).services??[];
207
+ id=typeof found[0]?.id==='string'?found[0].id:'';
208
+ }catch{id='';}
209
+ if(startEpoch===epoch)serviceIds.set(slug,{id,at:Date.now()});
210
+ return id;
211
+ })().finally(()=>{if(serviceIdLookups.get(slug)===lookup)serviceIdLookups.delete(slug);});
212
+ serviceIdLookups.set(slug,lookup);
213
+ return lookup;
214
+ }
215
+ /** A button whose link the app has a screen for opens that screen; a link it cannot place stays a web link. */
216
+ // This app's own site and the screens it has for products and services, as the screen passes them to `init`: only
217
+ // its own site's pages, and only to a screen the app registers, are opened in the app.
218
+ let ownSiteUrl='';
219
+ const ownScreens={product:'',service:''};
220
+ async function routeAction(action:BusinessArticleAction,productName:string):Promise<BusinessArticleAction>{
221
+ const route=action.routable?businessInAppRoute(action.url,ownSiteUrl):undefined;
222
+ const screenId=route?ownScreens[route.kind]:'';
223
+ if(!route||!screenId)return action;
224
+ const id=route.kind==='product'?(await productImageFor(route.slug,productName)).id:await serviceIdFor(route.slug);
225
+ return id?{...action,screenId,targetId:id,inApp:true,web:false}:action;
226
+ }
227
+ function slimRow(row:Record<string,unknown>):BusinessMessageRow{
228
+ const pick=(...keys:string[])=>Object.fromEntries(keys.map(key=>[key,row[key]]));
229
+ 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;
230
+ }
231
+ async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:string,participantId=businessParticipantId||'smart-chat:visitor'):Promise<BusinessMessageRow[]>{
232
+ const identity=me();const presentationEpoch=epoch;
233
+ const rows=chronologicalMessages(existing,raw,memberId) as MessageRow[];
234
+ const prepared=await Promise.all(rows.map(async row=>{
235
+ const parts=await Promise.all(row.parts.map(async part=>{
236
+ if(part.kind!=='image'&&part.kind!=='file'&&part.kind!=='audio'&&part.kind!=='video')return part;
237
+ if(part.mediaProvider==='WixMediaPrivate'&&part.mediaId){
238
+ try{return {...part,url:await privateAttachmentUrl(part,identity,presentationEpoch)};}catch{return {...part,url:''};}
239
+ }
240
+ return part;
241
+ }));
242
+ return {...row,parts};
243
+ }));
244
+ const presented=presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,participantId) as unknown as Record<string,unknown>[];
245
+ await Promise.all(presented.map(async row=>{
246
+ const slug=typeof row.productSlug==='string'?row.productSlug:'';
247
+ const name=String(row.productName||'');
248
+ if(slug&&!row.imageUrl){
249
+ const {url}=await productImageFor(slug,name);
250
+ if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:name||businessChatCopy('productImage')});
251
+ }
252
+ row.articleActions=await Promise.all((row.articleActions as BusinessArticleAction[]).map(action=>routeAction(action,name)));
253
+ }));
254
+ return presented.map(slimRow);
255
+ }
256
+ function me():ChatIdentity{
257
+ const identity=currentIdentity;
258
+ if(!identity?.authenticated)throw new Error('member_sign_in_required');
259
+ return identity;
260
+ }
261
+ async function getCurrentMember():Promise<ChatIdentity>{return chat.chatIdentity();}
262
+ async function resolveExistingBusinessRoom(startEpoch:number,businessName:string):Promise<{id:string;participantId:string}|undefined>{
263
+ const page=await chat.listRooms(me());
264
+ if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
265
+ const room=selectExistingBusinessRoom(page.items,businessName,true);
266
+ // An incomplete room list cannot prove there is no conversation.
267
+ if(page.more)throw new Error('business_room_list_incomplete');
268
+ return room;
269
+ }
270
+ function safeError(error:unknown,operation:'load'|'send'|'init'):string{
271
+ const text=String((error as Error)?.message||error);
272
+ if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(text))return businessChatCopy('sessionChanged');
273
+ if(/approved first-party|webwix_owner_only|chat_setup_scope_unavailable/i.test(text))return businessChatCopy('hostUnavailable');
274
+ return operation==='send'?businessChatCopy('sendFailed'):
275
+ operation==='init'?businessChatCopy('openFailed'):businessChatCopy('loadFailed');
276
+ }
277
+ async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
278
+ function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
279
+ attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
280
+ return shape({...initial,phase,error:phase==='guest'?businessChatCopy('signInPrompt'):s.error});
281
+ }
282
+ let refreshTail:Promise<unknown>=Promise.resolve();
283
+ /** One refresh runs at a time. A caller that needs the data (init, a send) waits for the one in flight and
284
+ * then runs its own; a realtime event only asks the running one to go again, and gets nothing back. */
285
+ async function loadHistory(s:State,wait=false):Promise<State|undefined>{
286
+ while(refreshFlight){
287
+ if(!wait){refreshQueued=true;return withLiveDraft(s);}
288
+ await refreshTail.catch(()=>{});
289
+ }
290
+ const run=refreshHistory(s);
291
+ refreshTail=run;
292
+ return run;
293
+ }
294
+ async function refreshHistory(s:State):Promise<State|undefined>{
295
+ refreshFlight=true;const startEpoch=epoch;const roomId=s.roomId;const principal=s.principal;const participantId=businessParticipantId||'smart-chat:visitor';const startDraftRevision=draftRevision;
296
+ // A refresh that outlived its member or session says nothing: returning a state would overwrite the new one.
297
+ const stale=()=>startEpoch!==epoch||(currentIdentity?.userId||currentIdentity?.email)!==principal;
298
+ try{
299
+ loadStage='history_request';
300
+ // The Members room and Smart Chat may be different conversations. Read Smart Chat's
301
+ // persisted transcript, not only its quick replies; retain Members history for sites without it.
302
+ const [memberRead,aiRead]=await Promise.all([
303
+ (roomId?chat.messagesPage(me(),roomId):Promise.resolve([])).then(page=>({page,error:null})).catch(error=>({page:[],error})),
304
+ loadSmartChatMessages().then(messages=>({messages,error:null})).catch(error=>({messages:[],error})),
305
+ ]);
306
+ if(stale())return undefined;
307
+ // Once this screen has followed Smart Chat, an unavailable read must not switch it back
308
+ // to the unrelated Members thread.
309
+ const liveSource=readServiceState();
310
+ const followedSmart=smartChatAcceptedEpoch===startEpoch||s.historySource==='smart-chat'||(liveSource.principal===principal&&liveSource.scope===s.scope&&liveSource.historySource==='smart-chat');
311
+ if(aiRead.error&&(!roomId||followedSmart||aiRead.error.code==='smart_chat_history_incomplete'))throw aiRead.error;
312
+ const smart=smartChatHistory(aiRead.messages,memberRead.page,participantId);
313
+ const useSmart=!!smart||followedSmart;
314
+ if(!useSmart&&memberRead.error)throw memberRead.error;
315
+ const page=useSmart?smart?.messages??[]:memberRead.page;
316
+ const historySource=useSmart?'smart-chat' as const:'members' as const;
317
+ const visitorOptions=businessVisitorOptionsFromAiMessages(aiRead.messages);
318
+ loadStage='history_normalization';
319
+ // Presented from the page alone: a row kept from an earlier page would be re-presented without the
320
+ // metadata that only the page carries (sender, avatar, AI badge, article buttons).
321
+ const messages=await presentMessages([],page,currentIdentity?.userId||undefined,participantId);
322
+ if(stale())return undefined;
323
+ // Read the live state last: what other actions changed while this one awaited is theirs to keep.
324
+ const latest=readServiceState();
325
+ // Resolving this member's first room is the same conversation, not a room switch.
326
+ const same=latest.principal===principal&&latest.scope===s.scope&&(latest.roomId===roomId||(!latest.roomId||!roomId));
327
+ // What was typed while this ran is kept even on the first load, when the live state is still empty.
328
+ const liveDraft=draftRevision!==startDraftRevision?latest.draft:s.draft;
329
+ const base:State=same?withLiveFields({...s,roomId:latest.roomId||roomId},latest,liveDraft):{...s,draft:liveDraft};
330
+ const intentObserved=intentObservedIn(messages,base.pendingIntent,historySource);
331
+ const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
332
+ // A send the server has not returned yet stays on screen until history does. Seeing it in history only replaces the
333
+ // placeholder: a send still in flight stays pending until its own request settles it, so its outcome is never lost.
334
+ // An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
335
+ const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
336
+ const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
337
+ const loaded:State={...base,historyRevision:++historyRevision,historySource,transcriptSource:historySource,smartChatEmpty:!aiRead.error&&aiRead.messages.length===0,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''};
338
+ return shape(same?withAcceptedRows(loaded,latest):loaded);
339
+ }catch(error){
340
+ const wrapped=error instanceof Error?error:new Error('load_failed');
341
+ Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
342
+ throw wrapped;
343
+ }finally{refreshFlight=false;}
344
+ }
345
+ /** What other actions own and may have changed while this one awaited, taken from the live state. A state
346
+ * built from a snapshot taken before an await must carry these forward or it rolls them back. */
347
+ function withLiveFields(st:State,latest:State,draft:string):State{
348
+ // The pending intent is always the live one: a send settled meanwhile (null now) must not come back from a snapshot.
349
+ return {...st,draft,pendingIntent:latest.pendingIntent,
350
+ usedSuggestionIds:[...new Set([...st.usedSuggestionIds,...latest.usedSuggestionIds])],
351
+ liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
352
+ scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
353
+ }
354
+ /** A successful empty pre-send read anchors accepted Smart Chat rows, never an
355
+ * older Members payload retained after a failed read. Keep both contexts intact. */
356
+ function intentObservedIn(messages:BusinessMessageRow[],intent:BusinessChatIntent|null,source:State['transcriptSource']):boolean{
357
+ return pendingIntentWasObserved(messages,source==='smart-chat'&&intent?.delivery==='sent'&&intent.smartChatFromStart
358
+ ?{...intent,afterId:undefined,fromStart:true}:intent);
359
+ }
360
+ /** Keep confirmed local deliveries independently of in-flight intent and history revision.
361
+ * Reconcile by the existing stored-id/anchor contract, never by ownership or text alone. */
362
+ function withAcceptedRows(st:State,latest:State):State{
363
+ let messages=st.messages;
364
+ for(const row of [...st.messages,...latest.messages]){
365
+ const intent=row.acceptedIntent;
366
+ if(!intent)continue;
367
+ if(intentObservedIn(messages,intent,st.transcriptSource))messages=messages.filter(candidate=>!(candidate.id===intent.id&&candidate.pending));
368
+ else if(!messages.some(candidate=>candidate.id===intent.id))messages=[...messages,row];
369
+ }
370
+ return {...st,messages,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':st.historySource};
371
+ }
372
+ function withLiveDraft(st:State):State{
373
+ const latest=readServiceState();
374
+ const same=latest.principal===st.principal&&latest.scope===st.scope&&(latest.roomId===st.roomId||!latest.roomId||!st.roomId);
375
+ // Failed reads also finish from old snapshots: keep a newer authoritative
376
+ // transcript and the member's first resolved room, just as successful reads do.
377
+ const newer=same&&latest.historyRevision>st.historyRevision;
378
+ const base=newer?{...st,historyRevision:latest.historyRevision,historySource:latest.historySource,transcriptSource:latest.transcriptSource,smartChatEmpty:latest.smartChatEmpty,messages:latest.messages,visitorOptions:latest.visitorOptions,quickReplies:latest.quickReplies}:st;
379
+ const live=withLiveFields({...base,roomId:st.roomId||(same?latest.roomId:'')},latest,latest.draft);
380
+ const merged=same?withAcceptedRows(live,latest):live;
381
+ // A later send owns both its intent and its visible row, even when this
382
+ // snapshot has a newer history revision that predates that send.
383
+ const intent=same?latest.pendingIntent:null;
384
+ return intent&&!intentObservedIn(merged.messages,intent,merged.transcriptSource)&&!merged.messages.some(row=>row.id===intent.id)
385
+ ?{...merged,messages:[...merged.messages,pendingRow(intent)]}:merged;
386
+ }
387
+ type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;storedId?:string;error?:unknown};
388
+ /** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
389
+ * until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
390
+ * stays held until this settles (the site's widget also waits for the answer). */
391
+ async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
392
+ const sendEpoch=epoch;
393
+ let result:SendResult={id:pending.id,outcome:'sent',draft};
394
+ try{
395
+ await Promise.resolve();// the action that started this has returned its state by now
396
+ result.storedId=await chat.sendMessage(me(),pending.text);
397
+ }catch(error){
398
+ const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
399
+ result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
400
+ }
401
+ if(sendEpoch===epoch)void svc.settleSend(result);
402
+ }
403
+ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
404
+ state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
405
+ actions:{
406
+ // A host runs `load` when the screen shows again, also when a sign-in modal over it closes.
407
+ // Only a guest loads again: a member's chat is already live. No args: init keeps the site
408
+ // and name the screen gave first.
409
+ load:async (s):Promise<State|undefined>=>s.phase==='guest'?svc.init():undefined,
410
+ init:async(s,a)=>{
411
+ // Keep $t: keys as values: renderers translate bound worker copy in the active locale.
412
+ const copy=Object.fromEntries(Object.keys(BUSINESS_CHAT_COPY).map(key=>[`businessChat.${key}`,param(a,`businessChat.${key}`)]).filter(([,text])=>!!text));
413
+ if(Object.keys(copy).length)stashCopy({copy:JSON.stringify(copy)});
414
+ if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
415
+ // A retry passes no site: the one the screen gave first stays. A site the screen passes, even an empty one (a
416
+ // business with no site), replaces it, so an earlier business's links are not routed to this one's screens.
417
+ if(hasParam(a,'siteUrl')){ownSiteUrl=param(a,'siteUrl');ownScreens.product=param(a,'productScreen');ownScreens.service=param(a,'serviceScreen');}
418
+ // A retry passes no name: the one the screen gave first stays. Under `displayName`, a copy key, so a template
419
+ // sweep that renames the business renames it here too and the initials follow.
420
+ const signedBy=s.hostedName||param(a,'displayName')||s.businessName;
421
+ const signed=(st:State):State=>({...st,businessName:signedBy,businessInitials:initialsOf(signedBy)});
422
+ s=signed(s);
423
+ const oldPrincipal=s.principal;
424
+ const initRevision=draftRevision;
425
+ // Text typed at any point of the load, subscribe step included, outlives the snapshot this started from. So does the
426
+ // live pending send, but only the same member's and only one still in flight: reopening is how a member checks an
427
+ // unconfirmed send (its notice says so), so it does not carry over and cannot hold Send for good.
428
+ // A reset for another member or scope (clearPrivate) ends the old send's settling, so nothing of it is kept.
429
+ let reset=false;
430
+ // Only an unconfirmed send the member reopened the chat to check is dropped. A send still in flight when this load
431
+ // began that settles during it (rejected, or turned unconfirmed) keeps what its settling told the member: its
432
+ // notice, its error and the draft it gave back.
433
+ const checkedId=s.pendingIntent?.delivery==='unconfirmed'?s.pendingIntent.id:'';
434
+ const inFlightId=s.pendingIntent&&s.pendingIntent.delivery!=='unconfirmed'?s.pendingIntent.id:'';
435
+ const keepTyped=(st:State):State=>{
436
+ const live=readServiceState();
437
+ const sameMember=!reset&&live.principal===st.principal&&live.scope===st.scope&&(live.roomId===st.roomId||!live.roomId||!st.roomId);
438
+ const own=sameMember?live.pendingIntent:null;
439
+ const intent=own&&!(own.delivery==='unconfirmed'&&own.id===checkedId)?own:null;
440
+ const settledMeanwhile=sameMember&&!!inFlightId&&!(live.pendingIntent?.id===inFlightId&&live.pendingIntent.delivery!=='unconfirmed');
441
+ // Accepted local rows can outlive pendingIntent while history is unavailable.
442
+ // Keep only rows the live session still owns; checked unconfirmed/rejected rows drop.
443
+ const retained=new Set(sameMember?live.messages.filter(row=>row.id!==checkedId).map(row=>row.id):[]);
444
+ // Equal history revisions share an authoritative read. Local send settling
445
+ // may still have advanced its rows/source while this reopen awaited rooms.
446
+ const history=sameMember&&live.historyRevision>=st.historyRevision
447
+ ?{...st,historyRevision:live.historyRevision,historySource:live.historySource,transcriptSource:live.transcriptSource,smartChatEmpty:live.smartChatEmpty,messages:live.messages,visitorOptions:live.visitorOptions,quickReplies:live.quickReplies}:st;
448
+ return {...history,pendingIntent:intent,historySource:sameMember&&live.historySource==='smart-chat'?'smart-chat':history.historySource,
449
+ messages:history.messages.filter(row=>!row.pending||row.id===intent?.id||retained.has(row.id)),
450
+ ...(settledMeanwhile?{notice:live.notice,error:live.error,draft:live.draft}:draftRevision!==initRevision?{draft:live.draft}:{})};
451
+ };
452
+ let myEpoch=epoch;
453
+ let openedStatus:{state:string;reason:string}|undefined;
454
+ const withOpenedStatus=(st:State):State=>{
455
+ const status=takeStatusDuringInit()??openedStatus;
456
+ return status?{...st,liveState:status.state,liveReason:status.reason}:st;
457
+ };
458
+ try{
459
+ loadStage='room_selection';
460
+ const identity=await getCurrentMember();
461
+ if(myEpoch!==epoch)return undefined;
462
+ if(!identity.authenticated)throw new Error('member_sign_in_required');
463
+ if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=signed(clearPrivate(s,'guest'));myEpoch=epoch;reset=true;}
464
+ currentIdentity=identity;
465
+ s=shape({...s,phase:'loading',member:true,principal:identity.userId||identity.email,scope:identity.scope,error:'',notice:''});
466
+ const existingRoom=await resolveExistingBusinessRoom(myEpoch,signedBy);
467
+ if(myEpoch!==epoch)return undefined;
468
+ const live=readServiceState();
469
+ const promoted=live.principal===s.principal&&live.scope===s.scope?live.roomId:'';
470
+ businessParticipantId=existingRoom?.participantId??(promoted?businessParticipantId:'');
471
+ s=shape({...s,roomId:existingRoom?.id??promoted});
472
+ // Subscribe before reading history, so anything that lands after the read arrives as a realtime event.
473
+ if(!subscriptionId){try{const result=await subscribeRealtime({onEvent:'businessChat.onEvent',onStatus:'businessChat.onStatus'});if(myEpoch!==epoch){if(result.subscriptionId)await unsubscribeRealtime({subscriptionId:result.subscriptionId});return undefined;}subscriptionId=result.subscriptionId;const status=subscriptionId?await getRealtimeStatus({subscriptionId}):result;s=shape({...s,liveState:status.state,liveReason:status.reason||''});}catch{s=shape({...s,liveState:'unavailable',liveReason:'runtime_unavailable'});}openedStatus={state:s.liveState,reason:s.liveReason};}
474
+ let loaded=await loadLatest(s,true);
475
+ if(!loaded||myEpoch!==epoch)return undefined;
476
+ // Until this returns the stored state has no room, so events are ignored; one that arrived meanwhile asks for another pass.
477
+ for(let pass=0;pass<3&&takeEventDuringInit();pass++){const again=await loadLatest(loaded,true);if(!again||myEpoch!==epoch)return undefined;loaded=again;}
478
+ return shape(keepTyped(withOpenedStatus(loaded)));
479
+ }catch(error){
480
+ if(myEpoch!==epoch)return undefined;
481
+ const sessionLost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
482
+ const loadFailure=typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error);
483
+ if(sessionLost)return clearPrivate(s,'guest');
484
+ const live=readServiceState();
485
+ // A failed reopen may finish after the first send promoted this member's
486
+ // room. Keep its accepted transcript, without importing another session.
487
+ const base=!reset&&live.principal===s.principal&&live.scope===s.scope?withLiveDraft(s):s;
488
+ return shape({...withOpenedStatus(keepTyped(base)),phase:'error',member:!!currentIdentity?.authenticated,error:safeError(error,'init'),loadFailure});
489
+ }finally{initFlight=false;}
490
+ },
491
+ // Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
492
+ setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled=canSend(s,draft);return {...s,draft,sendEnabled,sendDisabled:!sendEnabled};},
493
+ // Shows the message and clears the box at once; the network round trip and the history reload that follow
494
+ // run in the background and settle through `settleSend`.
495
+ send:s=>{
496
+ const text=s.draft.trim();
497
+ // One send at a time: the pending intent, set right here, holds the next one until this one is settled or seen.
498
+ if(!text||!canSend(s,text))return s;
499
+ // The member's name as their earlier messages show it, so the row keeps its size when the real message replaces it.
500
+ const ownName=[...s.messages].reverse().find(row=>row.mine&&row.senderName)?.senderName??'';
501
+ const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName,...sendAnchor(s.messages,text,s.phase==='ready'),smartChatFromStart:s.transcriptSource==='members'&&s.smartChatEmpty};
502
+ draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
503
+ void deliver(pending,s.draft);
504
+ return shape({...s,draft:'',pendingIntent:pending,messages:[...s.messages,pendingRow(pending)],error:'',notice:'',scrollSend:s.scrollSend+1});
505
+ },
506
+ settleSend:async(s,a)=>{
507
+ const result=a as SendResult|undefined;const pending=s.pendingIntent;
508
+ if(!result||!pending||pending.id!==result.id)return undefined;
509
+ const replaceRow=(intent:BusinessChatIntent)=>s.messages.map(row=>row.id===intent.id?pendingRow(intent):row);
510
+ if(result.outcome==='signed_out')return clearPrivate(s,'guest');
511
+ if(result.outcome==='rejected'){
512
+ // The row goes, and the text comes back unless the member has already started something new.
513
+ return shape({...s,messages:s.messages.filter(row=>row.id!==pending.id),pendingIntent:null,draft:s.draft.trim()?s.draft:result.draft,error:safeError(result.error,'send'),notice:businessChatCopy('rejected')});
514
+ }
515
+ // History cannot tell this send from the same words written elsewhere (the website), so an indeterminate failure is
516
+ // never reported as sent. If matching words are already there it does not hold Send, and the notice still asks to check.
517
+ if(result.outcome==='unconfirmed'){
518
+ const unconfirmed={...pending,delivery:'unconfirmed' as const};
519
+ const seen=intentObservedIn(s.messages,pending,s.transcriptSource);
520
+ return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:businessChatCopy('checkDelivery')});
521
+ }
522
+ const sent={...pending,storedId:result.storedId,delivery:'sent' as const};
523
+ // Every typed send uses Smart Chat, even when a Members room already exists.
524
+ smartChatAcceptedEpoch=epoch;
525
+ const accepted=shape({...s,historySource:'smart-chat',pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
526
+ const sendEpoch=epoch;
527
+ // The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
528
+ // A refresh that ran while the committed intent still said sending leaves a 'Sending…' row: swap it for the accepted one.
529
+ // History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
530
+ // is cleared: a newer one the live state carried in is that send's to settle.
531
+ const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
532
+ const settled=(st:State):State=>intentObservedIn(st.messages,sent,st.transcriptSource)?shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.filter(row=>!(row.id===sent.id&&row.pending))}):
533
+ shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):[...st.messages,pendingRow(sent)]});
534
+ // The stream finishes after the AI has persisted its answer. Reload even while connected:
535
+ // the Members-room subscription may never see the separate Smart Chat room's event.
536
+ // An existing-room refresh owns the next read. A first acceptance must wait for
537
+ // its own read/discovery pass so an overlapping refresh cannot skip room promotion.
538
+ if(refreshFlight&&accepted.roomId){refreshQueued=true;return settled(accepted);}
539
+ let resolved=accepted;
540
+ const latestResolved=():State=>{
541
+ const latest=readServiceState();
542
+ // Another refresh can finish while room discovery awaits. Its authoritative
543
+ // transcript/options win; draft/status-only updates cannot replace this read.
544
+ const same=latest.principal===resolved.principal&&latest.scope===resolved.scope;
545
+ const newer=same&&latest.historyRevision>resolved.historyRevision;
546
+ const transcript=newer?{...resolved,historyRevision:latest.historyRevision,historySource:latest.historySource,transcriptSource:latest.transcriptSource,smartChatEmpty:latest.smartChatEmpty,messages:latest.messages,visitorOptions:latest.visitorOptions,quickReplies:latest.quickReplies}:resolved;
547
+ return withLiveDraft({...transcript,roomId:resolved.roomId||(same?latest.roomId:'')});
548
+ };
549
+ try{
550
+ // Smart Chat's identity-scoped transcript is independent of Members-room
551
+ // discovery. Keep its persisted reply/options even when discovery fails.
552
+ const loaded=await loadLatest(resolved,true);
553
+ if(!loaded)return undefined;
554
+ resolved=loaded;
555
+ resolved=latestResolved();
556
+ // Creating a conversation is a consequence of the accepted send, never of opening chat.
557
+ if(!resolved.roomId){
558
+ const room=await resolveExistingBusinessRoom(sendEpoch,s.businessName);
559
+ if(sendEpoch!==epoch)return undefined;
560
+ const latest=latestResolved();
561
+ if(room&&!latest.roomId)businessParticipantId=room.participantId;
562
+ resolved={...latest,roomId:latest.roomId||room?.id||'',historySource:'smart-chat'};
563
+ }
564
+ return settled(resolved);
565
+ }catch(error){
566
+ // A session lost by an older operation says nothing about the member who is signed in now.
567
+ if(sendEpoch!==epoch)return undefined;
568
+ if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(accepted,'guest');
569
+ return settled(latestResolved());
570
+ }
571
+ },
572
+ sendSuggestion:async(s,a)=>{
573
+ if(suggestionFlight||!s.member||s.phase!=='ready'||!!s.pendingIntent)return s;
574
+ const id=param(a,'id');
575
+ if(!id||s.usedSuggestionIds.includes(id))return s;
576
+ const rows=[{suggestions:s.quickReplies} as {suggestions?:BusinessSuggestion[]},...s.messages];
577
+ if(!s.roomId&&findBusinessSuggestion(rows,id)?.kind!=='visitor_option')return s;
578
+ const options=s.visitorOptions;
579
+ const intent=newBusinessChatIntent('suggestion',s.roomId,s.principal);
580
+ const sendEpoch=epoch;
581
+ suggestionFlight=true;
582
+ s=shape({...s,usedSuggestionIds:[...s.usedSuggestionIds,id],error:'',notice:''});
583
+ let accepted:State|undefined;
584
+ const keptAccepted=():State=>{
585
+ const latest=withLiveDraft(accepted!);
586
+ return shape({...latest,historySource:accepted!.historySource==='smart-chat'?'smart-chat':latest.historySource,error:'',notice:businessChatCopy('suggestionSent')});
587
+ };
588
+ try{
589
+ const sent=await dispatchBusinessSuggestion(rows,id,{member:s.member,roomId:s.roomId,principal:s.principal,currentPrincipal:currentIdentity?.userId||currentIdentity?.email||'',scope:s.scope,currentScope:currentIdentity?.scope||''},suggestion=>suggestion.kind==='visitor_option'
590
+ ? sendBusinessVisitorOption(options,suggestion.label)
591
+ : chat.sendButton(me(),s.roomId,{id:intent.id,text:suggestion.label,appId:suggestion.appId!,interactionId:suggestion.interactionId!}));
592
+ if(sendEpoch!==epoch)return undefined;
593
+ if(!sent)return withLiveDraft(s);
594
+ const latest=readServiceState();
595
+ if(latest.principal!==s.principal||latest.scope!==s.scope||(latest.roomId!==s.roomId&&(!!s.roomId||findBusinessSuggestion(rows,id)?.kind!=='visitor_option')))return undefined;
596
+ if(findBusinessSuggestion(rows,id)?.kind==='visitor_option')smartChatAcceptedEpoch=epoch;
597
+ accepted=shape({...latest,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':latest.historySource,usedSuggestionIds:[...new Set([...s.usedSuggestionIds,...latest.usedSuggestionIds])],scrollSend:latest.scrollSend+1,error:'',notice:businessChatCopy('suggestionSent')});
598
+ const loaded=await loadLatest(keptAccepted(),true);
599
+ if(!loaded||sendEpoch!==epoch)return undefined;
600
+ accepted=loaded;
601
+ if(!accepted.roomId){
602
+ const room=await resolveExistingBusinessRoom(sendEpoch,s.businessName);
603
+ if(sendEpoch!==epoch)return undefined;
604
+ const current=keptAccepted();
605
+ if(room&&!current.roomId)businessParticipantId=room.participantId;
606
+ accepted={...current,roomId:current.roomId||room?.id||'',historySource:'smart-chat'};
607
+ }
608
+ return keptAccepted();
609
+ }catch(error){
610
+ if(sendEpoch!==epoch)return undefined;
611
+ if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
612
+ const latest=readServiceState();
613
+ if(latest.principal!==s.principal||latest.scope!==s.scope)return undefined;
614
+ if(accepted)return keptAccepted();
615
+ s=shape({...latest,usedSuggestionIds:[...new Set([...s.usedSuggestionIds,...latest.usedSuggestionIds])]});
616
+ const definitive=businessChatSendRejected(error);
617
+ return shape({...withLiveDraft(s),usedSuggestionIds:definitive?s.usedSuggestionIds.filter(value=>value!==id):s.usedSuggestionIds,error:safeError(error,'send'),notice:definitive?businessChatCopy('suggestionRejected'):businessChatCopy('suggestionUnconfirmed')});
618
+ }finally{suggestionFlight=false;}
619
+ },
620
+ onEvent:async(s,a)=>{
621
+ if(initFlight){eventDuringInit=true;return undefined;}
622
+ const startEpoch=epoch;
623
+ const envelope=a as RealtimeEventEnvelope|undefined;
624
+ const eventType=typeof envelope?.event?.type==='string'?envelope.event.type:'';
625
+ s=shape({...s,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':s.historySource,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
626
+ if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope},s.historySource==='smart-chat'))return s;
627
+ if(refreshFlight){refreshQueued=true;return withLiveDraft(s);}
628
+ try{return await loadLatest(s);}catch(error){
629
+ if(startEpoch!==epoch)return undefined;
630
+ return /member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error))?clearPrivate(s,'guest'):shape({...withLiveDraft(s),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});
631
+ }
632
+ },
633
+ onStatus:async(s,a)=>{
634
+ const startEpoch=epoch;const mySeq=++statusSeq;
635
+ const status=a as RealtimeStatus|undefined;
636
+ const liveState=status?.state||'unavailable';
637
+ // While init is opening, the stored state has no room yet: remember the status and let init apply it.
638
+ if(initFlight){statusDuringInit={state:liveState,reason:liveState==='connected'?'':status?.reason||''};return undefined;}
639
+ s={...s,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':s.historySource};
640
+ let next=shape({...s,liveState,liveReason:liveState==='connected'?'':status?.reason||''});
641
+ if(liveState!=='connected'||s.liveState==='connected'||!s.member||(!s.roomId&&s.historySource!=='smart-chat')||!currentIdentity||s.principal!==(currentIdentity.userId||currentIdentity.email)||s.scope!==currentIdentity.scope)return next;
642
+ try{
643
+ const loaded=await loadLatest(next);
644
+ if(!loaded)return undefined;
645
+ // A newer status applied while the refresh ran wins over the one this handler started with.
646
+ const live=readServiceState();
647
+ const newer=statusSeq!==mySeq;
648
+ return shape({...loaded,liveState:newer?live.liveState:liveState,liveReason:newer?live.liveReason:(liveState==='connected'?'':status?.reason||''),error:''});
649
+ }
650
+ 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)});}
651
+ },
652
+ dispose:async s=>{epoch++;attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
653
+ }
654
+ });
655
+ readServiceState=()=>svc.getState();
656
+ async function loadLatest(s:State,wait=false):Promise<State|undefined>{
657
+ // A caller that does not need the data asks the running refresh to go again and keeps its own change
658
+ // (a status, an event count): returning nothing would drop it.
659
+ if(!wait&&refreshFlight){refreshQueued=true;return withLiveDraft(s);}
660
+ let next=await loadHistory(s,wait);
661
+ // Events that arrived during a refresh ask for another pass, a few at most.
662
+ for(let pass=0;next&&refreshQueued&&pass<3;pass++){
663
+ refreshQueued=false;
664
+ next=await loadHistory(next,true);
665
+ }
666
+ if(!next)refreshQueued=false;
667
+ return next;
668
+ }
669
+ export { svc };