ajaxter-chat 3.0.15 → 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 +21 -4
- 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 +12 -0
- package/dist/utils/presenceStatus.d.ts +13 -0
- package/dist/utils/presenceStatus.js +45 -0
- package/package.json +1 -1
- package/src/components/ChatWidget.tsx +42 -9
- 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 +13 -0
- package/src/utils/presenceStatus.ts +56 -0
package/README.md
CHANGED
|
Binary file
|
|
@@ -375,6 +375,7 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
|
|
|
375
375
|
: [];
|
|
376
376
|
const otherDevelopers = useMemo(() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid), [allUsers, viewerUid]);
|
|
377
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]);
|
|
378
379
|
const handleTransferToDeveloper = useCallback((dev) => {
|
|
379
380
|
var _a;
|
|
380
381
|
if (!activeUser || !widgetConfig)
|
|
@@ -441,13 +442,29 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
|
|
|
441
442
|
@media (max-width: 1024px) {
|
|
442
443
|
.cw-drawer-panel { width: 100%; }
|
|
443
444
|
}
|
|
444
|
-
` }), !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 => {
|
|
445
446
|
e.currentTarget.style.transform = 'scale(1.06) translateY(-2px)';
|
|
446
447
|
e.currentTarget.style.boxShadow = `0 14px 36px ${theme.buttonColor}66`;
|
|
447
448
|
}, onMouseLeave: e => {
|
|
448
449
|
e.currentTarget.style.transform = 'scale(1)';
|
|
449
450
|
e.currentTarget.style.boxShadow = `0 8px 28px ${theme.buttonColor}55`;
|
|
450
|
-
}, 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: {
|
|
451
468
|
position: 'fixed', inset: 0, zIndex: 9997,
|
|
452
469
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
|
453
470
|
opacity: closing ? 0 : 1,
|
|
@@ -459,12 +476,12 @@ export const ChatWidget = ({ theme: localTheme, viewer }) => {
|
|
|
459
476
|
border: `3px solid ${primaryColor}30`,
|
|
460
477
|
borderTopColor: primaryColor,
|
|
461
478
|
animation: 'spin 0.8s linear infinite',
|
|
462
|
-
} }), _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: {
|
|
463
480
|
position: 'absolute', top: 12,
|
|
464
481
|
right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
|
|
465
482
|
left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
|
|
466
483
|
zIndex: 20, display: 'flex', gap: 6,
|
|
467
|
-
}, 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 })), 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, 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 => {
|
|
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 => {
|
|
468
485
|
setListEntranceAnimation(false);
|
|
469
486
|
setViewingTicketId(id);
|
|
470
487
|
setScreen('ticket-detail');
|
|
@@ -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;
|
|
@@ -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",
|
|
@@ -394,6 +394,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
394
394
|
);
|
|
395
395
|
const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
|
|
396
396
|
|
|
397
|
+
const totalUnread = useMemo(
|
|
398
|
+
() => recentChats.reduce((sum, c) => sum + Math.max(0, c.unread ?? 0), 0),
|
|
399
|
+
[recentChats],
|
|
400
|
+
);
|
|
401
|
+
|
|
397
402
|
const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
|
|
398
403
|
if (!activeUser || !widgetConfig) return;
|
|
399
404
|
const agent = widgetConfig.viewerName?.trim() || 'Agent';
|
|
@@ -467,12 +472,14 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
467
472
|
}
|
|
468
473
|
`}</style>
|
|
469
474
|
|
|
470
|
-
{/* ── Floating Button ── */}
|
|
475
|
+
{/* ── Floating Button (unread badge + tooltip when closed) ── */}
|
|
471
476
|
{!isOpen && (
|
|
472
477
|
<button
|
|
473
478
|
className="cw-root"
|
|
479
|
+
type="button"
|
|
474
480
|
onClick={openDrawer}
|
|
475
|
-
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}
|
|
476
483
|
style={{
|
|
477
484
|
position: 'fixed', bottom: 24, zIndex: 9999,
|
|
478
485
|
...posStyle,
|
|
@@ -495,10 +502,35 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
495
502
|
(e.currentTarget as HTMLElement).style.boxShadow = `0 8px 28px ${theme.buttonColor}55`;
|
|
496
503
|
}}
|
|
497
504
|
>
|
|
498
|
-
<
|
|
499
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
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>
|
|
502
534
|
<span>{theme.buttonLabel}</span>
|
|
503
535
|
</button>
|
|
504
536
|
)}
|
|
@@ -562,8 +594,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
562
594
|
{/* ── Main content ── */}
|
|
563
595
|
{!cfgLoading && !cfgError && widgetConfig && (
|
|
564
596
|
<>
|
|
565
|
-
{/* Resize + Close controls —
|
|
566
|
-
{screen !== 'chat' && screen !== 'call' && (
|
|
597
|
+
{/* Resize + Close controls — hidden on blocked screen (Close is in-panel) */}
|
|
598
|
+
{screen !== 'chat' && screen !== 'call' && !effectiveViewerBlocked && (
|
|
567
599
|
<div style={{
|
|
568
600
|
position: 'absolute', top: 12,
|
|
569
601
|
right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
|
|
@@ -594,7 +626,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
594
626
|
|
|
595
627
|
{/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
|
|
596
628
|
{widgetConfig.status === 'ACTIVE' && effectiveViewerBlocked && (
|
|
597
|
-
<ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
|
|
629
|
+
<ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} onClose={closeDrawer} />
|
|
598
630
|
)}
|
|
599
631
|
|
|
600
632
|
{/* ── ACTIVE: microphone, location, screen share required ── */}
|
|
@@ -613,6 +645,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewe
|
|
|
613
645
|
{screen === 'home' && (
|
|
614
646
|
<HomeScreen
|
|
615
647
|
config={widgetConfig}
|
|
648
|
+
apiKey={apiKey}
|
|
616
649
|
onNavigate={handleCardClick}
|
|
617
650
|
onOpenTicket={handleOpenTicket}
|
|
618
651
|
tickets={tickets}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|