ajaxter-chat 3.0.14 → 3.0.16

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/README.md CHANGED
Binary file
@@ -23,7 +23,7 @@ import { ViewerBlockedScreen } from './ViewerBlockedScreen';
23
23
  import { PermissionsGateScreen } from './PermissionsGateScreen';
24
24
  import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
25
25
  export const ChatWidget = ({ theme: localTheme, viewer }) => {
26
- var _a, _b, _c, _d, _e, _f;
26
+ var _a, _b, _c, _d, _e;
27
27
  /* SSR guard */
28
28
  const [mounted, setMounted] = useState(false);
29
29
  useEffect(() => { setMounted(true); }, []);
@@ -53,13 +53,14 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
53
53
  const [blockedUids, setBlockedUids] = useState((_b = data === null || data === void 0 ? void 0 : data.blockedUsers) !== null && _b !== void 0 ? _b : []);
54
54
  /* Sync remote data into local state once loaded */
55
55
  useEffect(() => {
56
- var _a, _b, _c;
56
+ var _a, _b, _c, _d;
57
57
  if (data) {
58
58
  setTickets(data.sampleTickets);
59
59
  setBlockedUids(data.blockedUsers);
60
60
  const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
61
- const inProject = (u) => !pid || u.project === pid;
62
- const all = [...((_b = data.developers) !== null && _b !== void 0 ? _b : []), ...((_c = data.users) !== null && _c !== void 0 ? _c : [])].filter(inProject);
61
+ const devs = (_b = data.developers) !== null && _b !== void 0 ? _b : [];
62
+ const usr = pid ? ((_c = data.users) !== null && _c !== void 0 ? _c : []).filter(u => u.project === pid) : ((_d = data.users) !== null && _d !== void 0 ? _d : []);
63
+ const all = [...devs, ...usr];
63
64
  const recents = Object.entries(data.sampleChats).map(([uid, msgs]) => {
64
65
  const user = all.find(u => u.uid === uid);
65
66
  if (!user || msgs.length === 0)
@@ -118,12 +119,18 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
118
119
  }, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.id]);
119
120
  const restoredRef = useRef(false);
120
121
  useEffect(() => {
121
- var _a, _b, _c, _d;
122
+ var _a, _b, _c, _d, _e, _f;
122
123
  if (!(data === null || data === void 0 ? void 0 : data.widget) || restoredRef.current)
123
124
  return;
124
125
  const w = data.widget;
125
126
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
126
- if (w.viewerBlocked) {
127
+ const uidForBlock = (_b = ((_a = viewer === null || viewer === void 0 ? void 0 : viewer.uid) !== null && _a !== void 0 ? _a : w.viewerUid)) === null || _b === void 0 ? void 0 : _b.trim();
128
+ let viewerIsBlocked = w.viewerBlocked === true;
129
+ if (!viewerIsBlocked && uidForBlock) {
130
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uidForBlock);
131
+ viewerIsBlocked = (rec === null || rec === void 0 ? void 0 : rec.viewerBlocked) === true;
132
+ }
133
+ if (viewerIsBlocked) {
127
134
  clearChat();
128
135
  setScreen('home');
129
136
  setActiveTab('home');
@@ -136,10 +143,10 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
136
143
  setScreen(p.screen);
137
144
  setActiveTab(p.activeTab);
138
145
  setUserListCtx(p.userListCtx);
139
- setViewingTicketId((_a = p.viewingTicketId) !== null && _a !== void 0 ? _a : null);
140
- setChatReturnCtx((_b = p.chatReturnCtx) !== null && _b !== void 0 ? _b : 'conversation');
146
+ setViewingTicketId((_c = p.viewingTicketId) !== null && _c !== void 0 ? _c : null);
147
+ setChatReturnCtx((_d = p.chatReturnCtx) !== null && _d !== void 0 ? _d : 'conversation');
141
148
  if (p.activeUserUid) {
142
- const pid = (_c = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _c === void 0 ? void 0 : _c.trim();
149
+ const pid = (_e = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _e === void 0 ? void 0 : _e.trim();
143
150
  const pool = pid
144
151
  ? [...data.developers, ...data.users].filter(u => u.project === pid)
145
152
  : [...data.developers, ...data.users];
@@ -147,27 +154,36 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
147
154
  if (u) {
148
155
  const hist = Array.isArray(p.messages) && p.messages.length
149
156
  ? p.messages
150
- : ((_d = data.sampleChats[u.uid]) !== null && _d !== void 0 ? _d : []);
157
+ : ((_f = data.sampleChats[u.uid]) !== null && _f !== void 0 ? _f : []);
151
158
  selectUser(u, hist);
152
159
  }
153
160
  }
154
161
  }
155
162
  restoredRef.current = true;
156
- }, [data, selectUser, clearChat, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
163
+ }, [data, selectUser, clearChat, viewer === null || viewer === void 0 ? void 0 : viewer.projectId, viewer === null || viewer === void 0 ? void 0 : viewer.uid]);
157
164
  useEffect(() => {
158
- var _a;
159
- if (!((_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.viewerBlocked))
165
+ var _a, _b;
166
+ if (!(data === null || data === void 0 ? void 0 : data.widget))
167
+ return;
168
+ const w = data.widget;
169
+ const uid = (_b = ((_a = viewer === null || viewer === void 0 ? void 0 : viewer.uid) !== null && _a !== void 0 ? _a : w.viewerUid)) === null || _b === void 0 ? void 0 : _b.trim();
170
+ let blocked = w.viewerBlocked === true;
171
+ if (!blocked && uid) {
172
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
173
+ blocked = (rec === null || rec === void 0 ? void 0 : rec.viewerBlocked) === true;
174
+ }
175
+ if (!blocked)
160
176
  return;
161
177
  clearChat();
162
178
  setScreen('home');
163
179
  setActiveTab('home');
164
180
  setViewingTicketId(null);
165
- }, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.viewerBlocked, clearChat]);
181
+ }, [data === null || data === void 0 ? void 0 : data.widget, data === null || data === void 0 ? void 0 : data.developers, data === null || data === void 0 ? void 0 : data.users, viewer === null || viewer === void 0 ? void 0 : viewer.uid, clearChat]);
166
182
  useEffect(() => {
167
183
  if (!(data === null || data === void 0 ? void 0 : data.widget))
168
184
  return;
169
185
  persistWidgetState();
170
- }, [(_e = data === null || data === void 0 ? void 0 : data.widget) === null || _e === void 0 ? void 0 : _e.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
186
+ }, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
171
187
  const incomingSoundRef = useRef(0);
172
188
  useEffect(() => {
173
189
  incomingSoundRef.current = messages.length;
@@ -318,16 +334,30 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
318
334
  return w;
319
335
  }, [data === null || data === void 0 ? void 0 : data.widget, viewer]);
320
336
  const primaryColor = theme.primaryColor;
337
+ /** All developers are listed; only end-`user` rows are filtered by `viewer.projectId`. */
321
338
  const allUsers = useMemo(() => {
322
- var _a;
339
+ var _a, _b;
323
340
  if (!data)
324
341
  return [];
325
342
  const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
326
- const list = [...data.developers, ...data.users];
343
+ const devs = (_b = data.developers) !== null && _b !== void 0 ? _b : [];
327
344
  if (!pid)
328
- return list;
329
- return list.filter(u => u.project === pid);
345
+ return [...devs, ...data.users];
346
+ const usersInProject = data.users.filter(u => u.project === pid);
347
+ return [...devs, ...usersInProject];
330
348
  }, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
349
+ const effectiveViewerBlocked = useMemo(() => {
350
+ var _a, _b;
351
+ if (!widgetConfig)
352
+ return false;
353
+ if (widgetConfig.viewerBlocked === true)
354
+ return true;
355
+ const uid = (_b = ((_a = viewer === null || viewer === void 0 ? void 0 : viewer.uid) !== null && _a !== void 0 ? _a : widgetConfig.viewerUid)) === null || _b === void 0 ? void 0 : _b.trim();
356
+ if (!uid || !data)
357
+ return false;
358
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
359
+ return (rec === null || rec === void 0 ? void 0 : rec.viewerBlocked) === true;
360
+ }, [widgetConfig, viewer === null || viewer === void 0 ? void 0 : viewer.uid, data]);
331
361
  const viewerIsDev = (widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerType) === 'developer';
332
362
  const viewerUid = widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerUid;
333
363
  const filteredUsers = screen === 'user-list'
@@ -345,6 +375,7 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
345
375
  : [];
346
376
  const otherDevelopers = useMemo(() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid), [allUsers, viewerUid]);
347
377
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
378
+ const totalUnread = useMemo(() => recentChats.reduce((sum, c) => { var _a; return sum + Math.max(0, (_a = c.unread) !== null && _a !== void 0 ? _a : 0); }, 0), [recentChats]);
348
379
  const handleTransferToDeveloper = useCallback((dev) => {
349
380
  var _a;
350
381
  if (!activeUser || !widgetConfig)
@@ -411,13 +442,29 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
411
442
  @media (max-width: 1024px) {
412
443
  .cw-drawer-panel { width: 100%; }
413
444
  }
414
- ` }), !isOpen && (_jsxs("button", { className: "cw-root", onClick: openDrawer, "aria-label": theme.buttonLabel, style: Object.assign(Object.assign({ position: 'fixed', bottom: 24, zIndex: 9999 }, posStyle), { display: 'flex', alignItems: 'center', gap: 10, padding: '13px 22px', backgroundColor: theme.buttonColor, color: theme.buttonTextColor, border: 'none', borderRadius: 50, cursor: 'pointer', fontSize: 15, fontWeight: 700, boxShadow: `0 8px 28px ${theme.buttonColor}55`, animation: 'cw-btnPop 0.4s cubic-bezier(0.34,1.56,0.64,1)', transition: 'transform 0.2s, box-shadow 0.2s' }), onMouseEnter: e => {
445
+ ` }), !isOpen && (_jsxs("button", { className: "cw-root", type: "button", onClick: openDrawer, "aria-label": totalUnread > 0 ? `${theme.buttonLabel}, ${totalUnread} unread` : theme.buttonLabel, title: totalUnread > 0 ? `${totalUnread} unread message${totalUnread === 1 ? '' : 's'}` : theme.buttonLabel, style: Object.assign(Object.assign({ position: 'fixed', bottom: 24, zIndex: 9999 }, posStyle), { display: 'flex', alignItems: 'center', gap: 10, padding: '13px 22px', backgroundColor: theme.buttonColor, color: theme.buttonTextColor, border: 'none', borderRadius: 50, cursor: 'pointer', fontSize: 15, fontWeight: 700, boxShadow: `0 8px 28px ${theme.buttonColor}55`, animation: 'cw-btnPop 0.4s cubic-bezier(0.34,1.56,0.64,1)', transition: 'transform 0.2s, box-shadow 0.2s' }), onMouseEnter: e => {
415
446
  e.currentTarget.style.transform = 'scale(1.06) translateY(-2px)';
416
447
  e.currentTarget.style.boxShadow = `0 14px 36px ${theme.buttonColor}66`;
417
448
  }, onMouseLeave: e => {
418
449
  e.currentTarget.style.transform = 'scale(1)';
419
450
  e.currentTarget.style.boxShadow = `0 8px 28px ${theme.buttonColor}55`;
420
- }, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z", stroke: theme.buttonTextColor, strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }), _jsx("span", { children: theme.buttonLabel })] })), isOpen && (_jsx("div", { "aria-hidden": true, style: {
451
+ }, children: [_jsxs("span", { style: { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z", stroke: theme.buttonTextColor, strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }), totalUnread > 0 && (_jsx("span", { style: {
452
+ position: 'absolute',
453
+ top: -8,
454
+ right: -10,
455
+ minWidth: 20,
456
+ height: 20,
457
+ padding: '0 5px',
458
+ borderRadius: 999,
459
+ background: '#ef4444',
460
+ color: '#fff',
461
+ fontSize: 11,
462
+ fontWeight: 800,
463
+ lineHeight: '20px',
464
+ textAlign: 'center',
465
+ border: '2px solid #fff',
466
+ boxSizing: 'border-box',
467
+ }, children: totalUnread > 99 ? '99+' : totalUnread }))] }), _jsx("span", { children: theme.buttonLabel })] })), isOpen && (_jsx("div", { "aria-hidden": true, style: {
421
468
  position: 'fixed', inset: 0, zIndex: 9997,
422
469
  backgroundColor: 'rgba(0,0,0,0.35)',
423
470
  opacity: closing ? 0 : 1,
@@ -429,12 +476,12 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
429
476
  border: `3px solid ${primaryColor}30`,
430
477
  borderTopColor: primaryColor,
431
478
  animation: 'spin 0.8s linear infinite',
432
- } }), _jsx("style", { children: `@keyframes spin { to { transform: rotate(360deg); } }` }), _jsx("p", { style: { fontSize: 14, color: '#7b8fa1' }, children: "Loading chat\u2026" })] })), cfgError && !cfgLoading && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12, padding: 32, textAlign: 'center' }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\u26A0\uFE0F" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Could not load chat configuration" }), _jsx("p", { style: { fontSize: 13, color: '#7b8fa1', lineHeight: 1.6 }, children: cfgError }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), !cfgLoading && !cfgError && widgetConfig && (_jsxs(_Fragment, { children: [screen !== 'chat' && screen !== 'call' && (_jsx("div", { style: {
479
+ } }), _jsx("style", { children: `@keyframes spin { to { transform: rotate(360deg); } }` }), _jsx("p", { style: { fontSize: 14, color: '#7b8fa1' }, children: "Loading chat\u2026" })] })), cfgError && !cfgLoading && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12, padding: 32, textAlign: 'center' }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\u26A0\uFE0F" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Could not load chat configuration" }), _jsx("p", { style: { fontSize: 13, color: '#7b8fa1', lineHeight: 1.6 }, children: cfgError }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), !cfgLoading && !cfgError && widgetConfig && (_jsxs(_Fragment, { children: [screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (_jsx("div", { style: {
433
480
  position: 'absolute', top: 12,
434
481
  right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
435
482
  left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
436
483
  zIndex: 20, display: 'flex', gap: 6,
437
- }, children: _jsx(CornerBtn, { onClick: closeDrawer, title: "Close", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6l12 12", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round" }) }) }) })), widgetConfig.status === 'MAINTENANCE' && (_jsx(MaintenanceView, { primaryColor: primaryColor })), widgetConfig.status === 'DISABLE' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 32, textAlign: 'center', gap: 12 }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Chat is disabled" }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (_jsx(ViewerBlockedScreen, { config: widgetConfig, apiKey: apiKey })), widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && !permissionsOk && (_jsx(PermissionsGateScreen, { primaryColor: primaryColor, widgetId: widgetConfig.id, onGranted: () => setPermissionsOk(true) })), widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (_jsxs("div", { className: "cw-scroll", style: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }, children: [screen === 'home' && (_jsx(HomeScreen, { config: widgetConfig, onNavigate: handleCardClick, onOpenTicket: handleOpenTicket, tickets: tickets })), screen === 'user-list' && (_jsx(UserListScreen, { context: userListCtx, users: filteredUsers, primaryColor: primaryColor, viewerType: (_f = widgetConfig.viewerType) !== null && _f !== void 0 ? _f : 'user', onBack: () => { setListEntranceAnimation(false); setScreen('home'); }, onSelectUser: handleSelectUser, onBlockList: userListCtx === 'conversation' ? () => setScreen('block-list') : undefined, useHomeHeader: userListCtx === 'support' && widgetConfig.viewerType !== 'developer', animateEntrance: listEntranceAnimation })), screen === 'chat' && activeUser && (_jsx(ChatScreen, { activeUser: activeUser, messages: messages, config: widgetConfig, isPaused: isPaused, isReported: isReported, isBlocked: isBlocked, onSend: sendMessage, onBack: handleBackFromChat, onClose: closeDrawer, onTogglePause: handleTogglePause, onReport: reportChat, onBlock: handleBlock, onStartCall: handleStartCall, onNavAction: handleNavFromMenu, otherDevelopers: otherDevelopers, onTransferToDeveloper: handleTransferToDeveloper, messageSoundEnabled: messageSoundEnabled, onToggleMessageSound: toggleMessageSound })), screen === 'call' && callSession.peer && (_jsx(CallScreen, { session: callSession, localVideoRef: localVideoRef, remoteVideoRef: remoteVideoRef, onEnd: handleEndCall, onToggleMute: toggleMute, onToggleCamera: toggleCamera, primaryColor: primaryColor })), screen === 'recent-chats' && (_jsx(RecentChatsScreen, { chats: recentChats, config: widgetConfig, onSelectChat: u => handleSelectUser(u, listCtxForUser(u, viewerIsDev)), animateEntrance: listEntranceAnimation })), screen === 'tickets' && (_jsx(TicketScreen, { tickets: tickets, config: widgetConfig, onNewTicket: () => { setListEntranceAnimation(false); setScreen('ticket-new'); }, onSelectTicket: id => {
484
+ }, children: _jsx(CornerBtn, { onClick: closeDrawer, title: "Close", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6l12 12", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round" }) }) }) })), widgetConfig.status === 'MAINTENANCE' && (_jsx(MaintenanceView, { primaryColor: primaryColor })), widgetConfig.status === 'DISABLE' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 32, textAlign: 'center', gap: 12 }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Chat is disabled" }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (_jsx(ViewerBlockedScreen, { config: widgetConfig, apiKey: apiKey, onClose: closeDrawer })), widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (_jsx(PermissionsGateScreen, { primaryColor: primaryColor, widgetId: widgetConfig.id, onGranted: () => setPermissionsOk(true) })), widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk && (_jsxs("div", { className: "cw-scroll", style: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }, children: [screen === 'home' && (_jsx(HomeScreen, { config: widgetConfig, apiKey: apiKey, onNavigate: handleCardClick, onOpenTicket: handleOpenTicket, tickets: tickets })), screen === 'user-list' && (_jsx(UserListScreen, { context: userListCtx, users: filteredUsers, primaryColor: primaryColor, viewerType: (_e = widgetConfig.viewerType) !== null && _e !== void 0 ? _e : 'user', onBack: () => { setListEntranceAnimation(false); setScreen('home'); }, onSelectUser: handleSelectUser, onBlockList: userListCtx === 'conversation' ? () => setScreen('block-list') : undefined, useHomeHeader: userListCtx === 'support' && widgetConfig.viewerType !== 'developer', animateEntrance: listEntranceAnimation })), screen === 'chat' && activeUser && (_jsx(ChatScreen, { activeUser: activeUser, messages: messages, config: widgetConfig, isPaused: isPaused, isReported: isReported, isBlocked: isBlocked, onSend: sendMessage, onBack: handleBackFromChat, onClose: closeDrawer, onTogglePause: handleTogglePause, onReport: reportChat, onBlock: handleBlock, onStartCall: handleStartCall, onNavAction: handleNavFromMenu, otherDevelopers: otherDevelopers, onTransferToDeveloper: handleTransferToDeveloper, messageSoundEnabled: messageSoundEnabled, onToggleMessageSound: toggleMessageSound })), screen === 'call' && callSession.peer && (_jsx(CallScreen, { session: callSession, localVideoRef: localVideoRef, remoteVideoRef: remoteVideoRef, onEnd: handleEndCall, onToggleMute: toggleMute, onToggleCamera: toggleCamera, primaryColor: primaryColor })), screen === 'recent-chats' && (_jsx(RecentChatsScreen, { chats: recentChats, config: widgetConfig, onSelectChat: u => handleSelectUser(u, listCtxForUser(u, viewerIsDev)), animateEntrance: listEntranceAnimation })), screen === 'tickets' && (_jsx(TicketScreen, { tickets: tickets, config: widgetConfig, onNewTicket: () => { setListEntranceAnimation(false); setScreen('ticket-new'); }, onSelectTicket: id => {
438
485
  setListEntranceAnimation(false);
