ajaxter-chat 3.0.12 → 3.0.14

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
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useState, useEffect, useCallback, useRef } from 'react';
3
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
4
4
  import { loadLocalConfig } from '../config';
5
5
  import { mergeTheme } from '../utils/theme';
6
6
  import { useRemoteConfig } from '../hooks/useRemoteConfig';
@@ -19,8 +19,11 @@ import { BlockListScreen } from './BlockList';
19
19
  import { CallScreen } from './CallScreen';
20
20
  import { MaintenanceView } from './MaintenanceView';
21
21
  import { BottomTabs } from './Tabs/BottomTabs';
22
- export const ChatWidget = ({ theme: localTheme }) => {
23
- var _a, _b, _c, _d, _e;
22
+ import { ViewerBlockedScreen } from './ViewerBlockedScreen';
23
+ import { PermissionsGateScreen } from './PermissionsGateScreen';
24
+ import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
25
+ export const ChatWidget = ({ theme: localTheme, viewer }) => {
26
+ var _a, _b, _c, _d, _e, _f;
24
27
  /* SSR guard */
25
28
  const [mounted, setMounted] = useState(false);
26
29
  useEffect(() => { setMounted(true); }, []);
@@ -42,18 +45,21 @@ export const ChatWidget = ({ theme: localTheme }) => {
42
45
  const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
43
46
  /** Stagger list animation only when opening from home burger menu */
44
47
  const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
48
+ /** Microphone, geolocation, and screen capture granted for this tab */
49
+ const [permissionsOk, setPermissionsOk] = useState(false);
45
50
  /* App state */
46
51
  const [tickets, setTickets] = useState((_a = data === null || data === void 0 ? void 0 : data.sampleTickets) !== null && _a !== void 0 ? _a : []);
47
52
  const [recentChats, setRecentChats] = useState([]);
48
53
  const [blockedUids, setBlockedUids] = useState((_b = data === null || data === void 0 ? void 0 : data.blockedUsers) !== null && _b !== void 0 ? _b : []);
49
54
  /* Sync remote data into local state once loaded */
50
55
  useEffect(() => {
51
- var _a, _b;
56
+ var _a, _b, _c;
52
57
  if (data) {
53
58
  setTickets(data.sampleTickets);
54
59
  setBlockedUids(data.blockedUsers);
55
- // Seed recent chats from sample chats
56
- const all = [...((_a = data.developers) !== null && _a !== void 0 ? _a : []), ...((_b = data.users) !== null && _b !== void 0 ? _b : [])];
60
+ const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
61
+ const inProject = (u) => !pid || u.project === pid;
62
+ const all = [...((_b = data.developers) !== null && _b !== void 0 ? _b : []), ...((_c = data.users) !== null && _c !== void 0 ? _c : [])].filter(inProject);
57
63
  const recents = Object.entries(data.sampleChats).map(([uid, msgs]) => {
58
64
  const user = all.find(u => u.uid === uid);
59
65
  if (!user || msgs.length === 0)
@@ -70,7 +76,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
70
76
  }).filter(Boolean);
71
77
  setRecentChats(recents);
72
78
  }
73
- }, [data]);
79
+ }, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
74
80
  /* Chat hook */
75
81
  const { messages, activeUser, isPaused, isReported, selectUser, sendMessage, togglePause, reportChat, clearChat, setMessages, } = useChat();
76
82
  /* WebRTC hook */
@@ -103,13 +109,28 @@ export const ChatWidget = ({ theme: localTheme }) => {
103
109
  setClosing(false);
104
110
  }, 300);
105
111
  }, [persistWidgetState]);
112
+ useEffect(() => {
113
+ var _a;
114
+ const id = (_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.id;
115
+ if (!id)
116
+ return;
117
+ setPermissionsOk(hasStoredPermissionsGrant(id));
118
+ }, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.id]);
106
119
  const restoredRef = useRef(false);
