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 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" }) }), _jsx("span", { children: theme.buttonLabel })] })), isOpen && (_jsx("div", { "aria-hidden": true, style: {
451
+ }, children: [_jsxs("span", { style: { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z", stroke: theme.buttonTextColor, strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }), totalUnread > 0 && (_jsx("span", { style: {
452
+ position: 'absolute',
453
+ top: -8,
454
+ right: -10,
455
+ minWidth: 20,
456
+ height: 20,
457
+ padding: '0 5px',
458
+ borderRadius: 999,
459
+ background: '#ef4444',
460
+ color: '#fff',
461
+ fontSize: 11,
462
+ fontWeight: 800,
463
+ lineHeight: '20px',
464
+ textAlign: 'center',
465
+ border: '2px solid #fff',
466
+ boxSizing: 'border-box',
467
+ }, children: totalUnread > 99 ? '99+' : totalUnread }))] }), _jsx("span", { children: theme.buttonLabel })] })), isOpen && (_jsx("div", { "aria-hidden": true, style: {
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
- export const HomeScreen = ({ config, onNavigate, onOpenTicket, tickets }) => {
5
+ import { resolveInitialPresence, savePresenceStatus, syncPresenceToServer, } from '../../utils/presenceStatus';
6
+ const STATUS_OPTIONS = [
7
+ { value: 'ACTIVE', label: 'Active' },
8
+ { value: 'AWAY', label: 'Away' },
9
+ { value: 'DND', label: 'DND' },
10
+ ];
11
+ export const HomeScreen = ({ config, apiKey, onNavigate, onOpenTicket, tickets }) => {
6
12
  var _a, _b, _c, _d;
7
13
  const [menuOpen, setMenuOpen] = useState(false);
14
+ const [presence, setPresence] = useState(() => resolveInitialPresence(config.id, config.presenceStatus));
15
+ useEffect(() => {
16
+ setPresence(resolveInitialPresence(config.id, config.presenceStatus));
17
+ }, [config.id, config.presenceStatus]);
18
+ const setPresenceAndSave = (s) => {
19
+ var _a, _b;
20
+ setPresence(s);
21
+ savePresenceStatus(config.id, s);
22
+ const url = (_a = config.presenceUpdateUrl) === null || _a === void 0 ? void 0 : _a.trim();
23
+ if (!url)
24
+ return;
25
+ void syncPresenceToServer(url, {
26
+ widgetId: config.id,
27
+ apiKey,
28
+ viewerUid: ((_b = config.viewerUid) === null || _b === void 0 ? void 0 : _b.trim()) || undefined,
29
+ status: s,
30
+ }).catch(err => {
31
+ console.error('[ajaxter-chat] presence sync failed', err);
32
+ });
33
+ };
8
34
  const showSupport = config.chatType === 'SUPPORT' || config.chatType === 'BOTH';
9
35
  const showChat = config.chatType === 'CHAT' || config.chatType === 'BOTH';
10
36
  const viewerIsDev = config.viewerType === 'developer';
@@ -25,28 +51,58 @@ export const HomeScreen = ({ config, onNavigate, onOpenTicket, tickets }) => {
25
51
  };
26
52
  return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', overflow: 'hidden', background: '#fafbfc' }, children: [_jsx(SlideNavMenu, { open: menuOpen, onClose: () => setMenuOpen(false), primaryColor: config.primaryColor, chatType: config.chatType, viewerType: (_d = config.viewerType) !== null && _d !== void 0 ? _d : 'user', onSelect: ctx => {
27
53
  onNavigate(ctx, { fromMenu: true });
28
- } }), _jsx("div", { style: {
54
+ } }), _jsxs("div", { style: {
29
55
  flexShrink: 0,
30
- padding: '14px 16px 10px',
56
+ padding: '12px 14px 12px',
31
57
  display: 'flex',
32
58
  alignItems: 'center',
33
- gap: 12,
59
+ gap: 10,
34
60
  background: '#fff',
35
61
  borderBottom: '1px solid #eef0f5',
36
- }, children: _jsxs("button", { type: "button", "aria-label": "Open menu", onClick: () => setMenuOpen(true), style: {
37
- width: 40,
38
- height: 40,
39
- borderRadius: 10,
40
- border: 'none',
41
- background: '#f1f5f9',
42
- cursor: 'pointer',
43
- display: 'flex',
44
- flexDirection: 'column',
45
- alignItems: 'center',
46
- justifyContent: 'center',
47
- gap: 5,
48
- flexShrink: 0,
49
- }, children: [_jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } })] }) }), _jsxs("div", { className: "cw-scroll", style: { flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }, children: [_jsx("h1", { style: {
62
+ }, children: [_jsxs("button", { type: "button", "aria-label": "Open menu", onClick: () => setMenuOpen(true), style: {
63
+ width: 40,
64
+ height: 40,
65
+ borderRadius: 10,
66
+ border: 'none',
67
+ background: '#f1f5f9',
68
+ cursor: 'pointer',
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ gap: 5,
74
+ flexShrink: 0,
75
+ }, children: [_jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } }), _jsx("span", { style: { width: 18, height: 2, background: '#334155', borderRadius: 1 } })] }), _jsx("div", { style: { flex: 1, minWidth: 0 } }), _jsxs("div", { style: {
76
+ display: 'flex',
77
+ alignItems: 'center',
78
+ gap: 6,
79
+ flexShrink: 0,
80
+ flexWrap: 'wrap',
81
+ justifyContent: 'flex-end',
82
+ }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }, children: "Status" }), _jsx("div", { role: "group", "aria-label": "Your status", style: {
83
+ display: 'flex',
84
+ borderRadius: 10,
85
+ padding: 3,
86
+ background: '#f1f5f9',
87
+ gap: 2,
88
+ }, children: STATUS_OPTIONS.map(({ value, label }) => {
89
+ const isOn = presence === value;
90
+ return (_jsx("button", { type: "button", onClick: () => setPresenceAndSave(value), style: {
91
+ border: 'none',
92
+ borderRadius: 8,
93
+ padding: '7px 10px',
94
+ fontSize: 11,
95
+ fontWeight: 700,
96
+ letterSpacing: '0.04em',
97
+ cursor: 'pointer',
98
+ fontFamily: 'inherit',
99
+ textTransform: 'uppercase',
100
+ background: isOn ? config.primaryColor : 'transparent',
101
+ color: isOn ? '#fff' : '#64748b',
102
+ boxShadow: isOn ? `0 2px 8px ${config.primaryColor}55` : 'none',
103
+ transition: 'background 0.15s, color 0.15s',
104
+ }, children: label }, value));
105
+ }) })] })] }), _jsxs("div", { className: "cw-scroll", style: { flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }, children: [_jsx("h1", { style: {
50
106
  margin: '0 0 8px',
51
107
  fontSize: 24,
52
108
  fontWeight: 800,
@@ -3,6 +3,7 @@ import { WidgetConfig } from '../../types';
3
3
  interface ViewerBlockedScreenProps {
4
4
  config: WidgetConfig;
5
5
  apiKey: string;
6
+ onClose: () => void;
6
7
  }
7
8
  export declare const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps>;
8
9
  export {};
@@ -1,9 +1,9 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { submitReenableRequest } from '../../utils/reenableRequest';
5
5
  const DEFAULT_MESSAGE = 'You have been marked as Blocked user due to spam';
6
- export const ViewerBlockedScreen = ({ config, apiKey }) => {
6
+ export const ViewerBlockedScreen = ({ config, apiKey, onClose }) => {
7
7
  var _a, _b;
8
8
  const [text, setText] = useState('');
9
9
  const [status, setStatus] = useState('idle');
@@ -56,7 +56,18 @@ export const ViewerBlockedScreen = ({ config, apiKey }) => {
56
56
  fontWeight: 600,
57
57
  color: '#1e293b',
58
58
  lineHeight: 1.55,
59
- }, children: body }), status === 'sent' ? (_jsx("p", { style: { margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }, children: "Your request was sent. We will review it shortly." })) : (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: "cw-reenable-msg", style: { display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }, children: "Request access restoration" }), _jsx("textarea", { id: "cw-reenable-msg", value: text, onChange: e => { setText(e.target.value); setError(null); setStatus('idle'); }, placeholder: "Explain briefly why your access should be restored\u2026", rows: 4, disabled: status === 'sending', style: {
59
+ }, children: body }), status === 'sent' ? (_jsxs(_Fragment, { children: [_jsx("p", { style: { margin: '0 0 16px', fontSize: 14, color: '#16a34a', fontWeight: 600 }, children: "Your request was sent. We will review it shortly." }), _jsx("button", { type: "button", onClick: onClose, style: {
60
+ width: '100%',
61
+ padding: '12px 16px',
62
+ borderRadius: 12,
63
+ border: '2px solid #ef4444',
64
+ background: '#fff',
65
+ color: '#ef4444',
66
+ fontWeight: 700,
67
+ fontSize: 15,
68
+ cursor: 'pointer',
69
+ fontFamily: 'inherit',
70
+ }, children: "Close" })] })) : (_jsxs(_Fragment, { children: [_jsx("label", { htmlFor: "cw-reenable-msg", style: { display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }, children: "Request access restoration" }), _jsx("textarea", { id: "cw-reenable-msg", value: text, onChange: e => { setText(e.target.value); setError(null); setStatus('idle'); }, placeholder: "Explain briefly why your access should be restored\u2026", rows: 4, maxLength: 500, minLength: 50, disabled: status === 'sending', style: {
60
71
  width: '100%',
61
72
  boxSizing: 'border-box',
62
73
  padding: '12px 14px',
@@ -65,8 +76,9 @@ export const ViewerBlockedScreen = ({ config, apiKey }) => {
65
76
  fontSize: 14,
66
77
  fontFamily: 'inherit',
67
78
  color: '#1e293b',
68
- resize: 'vertical',
79
+ resize: 'none',
69
80
  minHeight: 100,
81
+ maxHeight: 250,
70
82
  marginBottom: 14,
71
83
  outline: 'none',
72
84
  } }), _jsx("button", { type: "button", onClick: handleSubmit, disabled: status === 'sending' || !text.trim(), style: {
@@ -79,5 +91,17 @@ export const ViewerBlockedScreen = ({ config, apiKey }) => {
79
91
  fontWeight: 700,
80
92
  fontSize: 15,
81
93
  cursor: text.trim() && status !== 'sending' ? 'pointer' : 'default',
82
- }, children: status === 'sending' ? 'Sending…' : 'Submit request' }), error && (_jsx("p", { style: { margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }, children: error })), !url && (_jsxs("p", { style: { margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }, children: ["Your administrator must set ", _jsx("code", { style: { fontSize: 11 }, children: "reenableRequestUrl" }), " in widget config for online requests."] }))] }))] }) }));
94
+ }, children: status === 'sending' ? 'Sending…' : 'Submit request' }), _jsx("button", { type: "button", onClick: onClose, style: {
95
+ width: '100%',
96
+ marginTop: 12,
97
+ padding: '12px 16px',
98
+ borderRadius: 12,
99
+ border: '2px solid #ef4444',
100
+ background: '#fff',
101
+ color: '#ef4444',
102
+ fontWeight: 700,
103
+ fontSize: 15,
104
+ cursor: 'pointer',
105
+ fontFamily: 'inherit',
106
+ }, children: "Close" }), error && (_jsx("p", { style: { margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }, children: error })), !url && (_jsxs("p", { style: { margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }, children: ["Your administrator must set ", _jsx("code", { style: { fontSize: 11 }, children: "reenableRequestUrl" }), " in widget config for online requests."] }))] }))] }) }));
83
107
  };