439
486
  setViewingTicketId(id);
440
487
  setScreen('ticket-detail');
@@ -442,7 +489,7 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
442
489
  const t = tickets.find(x => x.id === viewingTicketId);
443
490
  return t ? (_jsx(TicketDetailScreen, { ticket: t, config: widgetConfig, onBack: () => { setViewingTicketId(null); setScreen('tickets'); } })) : null;
444
491
  })()), screen === 'block-list' && (_jsx(BlockListScreen, { blockedUsers: blockedUsers, config: widgetConfig, onUnblock: handleUnblock, onBack: () => { setScreen('home'); setActiveTab('home'); } }))] })), widgetConfig.status === 'ACTIVE' &&
445
- !widgetConfig.viewerBlocked &&
492
+ !effectiveViewerBlocked &&
446
493
  permissionsOk &&
447
494
  screen !== 'chat' &&
448
495
  screen !== 'call' &&
@@ -6,6 +6,8 @@ export interface HomeNavigateOptions {
6
6
  }
7
7
  interface HomeScreenProps {
8
8
  config: WidgetConfig;
9
+ /** Same as env / chatData — required to POST presence in production */
10
+ apiKey: string;
9
11
  onNavigate: (ctx: UserListContext | 'ticket', options?: HomeNavigateOptions) => void;
10
12
  /** Open a specific pending ticket (full detail) */
11
13
  onOpenTicket: (ticketId: string) => void;
@@ -1,10 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState, useMemo } from 'react';
2
+ import { useState, useMemo, useEffect } from 'react';
3
3
  import { SlideNavMenu } from '../SlideNavMenu';
