@wix/himalaya-cli 0.1573.0 → 0.1575.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 (112) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +16 -5
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +6 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/message-format.d.ts +59 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/telemetry/wix-bi/src/index.d.ts +15 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/telemetry/wix-bi/src/oneapp.d.ts +90 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +3 -3
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/l10n.mjs +8 -3
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +3 -3
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bi.mjs +165 -5
  10. package/dist/cli.mjs +5261 -2937
  11. package/dist/preview-assets.generated.bin +0 -0
  12. package/guides/designing-himi-screens.md +7 -1
  13. package/guides/hhp.md +2 -2
  14. package/guides/himi-json-output.md +53 -2
  15. package/guides/himi-mcp.md +1 -1
  16. package/guides/himi-test.md +3 -2
  17. package/guides/iap-products.md +1 -1
  18. package/guides/importing-oneapp-translations.md +115 -0
  19. package/guides/index.json +6 -0
  20. package/guides/one-app-section-mappings.md +16 -4
  21. package/guides/one-app-to-himi-app.md +167 -10
  22. package/guides/push-cli-onboarding.md +10 -0
  23. package/guides/qa-builds.md +21 -1
  24. package/guides/templates.md +3 -2
  25. package/guides/testing-himi-apps.md +1 -1
  26. package/package.json +3 -3
  27. package/templates/beauty-salon/dev/index.ts +96 -133
  28. package/templates/beauty-salon/tier5-src/_shared/exclusive-writes.ts +66 -0
  29. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +18 -5
  30. package/templates/beauty-salon-owner/dev/index.ts +27 -6
  31. package/templates/beauty-salon-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  32. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  33. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  34. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
  35. package/templates/branded-lite/dev/index.ts +136 -142
  36. package/templates/branded-lite/l10n/en.json +570 -0
  37. package/templates/branded-lite/l10n/he.json +200 -0
  38. package/templates/branded-lite/tier5-src/_shared/business-chat-copy.ts +40 -0
  39. package/templates/branded-lite/tier5-src/_shared/business-chat-message-model.ts +1 -1
  40. package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +3 -2
  41. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +26 -21
  42. package/templates/branded-lite/tier5-src/_shared/exclusive-writes.ts +66 -0
  43. package/templates/branded-lite/tier5-src/business-chat/index.ts +184 -63
  44. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +18 -5
  45. package/templates/community-club/dev/index.ts +22 -6
  46. package/templates/community-club-owner/dev/index.ts +27 -6
  47. package/templates/community-club-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  48. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  49. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  50. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  51. package/templates/events-meetups/dev/index.ts +22 -6
  52. package/templates/events-meetups-owner/dev/index.ts +25 -6
  53. package/templates/events-meetups-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  54. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  55. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  56. package/templates/gym-fitness/dev/index.ts +96 -133
  57. package/templates/gym-fitness/tier5-src/_shared/exclusive-writes.ts +66 -0
  58. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +18 -5
  59. package/templates/gym-fitness-owner/dev/index.ts +28 -6
  60. package/templates/gym-fitness-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  61. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  62. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  63. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  64. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
  65. package/templates/himi-chat/dev/index.ts +22 -6
  66. package/templates/nonprofit-cause/dev/index.ts +22 -6
  67. package/templates/nonprofit-cause-owner/dev/index.ts +26 -6
  68. package/templates/nonprofit-cause-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  69. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  70. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  71. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
  72. package/templates/owner-lite/dev/index.ts +28 -6
  73. package/templates/owner-lite/tier5-src/_shared/exclusive-writes.ts +66 -0
  74. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  75. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  76. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  77. package/templates/owner-lite/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
  78. package/templates/podcast-creator/dev/index.ts +22 -6
  79. package/templates/podcast-creator-owner/dev/index.ts +27 -6
  80. package/templates/podcast-creator-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  81. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  82. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  83. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  84. package/templates/portfolio-creative/dev/index.ts +96 -133
  85. package/templates/portfolio-creative/tier5-src/_shared/exclusive-writes.ts +66 -0
  86. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +18 -5
  87. package/templates/portfolio-creative-owner/dev/index.ts +27 -6
  88. package/templates/portfolio-creative-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  89. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  90. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  91. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
  92. package/templates/restaurant/dev/index.ts +22 -6
  93. package/templates/restaurant-owner/dev/index.ts +28 -6
  94. package/templates/restaurant-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  95. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  96. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  97. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  98. package/templates/restaurant-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
  99. package/templates/retail-store/dev/index.ts +96 -133
  100. package/templates/retail-store/tier5-src/_shared/exclusive-writes.ts +66 -0
  101. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +18 -5
  102. package/templates/retail-store-owner/dev/index.ts +26 -6
  103. package/templates/retail-store-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  104. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  105. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
  106. package/templates/surface-showcase/dev/index.ts +22 -6
  107. package/templates/yoga-studio/dev/index.ts +22 -6
  108. package/templates/yoga-studio-owner/dev/index.ts +26 -6
  109. package/templates/yoga-studio-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
  110. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
  111. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
  112. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