package/dist/index.d.ts CHANGED
@@ -18,5 +18,7 @@ export { submitReenableRequest } from './utils/reenableRequest';
18
18
  export type { ReenableRequestPayload } from './utils/reenableRequest';
19
19
  export { loadLocalConfig, fetchRemoteChatData } from './config';
20
20
  export { mergeTheme, darken } from './utils/theme';
21
+ export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
22
+ export type { PresenceSyncPayload } from './utils/presenceStatus';
21
23
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
22
- export type { ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, } from './types';
24
+ export type { ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, PresenceStatus, } from './types';
package/dist/index.js CHANGED
@@ -17,4 +17,5 @@ export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt }
17
17
  export { submitReenableRequest } from './utils/reenableRequest';
18
18
  export { loadLocalConfig, fetchRemoteChatData } from './config';
19
19
  export { mergeTheme, darken } from './utils/theme';
20
+ export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
20
21
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
@@ -58,6 +58,16 @@ export interface WidgetConfig {
58
58
  * @example https://api.example.com/widgets/reenable-request
59
59
  */
60
60
  reenableRequestUrl?: string;
61
+ /**
62
+ * Current presence from your API/DB (include in chatData or a session payload).
63
+ * When set, it initializes the status control and overrides session-only cache.
64
+ */
65
+ presenceStatus?: PresenceStatus;
66
+ /**
67
+ * Production: `POST` JSON `{ widgetId, apiKey, viewerUid?, status }` to save presence in your database.
68
+ * The client still mirrors to sessionStorage as a local fallback.
69
+ */
70
+ presenceUpdateUrl?: string;
61
71
  }
