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 +0 -0
- package/dist/components/ChatWidget.js +71 -24
- package/dist/components/HomeScreen/index.d.ts +2 -0
- package/dist/components/HomeScreen/index.js +75 -19
- package/dist/components/ViewerBlockedScreen/index.d.ts +1 -0
- package/dist/components/ViewerBlockedScreen/index.js +29 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/utils/presenceStatus.d.ts +13 -0
- package/dist/utils/presenceStatus.js +45 -0
- package/package.json +1 -1
- package/public/chatData.json +4 -2
- package/src/components/ChatWidget.tsx +81 -22
- package/src/components/HomeScreen/index.tsx +97 -5
- package/src/components/ViewerBlockedScreen/index.tsx +48 -5
- package/src/index.ts +3 -0
- package/src/types/index.ts +18 -0
- package/src/utils/presenceStatus.ts +56 -0
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
|
|
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
|
|
62
|
-
const
|
|
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
|
-
|
|
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((
|
|
140
|
-
setChatReturnCtx((
|
|
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 = (
|
|
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
|
-
: ((
|
|
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 (!(
|
|
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
|
-
}, [
|
|
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
|
-
}, [(
|
|
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
|
|
343
|
+
const devs = (_b = data.developers) !== null && _b !== void 0 ? _b : [];
|
|
327
344
|
if (!pid)
|
|
328
|
-
return
|
|
329
|
-
|
|
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" }) }),
|
|
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' &&
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
-
} }),
|
|
54
|
+
} }), _jsxs("div", { style: {
|
|
29
55
|
flexShrink: 0,
|
|
30
|
-
padding: '14px
|
|
56
|
+
padding: '12px 14px 12px',
|
|
31
57
|
display: 'flex',
|
|
32
58
|
alignItems: 'center',
|
|
33
|
-
gap:
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
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." })
|
|
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: '
|
|
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' }),
|
|
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';
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/public/chatData.json
CHANGED
|
@@ -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
|
|
73
|
-
const
|
|
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
|
-
|
|
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
|
|
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?.
|
|
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
|
|
345
|
-
if (!pid) return
|
|
346
|
-
|
|
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
|
-
<
|
|
473
|
-
<
|
|
474
|
-
|
|
475
|
-
|
|
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 —
|
|
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' &&
|
|
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' && !
|
|
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' && !
|
|
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
|
-
!
|
|
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
|
-
|
|
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 —
|
|
95
|
+
{/* Top bar — menu + presence status */}
|
|
60
96
|
<div
|
|
61
97
|
style={{
|
|
62
98
|
flexShrink: 0,
|
|
63
|
-
padding: '14px
|
|
99
|
+
padding: '12px 14px 12px',
|
|
64
100
|
display: 'flex',
|
|
65
101
|
alignItems: 'center',
|
|
66
|
-
gap:
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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: '
|
|
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';
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|