4
4
  import { truncateWords } from '../../utils/chat';
5
- export const HomeScreen = ({ config, onNavigate, onOpenTicket, tickets }) => {
5
+ import { resolveInitialPresence, savePresenceStatus, syncPresenceToServer, } from '../../utils/presenceStatus';
6
+ const STATUS_OPTIONS = [
7
+ { value: 'ACTIVE', label: 'Active' },
8
+ { value: 'AWAY', label: 'Away' },
9
+ { value: 'DND', label: 'DND' },
10
+ ];
11
+ export const HomeScreen = ({ config, apiKey, onNavigate, onOpenTicket, tickets }) => {
6
12
  var _a, _b, _c, _d;
7
13
  const [menuOpen, setMenuOpen] = useState(false);
14
+ const [presence, setPresence] = useState(() => resolveInitialPresence(config.id, config.presenceStatus));
15
+ useEffect(() => {
16
+ setPresence(resolveInitialPresence(config.id, config.presenceStatus));
17
+ }, [config.id, config.presenceStatus]);
18
+ const setPresenceAndSave = (s) => {
19
+ var _a, _b;
20
+ setPresence(s);
21
+ savePresenceStatus(config.id, s);
22
+ const url = (_a = config.presenceUpdateUrl) === null || _a === void 0 ? void 0 : _a.trim();
23
+ if (!url)
24
+ return;
25
+ void syncPresenceToServer(url, {
26
+ widgetId: config.id,
27
+ apiKey,
28
+ viewerUid: ((_b = config.viewerUid) === null || _b === void 0 ? void 0 : _b.trim()) || undefined,
29
+ status: s,
30
+ }).catch(err => {
31
+ console.error('[ajaxter-chat] presence sync failed', err);
32
+ });
33
+ };
8
34
  const showSupport = config.chatType === 'SUPPORT' || config.chatType === 'BOTH';
9
35
  const showChat = config.chatType === 'CHAT' || config.chatType === 'BOTH';
10
36
  const viewerIsDev = config.viewerType === 'developer';
@@ -25,28 +51,58 @@ export const HomeScreen = ({ config, onNavigate, onOpenTicket, tickets }) => {
25
51
  };
26
52
  return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', overflow: 'hidden', background: '#fafbfc' }, children: [_jsx(SlideNavMenu, { open: menuOpen, onClose: () => setMenuOpen(false), primaryColor: config.primaryColor, chatType: config.chatType, viewerType: (_d = config.viewerType) !== null && _d !== void 0 ? _d : 'user', onSelect: ctx => {
27
53
  onNavigate(ctx, { fromMenu: true });
28
- } }), _jsx("div", { style: {
54
+ } }), _jsxs("div", { style: {
29
55
  flexShrink: 0,
30
- padding: '14px 16px 10px',
56
+ padding: '12px 14px 12px',
31
57
  display: 'flex',
32
58
  alignItems: 'center',
33
- gap: 12,
59
+ gap: 10,
34
60
  background: '#fff',
35
61
  borderBottom: '1px solid #eef0f5',
36
- }, children: _jsxs("button", { type: "button", "aria-label": "Open menu", onClick: () => setMenuOpen(true), style: {
37
- width: 40,
38
- height: 40,
39
- borderRadius: 10,
40
- border: 'none',
41
- background: '#f1f5f9',
42
- cursor: 'pointer',
43
- display: 'flex',
44
- flexDirection: 'column',
45
- alignItems: 'center',
46
- justifyContent: 'center',
47
- gap: 5,
48
- flexShrink: 0,
49
- }, children: [_jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } })] }) }), _jsxs("div", { className: "cw-scroll", style: { flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }, children: [_jsx("h1", { style: {
62
+ }, children: [_jsxs("button", { type: "button", "aria-label": "Open menu", onClick: () => setMenuOpen(true), style: {
63
+ width: 40,
64
+ height: 40,
65
+ borderRadius: 10,
66
+ border: 'none',
67
+ background: '#f1f5f9',
68
+ cursor: 'pointer',
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ gap: 5,
74
+ flexShrink: 0,
75
+ }, children: [_jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } })] }), _jsx("div", { style: { flex: 1, minWidth: 0 } }), _jsxs("div", { style: {
76
+ display: 'flex',
77
+ alignItems: 'center',
78
+ gap: 6,
79
+ flexShrink: 0,
80
+ flexWrap: 'wrap',
81
+ justifyContent: 'flex-end',
82
+ }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }, children: "Status" }), _jsx("div", { role: "group", "aria-label": "Your status", style: {
83
+ display: 'flex',
84
+ borderRadius: 10,
85
+ padding: 3,
86
+ background: '#f1f5f9',
87
+ gap: 2,
88
+ }, children: STATUS_OPTIONS.map(({ value, label }) => {
89
+ const isOn = presence === value;
90
+ return (_jsx("button", { type: "button", onClick: () => setPresenceAndSave(value), style: {
91
+ border: 'none',
92
+ borderRadius: 8,
93
+ padding: '7px 10px',
94
+ fontSize: 11,
95
+ fontWeight: 700,
96
+ letterSpacing: '0.04em',
97
+ cursor: 'pointer',
98
+ fontFamily: 'inherit',
99
+ textTransform: 'uppercase',
100
+ background: isOn ? config.primaryColor : 'transparent',
101
+ color: isOn ? '#fff' : '#64748b',
102
+ boxShadow: isOn ? `0 2px 8px ${config.primaryColor}55` : 'none',
103
+ transition: 'background 0.15s, color 0.15s',
104
+ }, children: label }, value));
105
+ }) })] })] }), _jsxs("div", { className: "cw-scroll", style: { flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }, children: [_jsx("h1", { style: {
50
106
  margin: '0 0 8px',
51
107
  fontSize: 24,
52
108
  fontWeight: 800,
@@ -3,6 +3,7 @@ import { WidgetConfig } from '../../types';
3
3
  interface ViewerBlockedScreenProps {
4
4
  config: WidgetConfig;
5
5
  apiKey: string;
6
+ onClose: () => void;
6
7
  }
7
8
  export declare const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps>;
8
9
  export {};
@@ -1,9 +1,9 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { submitReenableRequest } from '../../utils/reenableRequest';
5
5
  const DEFAULT_MESSAGE = 'You have been marked as Blocked user due to spam';
6
- export const ViewerBlockedScreen = ({ config, apiKey }) => {
6
+ export const ViewerBlockedScreen = ({ config, apiKey, onClose }) => {
7
7
  var _a, _b;
8
8
  const [text, setText] = useState('');
9
9
  const [status, setStatus] = useState('idle');
@@ -56,7 +56,18 @@ export const ViewerBlockedScreen = ({ config, apiKey }) => {
56
56
  fontWeight: 600,
57
57
  color: '#1e293b',
58
58
  lineHeight: 1.55,
59
- }, children: body }), status === 'sent' ? (_jsx("p", { style: { margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }, children: "Your request was sent. We will review it shortly." })) : (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: "cw-reenable-msg", style: { display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }, children: "Request access restoration" }), _jsx("textarea", { id: "cw-reenable-msg", value: text, onChange: e => { setText(e.target.value); setError(null); setStatus('idle'); }, placeholder: "Explain briefly why your access should be restored\u2026", rows: 4, disabled: status === 'sending', style: {
59
+ }, children: body }), status === 'sent' ? (_jsxs(_Fragment, { children: [_jsx("p", { style: { margin: '0 0 16px', fontSize: 14, color: '#16a34a', fontWeight: 600 }, children: "Your request was sent. We will review it shortly." }), _jsx("button", { type: "button", onClick: onClose, style: {
60
+ width: '100%',
61
+ padding: '12px 16px',
62
+ borderRadius: 12,
63
+ border: '2px solid #ef4444',
64
+ background: '#fff',
65
+ color: '#ef4444',
66
+ fontWeight: 700,
67
+ fontSize: 15,
68
+ cursor: 'pointer',
69
+ fontFamily: 'inherit',
70
+ }, children: "Close" })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: "cw-reenable-msg", style: { display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }, children: "Request access restoration" }), _jsx("textarea", { id: "cw-reenable-msg", value: text, onChange: e => { setText(e.target.value); setError(null); setStatus('idle'); }, placeholder: "Explain briefly why your access should be restored\u2026", rows: 4, maxLength: 500, minLength: 50, disabled: status === 'sending', style: {
60
71
  width: '100%',
61
72
  boxSizing: 'border-box',
62
73
  padding: '12px 14px',
@@ -65,8 +76,9 @@ export const ViewerBlockedScreen = ({ config, apiKey }) => {
65
76
  fontSize: 14,
66
77
  fontFamily: 'inherit',
67
78
  color: '#1e293b',
68
- resize: 'vertical',
79
+ resize: 'none',
69
80
  minHeight: 100,
81
+ maxHeight: 250,
70
82
  marginBottom: 14,
71
83
  outline: 'none',
72
84
  } }), _jsx("button", { type: "button", onClick: handleSubmit, disabled: status === 'sending' || !text.trim(), style: {
@@ -79,5 +91,17 @@ export const ViewerBlockedScreen = ({ config, apiKey }) => {
79
91
  fontWeight: 700,
80
92
  fontSize: 15,
81
93
  cursor: text.trim() && status !== 'sending' ? 'pointer' : 'default',
82
- }, children: status === 'sending' ? 'Sending…' : 'Submit request' }), error && (_jsx("p", { style: { margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }, children: error })), !url && (_jsxs("p", { style: { margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }, children: ["Your administrator must set ", _jsx("code", { style: { fontSize: 11 }, children: "reenableRequestUrl" }), " in widget config for online requests."] }))] }))] }) }));
94
+ }, children: status === 'sending' ? 'Sending…' : 'Submit request' }), _jsx("button", { type: "button", onClick: onClose, style: {
95
+ width: '100%',
96
+ marginTop: 12,
97
+ padding: '12px 16px',
98
+ borderRadius: 12,
99
+ border: '2px solid #ef4444',
100
+ background: '#fff',
101
+ color: '#ef4444',
102
+ fontWeight: 700,
103
+ fontSize: 15,
104
+ cursor: 'pointer',
105
+ fontFamily: 'inherit',
106
+ }, children: "Close" }), error && (_jsx("p", { style: { margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }, children: error })), !url && (_jsxs("p", { style: { margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }, children: ["Your administrator must set ", _jsx("code", { style: { fontSize: 11 }, children: "reenableRequestUrl" }), " in widget config for online requests."] }))] }))] }) }));
83
107
  };