62
72
  export interface RemoteChatData {
63
73
  widget: WidgetConfig;
@@ -75,6 +85,8 @@ export type BottomTab = 'home' | 'chats' | 'tickets';
75
85
  export type Screen = 'home' | 'user-list' | 'chat' | 'recent-chats' | 'tickets' | 'ticket-new' | 'ticket-detail' | 'block-list' | 'call';
76
86
  export type UserListContext = 'support' | 'conversation';
77
87
  export type MessageType = 'text' | 'voice' | 'attachment' | 'emoji';
88
+ /** Home status selector; persist via `presenceUpdateUrl` in production */
89
+ export type PresenceStatus = 'ACTIVE' | 'AWAY' | 'DND';
78
90
  export interface ChatUser {
79
91
  uid: string;
80
92
  name: string;
@@ -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.15",
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
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
499
- <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
500
- stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
501
- </svg>
505
+ <span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
506
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
507
+ <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"
508
+ stroke={theme.buttonTextColor} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
509
+ </svg>
510
+ {totalUnread > 0 && (
511
+ <span
512
+ style={{
513
+ position: 'absolute',
514
+ top: -8,
515
+ right: -10,
516
+ minWidth: 20,
517
+ height: 20,
518
+ padding: '0 5px',
519
+ borderRadius: 999,
520
+ background: '#ef4444',
521
+ color: '#fff',
522
+ fontSize: 11,
523
+ fontWeight: 800,
524
+ lineHeight: '20px',
525
+ textAlign: 'center',
526
+ border: '2px solid #fff',
527
+ boxSizing: 'border-box',
528
+ }}
529
+ >
530
+ {totalUnread > 99 ? '99+' : totalUnread}
531
+ </span>
532
+ )}
533
+ </span>
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 — shown outside chat/call screens */}
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
- export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOpenTicket, tickets }) => {
27
+ const STATUS_OPTIONS: { value: PresenceStatus; label: string }[] = [
28
+ { value: 'ACTIVE', label: 'Active' },
29
+ { value: 'AWAY', label: 'Away' },
30
+ { value: 'DND', label: 'DND' },
31
+ ];
32
+
33
+ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, apiKey, onNavigate, onOpenTicket, tickets }) => {
20
34
  const [menuOpen, setMenuOpen] = useState(false);
35
+ const [presence, setPresence] = useState<PresenceStatus>(() =>
36
+ resolveInitialPresence(config.id, config.presenceStatus),
37
+ );
38
+
39
+ useEffect(() => {
40
+ setPresence(resolveInitialPresence(config.id, config.presenceStatus));
41
+ }, [config.id, config.presenceStatus]);
42
+
43
+ const setPresenceAndSave = (s: PresenceStatus) => {
44
+ setPresence(s);
45
+ savePresenceStatus(config.id, s);
46
+ const url = config.presenceUpdateUrl?.trim();
47
+ if (!url) return;
48
+ void syncPresenceToServer(url, {
49
+ widgetId: config.id,
50
+ apiKey,
51
+ viewerUid: config.viewerUid?.trim() || undefined,
52
+ status: s,
53
+ }).catch(err => {
54
+ console.error('[ajaxter-chat] presence sync failed', err);
55
+ });
56
+ };
21
57
  const showSupport = config.chatType === 'SUPPORT' || config.chatType === 'BOTH';
22
58
  const showChat = config.chatType === 'CHAT' || config.chatType === 'BOTH';
23
59
  const viewerIsDev = config.viewerType === 'developer';
@@ -56,14 +92,14 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOp
56
92
  }}