@@ -1,10 +1,12 @@
1
+ import { stashCopy } from "../_shared/himi-copy.js";
2
+ import { businessChatCopy, BUSINESS_CHAT_COPY } from "../_shared/business-chat-copy.js";
1
3
  import { defineService } from '@himalaya/state';
2
4
  import * as chat from '../_shared/business-chat-transport.js';
3
5
  import type { ChatIdentity } from '../_shared/business-chat-transport.js';
4
6
  import { type MessageRow } from '../_shared/business-chat-message-model.js';
5
7
  import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
6
8
  import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
7
- import { businessInAppRoute, type BusinessArticleAction, dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
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';
8
10
  import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
9
11
  import { smartChatHistory } from '../_shared/business-chat-history.js';
10
12
  import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
@@ -20,11 +22,15 @@ type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mi
20
22
  dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
21
23
  /** Only the member's own row that the server has not returned yet. */
22
24
  pending?:boolean;
25
+ /** Confirmed local delivery, retained until the authoritative history returns it. */
26
+ acceptedIntent?:BusinessChatIntent;
23
27
  };
24
28
  type State = {
25
29
  phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
26
30
  roomId:string; principal:string; scope:string; messages:BusinessMessageRow[]; draft:string; busy:boolean;
27
- historySource:'members'|'smart-chat';
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';
28
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;
29
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;
30
36
  hostedName:string; hasHostedName:boolean; noHostedName:boolean;
@@ -47,7 +53,7 @@ function initialsOf(name:string):string{
47
53
  const words=name.trim().split(/\s+/).filter(Boolean);
48
54
  return (words.length>1?`${first(words[0]!)}${first(words[words.length-1]!)}`:first(words[0]??'')).toUpperCase();
49
55
  }
50
- 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',
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',
51
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:''};
52
58
  let currentIdentity:ChatIdentity|null=null;
53
59
  let subscriptionId='';
@@ -56,6 +62,9 @@ let suggestionFlight=false;
56
62
  let refreshFlight=false;
57
63
  let refreshQueued=false;
58
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;
59
68
  let statusSeq=0;
60
69
  let eventDuringInit=false;
61
70
  let statusDuringInit:{state:string;reason:string}|null=null;
@@ -63,6 +72,7 @@ let statusDuringInit:{state:string;reason:string}|null=null;
63
72
  function takeEventDuringInit():boolean{const seen=eventDuringInit;eventDuringInit=false;return seen;}
64
73
  function takeStatusDuringInit():{state:string;reason:string}|null{const seen=statusDuringInit;statusDuringInit=null;return seen;}
65
74
  let draftRevision=0;
75
+ let historyRevision=0;
66
76
  let businessParticipantId='';
67
77
  let loadStage:'room_selection'|'history_request'|'history_normalization'|'ai_enrichment'='room_selection';
68
78
  let readServiceState:()=>State=()=>shape(initial);
@@ -106,16 +116,16 @@ function liveDescription(state:string,reason:string):string{return businessRealt
106
116
  /** The member's own message as it shows the instant they tap Send, before the server has answered. It carries the
107
117
  * id sent to the server, so the real message replaces it once history returns it. */
108
118
  function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
109
- const when=intent.delivery==='sending'?'Sending…':intent.delivery==='unconfirmed'?'Not confirmed':messageTimeLabel(intent.createdDate);
119
+ const when=intent.delivery==='sending'?businessChatCopy('sending'):intent.delivery==='unconfirmed'?businessChatCopy('unconfirmed'):messageTimeLabel(intent.createdDate);
110
120
  return {id:intent.id,sender:'',when,createdDate:intent.createdDate,mine:true,
111
121
  senderAvatar:'',senderName:intent.senderName??'',senderInitials:'',senderBadge:'',imageUrl:'',imageDescription:'',noImage:true,bodyText:intent.text,bodyMarkdown:intent.text,
112
- 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};
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};
113
123
  }