package/dist/index.d.ts CHANGED
@@ -18,5 +18,7 @@ export { submitReenableRequest } from './utils/reenableRequest';
18
18
  export type { ReenableRequestPayload } from './utils/reenableRequest';
19
19
  export { loadLocalConfig, fetchRemoteChatData } from './config';
20
20
  export { mergeTheme, darken } from './utils/theme';
21
+ export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
22
+ export type { PresenceSyncPayload } from './utils/presenceStatus';
21
23
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
22
- export type { ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, } from './types';
24
+ export type { ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, PresenceStatus, } from './types';
package/dist/index.js CHANGED
@@ -17,4 +17,5 @@ export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt }
17
17
  export { submitReenableRequest } from './utils/reenableRequest';
18
18
  export { loadLocalConfig, fetchRemoteChatData } from './config';
19
19
  export { mergeTheme, darken } from './utils/theme';
20
+ export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
20
21
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
@@ -58,6 +58,16 @@ export interface WidgetConfig {
58
58
  * @example https://api.example.com/widgets/reenable-request
59
59
  */
60
60
  reenableRequestUrl?: string;
61
+ /**
62
+ * Current presence from your API/DB (include in chatData or a session payload).
63
+ * When set, it initializes the status control and overrides session-only cache.
64
+ */
65
+ presenceStatus?: PresenceStatus;
66
+ /**
67
+ * Production: `POST` JSON `{ widgetId, apiKey, viewerUid?, status }` to save presence in your database.
68
+ * The client still mirrors to sessionStorage as a local fallback.
69
+ */
70
+ presenceUpdateUrl?: string;
61
71
  }