57
93
  />
58
94
 
59
- {/* Top bar — burger left */}
95
+ {/* Top bar — menu + presence status */}
60
96
  <div
61
97
  style={{
62
98
  flexShrink: 0,
63
- padding: '14px 16px 10px',
99
+ padding: '12px 14px 12px',
64
100
  display: 'flex',
65
101
  alignItems: 'center',
66
- gap: 12,
102
+ gap: 10,
67
103
  background: '#fff',
68
104
  borderBottom: '1px solid #eef0f5',
69
105
  }}
@@ -91,6 +127,62 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ config, onNavigate, onOp
91
127
  <span style={{ width: 18, height: 2, background: '#334155', borderRadius: 1 }} />
92
128
  <span style={{ width: 18, height: 2, background: '#334155', borderRadius: 1 }} />
93
129
  </button>
130
+
131
+ <div style={{ flex: 1, minWidth: 0 }} />
132
+
133
+ <div
134
+ style={{
135
+ display: 'flex',
136
+ alignItems: 'center',
137
+ gap: 6,
138
+ flexShrink: 0,
139
+ flexWrap: 'wrap',
140
+ justifyContent: 'flex-end',
141
+ }}
142
+ >
143
+ <span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
144
+ Status
145
+ </span>
146
+ <div
147
+ role="group"
148
+ aria-label="Your status"
149
+ style={{
150
+ display: 'flex',
151
+ borderRadius: 10,
152
+ padding: 3,
153
+ background: '#f1f5f9',
154
+ gap: 2,
155
+ }}
156
+ >
157
+ {STATUS_OPTIONS.map(({ value, label }) => {
158
+ const isOn = presence === value;
159
+ return (
160
+ <button
161
+ key={value}
162
+ type="button"
163
+ onClick={() => setPresenceAndSave(value)}
164
+ style={{
165
+ border: 'none',
166
+ borderRadius: 8,
167
+ padding: '7px 10px',
168
+ fontSize: 11,
169
+ fontWeight: 700,
170
+ letterSpacing: '0.04em',
171
+ cursor: 'pointer',
172
+ fontFamily: 'inherit',
173
+ textTransform: 'uppercase',
174
+ background: isOn ? config.primaryColor : 'transparent',
175
+ color: isOn ? '#fff' : '#64748b',
176
+ boxShadow: isOn ? `0 2px 8px ${config.primaryColor}55` : 'none',
177
+ transition: 'background 0.15s, color 0.15s',
178
+ }}
179
+ >
180
+ {label}
181
+ </button>
182
+ );
183
+ })}
184
+ </div>
185
+ </div>
94
186
  </div>