114
- function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,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=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,
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,
115
125
  notLoading:s.phase!=='loading',sendDisabled:!canSend(s,s.draft),sendEnabled:canSend(s,s.draft),noLive:!s.roomId||s.liveState==='connected'};}
116
- /** Send is offered only once the room's history has loaded: a send needs it as its anchor, the Smart Chat send does
117
- * not let the app name the stored message, and history is how the app finds it. */
118
- function canSend(s:State,draft:string):boolean{return !!s.member&&!!s.roomId&&s.phase==='ready'&&!s.busy&&!!draft.trim()&&!s.pendingIntent;}
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;}
119
129
  function safeLoadFailure(error:unknown):string{
120
130
  const name=error instanceof Error?error.name:'UnknownError';
121
131
  const code=(error as {code?:unknown}|null)?.code;
@@ -128,11 +138,13 @@ function param(arg:unknown,key:string):string{const a=arg as Record<string,unkno
128
138
  // A private attachment's download URL costs a round trip and stays valid for minutes, so a refresh reuses it.
129
139
  const attachmentUrls=new Map<string,{url:string;at:number}>();
130
140
  const ATTACHMENT_URL_TTL_MS=4*60*1000;
131
- async function privateAttachmentUrl(part:{mediaId?:string;mediaProvider?:string;url?:string;fileName?:string}):Promise<string>{
132
- const key=`${currentIdentity?.email??''}|${part.mediaId??''}`;
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??''}`;
133
144
  const hit=attachmentUrls.get(key);
134
145
  if(hit&&Date.now()-hit.at<ATTACHMENT_URL_TTL_MS)return hit.url;
135
- const url=await chat.attachmentUrl(me(),part);
146
+ const url=await chat.attachmentUrl(identity,part);
147
+ if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
136
148
  if(url)attachmentUrls.set(key,{url,at:Date.now()});
137
149
  return url;
138
150
  }
@@ -216,25 +228,26 @@ function slimRow(row:Record<string,unknown>):BusinessMessageRow{
216
228
  const pick=(...keys:string[])=>Object.fromEntries(keys.map(key=>[key,row[key]]));
217
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;
218
230
  }
219
- async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
231
+ async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:string,participantId=businessParticipantId||'smart-chat:visitor'):Promise<BusinessMessageRow[]>{
232
+ const identity=me();const presentationEpoch=epoch;
220
233
  const rows=chronologicalMessages(existing,raw,memberId) as MessageRow[];
221
234
  const prepared=await Promise.all(rows.map(async row=>{
222
235
  const parts=await Promise.all(row.parts.map(async part=>{
223
236
  if(part.kind!=='image'&&part.kind!=='file'&&part.kind!=='audio'&&part.kind!=='video')return part;
224
237
  if(part.mediaProvider==='WixMediaPrivate'&&part.mediaId){
225
- try{return {...part,url:await privateAttachmentUrl(part)};}catch{return {...part,url:''};}
238
+ try{return {...part,url:await privateAttachmentUrl(part,identity,presentationEpoch)};}catch{return {...part,url:''};}
226
239
  }
227
240
  return part;
228
241
  }));
229
242
  return {...row,parts};
230
243
  }));
231
- const presented=presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as Record<string,unknown>[];
244
+ const presented=presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,participantId) as unknown as Record<string,unknown>[];
232
245
  await Promise.all(presented.map(async row=>{
233
246
  const slug=typeof row.productSlug==='string'?row.productSlug:'';
234
247
  const name=String(row.productName||'');
235
248
  if(slug&&!row.imageUrl){
236
249
  const {url}=await productImageFor(slug,name);
237
- if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:name||'Product image'});
250
+ if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:name||businessChatCopy('productImage')});
238
251
  }
239
252
  row.articleActions=await Promise.all((row.articleActions as BusinessArticleAction[]).map(action=>routeAction(action,name)));
240
253
  }));
@@ -246,31 +259,30 @@ function me():ChatIdentity{
246
259
  return identity;
247
260
  }
248
261
  async function getCurrentMember():Promise<ChatIdentity>{return chat.chatIdentity();}
249
- async function resolveExistingBusinessRoom(startEpoch:number,businessName:string):Promise<{id:string;participantId:string}>{
250
- businessParticipantId='';
262
+ async function resolveExistingBusinessRoom(startEpoch:number,businessName:string):Promise<{id:string;participantId:string}|undefined>{
251
263
  const page=await chat.listRooms(me());
252
264
  if(startEpoch!==epoch)throw new Error('MemberAccessChanged');
253
- const room=selectExistingBusinessRoom(page.items,businessName);
254
- if(!room)throw new Error('existing_business_conversation_unavailable');
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');
255
268
  return room;
256
269
  }
257
270
  function safeError(error:unknown,operation:'load'|'send'|'init'):string{
258
271
  const text=String((error as Error)?.message||error);
259
- if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(text))return 'Your member session changed. Sign in again to continue.';
260
- if(/approved first-party|webwix_owner_only|chat_setup_scope_unavailable/i.test(text))return 'This app host cannot reach the inbox yet.';
261
- return operation==='send'?'Message delivery could not be confirmed. Check the conversation before trying again.':
262
- operation==='init'?'Couldn’t open the conversation. Try again.':'Couldn’t load the conversation. Try again.';
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');
263
276
  }
264
277
  async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
265
278
  function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
266
279
  attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
267
- return shape({...initial,phase,error:phase==='guest'?'Sign in to send a message.':s.error});
280
+ return shape({...initial,phase,error:phase==='guest'?businessChatCopy('signInPrompt'):s.error});
268
281
  }
269
282
  let refreshTail:Promise<unknown>=Promise.resolve();
270
283
  /** One refresh runs at a time. A caller that needs the data (init, a send) waits for the one in flight and
271
284
  * then runs its own; a realtime event only asks the running one to go again, and gets nothing back. */
272
285
  async function loadHistory(s:State,wait=false):Promise<State|undefined>{
273
- if(!s.roomId)return s;
274
286
  while(refreshFlight){
275
287
  if(!wait){refreshQueued=true;return withLiveDraft(s);}
276
288
  await refreshTail.catch(()=>{});
@@ -280,7 +292,7 @@ async function loadHistory(s:State,wait=false):Promise<State|undefined>{
280
292
  return run;
281
293
  }
282
294
  async function refreshHistory(s:State):Promise<State|undefined>{
283
- refreshFlight=true;const startEpoch=epoch;const roomId=s.roomId;const principal=s.principal;const startDraftRevision=draftRevision;
295
+ refreshFlight=true;const startEpoch=epoch;const roomId=s.roomId;const principal=s.principal;const participantId=businessParticipantId||'smart-chat:visitor';const startDraftRevision=draftRevision;
284
296
  // A refresh that outlived its member or session says nothing: returning a state would overwrite the new one.
285
297
  const stale=()=>startEpoch!==epoch||(currentIdentity?.userId||currentIdentity?.email)!==principal;
286
298
  try{
@@ -288,15 +300,17 @@ async function refreshHistory(s:State):Promise<State|undefined>{
288
300
  // The Members room and Smart Chat may be different conversations. Read Smart Chat's
289
301
  // persisted transcript, not only its quick replies; retain Members history for sites without it.
290
302
  const [memberRead,aiRead]=await Promise.all([
291
- chat.messagesPage(me(),roomId).then(page=>({page,error:null})).catch(error=>({page:[],error})),
303
+ (roomId?chat.messagesPage(me(),roomId):Promise.resolve([])).then(page=>({page,error:null})).catch(error=>({page:[],error})),
292
304
  loadSmartChatMessages().then(messages=>({messages,error:null})).catch(error=>({messages:[],error})),
293
305
  ]);
294
306
  if(stale())return undefined;
295
307
  // Once this screen has followed Smart Chat, an unavailable read must not switch it back
296
308
  // to the unrelated Members thread.
297
- if(aiRead.error&&(s.historySource==='smart-chat'||aiRead.error.code==='smart_chat_history_incomplete'))throw aiRead.error;
298
- const smart=smartChatHistory(aiRead.messages,memberRead.page,businessParticipantId);
299
- const useSmart=!!smart||s.historySource==='smart-chat';
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;
300
314
  if(!useSmart&&memberRead.error)throw memberRead.error;
301
315
  const page=useSmart?smart?.messages??[]:memberRead.page;
302
316
  const historySource=useSmart?'smart-chat' as const:'members' as const;
@@ -304,22 +318,24 @@ async function refreshHistory(s:State):Promise<State|undefined>{
304
318
  loadStage='history_normalization';
305
319
  // Presented from the page alone: a row kept from an earlier page would be re-presented without the
306
320
  // metadata that only the page carries (sender, avatar, AI badge, article buttons).
307
- const messages=await presentMessages([],page,currentIdentity?.userId||undefined);
321
+ const messages=await presentMessages([],page,currentIdentity?.userId||undefined,participantId);
308
322
  if(stale())return undefined;
309
323
  // Read the live state last: what other actions changed while this one awaited is theirs to keep.
310
324
  const latest=readServiceState();
311
- const same=latest.roomId===roomId&&latest.principal===principal;
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));
312
327
  // What was typed while this ran is kept even on the first load, when the live state is still empty.
313
328
  const liveDraft=draftRevision!==startDraftRevision?latest.draft:s.draft;
314
- const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
315
- const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
329
+ const base:State=same?withLiveFields({...s,roomId:latest.roomId||roomId},latest,liveDraft):{...s,draft:liveDraft};
330
+ const intentObserved=intentObservedIn(messages,base.pendingIntent,historySource);
316
331
  const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
317
332
  // A send the server has not returned yet stays on screen until history does. Seeing it in history only replaces the
318
333
  // placeholder: a send still in flight stays pending until its own request settles it, so its outcome is never lost.
319
334
  // An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
320
335
  const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
321
336
  const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
322
- return shape({...base,historySource,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
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);
323
339
  }catch(error){
324
340
  const wrapped=error instanceof Error?error:new Error('load_failed');
325
341
  Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
@@ -335,7 +351,39 @@ function withLiveFields(st:State,latest:State,draft:string):State{
335
351
  liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
336
352
  scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
337
353
  }
338
- function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
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
+ }
339
387
  type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;storedId?:string;error?:unknown};
340
388
  /** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
341
389
  * until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
@@ -360,6 +408,9 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
360
408
  // and name the screen gave first.
361
409
  load:async (s):Promise<State|undefined>=>s.phase==='guest'?svc.init():undefined,
362
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)});
363
414
  if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
364
415
  // A retry passes no site: the one the screen gave first stays. A site the screen passes, even an empty one (a
365
416
  // business with no site), replaces it, so an earlier business's links are not routed to this one's screens.
@@ -383,14 +434,27 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
383
434
  const inFlightId=s.pendingIntent&&s.pendingIntent.delivery!=='unconfirmed'?s.pendingIntent.id:'';
384
435
  const keepTyped=(st:State):State=>{
385
436
  const live=readServiceState();
386
- const sameMember=!reset&&live.principal===st.principal;
437
+ const sameMember=!reset&&live.principal===st.principal&&live.scope===st.scope&&(live.roomId===st.roomId||!live.roomId||!st.roomId);
387
438
  const own=sameMember?live.pendingIntent:null;
388
439
  const intent=own&&!(own.delivery==='unconfirmed'&&own.id===checkedId)?own:null;
389
440
  const settledMeanwhile=sameMember&&!!inFlightId&&!(live.pendingIntent?.id===inFlightId&&live.pendingIntent.delivery!=='unconfirmed');
390
- return {...st,pendingIntent:intent,messages:st.messages.filter(row=>!row.pending||row.id===intent?.id),
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)),
391
450
  ...(settledMeanwhile?{notice:live.notice,error:live.error,draft:live.draft}:draftRevision!==initRevision?{draft:live.draft}:{})};
392
451
  };
393
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
+ };
394
458
  try{
395
459
  loadStage='room_selection';
396
460
  const identity=await getCurrentMember();
@@ -401,20 +465,27 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
401
465
  s=shape({...s,phase:'loading',member:true,principal:identity.userId||identity.email,scope:identity.scope,error:'',notice:''});
402
466
  const existingRoom=await resolveExistingBusinessRoom(myEpoch,signedBy);
403
467
  if(myEpoch!==epoch)return undefined;
404
- businessParticipantId=existingRoom.participantId;
405
- s=shape({...s,roomId:existingRoom.id});
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});
406
472
  // Subscribe before reading history, so anything that lands after the read arrives as a realtime event.
407
- 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'});}}
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};}
408
474
  let loaded=await loadLatest(s,true);
409
475
  if(!loaded||myEpoch!==epoch)return undefined;
410
476
  // Until this returns the stored state has no room, so events are ignored; one that arrived meanwhile asks for another pass.
411
477
  for(let pass=0;pass<3&&takeEventDuringInit();pass++){const again=await loadLatest(loaded,true);if(!again||myEpoch!==epoch)return undefined;loaded=again;}
412
- const opened=takeStatusDuringInit();
413
- return shape(keepTyped(opened?{...loaded,liveState:opened.state,liveReason:opened.reason}:loaded));
478
+ return shape(keepTyped(withOpenedStatus(loaded)));
414
479
  }catch(error){
480
+ if(myEpoch!==epoch)return undefined;
415
481
  const sessionLost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
416
482
  const loadFailure=typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error);
417
- return sessionLost?clearPrivate(s,'guest'):shape(keepTyped({...s,phase:'error',member:!!currentIdentity?.authenticated,error:safeError(error,'init'),loadFailure}));
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});
418
489
  }finally{initFlight=false;}
419
490
  },
420
491
  // Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
@@ -427,7 +498,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
427
498
  if(!text||!canSend(s,text))return s;
428
499
  // The member's name as their earlier messages show it, so the row keeps its size when the real message replaces it.
429
500
  const ownName=[...s.messages].reverse().find(row=>row.mine&&row.senderName)?.senderName??'';
430
- const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName,...sendAnchor(s.messages,text,s.phase==='ready')};
501
+ const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName,...sendAnchor(s.messages,text,s.phase==='ready'),smartChatFromStart:s.transcriptSource==='members'&&s.smartChatEmpty};
431
502
  draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
432
503
  void deliver(pending,s.draft);
433
504
  return shape({...s,draft:'',pendingIntent:pending,messages:[...s.messages,pendingRow(pending)],error:'',notice:'',scrollSend:s.scrollSend+1});
@@ -439,47 +510,81 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
439
510
  if(result.outcome==='signed_out')return clearPrivate(s,'guest');
440
511
  if(result.outcome==='rejected'){
441
512
  // The row goes, and the text comes back unless the member has already started something new.
442
- 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:'The message was rejected. Your draft is retained.'});
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')});
443
514
  }
444
515
  // History cannot tell this send from the same words written elsewhere (the website), so an indeterminate failure is
445
516
  // never reported as sent. If matching words are already there it does not hold Send, and the notice still asks to check.
446
517
  if(result.outcome==='unconfirmed'){
447
518
  const unconfirmed={...pending,delivery:'unconfirmed' as const};
448
- const seen=pendingIntentWasObserved(s.messages,pending);
449
- return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
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')});
450
521
  }
451
522
  const sent={...pending,storedId:result.storedId,delivery:'sent' as const};
452
- const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
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:''});
453
526
  const sendEpoch=epoch;
454
527
  // The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
455
528
  // A refresh that ran while the committed intent still said sending leaves a 'Sending…' row: swap it for the accepted one.
456
529
  // History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
457
530
  // is cleared: a newer one the live state carried in is that send's to settle.
458
531
  const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
459
- const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)?shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.filter(row=>!(row.id===sent.id&&row.pending))}):
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))}):
460
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)]});
461
534
  // The stream finishes after the AI has persisted its answer. Reload even while connected:
462
535
  // the Members-room subscription may never see the separate Smart Chat room's event.
463
- // A refresh already running owns the next read. Queue one more pass without keeping Send
464
- // locked behind that request (or allowing its old snapshot to restore this intent).
465
- if(refreshFlight){refreshQueued=true;return settled(accepted);}
466
- try{const loaded=await loadLatest(accepted,true);return loaded?settled(loaded):undefined;}catch(error){
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){
467
566
  // A session lost by an older operation says nothing about the member who is signed in now.
468
567
  if(sendEpoch!==epoch)return undefined;
469
568
  if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(accepted,'guest');
470
- return settled(withLiveDraft(accepted));
569
+ return settled(latestResolved());
471
570
  }
472
571
  },
473
572
  sendSuggestion:async(s,a)=>{
474
- if(suggestionFlight||!s.member||!s.roomId||!!s.pendingIntent)return s;
573
+ if(suggestionFlight||!s.member||s.phase!=='ready'||!!s.pendingIntent)return s;
475
574
  const id=param(a,'id');
476
575
  if(!id||s.usedSuggestionIds.includes(id))return s;
477
576
  const rows=[{suggestions:s.quickReplies} as {suggestions?:BusinessSuggestion[]},...s.messages];
577
+ if(!s.roomId&&findBusinessSuggestion(rows,id)?.kind!=='visitor_option')return s;
478
578
  const options=s.visitorOptions;
479
579
  const intent=newBusinessChatIntent('suggestion',s.roomId,s.principal);
480
580
  const sendEpoch=epoch;
481
581
  suggestionFlight=true;
482
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
+ };
483
588
  try{
484
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'
485
590
  ? sendBusinessVisitorOption(options,suggestion.label)
@@ -487,14 +592,29 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
487
592
  if(sendEpoch!==epoch)return undefined;
488
593
  if(!sent)return withLiveDraft(s);
489
594
  const latest=readServiceState();
490
- if(latest.roomId!==s.roomId||latest.principal!==s.principal)return undefined;
491
- try{const loaded=await loadLatest(withLiveDraft(shape({...s,scrollSend:s.scrollSend+1,error:'',notice:'Suggestion sent.'})),true);return loaded?shape({...loaded,notice:'Suggestion sent.'}):undefined;}
492
- catch{return withLiveDraft(shape({...s,scrollSend:s.scrollSend+1,error:'',notice:'Suggestion sent.'}));}
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();
493
609
  }catch(error){
494
610
  if(sendEpoch!==epoch)return undefined;
495
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])]});
496
616
  const definitive=businessChatSendRejected(error);
497
- return shape({...withLiveDraft(s),usedSuggestionIds:definitive?s.usedSuggestionIds.filter(value=>value!==id):s.usedSuggestionIds,error:safeError(error,'send'),notice:definitive?'The suggestion was rejected.':'Suggestion delivery is unconfirmed; it will not be sent again automatically.'});
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')});
498
618
  }finally{suggestionFlight=false;}
499
619
  },
500
620
  onEvent:async(s,a)=>{
@@ -502,7 +622,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
502
622
  const startEpoch=epoch;
503
623
  const envelope=a as RealtimeEventEnvelope|undefined;
504
624
  const eventType=typeof envelope?.event?.type==='string'?envelope.event.type:'';
505
- s=shape({...s,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
625
+ s=shape({...s,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':s.historySource,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
506
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;
507
627
  if(refreshFlight){refreshQueued=true;return withLiveDraft(s);}
508
628
  try{return await loadLatest(s);}catch(error){
@@ -516,8 +636,9 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
516
636
  const liveState=status?.state||'unavailable';
517
637
  // While init is opening, the stored state has no room yet: remember the status and let init apply it.
518
638
  if(initFlight){statusDuringInit={state:liveState,reason:liveState==='connected'?'':status?.reason||''};return undefined;}
639
+ s={...s,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':s.historySource};
519
640
  let next=shape({...s,liveState,liveReason:liveState==='connected'?'':status?.reason||''});
520
- if(liveState!=='connected'||s.liveState==='connected'||!s.member||!s.roomId||!currentIdentity||s.principal!==(currentIdentity.userId||currentIdentity.email)||s.scope!==currentIdentity.scope)return next;
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;
521
642
  try{
522
643
  const loaded=await loadLatest(next);
523
644
  if(!loaded)return undefined;
@@ -115,6 +115,7 @@ interface State {
115
115
  checkoutHint: string;
116
116
  itemCount: number;
117
117
  promoCode: string;
118
+ promoEditRevision: number;
118
119
  appliedCode: string;
119
120
  appliedLabel: string;
120
121
  couponId: string;
@@ -380,11 +381,24 @@ function couponDetails(result: unknown): { id: string; code: string } {
380
381
  return { id: coupon?.coupon?.id ?? coupon?.id ?? "", code: coupon?.coupon?.code ?? coupon?.code ?? "" };
381
382
  }
382
383
 
384
+ import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
385
+
383
386
  let cartPricing: IapCommerce;
384
387
  const defineService = withIapPresentation<State>({
385
- register: withOnlineWrites(registerState, ["remove", "increment", "decrement", "checkout", "beginPromo", "applyPromo", "removePromo"], { derive, messageFields: action => action.includes("Promo") ? ["promoError"] : ["checkoutHint"] }),
388
+ register: withExclusiveWrites<State>(withOnlineWrites(registerState, ["remove", "increment", "decrement", "checkout", "beginPromo", "applyPromo", "removePromo"], { derive, messageFields: action => action.includes("Promo") ? ["promoError"] : ["checkoutHint"] }), ["load", "remove", "increment", "decrement", "checkout", "applyPromo", "removePromo", "save"], {
389
+ prepare: (action, args) => { if (action === "load") stashCopy(args[0]); },
390
+ coalesce: ["load"],
391
+ // Full cart reloads share the transaction, since they also replace rows and
392
+ // reset promo fields. Only setPromo increments the edit revision: beginPromo's internal error reset
393
+ // must not masquerade as a user edit when a rejected code is retried.
394
+ settle: (before, next, live) => {
395
+ if ((before.promoEditRevision ?? 0) === (live.promoEditRevision ?? 0)) return next;
396
+ return derive({ ...next, promoEditRevision: live.promoEditRevision,
397
+ ...(next.items.length ? { promoCode: live.promoCode, promoError: live.promoError } : {}) });
398
+ },
399
+ }),
386
400
  onCreate: commerce => { cartPricing = commerce; },
387
- prefix: "cart", transientKeys: ["items", "subtotal", "promoBusy"], contentActions: ["load", "remove", "increment", "decrement"],
401
+ prefix: "cart", transientKeys: ["items", "subtotal", "promoBusy", "promoEditRevision"], contentActions: ["load", "remove", "increment", "decrement"],
388
402
  items: state => state.items.flatMap(row => row.catalogReference?.catalogItemId ? [{ reference: { kind: "storesProduct", id: row.catalogReference.catalogItemId }, commerceDisplayPrice: row.commerceTotal ?? row.total }] : []),
389
403
  present: (state, price, allowed) => ({ ...state,
390
404
  items: state.items.map(row => ({ ...row, total: row.catalogReference?.catalogItemId ? price(row.catalogReference.catalogItemId, row.commerceTotal ?? row.total) : row.total })),
@@ -404,10 +418,9 @@ const svc = defineService<State, {
404
418
  decrement: (state: State, arg?: unknown) => Promise<State>;
405
419
  checkout: (state: State) => Promise<State>;
406
420
  }>("himiStoreCart", {
407
- state: derive({ status: "loading", errorKind: "", items: [], subtotal: "", subtotalLabel: "", emptyText: "", checkoutHint: "", showPaidToast: false, noPaidToast: true, itemCount: 0, promoCode: "", appliedCode: "", appliedLabel: "", couponId: "", promoError: "", promoBusy: false, noPromoError: true, hasCoupon: false, discount: "", noDiscount: true, noCheckoutHint: true, noCoupon: true, isLoading: true, notLoading: false, isErrored: false, notErrored: true, noOfflineError: true, noServerError: true, hideContent: true, noEmpty: true, noItems: true }),
421
+ state: derive({ status: "loading", errorKind: "", items: [], subtotal: "", subtotalLabel: "", emptyText: "", checkoutHint: "", showPaidToast: false, noPaidToast: true, itemCount: 0, promoCode: "", promoEditRevision: 0, appliedCode: "", appliedLabel: "", couponId: "", promoError: "", promoBusy: false, noPromoError: true, hasCoupon: false, discount: "", noDiscount: true, noCheckoutHint: true, noCoupon: true, isLoading: true, notLoading: false, isErrored: false, notErrored: true, noOfflineError: true, noServerError: true, hideContent: true, noEmpty: true, noItems: true }),
408
422
  actions: {
409
423
  load: async (state, arg) => {
410
- stashCopy(arg);
411
424
  // 1) Instant paint from the on-device cache (offline-safe; empty on first-ever run).
412
425
  const cached = await readCachedItems();
413
426
  const base = cached.length
@@ -423,7 +436,7 @@ const svc = defineService<State, {
423
436
  : derive({ ...state, status: "error", errorKind: isOfflineFailure(error) ? "offline" : "server", items: [], subtotal: "", emptyText: copyParam("cart.loadErrorText", "Cart could not load."), checkoutHint: "" });
424
437
  }
425
438
  },
426
- setPromo: (state, arg) => derive({ ...state, promoCode: (typeof arg === "string" ? arg : String((arg as { value?: unknown } | undefined)?.value ?? "")).slice(0, 100), promoError: "" }),
439
+ setPromo: (state, arg) => derive({ ...state, promoEditRevision: (state.promoEditRevision ?? 0) + 1, promoCode: (typeof arg === "string" ? arg : String((arg as { value?: unknown } | undefined)?.value ?? "")).slice(0, 100), promoError: "" }),
427
440
  beginPromo: state => derive({ ...state, promoBusy: true, promoError: "" }),
428
441
  applyPromo: async (state): Promise<State> => {
429
442
  const code = state.promoCode.trim();