@wix/himalaya-cli 0.1574.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.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +16 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +6 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/telemetry/wix-bi/src/index.d.ts +15 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/telemetry/wix-bi/src/oneapp.d.ts +90 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bi.mjs +165 -5
- package/dist/cli.mjs +3479 -2601
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/designing-himi-screens.md +7 -1
- package/guides/hhp.md +2 -2
- package/guides/himi-json-output.md +12 -0
- package/guides/push-cli-onboarding.md +10 -0
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +74 -127
- package/templates/beauty-salon/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/beauty-salon-owner/dev/index.ts +5 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/branded-lite/dev/index.ts +114 -136
- package/templates/branded-lite/l10n/en.json +570 -0
- package/templates/branded-lite/l10n/he.json +200 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-message-model.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +3 -2
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +26 -21
- package/templates/branded-lite/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +184 -63
- package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/community-club-owner/dev/index.ts +5 -0
- package/templates/community-club-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/events-meetups-owner/dev/index.ts +3 -0
- package/templates/events-meetups-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/gym-fitness/dev/index.ts +74 -127
- package/templates/gym-fitness/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/gym-fitness-owner/dev/index.ts +6 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +4 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/owner-lite/dev/index.ts +6 -0
- package/templates/owner-lite/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/podcast-creator-owner/dev/index.ts +5 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/portfolio-creative/dev/index.ts +74 -127
- package/templates/portfolio-creative/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/portfolio-creative-owner/dev/index.ts +5 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/restaurant-owner/dev/index.ts +6 -0
- package/templates/restaurant-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/retail-store/dev/index.ts +74 -127
- package/templates/retail-store/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/retail-store-owner/dev/index.ts +4 -0
- package/templates/retail-store-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/yoga-studio-owner/dev/index.ts +4 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- 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'?'
|
|
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
|
-
/**
|
|
117
|
-
*
|
|
118
|
-
function canSend(s:State,draft:string):boolean{return !!s.member
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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||'
|
|
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
|
-
|
|
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 '
|
|
260
|
-
if(/approved first-party|webwix_owner_only|chat_setup_scope_unavailable/i.test(text))return '
|
|
261
|
-
return operation==='send'?'
|
|
262
|
-
operation==='init'?'
|
|
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'?'
|
|
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
|
-
|
|
298
|
-
const smart
|
|
299
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
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:'
|
|
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=
|
|
449
|
-
return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'
|
|
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
|
-
|
|
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=>
|
|
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
|
-
//
|
|
464
|
-
//
|
|
465
|
-
if(refreshFlight){refreshQueued=true;return settled(accepted);}
|
|
466
|
-
|
|
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(
|
|
569
|
+
return settled(latestResolved());
|
|
471
570
|
}
|
|
472
571
|
},
|
|
473
572
|
sendSuggestion:async(s,a)=>{
|
|
474
|
-
if(suggestionFlight||!s.member
|
|
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.
|
|
491
|
-
|
|
492
|
-
|
|
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?'
|
|
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
|
|
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();
|