62
72
  export interface RemoteChatData {
63
73
  widget: WidgetConfig;
@@ -75,6 +85,8 @@ export type BottomTab = 'home' | 'chats' | 'tickets';
75
85
  export type Screen = 'home' | 'user-list' | 'chat' | 'recent-chats' | 'tickets' | 'ticket-new' | 'ticket-detail' | 'block-list' | 'call';
76
86
  export type UserListContext = 'support' | 'conversation';
77
87
  export type MessageType = 'text' | 'voice' | 'attachment' | 'emoji';
88
+ /** Home status selector; persist via `presenceUpdateUrl` in production */
89
+ export type PresenceStatus = 'ACTIVE' | 'AWAY' | 'DND';
78
90
  export interface ChatUser {
79
91
  uid: string;
80
92
  name: string;
@@ -85,6 +97,11 @@ export interface ChatUser {
85
97
  avatar: string | null;
86
98
  status: OnlineStatus;
87
99
  designation: string;
100
+ /**
101
+ * When `true` for the row matching the current viewer (`viewerUid` / `viewer.uid`),
102
+ * the widget shows the spam/blocked screen (same as `widget.viewerBlocked`).
103
+ */
104
+ viewerBlocked?: boolean;
88
105
  }
89
106
  export interface ChatMessage {
90
107
  id: string;
@@ -0,0 +1,13 @@
1
+ import type { PresenceStatus } from '../types';
2
+ export declare function loadPresenceStatus(widgetId: string): PresenceStatus;
3
+ export declare function savePresenceStatus(widgetId: string, status: PresenceStatus): void;
4
+ /** Prefer server value from DB when the host includes it in config */
5
+ export declare function resolveInitialPresence(widgetId: string, serverStatus: PresenceStatus | undefined): PresenceStatus;
6
+ export interface PresenceSyncPayload {
7
+ widgetId: string;
8
+ apiKey: string;
9
+ viewerUid?: string;
10
+ status: PresenceStatus;
11
+ }
12
+ /** Call your backend to persist presence (production DB). */
13
+ export declare function syncPresenceToServer(url: string, payload: PresenceSyncPayload): Promise<void>;
@@ -0,0 +1,45 @@
1
+ const key = (widgetId) => `ajaxter_presence_${widgetId}`;
2
+ export function loadPresenceStatus(widgetId) {
3
+ if (typeof sessionStorage === 'undefined')
4
+ return 'ACTIVE';
5
+ try {
6
+ const v = sessionStorage.getItem(key(widgetId));
7
+ if (v === 'ACTIVE' || v === 'AWAY' || v === 'DND')
8
+ return v;
9
+ }
10
+ catch (_a) {
11
+ /* */
12
+ }
13
+ return 'ACTIVE';
14
+ }
15
+ export function savePresenceStatus(widgetId, status) {
16
+ try {
17
+ sessionStorage.setItem(key(widgetId), status);
18
+ }
19
+ catch (_a) {
20
+ /* quota */
21
+ }
22
+ }
23
+ /** Prefer server value from DB when the host includes it in config */
24
+ export function resolveInitialPresence(widgetId, serverStatus) {
25
+ if (serverStatus === 'ACTIVE' || serverStatus === 'AWAY' || serverStatus === 'DND')
26
+ return serverStatus;
27
+ return loadPresenceStatus(widgetId);
28
+ }
29
+ /** Call your backend to persist presence (production DB). */
30
+ export async function syncPresenceToServer(url, payload) {
31
+ const res = await fetch(url, {
32
+ method: 'POST',
33
+ headers: {
34
+ Accept: 'application/json',
35
+ 'Content-Type': 'application/json',
36
+ },
37
+ body: JSON.stringify(payload),
38
+ mode: 'cors',
39
+ credentials: 'omit',
40
+ });
41
+ if (!res.ok) {
42
+ const t = await res.text().catch(() => '');
43
+ throw new Error(t || `Presence sync failed (${res.status})`);
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ajaxter-chat",
3
- "version": "3.0.14",
3
+ "version": "3.0.16",
4
4
  "description": "Drawer-based chat widget with support chat, tickets, WebRTC calling, voice messages, block list, and transcript download.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -62,7 +62,8 @@
62
62
  "type": "user",
63
63
  "avatar": null,
64
64
  "status": "online",
65
- "designation": "Product Manager"
65
+ "designation": "Product Manager",
66
+ "viewerBlocked": false
66
67
  },
67
68
  {
68
69
  "uid": "usr_002",
@@ -73,7 +74,8 @@
73
74
  "type": "user",
74
75
  "avatar": null,
75
76
  "status": "away",
76
- "designation": "Business Analyst"
77
+ "designation": "Business Analyst",
78
+ "viewerBlocked": true
77
79
  },
78
80
  {
79
81
  "uid": "usr_003",
@@ -69,8 +69,9 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
69
69
  setTickets(data.sampleTickets);
70
70
  setBlockedUids(data.blockedUsers);
71
71
  const pid = viewer?.projectId?.trim();
72
- const inProject = (u: ChatUser) => !pid || u.project === pid;
73
- const all = [...(data.developers ?? []), ...(data.users ?? [])].filter(inProject);
72
+ const devs = data.developers ?? [];
73
+ const usr = pid ? (data.users ?? []).filter(u => u.project === pid) : (data.users ?? []);
74
+ const all = [...devs, ...usr];
74
75
  const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
75
76
  const user = all.find(u => u.uid === uid);
76
77
  if (!user || msgs.length === 0) return null;
@@ -137,7 +138,13 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
137
138
  if (!data?.widget || restoredRef.current) return;
138
139
  const w = data.widget;
139
140
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
140
- if (w.viewerBlocked) {
141
+ const uidForBlock = (viewer?.uid ?? w.viewerUid)?.trim();
142
+ let viewerIsBlocked = w.viewerBlocked === true;
143
+ if (!viewerIsBlocked && uidForBlock) {
144
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uidForBlock);
145
+ viewerIsBlocked = rec?.viewerBlocked === true;
146
+ }
147
+ if (viewerIsBlocked) {
141
148
  clearChat();
142
149
  setScreen('home');
143
150
  setActiveTab('home');
@@ -167,15 +174,23 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
167
174
  }
168
175
  }
169
176
  restoredRef.current = true;
170
- }, [data, selectUser, clearChat, viewer?.projectId]);
177
+ }, [data, selectUser, clearChat, viewer?.projectId, viewer?.uid]);
171
178
 