107
120
  useEffect(() => {
108
- var _a, _b, _c;
121
+ var _a, _b, _c, _d;
109
122
  if (!(data === null || data === void 0 ? void 0 : data.widget) || restoredRef.current)
110
123
  return;
111
124
  const w = data.widget;
112
125
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
126
+ if (w.viewerBlocked) {
127
+ clearChat();
128
+ setScreen('home');
129
+ setActiveTab('home');
130
+ setViewingTicketId(null);
131
+ restoredRef.current = true;
132
+ return;
133
+ }
113
134
  const p = loadSession(w.id);
114
135
  if (p) {
115
136
  setScreen(p.screen);
@@ -118,22 +139,35 @@ export const ChatWidget = ({ theme: localTheme }) => {
118
139
  setViewingTicketId((_a = p.viewingTicketId) !== null && _a !== void 0 ? _a : null);
119
140
  setChatReturnCtx((_b = p.chatReturnCtx) !== null && _b !== void 0 ? _b : 'conversation');
120
141
  if (p.activeUserUid) {
121
- const u = [...data.developers, ...data.users].find(x => x.uid === p.activeUserUid);
142
+ const pid = (_c = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _c === void 0 ? void 0 : _c.trim();
143
+ const pool = pid
144
+ ? [...data.developers, ...data.users].filter(u => u.project === pid)
145
+ : [...data.developers, ...data.users];
146
+ const u = pool.find(x => x.uid === p.activeUserUid);
122
147
  if (u) {
123
148
  const hist = Array.isArray(p.messages) && p.messages.length
124
149
  ? p.messages
125
- : ((_c = data.sampleChats[u.uid]) !== null && _c !== void 0 ? _c : []);
150
+ : ((_d = data.sampleChats[u.uid]) !== null && _d !== void 0 ? _d : []);
126
151
  selectUser(u, hist);
127
152
  }
128
153
  }
129
154
  }
130
155
  restoredRef.current = true;
131
- }, [data, selectUser]);
156
+ }, [data, selectUser, clearChat, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
157
+ useEffect(() => {
158
+ var _a;
159
+ if (!((_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.viewerBlocked))
160
+ return;
161
+ clearChat();
162
+ setScreen('home');
163
+ setActiveTab('home');
164
+ setViewingTicketId(null);
165
+ }, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.viewerBlocked, clearChat]);
132
166
  useEffect(() => {
133
167
  if (!(data === null || data === void 0 ? void 0 : data.widget))
134
168
  return;
135
169
  persistWidgetState();
136
- }, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
170
+ }, [(_e = data === null || data === void 0 ? void 0 : data.widget) === null || _e === void 0 ? void 0 : _e.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
137
171
  const incomingSoundRef = useRef(0);
138
172
  useEffect(() => {
139
173
  incomingSoundRef.current = messages.length;
@@ -269,9 +303,31 @@ export const ChatWidget = ({ theme: localTheme }) => {
269
303
  }, [endCall]);
270
304
  /* ── Derived ─────────────────────────────────────────────────────────── */
271
305
  const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
272
- const widgetConfig = data === null || data === void 0 ? void 0 : data.widget;
306
+ const widgetConfig = useMemo(() => {
307
+ var _a;
308
+ if (!(data === null || data === void 0 ? void 0 : data.widget))
309
+ return undefined;
310
+ const w = Object.assign({}, data.widget);
311
+ if (viewer) {
312
+ w.viewerUid = viewer.uid;
313
+ w.viewerName = viewer.name;
314
+ w.viewerType = viewer.type;
315
+ if ((_a = viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim())
316
+ w.viewerProjectId = viewer.projectId.trim();
317
+ }
318
+ return w;
319
+ }, [data === null || data === void 0 ? void 0 : data.widget, viewer]);
273
320
  const primaryColor = theme.primaryColor;
274
- const allUsers = data ? [...data.developers, ...data.users] : [];
321
+ const allUsers = useMemo(() => {
322
+ var _a;
323
+ if (!data)
324
+ return [];
325
+ const pid = (_a = viewer === null || viewer === void 0 ? void 0 : viewer.projectId) === null || _a === void 0 ? void 0 : _a.trim();
326
+ const list = [...data.developers, ...data.users];
327
+ if (!pid)
328
+ return list;
329
+ return list.filter(u => u.project === pid);
330
+ }, [data, viewer === null || viewer === void 0 ? void 0 : viewer.projectId]);
275
331
  const viewerIsDev = (widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerType) === 'developer';
276
332
  const viewerUid = widgetConfig === null || widgetConfig === void 0 ? void 0 : widgetConfig.viewerUid;
277
333
  const filteredUsers = screen === 'user-list'
@@ -287,7 +343,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
287
343
  return u.type === 'user';
288
344
  })
289
345
  : [];
290
- const otherDevelopers = (_d = data === null || data === void 0 ? void 0 : data.developers.filter(d => d.uid !== viewerUid)) !== null && _d !== void 0 ? _d : [];
346
+ const otherDevelopers = useMemo(() => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid), [allUsers, viewerUid]);
291
347
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
292
348
  const handleTransferToDeveloper = useCallback((dev) => {
293
349
  var _a;
@@ -378,7 +434,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
378
434
  right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
379
435
  left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
380
436
  zIndex: 20, display: 'flex', gap: 6,
381
- }, 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' && (_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 => {
437
+ }, children: _jsx(CornerBtn, { onClick: closeDrawer, title: "Close", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", children: _jsx("path", { d: "M18 6L6 18M6 6l12 12", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round" }) }) }) })), widgetConfig.status === 'MAINTENANCE' && (_jsx(MaintenanceView, { primaryColor: primaryColor })), widgetConfig.status === 'DISABLE' && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 32, textAlign: 'center', gap: 12 }, children: [_jsx("div", { style: { fontSize: 40 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { fontWeight: 700, color: '#1a2332' }, children: "Chat is disabled" }), _jsx("button", { onClick: closeDrawer, style: { padding: '9px 20px', borderRadius: 10, border: 'none', background: primaryColor, color: '#fff', cursor: 'pointer', fontWeight: 700 }, children: "Close" })] })), widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (_jsx(ViewerBlockedScreen, { config: widgetConfig, apiKey: apiKey })), widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && !permissionsOk && (_jsx(PermissionsGateScreen, { primaryColor: primaryColor, widgetId: widgetConfig.id, onGranted: () => setPermissionsOk(true) })), widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (_jsxs("div", { className: "cw-scroll", style: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }, children: [screen === 'home' && (_jsx(HomeScreen, { config: widgetConfig, onNavigate: handleCardClick, onOpenTicket: handleOpenTicket, tickets: tickets })), screen === 'user-list' && (_jsx(UserListScreen, { context: userListCtx, users: filteredUsers, primaryColor: primaryColor, viewerType: (_f = widgetConfig.viewerType) !== null && _f !== void 0 ? _f : 'user', onBack: () => { setListEntranceAnimation(false); setScreen('home'); }, onSelectUser: handleSelectUser, onBlockList: userListCtx === 'conversation' ? () => setScreen('block-list') : undefined, useHomeHeader: userListCtx === 'support' && widgetConfig.viewerType !== 'developer', animateEntrance: listEntranceAnimation })), screen === 'chat' && activeUser && (_jsx(ChatScreen, { activeUser: activeUser, messages: messages, config: widgetConfig, isPaused: isPaused, isReported: isReported, isBlocked: isBlocked, onSend: sendMessage, onBack: handleBackFromChat, onClose: closeDrawer, onTogglePause: handleTogglePause, onReport: reportChat, onBlock: handleBlock, onStartCall: handleStartCall, onNavAction: handleNavFromMenu, otherDevelopers: otherDevelopers, onTransferToDeveloper: handleTransferToDeveloper, messageSoundEnabled: messageSoundEnabled, onToggleMessageSound: toggleMessageSound })), screen === 'call' && callSession.peer && (_jsx(CallScreen, { session: callSession, localVideoRef: localVideoRef, remoteVideoRef: remoteVideoRef, onEnd: handleEndCall, onToggleMute: toggleMute, onToggleCamera: toggleCamera, primaryColor: primaryColor })), screen === 'recent-chats' && (_jsx(RecentChatsScreen, { chats: recentChats, config: widgetConfig, onSelectChat: u => handleSelectUser(u, listCtxForUser(u, viewerIsDev)), animateEntrance: listEntranceAnimation })), screen === 'tickets' && (_jsx(TicketScreen, { tickets: tickets, config: widgetConfig, onNewTicket: () => { setListEntranceAnimation(false); setScreen('ticket-new'); }, onSelectTicket: id => {
382
438
  setListEntranceAnimation(false);
383
439
  setViewingTicketId(id);
384
440
  setScreen('ticket-detail');
@@ -386,6 +442,8 @@ export const ChatWidget = ({ theme: localTheme }) => {
386
442
  const t = tickets.find(x => x.id === viewingTicketId);
387
443
  return t ? (_jsx(TicketDetailScreen, { ticket: t, config: widgetConfig, onBack: () => { setViewingTicketId(null); setScreen('tickets'); } })) : null;
388
444
  })()), screen === 'block-list' && (_jsx(BlockListScreen, { blockedUsers: blockedUsers, config: widgetConfig, onUnblock: handleUnblock, onBack: () => { setScreen('home'); setActiveTab('home'); } }))] })), widgetConfig.status === 'ACTIVE' &&
445
+ !widgetConfig.viewerBlocked &&
446
+ permissionsOk &&
389
447
  screen !== 'chat' &&
390
448
  screen !== 'call' &&
391
449
  screen !== 'user-list' &&
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface PermissionsGateScreenProps {
3
+ primaryColor: string;
4
+ widgetId: string;
5
+ onGranted: () => void;
6
+ }
7
+ export declare const PermissionsGateScreen: React.FC<PermissionsGateScreenProps>;
8
+ export {};
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { requestWidgetPermissions, storePermissionsGrant } from '../../utils/widgetPermissions';
5
+ const DENIED = 'You cannot use this widget due to less permission granted by you.';
6
+ export const PermissionsGateScreen = ({ primaryColor, widgetId, onGranted, }) => {
7
+ const [phase, setPhase] = useState('prompt');
8
+ const handleAllow = async () => {
9
+ setPhase('checking');
10
+ const ok = await requestWidgetPermissions();
11
+ if (ok) {
12
+ storePermissionsGrant(widgetId);
13
+ onGranted();
14
+ }
15
+ else {
16
+ setPhase('denied');
17
+ }
18
+ };
19
+ return (_jsx("div", { style: {
20
+ flex: 1,
21
+ display: 'flex',
22
+ flexDirection: 'column',
23
+ alignItems: 'center',
24
+ justifyContent: 'center',
25
+ padding: '28px 22px',
26
+ textAlign: 'center',
27
+ minHeight: 0,
28
+ }, children: phase === 'denied' ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: 44, marginBottom: 16 }, children: "\uD83D\uDD12" }), _jsx("p", { style: { margin: '0 0 20px', fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.55, maxWidth: 320 }, children: DENIED }), _jsx("p", { style: { margin: '0 0 22px', fontSize: 13, color: '#64748b', lineHeight: 1.5, maxWidth: 340 }, children: "Allow microphone, location, and screen sharing in your browser settings for this site, then try again." }), _jsx("button", { type: "button", onClick: () => { setPhase('prompt'); void handleAllow(); }, style: {
29
+ padding: '12px 22px',
30
+ borderRadius: 12,
31
+ border: 'none',
32
+ background: primaryColor,
33
+ color: '#fff',
34
+ fontWeight: 700,
35
+ fontSize: 14,
36
+ cursor: 'pointer',
37
+ }, children: "Try again" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: 44, marginBottom: 16 }, children: "\uD83C\uDF99\uFE0F" }), _jsx("p", { style: { margin: '0 0 10px', fontSize: 16, fontWeight: 700, color: '#0f172a' }, children: "Permissions required" }), _jsxs("p", { style: { margin: '0 0 8px', fontSize: 14, color: '#475569', lineHeight: 1.55, maxWidth: 340 }, children: ["This widget needs ", _jsx("strong", { children: "microphone" }), " (voice & calls), ", _jsx("strong", { children: "location" }), ", and", ' ', _jsx("strong", { children: "screen sharing" }), " to work."] }), _jsx("p", { style: { margin: '0 0 22px', fontSize: 12, color: '#94a3b8', lineHeight: 1.45, maxWidth: 360 }, children: "You will be asked to pick a screen once \u2014 you can stop sharing immediately after; we only verify access." }), _jsx("button", { type: "button", disabled: phase === 'checking', onClick: handleAllow, style: {
38
+ padding: '14px 28px',
39
+ borderRadius: 12,
40
+ border: 'none',
41
+ background: phase === 'checking' ? '#94a3b8' : primaryColor,
42
+ color: '#fff',
43
+ fontWeight: 700,
44
+ fontSize: 15,
45
+ cursor: phase === 'checking' ? 'default' : 'pointer',
46
+ minWidth: 200,
47
+ }, children: phase === 'checking' ? 'Checking…' : 'Allow & continue' })] })) }));
48
+ };
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { WidgetConfig } from '../../types';
3
+ interface ViewerBlockedScreenProps {
4
+ config: WidgetConfig;
5
+ apiKey: string;
6
+ }
7
+ export declare const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps>;
8
+ export {};
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { submitReenableRequest } from '../../utils/reenableRequest';
5
+ const DEFAULT_MESSAGE = 'You have been marked as Blocked user due to spam';
6
+ export const ViewerBlockedScreen = ({ config, apiKey }) => {
7
+ var _a, _b;
8
+ const [text, setText] = useState('');
9
+ const [status, setStatus] = useState('idle');
10
+ const [error, setError] = useState(null);
11
+ const primary = config.primaryColor;
12
+ const body = (((_a = config.blockedViewerMessage) === null || _a === void 0 ? void 0 : _a.trim()) || DEFAULT_MESSAGE);
13
+ const url = (_b = config.reenableRequestUrl) === null || _b === void 0 ? void 0 : _b.trim();
14
+ const handleSubmit = async () => {
15
+ var _a;
16
+ if (!url) {
17
+ setError('Re-enable endpoint is not configured. Contact support directly.');
18
+ setStatus('error');
19
+ return;
20
+ }
21
+ const msg = text.trim();
22
+ if (!msg) {
23
+ setError('Please describe why you should be re-enabled.');
24
+ return;
25
+ }
26
+ setError(null);
27
+ setStatus('sending');
28
+ try {
29
+ await submitReenableRequest(url, {
30
+ widgetId: config.id,
31
+ apiKey,
32
+ viewerUid: ((_a = config.viewerUid) === null || _a === void 0 ? void 0 : _a.trim()) || undefined,
33
+ message: msg,
34
+ });
35
+ setStatus('sent');
36
+ setText('');
37
+ }
38
+ catch (e) {
39
+ setStatus('error');
40
+ setError(e instanceof Error ? e.message : 'Request failed');
41
+ }
42
+ };
43
+ return (_jsx("div", { className: "cw-scroll", style: {
44
+ flex: 1,
45
+ display: 'flex',
46
+ flexDirection: 'column',
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ padding: '28px 20px 32px',
50
+ textAlign: 'center',
51
+ overflowY: 'auto',
52
+ minHeight: 0,
53
+ }, children: _jsxs("div", { style: { maxWidth: 380, width: '100%' }, children: [_jsx("div", { style: { fontSize: 44, marginBottom: 16 }, children: "\uD83D\uDEAB" }), _jsx("p", { style: {
54
+ margin: '0 0 28px',
55
+ fontSize: 15,
56
+ fontWeight: 600,
57
+ color: '#1e293b',
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: {
60
+ width: '100%',
61
+ boxSizing: 'border-box',
62
+ padding: '12px 14px',
63
+ borderRadius: 12,
64
+ border: '1.5px solid #e2e8f0',
65
+ fontSize: 14,
66
+ fontFamily: 'inherit',
67
+ color: '#1e293b',
68
+ resize: 'vertical',
69
+ minHeight: 100,
70
+ marginBottom: 14,
71
+ outline: 'none',
72
+ } }), _jsx("button", { type: "button", onClick: handleSubmit, disabled: status === 'sending' || !text.trim(), style: {
73
+ width: '100%',
74
+ padding: '12px 16px',
75
+ borderRadius: 12,
76
+ border: 'none',
77
+ background: text.trim() && status !== 'sending' ? primary : '#e2e8f0',
78
+ color: text.trim() && status !== 'sending' ? '#fff' : '#94a3b8',
79
+ fontWeight: 700,
80
+ fontSize: 15,
81
+ 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."] }))] }))] }) }));
83
+ };
package/dist/index.d.ts CHANGED
@@ -14,7 +14,9 @@ export { useChat } from './hooks/useChat';
14
14
  export { useWebRTC } from './hooks/useWebRTC';
15
15
  export { useRemoteConfig } from './hooks/useRemoteConfig';
16
16
  export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt } from './utils/privacyConsent';
17
+ export { submitReenableRequest } from './utils/reenableRequest';
18
+ export type { ReenableRequestPayload } from './utils/reenableRequest';
17
19
  export { loadLocalConfig, fetchRemoteChatData } from './config';
18
20
  export { mergeTheme, darken } from './utils/theme';
19
21
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
20
- export type { ChatWidgetProps, ChatWidgetTheme, WidgetConfig, RemoteChatData, ChatUser, ChatMessage, Ticket, RecentChat, CallSession, CallState, ChatStatus, ChatType, UserType, OnlineStatus, Screen, BottomTab, UserListContext, MessageType, LocalEnvConfig, } from './types';
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';
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export { useChat } from './hooks/useChat';
14
14
  export { useWebRTC } from './hooks/useWebRTC';
15
15
  export { useRemoteConfig } from './hooks/useRemoteConfig';
16
16
  export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt } from './utils/privacyConsent';
17
+ export { submitReenableRequest } from './utils/reenableRequest';
17
18
  export { loadLocalConfig, fetchRemoteChatData } from './config';
18
19
  export { mergeTheme, darken } from './utils/theme';
19
20
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
@@ -23,6 +23,11 @@ export interface WidgetConfig {
23
23
  viewerUid?: string;
24
24
  /** Display name for transfer notes (optional) */
25
25
  viewerName?: string;
26
+ /**
27
+ * Host app project scope (set when embedding passes `viewer.projectId`).
28
+ * Use for API calls; lists can be filtered to users in the same project.
29
+ */
30
+ viewerProjectId?: string;
26
31
  /** Privacy Policy URL (linked from chat consent banner) */
27
32
  privacyPolicyUrl?: string;
28
33
  /** Set false to hide the consent note above the composer */
@@ -41,6 +46,18 @@ export interface WidgetConfig {
41
46
  allowTranscriptDownload: boolean;
42
47
  allowReport: boolean;
43
48
  allowBlock: boolean;
49
+ /**
50
+ * When `true` (set by the server if this viewer is spam-blocked or not in allowed user/ticket/chat lists),
51
+ * the widget hides all normal navigation and shows only the blocked-user screen with a re-enable request form.
52
+ */
53
+ viewerBlocked?: boolean;
54
+ /** Optional override for the blocked message (default is a fixed spam notice). */
55
+ blockedViewerMessage?: string;
56
+ /**
57
+ * Absolute URL for `POST` JSON re-enable requests. If omitted, the submit button explains that no endpoint is configured.
58
+ * @example https://api.example.com/widgets/reenable-request
59
+ */
60
+ reenableRequestUrl?: string;
44
61
  }
45
62
  export interface RemoteChatData {
46
63
  widget: WidgetConfig;
@@ -126,6 +143,18 @@ export interface ChatWidgetTheme {
126
143
  buttonPosition?: 'bottom-right' | 'bottom-left';
127
144
  borderRadius?: string;
128
145
  }
146
+ /**
147
+ * Pass the logged-in user from your React app so the widget matches identity and UI (user vs developer).
148
+ * Overrides `viewerUid`, `viewerName`, `viewerType` from remote `chatData.json` when provided.
149
+ */
150
+ export interface ChatWidgetViewer {
151
+ uid: string;
152
+ name: string;
153
+ type: UserType;
154
+ /** When set, directory lists only include users whose `ChatUser.project` equals this string (exact match). */
155
+ projectId?: string;
156
+ }
129
157
  export interface ChatWidgetProps {
130
158
  theme?: ChatWidgetTheme;
159
+ viewer?: ChatWidgetViewer;
131
160
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Payload sent when a blocked viewer requests access restoration.
3
+ * Backend should validate `apiKey` + `widgetId` and associate `viewerUid` with the session.
4
+ */
5
+ export interface ReenableRequestPayload {
6
+ widgetId: string;
7
+ apiKey: string;
8
+ viewerUid?: string;
9
+ message: string;
10
+ }
11
+ export declare function submitReenableRequest(url: string, payload: ReenableRequestPayload): Promise<void>;
@@ -0,0 +1,16 @@
1
+ export async function submitReenableRequest(url, payload) {
2
+ const res = await fetch(url, {
3
+ method: 'POST',
4
+ headers: {
5
+ Accept: 'application/json',
6
+ 'Content-Type': 'application/json',
7
+ },
8
+ body: JSON.stringify(payload),
9
+ mode: 'cors',
10
+ credentials: 'omit',
11
+ });
12
+ if (!res.ok) {
13
+ const text = await res.text().catch(() => '');
14
+ throw new Error(text || `Request failed (${res.status})`);
15
+ }
16
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Requests microphone, geolocation, and screen-capture access required by the widget.
3
+ * Stops all tracks immediately after success (probe only).
4
+ */
5
+ export declare function requestWidgetPermissions(): Promise<boolean>;
6
+ export declare function permissionsSessionKey(widgetId: string): string;
7
+ export declare function hasStoredPermissionsGrant(widgetId: string): boolean;
8
+ export declare function storePermissionsGrant(widgetId: string): void;
9
+ export declare function clearStoredPermissionsGrant(widgetId: string): void;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Requests microphone, geolocation, and screen-capture access required by the widget.
3
+ * Stops all tracks immediately after success (probe only).
4
+ */
5
+ export async function requestWidgetPermissions() {
6
+ var _a, _b, _c;
7
+ if (typeof navigator === 'undefined')
8
+ return false;
9
+ try {
10
+ if (!((_a = navigator.mediaDevices) === null || _a === void 0 ? void 0 : _a.getUserMedia))
11
+ return false;
12
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
13
+ mic.getTracks().forEach(t => t.stop());
14
+ }
15
+ catch (_d) {
16
+ return false;
17
+ }
18
+ try {
19
+ if (!((_b = navigator.geolocation) === null || _b === void 0 ? void 0 : _b.getCurrentPosition))
20
+ return false;
21
+ await new Promise((resolve, reject) => {
22
+ navigator.geolocation.getCurrentPosition(() => resolve(), e => reject(e), { enableHighAccuracy: false, timeout: 20000, maximumAge: 60000 });
23
+ });
24
+ }
25
+ catch (_e) {
26
+ return false;
27
+ }
28
+ try {
29
+ if (!((_c = navigator.mediaDevices) === null || _c === void 0 ? void 0 : _c.getDisplayMedia))
30
+ return false;
31
+ const screen = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
32
+ screen.getTracks().forEach(t => t.stop());
33
+ }
34
+ catch (_f) {
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ export function permissionsSessionKey(widgetId) {
40
+ return `ajaxter_widget_permissions_ok_${widgetId}`;
41
+ }
42
+ export function hasStoredPermissionsGrant(widgetId) {
43
+ if (typeof sessionStorage === 'undefined')
44
+ return false;
45
+ try {
46
+ return sessionStorage.getItem(permissionsSessionKey(widgetId)) === '1';
47
+ }
48
+ catch (_a) {
49
+ return false;
50
+ }
51
+ }
52
+ export function storePermissionsGrant(widgetId) {
53
+ try {
54
+ sessionStorage.setItem(permissionsSessionKey(widgetId), '1');
55
+ }
56
+ catch (_a) {
57
+ /* quota */
58
+ }
59
+ }
60
+ export function clearStoredPermissionsGrant(widgetId) {
61
+ try {
62
+ sessionStorage.removeItem(permissionsSessionKey(widgetId));
63
+ }
64
+ catch (_a) {
65
+ /* */
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ajaxter-chat",
3
- "version": "3.0.12",
3
+ "version": "3.0.14",
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",
@@ -25,7 +25,8 @@
25
25
  "maxEmojiCount": 20,
26
26
  "allowTranscriptDownload": true,
27
27
  "allowReport": true,
28
- "allowBlock": true
28
+ "allowBlock": true,
29
+ "viewerBlocked": false
29
30
  },
30
31
  "developers": [
31
32
  {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect, useCallback, useRef } from 'react';
3
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
4
4
  import { ChatWidgetProps, BottomTab, Screen, UserListContext, ChatUser, Ticket, RecentChat, ChatMessage } from '../types';
5
5
  import { loadLocalConfig } from '../config';
6
6
  import { mergeTheme } from '../utils/theme';
@@ -21,8 +21,11 @@ import { BlockListScreen } from './BlockList';
21
21
  import { CallScreen } from './CallScreen';
22
22
  import { MaintenanceView } from './MaintenanceView';
23
23
  import { BottomTabs } from './Tabs/BottomTabs';
24
+ import { ViewerBlockedScreen } from './ViewerBlockedScreen';
25
+ import { PermissionsGateScreen } from './PermissionsGateScreen';
26
+ import { hasStoredPermissionsGrant } from '../utils/widgetPermissions';
24
27
 
25
- export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) => {
28
+ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme, viewer }) => {
26
29
  /* SSR guard */
27
30
  const [mounted, setMounted] = useState(false);
28
31
  useEffect(() => { setMounted(true); }, []);
@@ -52,6 +55,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
52
55
  const [messageSoundEnabled, setMessageSoundEnabledState] = useState(true);
53
56
  /** Stagger list animation only when opening from home burger menu */
54
57
  const [listEntranceAnimation, setListEntranceAnimation] = useState(false);
58
+ /** Microphone, geolocation, and screen capture granted for this tab */
59
+ const [permissionsOk, setPermissionsOk] = useState(false);
55
60
 
56
61
  /* App state */
57
62
  const [tickets, setTickets] = useState<Ticket[]>(data?.sampleTickets ?? []);
@@ -63,8 +68,9 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
63
68
  if (data) {
64
69
  setTickets(data.sampleTickets);
65
70
  setBlockedUids(data.blockedUsers);
66
- // Seed recent chats from sample chats
67
- const all = [...(data.developers ?? []), ...(data.users ?? [])];
71
+ const pid = viewer?.projectId?.trim();
72
+ const inProject = (u: ChatUser) => !pid || u.project === pid;
73
+ const all = [...(data.developers ?? []), ...(data.users ?? [])].filter(inProject);
68
74
  const recents: RecentChat[] = Object.entries(data.sampleChats).map(([uid, msgs]) => {
69
75
  const user = all.find(u => u.uid === uid);
70
76
  if (!user || msgs.length === 0) return null;
@@ -80,7 +86,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
80
86
  }).filter(Boolean) as RecentChat[];
81
87
  setRecentChats(recents);
82
88
  }
83
- }, [data]);
89
+ }, [data, viewer?.projectId]);
84
90
 
85
91
  /* Chat hook */
86
92
  const {
@@ -120,11 +126,25 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
120
126
  }, 300);
121
127
  }, [persistWidgetState]);
122
128
 
129
+ useEffect(() => {
130
+ const id = data?.widget?.id;
131
+ if (!id) return;
132
+ setPermissionsOk(hasStoredPermissionsGrant(id));
133
+ }, [data?.widget?.id]);
134
+
123
135
  const restoredRef = useRef(false);
124
136
  useEffect(() => {
125
137
  if (!data?.widget || restoredRef.current) return;
126
138
  const w = data.widget;
127
139
  setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
140
+ if (w.viewerBlocked) {
141
+ clearChat();
142
+ setScreen('home');
143
+ setActiveTab('home');
144
+ setViewingTicketId(null);
145
+ restoredRef.current = true;
146
+ return;
147
+ }
128
148
  const p = loadSession(w.id);
129
149
  if (p) {
130
150
  setScreen(p.screen);
@@ -133,7 +153,11 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
133
153
  setViewingTicketId(p.viewingTicketId ?? null);
134
154
  setChatReturnCtx(p.chatReturnCtx ?? 'conversation');
135
155
  if (p.activeUserUid) {
136
- const u = [...data.developers, ...data.users].find(x => x.uid === p.activeUserUid);
156
+ const pid = viewer?.projectId?.trim();
157
+ const pool = pid
158
+ ? [...data.developers, ...data.users].filter(u => u.project === pid)
159
+ : [...data.developers, ...data.users];
160
+ const u = pool.find(x => x.uid === p.activeUserUid);
137
161
  if (u) {
138
162
  const hist = Array.isArray(p.messages) && p.messages.length
139
163
  ? p.messages
@@ -143,7 +167,15 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
143
167
  }
144
168
  }
145
169
  restoredRef.current = true;
146
- }, [data, selectUser]);
170
+ }, [data, selectUser, clearChat, viewer?.projectId]);
171
+
172
+ useEffect(() => {
173
+ if (!data?.widget?.viewerBlocked) return;
174
+ clearChat();
175
+ setScreen('home');
176
+ setActiveTab('home');
177
+ setViewingTicketId(null);
178
+ }, [data?.widget?.viewerBlocked, clearChat]);
147
179
 
148
180
  useEffect(() => {
149
181
  if (!data?.widget) return;
@@ -291,10 +323,29 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
291
323
 
292
324
  /* ── Derived ─────────────────────────────────────────────────────────── */
293
325
  const isBlocked = activeUser ? blockedUids.includes(activeUser.uid) : false;
294
- const widgetConfig = data?.widget;
326
+
327
+ const widgetConfig = useMemo(() => {
328
+ if (!data?.widget) return undefined;
329
+ const w = { ...data.widget };
330
+ if (viewer) {
331
+ w.viewerUid = viewer.uid;
332
+ w.viewerName = viewer.name;
333
+ w.viewerType = viewer.type;
334
+ if (viewer.projectId?.trim()) w.viewerProjectId = viewer.projectId.trim();
335
+ }
336
+ return w;
337
+ }, [data?.widget, viewer]);
338
+
295
339
  const primaryColor = theme.primaryColor;
296
340
 
297
- const allUsers = data ? [...data.developers, ...data.users] : [];
341
+ const allUsers = useMemo(() => {
342
+ if (!data) return [];
343
+ const pid = viewer?.projectId?.trim();
344
+ const list = [...data.developers, ...data.users];
345
+ if (!pid) return list;
346
+ return list.filter(u => u.project === pid);
347
+ }, [data, viewer?.projectId]);
348
+
298
349
  const viewerIsDev = widgetConfig?.viewerType === 'developer';
299
350
  const viewerUid = widgetConfig?.viewerUid;
300
351
 
@@ -311,7 +362,10 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
311
362
  })
312
363
  : [];
313
364
 
314
- const otherDevelopers = data?.developers.filter(d => d.uid !== viewerUid) ?? [];
365
+ const otherDevelopers = useMemo(
366
+ () => allUsers.filter(u => u.type === 'developer' && u.uid !== viewerUid),
367
+ [allUsers, viewerUid],
368
+ );
315
369
  const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
316
370
 
317
371
  const handleTransferToDeveloper = useCallback((dev: ChatUser) => {
@@ -512,8 +566,22 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
512
566
  </div>
513
567
  )}
514
568
 
569
+ {/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
570
+ {widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (
571
+ <ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
572
+ )}
573
+
574
+ {/* ── ACTIVE: microphone, location, screen share required ── */}
575
+ {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && !permissionsOk && (
576
+ <PermissionsGateScreen
577
+ primaryColor={primaryColor}
578
+ widgetId={widgetConfig.id}
579
+ onGranted={() => setPermissionsOk(true)}
580
+ />
581
+ )}
582
+
515
583
  {/* ── ACTIVE ── */}
516
- {widgetConfig.status === 'ACTIVE' && (
584
+ {widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && permissionsOk && (
517
585
  <div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
518
586
 
519
587
  {screen === 'home' && (
@@ -631,6 +699,8 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
631
699
 
632
700
  {/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
633
701
  {widgetConfig.status === 'ACTIVE' &&
702
+ !widgetConfig.viewerBlocked &&
703
+ permissionsOk &&
634
704
  screen !== 'chat' &&
635
705
  screen !== 'call' &&
636
706
  screen !== 'user-list' &&
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { requestWidgetPermissions, storePermissionsGrant } from '../../utils/widgetPermissions';
5
+
6
+ const DENIED =
7
+ 'You cannot use this widget due to less permission granted by you.';
8
+
9
+ interface PermissionsGateScreenProps {
10
+ primaryColor: string;
11
+ widgetId: string;
12
+ onGranted: () => void;
13
+ }
14
+
15
+ export const PermissionsGateScreen: React.FC<PermissionsGateScreenProps> = ({
16
+ primaryColor,
17
+ widgetId,
18
+ onGranted,
19
+ }) => {
20
+ const [phase, setPhase] = useState<'prompt' | 'checking' | 'denied'>('prompt');
21
+
22
+ const handleAllow = async () => {
23
+ setPhase('checking');
24
+ const ok = await requestWidgetPermissions();
25
+ if (ok) {
26
+ storePermissionsGrant(widgetId);
27
+ onGranted();
28
+ } else {
29
+ setPhase('denied');
30
+ }
31
+ };
32
+
33
+ return (
34
+ <div
35
+ style={{
36
+ flex: 1,
37
+ display: 'flex',
38
+ flexDirection: 'column',
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ padding: '28px 22px',
42
+ textAlign: 'center',
43
+ minHeight: 0,
44
+ }}
45
+ >
46
+ {phase === 'denied' ? (
47
+ <>
48
+ <div style={{ fontSize: 44, marginBottom: 16 }}>🔒</div>
49
+ <p style={{ margin: '0 0 20px', fontSize: 15, fontWeight: 600, color: '#1e293b', lineHeight: 1.55, maxWidth: 320 }}>
50
+ {DENIED}
51
+ </p>
52
+ <p style={{ margin: '0 0 22px', fontSize: 13, color: '#64748b', lineHeight: 1.5, maxWidth: 340 }}>
53
+ Allow microphone, location, and screen sharing in your browser settings for this site, then try again.
54
+ </p>
55
+ <button
56
+ type="button"
57
+ onClick={() => { setPhase('prompt'); void handleAllow(); }}
58
+ style={{
59
+ padding: '12px 22px',
60
+ borderRadius: 12,
61
+ border: 'none',
62
+ background: primaryColor,
63
+ color: '#fff',
64
+ fontWeight: 700,
65
+ fontSize: 14,
66
+ cursor: 'pointer',
67
+ }}
68
+ >
69
+ Try again
70
+ </button>
71
+ </>
72
+ ) : (
73
+ <>
74
+ <div style={{ fontSize: 44, marginBottom: 16 }}>🎙️</div>
75
+ <p style={{ margin: '0 0 10px', fontSize: 16, fontWeight: 700, color: '#0f172a' }}>Permissions required</p>
76
+ <p style={{ margin: '0 0 8px', fontSize: 14, color: '#475569', lineHeight: 1.55, maxWidth: 340 }}>
77
+ This widget needs <strong>microphone</strong> (voice &amp; calls), <strong>location</strong>, and{' '}
78
+ <strong>screen sharing</strong> to work.
79
+ </p>
80
+ <p style={{ margin: '0 0 22px', fontSize: 12, color: '#94a3b8', lineHeight: 1.45, maxWidth: 360 }}>
81
+ You will be asked to pick a screen once — you can stop sharing immediately after; we only verify access.
82
+ </p>
83
+ <button
84
+ type="button"
85
+ disabled={phase === 'checking'}
86
+ onClick={handleAllow}
87
+ style={{
88
+ padding: '14px 28px',
89
+ borderRadius: 12,
90
+ border: 'none',
91
+ background: phase === 'checking' ? '#94a3b8' : primaryColor,
92
+ color: '#fff',
93
+ fontWeight: 700,
94
+ fontSize: 15,
95
+ cursor: phase === 'checking' ? 'default' : 'pointer',
96
+ minWidth: 200,
97
+ }}
98
+ >
99
+ {phase === 'checking' ? 'Checking…' : 'Allow & continue'}
100
+ </button>
101
+ </>
102
+ )}
103
+ </div>
104
+ );
105
+ };
@@ -0,0 +1,148 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { WidgetConfig } from '../../types';
5
+ import { submitReenableRequest } from '../../utils/reenableRequest';
6
+
7
+ const DEFAULT_MESSAGE =
8
+ 'You have been marked as Blocked user due to spam';
9
+
10
+ interface ViewerBlockedScreenProps {
11
+ config: WidgetConfig;
12
+ apiKey: string;
13
+ }
14
+
15
+ export const ViewerBlockedScreen: React.FC<ViewerBlockedScreenProps> = ({ config, apiKey }) => {
16
+ const [text, setText] = useState('');
17
+ const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
18
+ const [error, setError] = useState<string | null>(null);
19
+
20
+ const primary = config.primaryColor;
21
+ const body = (config.blockedViewerMessage?.trim() || DEFAULT_MESSAGE);
22
+ const url = config.reenableRequestUrl?.trim();
23
+
24
+ const handleSubmit = async () => {
25
+ if (!url) {
26
+ setError('Re-enable endpoint is not configured. Contact support directly.');
27
+ setStatus('error');
28
+ return;
29
+ }
30
+ const msg = text.trim();
31
+ if (!msg) {
32
+ setError('Please describe why you should be re-enabled.');
33
+ return;
34
+ }
35
+ setError(null);
36
+ setStatus('sending');
37
+ try {
38
+ await submitReenableRequest(url, {
39
+ widgetId: config.id,
40
+ apiKey,
41
+ viewerUid: config.viewerUid?.trim() || undefined,
42
+ message: msg,
43
+ });
44
+ setStatus('sent');
45
+ setText('');
46
+ } catch (e) {
47
+ setStatus('error');
48
+ setError(e instanceof Error ? e.message : 'Request failed');
49
+ }
50
+ };
51
+
52
+ return (
53
+ <div
54
+ className="cw-scroll"
55
+ style={{
56
+ flex: 1,
57
+ display: 'flex',
58
+ flexDirection: 'column',
59
+ alignItems: 'center',
60
+ justifyContent: 'center',
61
+ padding: '28px 20px 32px',
62
+ textAlign: 'center',
63
+ overflowY: 'auto',
64
+ minHeight: 0,
65
+ }}
66
+ >
67
+ <div style={{ maxWidth: 380, width: '100%' }}>
68
+ <div style={{ fontSize: 44, marginBottom: 16 }}>🚫</div>
69
+ <p
70
+ style={{
71
+ margin: '0 0 28px',
72
+ fontSize: 15,
73
+ fontWeight: 600,
74
+ color: '#1e293b',
75
+ lineHeight: 1.55,
76
+ }}
77
+ >
78
+ {body}
79
+ </p>
80
+
81
+ {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>
85
+ ) : (
86
+ <>
87
+ <label
88
+ htmlFor="cw-reenable-msg"
89
+ style={{ display: 'block', textAlign: 'left', fontSize: 13, fontWeight: 600, color: '#475569', marginBottom: 8 }}
90
+ >
91
+ Request access restoration
92
+ </label>
93
+ <textarea
94
+ id="cw-reenable-msg"
95
+ value={text}
96
+ onChange={e => { setText(e.target.value); setError(null); setStatus('idle'); }}
97
+ placeholder="Explain briefly why your access should be restored…"
98
+ rows={4}
99
+ disabled={status === 'sending'}
100
+ style={{
101
+ width: '100%',
102
+ boxSizing: 'border-box',
103
+ padding: '12px 14px',
104
+ borderRadius: 12,
105
+ border: '1.5px solid #e2e8f0',
106
+ fontSize: 14,
107
+ fontFamily: 'inherit',
108
+ color: '#1e293b',
109
+ resize: 'vertical',
110
+ minHeight: 100,
111
+ marginBottom: 14,
112
+ outline: 'none',
113
+ }}
114
+ />
115
+ <button
116
+ type="button"
117
+ onClick={handleSubmit}
118
+ disabled={status === 'sending' || !text.trim()}
119
+ style={{
120
+ width: '100%',
121
+ padding: '12px 16px',
122
+ borderRadius: 12,
123
+ border: 'none',
124
+ background: text.trim() && status !== 'sending' ? primary : '#e2e8f0',
125
+ color: text.trim() && status !== 'sending' ? '#fff' : '#94a3b8',
126
+ fontWeight: 700,
127
+ fontSize: 15,
128
+ cursor: text.trim() && status !== 'sending' ? 'pointer' : 'default',
129
+ }}
130
+ >
131
+ {status === 'sending' ? 'Sending…' : 'Submit request'}
132
+ </button>
133
+ {error && (
134
+ <p style={{ margin: '12px 0 0', fontSize: 13, color: '#dc2626', lineHeight: 1.45 }}>
135
+ {error}
136
+ </p>
137
+ )}
138
+ {!url && (
139
+ <p style={{ margin: '14px 0 0', fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }}>
140
+ Your administrator must set <code style={{ fontSize: 11 }}>reenableRequestUrl</code> in widget config for online requests.
141
+ </p>
142
+ )}
143
+ </>
144
+ )}
145
+ </div>
146
+ </div>
147
+ );
148
+ };
package/src/index.ts CHANGED
@@ -16,12 +16,14 @@ export { useWebRTC } from './hooks/useWebRTC';
16
16
  export { useRemoteConfig } from './hooks/useRemoteConfig';
17
17
 
18
18
  export { shouldShowPrivacyNotice, dismissPrivacyNotice, getPrivacyDismissedAt } from './utils/privacyConsent';
19
+ export { submitReenableRequest } from './utils/reenableRequest';
20
+ export type { ReenableRequestPayload } from './utils/reenableRequest';
19
21
  export { loadLocalConfig, fetchRemoteChatData } from './config';
20
22
  export { mergeTheme, darken } from './utils/theme';
21
23
  export { avatarColor, initials, formatTime, formatDate, generateTranscript, downloadText, truncateWords } from './utils/chat';
22
24
 
23
25
  export type {
24
- ChatWidgetProps, ChatWidgetTheme,
26
+ ChatWidgetProps, ChatWidgetTheme, ChatWidgetViewer,
25
27
  WidgetConfig, RemoteChatData,
26
28
  ChatUser, ChatMessage, Ticket, RecentChat,
27
29
  CallSession, CallState,
@@ -24,6 +24,11 @@ export interface WidgetConfig {
24
24
  viewerUid?: string;
25
25
  /** Display name for transfer notes (optional) */
26
26
  viewerName?: string;
27
+ /**
28
+ * Host app project scope (set when embedding passes `viewer.projectId`).
29
+ * Use for API calls; lists can be filtered to users in the same project.
30
+ */
31
+ viewerProjectId?: string;
27
32
  /** Privacy Policy URL (linked from chat consent banner) */
28
33
  privacyPolicyUrl?: string;
29
34
  /** Set false to hide the consent note above the composer */
@@ -42,6 +47,18 @@ export interface WidgetConfig {
42
47
  allowTranscriptDownload: boolean;
43
48
  allowReport: boolean;
44
49
  allowBlock: boolean;
50
+ /**
51
+ * When `true` (set by the server if this viewer is spam-blocked or not in allowed user/ticket/chat lists),
52
+ * the widget hides all normal navigation and shows only the blocked-user screen with a re-enable request form.
53
+ */
54
+ viewerBlocked?: boolean;
55
+ /** Optional override for the blocked message (default is a fixed spam notice). */
56
+ blockedViewerMessage?: string;
57
+ /**
58
+ * Absolute URL for `POST` JSON re-enable requests. If omitted, the submit button explains that no endpoint is configured.
59
+ * @example https://api.example.com/widgets/reenable-request
60
+ */
61
+ reenableRequestUrl?: string;
45
62
  }
46
63
 
47
64
  export interface RemoteChatData {
@@ -155,6 +172,19 @@ export interface ChatWidgetTheme {
155
172
  borderRadius?: string;
156
173
  }
157
174
 
175
+ /**
176
+ * Pass the logged-in user from your React app so the widget matches identity and UI (user vs developer).
177
+ * Overrides `viewerUid`, `viewerName`, `viewerType` from remote `chatData.json` when provided.
178
+ */
179
+ export interface ChatWidgetViewer {
180
+ uid: string;
181
+ name: string;
182
+ type: UserType;
183
+ /** When set, directory lists only include users whose `ChatUser.project` equals this string (exact match). */
184
+ projectId?: string;
185
+ }
186
+
158
187
  export interface ChatWidgetProps {
159
188
  theme?: ChatWidgetTheme;
189
+ viewer?: ChatWidgetViewer;
160
190
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Payload sent when a blocked viewer requests access restoration.
3
+ * Backend should validate `apiKey` + `widgetId` and associate `viewerUid` with the session.
4
+ */
5
+ export interface ReenableRequestPayload {
6
+ widgetId: string;
7
+ apiKey: string;
8
+ viewerUid?: string;
9
+ message: string;
10
+ }
11
+
12
+ export async function submitReenableRequest(url: string, payload: ReenableRequestPayload): Promise<void> {
13
+ const res = await fetch(url, {
14
+ method: 'POST',
15
+ headers: {
16
+ Accept: 'application/json',
17
+ 'Content-Type': 'application/json',
18
+ },
19
+ body: JSON.stringify(payload),
20
+ mode: 'cors',
21
+ credentials: 'omit',
22
+ });
23
+ if (!res.ok) {
24
+ const text = await res.text().catch(() => '');
25
+ throw new Error(text || `Request failed (${res.status})`);
26
+ }
27
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Requests microphone, geolocation, and screen-capture access required by the widget.
3
+ * Stops all tracks immediately after success (probe only).
4
+ */
5
+ export async function requestWidgetPermissions(): Promise<boolean> {
6
+ if (typeof navigator === 'undefined') return false;
7
+
8
+ try {
9
+ if (!navigator.mediaDevices?.getUserMedia) return false;
10
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
11
+ mic.getTracks().forEach(t => t.stop());
12
+ } catch {
13
+ return false;
14
+ }
15
+
16
+ try {
17
+ if (!navigator.geolocation?.getCurrentPosition) return false;
18
+ await new Promise<void>((resolve, reject) => {
19
+ navigator.geolocation.getCurrentPosition(
20
+ () => resolve(),
21
+ e => reject(e),
22
+ { enableHighAccuracy: false, timeout: 20_000, maximumAge: 60_000 },
23
+ );
24
+ });
25
+ } catch {
26
+ return false;
27
+ }
28
+
29
+ try {
30
+ if (!navigator.mediaDevices?.getDisplayMedia) return false;
31
+ const screen = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
32
+ screen.getTracks().forEach(t => t.stop());
33
+ } catch {
34
+ return false;
35
+ }
36
+
37
+ return true;
38
+ }
39
+
40
+ export function permissionsSessionKey(widgetId: string): string {
41
+ return `ajaxter_widget_permissions_ok_${widgetId}`;
42
+ }
43
+
44
+ export function hasStoredPermissionsGrant(widgetId: string): boolean {
45
+ if (typeof sessionStorage === 'undefined') return false;
46
+ try {
47
+ return sessionStorage.getItem(permissionsSessionKey(widgetId)) === '1';
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ export function storePermissionsGrant(widgetId: string): void {
54
+ try {
55
+ sessionStorage.setItem(permissionsSessionKey(widgetId), '1');
56
+ } catch {
57
+ /* quota */
58
+ }
59
+ }
60
+
61
+ export function clearStoredPermissionsGrant(widgetId: string): void {
62
+ try {
63
+ sessionStorage.removeItem(permissionsSessionKey(widgetId));
64
+ } catch {
65
+ /* */
66
+ }
67
+ }