95
187
 
96
188
  <div className="cw-scroll" style={{ flex: 1, overflowY: 'auto', padding: '20px 18px 28px' }}>
@@ -10,9 +10,10 @@ const DEFAULT_MESSAGE =
10
10
  interface ViewerBlockedScreenProps {
11
11
  config: WidgetConfig;
12
12
  apiKey: string;
13
+ onClose: () => void;
13
14
  }
14
15
 
15
- export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey }) => {
16
+ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey, onClose }) => {
16
17
  const [text, setText] = useState('');
17
18
  const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
18
19
  const [error, setError] = useState<string | null>(null);
@@ -79,9 +80,29 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
79
80
  </p>
80
81
 
81
82
  {status === 'sent' ? (
82
- <p style={{ margin: 0, fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
83
- Your request was sent. We will review it shortly.
84
- </p>
83
+ <>
84
+ <p style={{ margin: '0 0 16px', fontSize: 14, color: '#16a34a', fontWeight: 600 }}>
85
+ Your request was sent. We will review it shortly.
86
+ </p>
87
+ <button
88
+ type="button"
89
+ onClick={onClose}
90
+ style={{
91
+ width: '100%',
92
+ padding: '12px 16px',
93
+ borderRadius: 12,
94
+ border: '2px solid #ef4444',
95
+ background: '#fff',
96
+ color: '#ef4444',
97
+ fontWeight: 700,
98
+ fontSize: 15,
99
+ cursor: 'pointer',
100
+ fontFamily: 'inherit',
101
+ }}
102
+ >
103
+ Close
104
+ </button>
105
+ </>
85
106
  ) : (
86
107
  <>
87
108
  <label
@@ -96,6 +117,8 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
96
117
  onChange={e => { setText(e.target.value); setError(null); setStatus('idle'); }}
97
118
  placeholder="Explain briefly why your access should be restored…"
98
119
  rows={4}
120
+ maxLength={500}
121
+ minLength={50}
99
122
  disabled={status === 'sending'}
100
123
  style={{
101
124
  width: '100%',
@@ -106,8 +129,9 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
106
129
  fontSize: 14,
107
130
  fontFamily: 'inherit',
108
131
  color: '#1e293b',
109
- resize: 'vertical',
132
+ resize: 'none',
110
133
  minHeight: 100,
134
+ maxHeight: 250,
111
135
  marginBottom: 14,
112
136
  outline: 'none',
113
137
  }}
@@ -130,6 +154,25 @@ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config
130
154
  >
131
155
  {status === 'sending' ? 'Sending…' : 'Submit request'}
132
156
  </button>
157
+ <button
158
+ type="button"
159
+ onClick={onClose}
160
+ style={{
161
+ width: '100%',
162
+ marginTop: 12,
163
+ padding: '12px 16px',
164
+ borderRadius: 12,
165
+ border: '2px solid #ef4444',
166
+ background: '#fff',
167
+ color: '#ef4444',
168
+ fontWeight: 700,
169
+ fontSize: 15,
170
+ cursor: 'pointer',
171
+ fontFamily: 'inherit',
172
+ }}
173
+ >
174
+ Close
175
+ </button>
133
176
  {error && (
134
177
  <p style={{ margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }}>
135
178
  {error}
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export { submitReenableRequest } from './utils/reenableRequest';
20
20
  export type { ReenableRequestPayload } from './utils/reenableRequest';
21
21
  export { loadLocalConfig, fetchRemoteChatData } from './config';
22
22
  export { mergeTheme, darken } from './utils/theme';
23
+ export { loadPresenceStatus, savePresenceStatus, resolveInitialPresence, syncPresenceToServer } from './utils/presenceStatus';
24
+ export type { PresenceSyncPayload } from './utils/presenceStatus';
23
25
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
24
26
 
25
27
  export type {
@@ -30,4 +32,5 @@ export type {
30
32
  ChatStatus, ChatType, UserType, OnlineStatus,
31
33
  Screen, BottomTab, UserListContext, MessageType,
32
34
  LocalEnvConfig,
35
+ PresenceStatus,
33
36
  } from './types';
@@ -59,6 +59,16 @@ export interface WidgetConfig {
59
59
  * @example https://api.example.com/widgets/reenable-request
60
60
  */
61
61
  reenableRequestUrl?: string;
62
+ /**
63
+ * Current presence from your API/DB (include in chatData or a session payload).
64
+ * When set, it initializes the status control and overrides session-only cache.
65
+ */
66
+ presenceStatus?: PresenceStatus;
67
+ /**
68
+ * Production: `POST` JSON `{ widgetId, apiKey, viewerUid?, status }` to save presence in your database.
69
+ * The client still mirrors to sessionStorage as a local fallback.
70
+ */
71
+ presenceUpdateUrl?: string;
62
72
  }
63
73
 
64
74
  export interface RemoteChatData {
@@ -89,6 +99,9 @@ export type Screen =
89
99
  export type UserListContext = 'support' | 'conversation';
90
100
  export type MessageType = 'text' | 'voice' | 'attachment' | 'emoji';
91
101
 
102
+ /** Home status selector; persist via `presenceUpdateUrl` in production */
103
+ export type PresenceStatus = 'ACTIVE' | 'AWAY' | 'DND';
104
+
92
105
  // ─── User ───────────────────────────────────────────────────────────────────
93
106
  export interface ChatUser {
94
107
  uid: string;
@@ -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
+ }