172
179
  useEffect(() => {
173
- if (!data?.widget?.viewerBlocked) return;
180
+ if (!data?.widget) return;
181
+ const w = data.widget;
182
+ const uid = (viewer?.uid ?? w.viewerUid)?.trim();
183
+ let blocked = w.viewerBlocked === true;
184
+ if (!blocked && uid) {
185
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
186
+ blocked = rec?.viewerBlocked === true;
187
+ }
188
+ if (!blocked) return;
174
189
  clearChat();
175
190
  setScreen('home');
176
191
  setActiveTab('home');
177
192
  setViewingTicketId(null);
178
- }, [data?.widget?.viewerBlocked, clearChat]);
193
+ }, [data?.widget, data?.developers, data?.users, viewer?.uid, clearChat]);
179
194
 
180
195
  useEffect(() => {
181
196
  if (!data?.widget) return;
@@ -338,14 +353,25 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
338
353
 
339
354
  const primaryColor = theme.primaryColor;
340
355
 
356
+ /** All developers are listed; only end-`user` rows are filtered by `viewer.projectId`. */
341
357
  const allUsers = useMemo(() => {
342
358
  if (!data) return [];
343
359
  const pid = viewer?.projectId?.trim();
344
- const list = [...data.developers, ...data.users];
345
- if (!pid) return list;
346
- return list.filter(u => u.project === pid);
360
+ const devs = data.developers ?? [];
361
+ if (!pid) return [...devs, ...data.users];
362
+ const usersInProject = data.users.filter(u => u.project === pid);
363
+ return [...devs, ...usersInProject];
347
364
  }, [data, viewer?.projectId]);
348
365
 
366
+ const effectiveViewerBlocked = useMemo(() => {
367
+ if (!widgetConfig) return false;
368
+ if (widgetConfig.viewerBlocked === true) return true;
369
+ const uid = (viewer?.uid ?? widgetConfig.viewerUid)?.trim();
370
+ if (!uid || !data) return false;
371
+ const rec = [...data.developers, ...data.users].find(x => x.uid === uid);
372
+ return rec?.viewerBlocked === true;
373
+ }, [widgetConfig, viewer?.uid, data]);
374
+
349
375
  const viewerIsDev = widgetConfig?.viewerType === 'developer';
350
376
  const viewerUid = widgetConfig?.viewerUid;
351
377
 
@@ -368,6 +394,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
368
394
  );
369
395
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
370
396
 
397
+ const totalUnread = useMemo(
398
+ () => recentChats.reduce((sum, c) => sum + Math.max(0, c.unread ?? 0), 0),
399
+ [recentChats],
400
+ );
401
+
371
402
  const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
372
403
  if (!activeUser || !widgetConfig) return;
373
404
  const agent = widgetConfig.viewerName?.trim() || 'Agent';
@@ -441,12 +472,14 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
441
472
  }
442
473
  `}</style>
443
474
 
444
- {/* ── Floating Button ── */}
475
+ {/* ── Floating Button (unread badge + tooltip when closed) ── */}
445
476
  {!isOpen && (
446
477
  <button
447
478
  className="cw-root"
479
+ type="button"
448
480
  onClick={openDrawer}
449
- aria-label={theme.buttonLabel}
481
+ aria-label={totalUnread > 0 ? `${theme.buttonLabel}, ${totalUnread} unread` : theme.buttonLabel}
482
+ title={totalUnread > 0 ? `${totalUnread} unread message${totalUnread === 1 ? '' : 's'}` : theme.buttonLabel}
450
483
  style={{
451
484
  position: 'fixed', bottom: 24, zIndex: 9999,
452
485
  ...posStyle,
@@ -469,10 +502,35 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
469
502
  (e.currentTarget as HTMLElement).style.boxShadow = `0 8px 28px ${theme.buttonColor}55`;
470
503
  }}
471
504
  >
472
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
473
- <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
474
- stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
475
- </svg>
505
+ <span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
506
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
507
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
508
+ stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
509
+ </svg>
510
+ {totalUnread > 0 && (
511
+ <span
512
+ style={{
513
+ position: 'absolute',
514
+ top: -8,
515
+ right: -10,
516
+ minWidth: 20,
517
+ height: 20,
518
+ padding: '0 5px',
519
+ borderRadius: 999,
520
+ background: '#ef4444',
521
+ color: '#fff',
522
+ fontSize: 11,
523
+ fontWeight: 800,
524
+ lineHeight: '20px',
525
+ textAlign: 'center',
526
+ border: '2px solid #fff',
527
+ boxSizing: 'border-box',
528
+ }}
529
+ >
530
+ {totalUnread > 99 ? '99+' : totalUnread}
531
+ </span>
532
+ )}
533
+ </span>
476
534
  <span>{theme.buttonLabel}</span>
477
535
  </button>
478
536
  )}
@@ -536,8 +594,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
536
594
  {/* ── Main content ── */}
537
595
  {!cfgLoading && !cfgError && widgetConfig && (
538
596
  <>
539
- {/* Resize + Close controls — shown outside chat/call screens */}
540
- {screen !== 'chat' && screen !== 'call' && (
597
+ {/* Resize + Close controls — hidden on blocked screen (Close is in-panel) */}
598
+ {screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
541
599
  <div style={{
542
600
  position: 'absolute', top: 12,
543
601
  right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
@@ -567,12 +625,12 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
567
625
  )}
568
626
 
569
627
  {/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
570
- {widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (
571
- <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
628
+ {widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (
629
+ <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} onClose={closeDrawer} />
572
630
  )}
573
631
 
574
632
  {/* ── ACTIVE: microphone, location, screen share required ── */}
575
- {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && !permissionsOk && (
633
+ {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && !permissionsOk && (
576
634
  <PermissionsGateScreen
577
635
  primaryColor={primaryColor}
578
636
  widgetId={widgetConfig.id}
@@ -581,12 +639,13 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
581
639
  )}
582
640
 
583
641
  {/* ── ACTIVE ── */}
584
- {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (
642
+ {widgetConfig.status === 'ACTIVE' && !effectiveViewerBlocked && permissionsOk && (
585
643
  <div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
586
644
 
587
645
  {screen === 'home' && (
588
646
  <HomeScreen
589
647
  config={widgetConfig}
648
+ apiKey={apiKey}
590
649
  onNavigate={handleCardClick}
591
650
  onOpenTicket={handleOpenTicket}
592
651
  tickets={tickets}
@@ -699,7 +758,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
699
758
 
700
759
  {/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
701
760
  {widgetConfig.status === 'ACTIVE' &&
702
- !widgetConfig.viewerBlocked &&
761
+ !effectiveViewerBlocked &&
703
762
  permissionsOk &&
704
763
  screen !== 'chat' &&
705
764
  screen !== 'call' &&
@@ -1,7 +1,13 @@
1
- import React, { useState, useMemo } from 'react';
1
+ import React, { useState, useMemo, useEffect } from 'react';
2
2
  import { WidgetConfig, UserListContext, Ticket } from '../../types';
3
3
  import { SlideNavMenu } from '../SlideNavMenu';
4
4
  import { truncateWords } from '../../utils/chat';
5
+ import type { PresenceStatus } from '../../types';
6
+ import {
7
+ resolveInitialPresence,
8
+ savePresenceStatus,
9
+ syncPresenceToServer,
10
+ } from '../../utils/presenceStatus';
5
11
 
6
12
  export interface HomeNavigateOptions {
7
13
  /** When true, list screens play stagger animation (home burger menu only) */
@@ -10,14 +16,44 @@ export interface HomeNavigateOptions {
10
16
 
11
17
  interface HomeScreenProps {
12
18
  config: WidgetConfig;
19
+ /** Same as env / chatData — required to POST presence in production */
20
+ apiKey: string;
13
21
  onNavigate: (ctx: UserListContext | 'ticket', options?: HomeNavigateOptions) => void;
14
22
  /** Open a specific pending ticket (full detail) */
15
23
  onOpenTicket: (ticketId: string) => void;
16
24
  tickets: Ticket[];
17
25
  }
18
26
 
19
- export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOpenTicket, tickets }) => {
27
+ const STATUS_OPTIONS: { value: PresenceStatus; label: string }[] = [
28
+ { value: 'ACTIVE', label: 'Active' },
29
+ { value: 'AWAY', label: 'Away' },
30
+ { value: 'DND', label: 'DND' },
31
+ ];
32
+
33
+ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, apiKey, onNavigate, onOpenTicket, tickets }) => {
20
34
  const [menuOpen, setMenuOpen] = useState(false);
35
+ const [presence, setPresence] = useState<PresenceStatus>(() =>
36
+ resolveInitialPresence(config.id, config.presenceStatus),
37
+ );
38
+
39
+ useEffect(() => {
40
+ setPresence(resolveInitialPresence(config.id, config.presenceStatus));
41
+ }, [config.id, config.presenceStatus]);
42
+
43
+ const setPresenceAndSave = (s: PresenceStatus) => {
44
+ setPresence(s);
45
+ savePresenceStatus(config.id, s);
46
+ const url = config.presenceUpdateUrl?.trim();
47
+ if (!url) return;
48
+ void syncPresenceToServer(url, {
49
+ widgetId: config.id,
50
+ apiKey,
51
+ viewerUid: config.viewerUid?.trim() || undefined,
52
+ status: s,
53
+ }).catch(err => {
54
+ console.error('[ajaxter-chat] presence sync failed', err);
55
+ });
56
+ };
21
57
  const showSupport = config.chatType === 'SUPPORT' || config.chatType === 'BOTH';
22
58
  const showChat = config.chatType === 'CHAT' || config.chatType === 'BOTH';
23
59
  const viewerIsDev = config.viewerType === 'developer';
@@ -56,14 +92,14 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOp
56
92
  }}
57
93
  />
58
94
 
59
- {/* Top bar — burger left */}
95
+ {/* Top bar — menu + presence status */}
60
96
  <div
61
97
  style={{
62
98
  flexShrink: 0,
63
- padding: '14px 16px 10px',
99
+ padding: '12px 14px 12px',
64
100
  display: 'flex',
65
101
  alignItems: 'center',
66
- gap: 12,
102
+ gap: 10,
67
103
  background: '#fff',
68
104
  borderBottom: '1px solid #eef0f5',
69
105
  }}
@@ -91,6 +127,62 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOp
91
127
  <span style={{ width: 18, height: 2, background: '#334155', borderRadius: 1 }} />
92
128
  <span style={{ width: 18, height: 2, background: '#334155', borderRadius: 1 }} />
93
129
  </button>
130
+
131
+ <div style={{ flex: 1, minWidth: 0 }} />
132
+
133
+ <div
134
+ style={{
135
+ display: 'flex',
136
+ alignItems: 'center',
137
+ gap: 6,
138
+ flexShrink: 0,
139
+ flexWrap: 'wrap',
140
+ justifyContent: 'flex-end',
141
+ }}
142
+ >
143
+ <span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
144
+ Status
145
+ </span>
146
+ <div
147
+ role="group"
148
+ aria-label="Your status"
149
+ style={{
150
+ display: 'flex',
151
+ borderRadius: 10,
152
+ padding: 3,
153
+ background: '#f1f5f9',
154
+ gap: 2,
155
+ }}
156
+ >
157
+ {STATUS_OPTIONS.map(({ value, label }) => {
158
+ const isOn = presence === value;
159
+ return (
160
+ <button
161
+ key={value}
162
+ type="button"
163
+ onClick={() => setPresenceAndSave(value)}
164
+ style={{
165
+ border: 'none',
166
+ borderRadius: 8,
167
+ padding: '7px 10px',
168
+ fontSize: 11,
169
+ fontWeight: 700,
170
+ letterSpacing: '0.04em',
171
+ cursor: 'pointer',
172
+ fontFamily: 'inherit',
173
+ textTransform: 'uppercase',
174
+ background: isOn ? config.primaryColor : 'transparent',
175
+ color: isOn ? '#fff' : '#64748b',
176
+ boxShadow: isOn ? `0 2px 8px ${config.primaryColor}55` : 'none',
177
+ transition: 'background 0.15s, color 0.15s',
178
+ }}
179
+ >
180
+ {label}
181
+ </button>
182
+ );
183
+ })}
184
+ </div>
185
+ </div>
94
186
  </div>
95
187
 
96
188
  <div className="cw-scroll" style={{ flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }}>
@@ -10,9 +10,10 @@ const DEFAULT_MESSAGE =
10
10
  interface ViewerBlockedScreenProps {
11
11
  config: WidgetConfig;
12
12
  apiKey: string;
13
+ onClose: () => void;
13
14
  }
14
15
 
15
- export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey }) => {
16
+ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey, onClose }) => {
16
17
  const [text, setText] = useState('');
17
18
  const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
18
19
  const [error, setError] = useState<string | null>(null);
@@ -79,9 +80,29 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
79
80
  </p>
80
81
 
81
82
  {status === 'sent' ? (
82
- <p style={{ margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
83
- Your request was sent. We will review it shortly.
84
- </p>
83
+ <>
84
+ <p style={{ margin: '0 0 16px', fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
85
+ Your request was sent. We will review it shortly.
86
+ </p>
87
+ <button
88
+ type="button"
89
+ onClick={onClose}
90
+ style={{
91
+ width: '100%',
92
+ padding: '12px 16px',
93
+ borderRadius: 12,
94
+ border: '2px solid #ef4444',
95
+ background: '#fff',
96
+ color: '#ef4444',
97
+ fontWeight: 700,
98
+ fontSize: 15,
99
+ cursor: 'pointer',
100
+ fontFamily: 'inherit',
101
+ }}
102
+ >
103
+ Close
104
+ </button>
105
+ </>
85
106
  ) : (
86
107
  <>
87
108
  <label
@@ -96,6 +117,8 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
96
117
  onChange={e => { setText(e.target.value); setError(null); setStatus('idle'); }}
97
118
  placeholder="Explain briefly why your access should be restored…"
98
119
  rows={4}
120
+ maxLength={500}
121
+ minLength={50}
99
122
  disabled={status === 'sending'}
100
123
  style={{
101
124
  width: '100%',
@@ -106,8 +129,9 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
106
129
  fontSize: 14,
107
130
  fontFamily: 'inherit',
108
131
  color: '#1e293b',
109
- resize: 'vertical',
132
+ resize: 'none',
110
133
  minHeight: 100,
134
+ maxHeight: 250,
111
135
  marginBottom: 14,
112
136
  outline: 'none',
113
137
  }}
@@ -130,6 +154,25 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
130
154
  >
131
155
  {status === 'sending' ? 'Sending…' : 'Submit request'}
132
156
  </button>
157
+ <button
158
+ type="button"
159
+ onClick={onClose}
160
+ style={{
161
+ width: '100%',
162
+ marginTop: 12,
163
+ padding: '12px 16px',
164
+ borderRadius: 12,
165
+ border: '2px solid #ef4444',
166
+ background: '#fff',
167
+ color: '#ef4444',
168
+ fontWeight: 700,
169
+ fontSize: 15,
170
+ cursor: 'pointer',
171
+ fontFamily: 'inherit',
172
+ }}
173
+ >
174
+ Close
175
+ </button>
133
176
  {error && (
134
177
  <p style={{ margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }}>
135
178
  {error}
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export { submitReenableRequest } from './utils/reenableRequest';
20
20
  export type { ReenableRequestPayload } from './utils/reenableRequest';
21
21
  export { loadLocalConfig, fetchRemoteChatData } from './config';
22
22
  export { mergeTheme, darken } from './utils/theme';
23
+ export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
24
+ export type { PresenceSyncPayload } from './utils/presenceStatus';
23
25
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
24
26
 
25
27
  export type {
@@ -30,4 +32,5 @@ export type {
30
32
  ChatStatus, ChatType, UserType, OnlineStatus,
31
33
  Screen, BottomTab, UserListContext, MessageType,
32
34
  LocalEnvConfig,
35
+ PresenceStatus,
33
36
  } from './types';
@@ -59,6 +59,16 @@ export interface WidgetConfig {
59
59
  * @example https://api.example.com/widgets/reenable-request
60
60
  */
61
61
  reenableRequestUrl?: string;
62
+ /**
63
+ * Current presence from your API/DB (include in chatData or a session payload).
64
+ * When set, it initializes the status control and overrides session-only cache.
65
+ */
66
+ presenceStatus?: PresenceStatus;
67
+ /**
68
+ * Production: `POST` JSON `{ widgetId, apiKey, viewerUid?, status }` to save presence in your database.
69
+ * The client still mirrors to sessionStorage as a local fallback.
70
+ */
71
+ presenceUpdateUrl?: string;
62
72
  }
63
73
 
64
74
  export interface RemoteChatData {
@@ -89,6 +99,9 @@ export type Screen =
89
99
  export type UserListContext = 'support' | 'conversation';
90
100
  export type MessageType = 'text' | 'voice' | 'attachment' | 'emoji';
91
101
 
102
+ /** Home status selector; persist via `presenceUpdateUrl` in production */
103
+ export type PresenceStatus = 'ACTIVE' | 'AWAY' | 'DND';
104
+
92
105
  // ─── User ───────────────────────────────────────────────────────────────────
93
106
  export interface ChatUser {
94
107
  uid: string;
@@ -100,6 +113,11 @@ export interface ChatUser {
100
113
  avatar: string | null;
101
114
  status: OnlineStatus;
102
115
  designation: string;
116
+ /**
117
+ * When `true` for the row matching the current viewer (`viewerUid` / `viewer.uid`),
118
+ * the widget shows the spam/blocked screen (same as `widget.viewerBlocked`).
119
+ */
120
+ viewerBlocked?: boolean;
103
121
  }
104
122
 
105
123
  // ─── Message ────────────────────────────────────────────────────────────────
@@ -0,0 +1,56 @@
1
+ import type { PresenceStatus } from '../types';
2
+
3
+ const key = (widgetId: string) => `ajaxter_presence_${widgetId}`;
4
+
5
+ export function loadPresenceStatus(widgetId: string): PresenceStatus {
6
+ if (typeof sessionStorage === 'undefined') return 'ACTIVE';
7
+ try {
8
+ const v = sessionStorage.getItem(key(widgetId));
9
+ if (v === 'ACTIVE' || v === 'AWAY' || v === 'DND') return v;
10
+ } catch {
11
+ /* */
12
+ }
13
+ return 'ACTIVE';
14
+ }
15
+
16
+ export function savePresenceStatus(widgetId: string, status: PresenceStatus): void {
17
+ try {
18
+ sessionStorage.setItem(key(widgetId), status);
19
+ } catch {
20
+ /* quota */
21
+ }
22
+ }
23
+
24
+ /** Prefer server value from DB when the host includes it in config */
25
+ export function resolveInitialPresence(
26
+ widgetId: string,
27
+ serverStatus: PresenceStatus | undefined,
28
+ ): PresenceStatus {
29
+ if (serverStatus === 'ACTIVE' || serverStatus === 'AWAY' || serverStatus === 'DND') return serverStatus;
30
+ return loadPresenceStatus(widgetId);
31
+ }
32
+
33
+ export interface PresenceSyncPayload {
34
+ widgetId: string;
35
+ apiKey: string;
36
+ viewerUid?: string;
37
+ status: PresenceStatus;
38
+ }
39
+
40
+ /** Call your backend to persist presence (production DB). */
41
+ export async function syncPresenceToServer(url: string, payload: PresenceSyncPayload): Promise<void> {
42
+ const res = await fetch(url, {
43
+ method: 'POST',
44
+ headers: {
45
+ Accept: 'application/json',
46
+ 'Content-Type': 'application/json',
47
+ },
48
+ body: JSON.stringify(payload),
49
+ mode: 'cors',
50
+ credentials: 'omit',
51
+ });
52
+ if (!res.ok) {
53
+ const t = await res.text().catch(() => '');
54
+ throw new Error(t || `Presence sync failed (${res.status})`);
55
+ }
56
+ }