ajaxter-chat 3.0.11 → 3.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -0
- package/dist/components/ChatWidget.js +24 -5
- package/dist/components/ViewerBlockedScreen/index.d.ts +8 -0
- package/dist/components/ViewerBlockedScreen/index.js +83 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/reenableRequest.d.ts +11 -0
- package/dist/utils/reenableRequest.js +16 -0
- package/package.json +1 -1
- package/public/chatData.json +2 -1
- package/src/components/ChatWidget.tsx +25 -2
- package/src/components/ViewerBlockedScreen/index.tsx +148 -0
- package/src/index.ts +2 -0
- package/src/types/index.ts +12 -0
- package/src/utils/reenableRequest.ts +27 -0
package/README.md
CHANGED
|
Binary file
|
|
@@ -19,8 +19,9 @@ import { BlockListScreen } from './BlockList';
|
|
|
19
19
|
import { CallScreen } from './CallScreen';
|
|
20
20
|
import { MaintenanceView } from './MaintenanceView';
|
|
21
21
|
import { BottomTabs } from './Tabs/BottomTabs';
|
|
22
|
+
import { ViewerBlockedScreen } from './ViewerBlockedScreen';
|
|
22
23
|
export const ChatWidget = ({ theme: localTheme }) => {
|
|
23
|
-
var _a, _b, _c, _d, _e;
|
|
24
|
+
var _a, _b, _c, _d, _e, _f;
|
|
24
25
|
/* SSR guard */
|
|
25
26
|
const [mounted, setMounted] = useState(false);
|
|
26
27
|
useEffect(() => { setMounted(true); }, []);
|
|
@@ -110,6 +111,14 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
110
111
|
return;
|
|
111
112
|
const w = data.widget;
|
|
112
113
|
setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
|
|
114
|
+
if (w.viewerBlocked) {
|
|
115
|
+
clearChat();
|
|
116
|
+
setScreen('home');
|
|
117
|
+
setActiveTab('home');
|
|
118
|
+
setViewingTicketId(null);
|
|
119
|
+
restoredRef.current = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
113
122
|
const p = loadSession(w.id);
|
|
114
123
|
if (p) {
|
|
115
124
|
setScreen(p.screen);
|
|
@@ -128,12 +137,21 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
139
|
restoredRef.current = true;
|
|
131
|
-
}, [data, selectUser]);
|
|
140
|
+
}, [data, selectUser, clearChat]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
var _a;
|
|
143
|
+
if (!((_a = data === null || data === void 0 ? void 0 : data.widget) === null || _a === void 0 ? void 0 : _a.viewerBlocked))
|
|
144
|
+
return;
|
|
145
|
+
clearChat();
|
|
146
|
+
setScreen('home');
|
|
147
|
+
setActiveTab('home');
|
|
148
|
+
setViewingTicketId(null);
|
|
149
|
+
}, [(_c = data === null || data === void 0 ? void 0 : data.widget) === null || _c === void 0 ? void 0 : _c.viewerBlocked, clearChat]);
|
|
132
150
|
useEffect(() => {
|
|
133
151
|
if (!(data === null || data === void 0 ? void 0 : data.widget))
|
|
134
152
|
return;
|
|
135
153
|
persistWidgetState();
|
|
136
|
-
}, [(
|
|
154
|
+
}, [(_d = data === null || data === void 0 ? void 0 : data.widget) === null || _d === void 0 ? void 0 : _d.id, screen, activeTab, userListCtx, activeUser === null || activeUser === void 0 ? void 0 : activeUser.uid, messages, viewingTicketId, chatReturnCtx, persistWidgetState]);
|
|
137
155
|
const incomingSoundRef = useRef(0);
|
|
138
156
|
useEffect(() => {
|
|
139
157
|
incomingSoundRef.current = messages.length;
|
|
@@ -287,7 +305,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
287
305
|
return u.type === 'user';
|
|
288
306
|
})
|
|
289
307
|
: [];
|
|
290
|
-
const otherDevelopers = (
|
|
308
|
+
const otherDevelopers = (_e = data === null || data === void 0 ? void 0 : data.developers.filter(d => d.uid !== viewerUid)) !== null && _e !== void 0 ? _e : [];
|
|
291
309
|
const blockedUsers = allUsers.filter(u => blockedUids.includes(u.uid));
|
|
292
310
|
const handleTransferToDeveloper = useCallback((dev) => {
|
|
293
311
|
var _a;
|
|
@@ -378,7 +396,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
378
396
|
right: theme.buttonPosition === 'bottom-left' ? 'auto' : 12,
|
|
379
397
|
left: theme.buttonPosition === 'bottom-left' ? 12 : 'auto',
|
|
380
398
|
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: (
|
|
399
|
+
}, 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 && (_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
400
|
setListEntranceAnimation(false);
|
|
383
401
|
setViewingTicketId(id);
|
|
384
402
|
setScreen('ticket-detail');
|
|
@@ -386,6 +404,7 @@ export const ChatWidget = ({ theme: localTheme }) => {
|
|
|
386
404
|
const t = tickets.find(x => x.id === viewingTicketId);
|
|
387
405
|
return t ? (_jsx(TicketDetailScreen, { ticket: t, config: widgetConfig, onBack: () => { setViewingTicketId(null); setScreen('tickets'); } })) : null;
|
|
388
406
|
})()), screen === 'block-list' && (_jsx(BlockListScreen, { blockedUsers: blockedUsers, config: widgetConfig, onUnblock: handleUnblock, onBack: () => { setScreen('home'); setActiveTab('home'); } }))] })), widgetConfig.status === 'ACTIVE' &&
|
|
407
|
+
!widgetConfig.viewerBlocked &&
|
|
389
408
|
screen !== 'chat' &&
|
|
390
409
|
screen !== 'call' &&
|
|
391
410
|
screen !== 'user-list' &&
|
|
@@ -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,6 +14,8 @@ 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';
|
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';
|
package/dist/types/index.d.ts
CHANGED
|
@@ -41,6 +41,18 @@ export interface WidgetConfig {
|
|
|
41
41
|
allowTranscriptDownload: boolean;
|
|
42
42
|
allowReport: boolean;
|
|
43
43
|
allowBlock: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* When `true` (set by the server if this viewer is spam-blocked or not in allowed user/ticket/chat lists),
|
|
46
|
+
* the widget hides all normal navigation and shows only the blocked-user screen with a re-enable request form.
|
|
47
|
+
*/
|
|
48
|
+
viewerBlocked?: boolean;
|
|
49
|
+
/** Optional override for the blocked message (default is a fixed spam notice). */
|
|
50
|
+
blockedViewerMessage?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Absolute URL for `POST` JSON re-enable requests. If omitted, the submit button explains that no endpoint is configured.
|
|
53
|
+
* @example https://api.example.com/widgets/reenable-request
|
|
54
|
+
*/
|
|
55
|
+
reenableRequestUrl?: string;
|
|
44
56
|
}
|
|
45
57
|
export interface RemoteChatData {
|
|
46
58
|
widget: WidgetConfig;
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ajaxter-chat",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.13",
|
|
4
4
|
"description": "Drawer-based chat widget with support chat, tickets, WebRTC calling, voice messages, block list, and transcript download.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/public/chatData.json
CHANGED
|
@@ -21,6 +21,7 @@ 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';
|
|
24
25
|
|
|
25
26
|
export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) => {
|
|
26
27
|
/* SSR guard */
|
|
@@ -125,6 +126,14 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
125
126
|
if (!data?.widget || restoredRef.current) return;
|
|
126
127
|
const w = data.widget;
|
|
127
128
|
setMessageSoundEnabledState(getMessageSoundEnabled(w.id));
|
|
129
|
+
if (w.viewerBlocked) {
|
|
130
|
+
clearChat();
|
|
131
|
+
setScreen('home');
|
|
132
|
+
setActiveTab('home');
|
|
133
|
+
setViewingTicketId(null);
|
|
134
|
+
restoredRef.current = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
128
137
|
const p = loadSession(w.id);
|
|
129
138
|
if (p) {
|
|
130
139
|
setScreen(p.screen);
|
|
@@ -143,7 +152,15 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
143
152
|
}
|
|
144
153
|
}
|
|
145
154
|
restoredRef.current = true;
|
|
146
|
-
}, [data, selectUser]);
|
|
155
|
+
}, [data, selectUser, clearChat]);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!data?.widget?.viewerBlocked) return;
|
|
159
|
+
clearChat();
|
|
160
|
+
setScreen('home');
|
|
161
|
+
setActiveTab('home');
|
|
162
|
+
setViewingTicketId(null);
|
|
163
|
+
}, [data?.widget?.viewerBlocked, clearChat]);
|
|
147
164
|
|
|
148
165
|
useEffect(() => {
|
|
149
166
|
if (!data?.widget) return;
|
|
@@ -512,8 +529,13 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
512
529
|
</div>
|
|
513
530
|
)}
|
|
514
531
|
|
|
532
|
+
{/* ── ACTIVE: viewer spam-blocked (no chat/tickets UI) ── */}
|
|
533
|
+
{widgetConfig.status === 'ACTIVE' && widgetConfig.viewerBlocked && (
|
|
534
|
+
<ViewerBlockedScreen config={widgetConfig} apiKey={apiKey} />
|
|
535
|
+
)}
|
|
536
|
+
|
|
515
537
|
{/* ── ACTIVE ── */}
|
|
516
|
-
{widgetConfig.status === 'ACTIVE' && (
|
|
538
|
+
{widgetConfig.status === 'ACTIVE' && !widgetConfig.viewerBlocked && (
|
|
517
539
|
<div className="cw-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
518
540
|
|
|
519
541
|
{screen === 'home' && (
|
|
@@ -631,6 +653,7 @@ export const ChatWidget: React.FC<ChatWidgetProps> = ({ theme: localTheme }) =>
|
|
|
631
653
|
|
|
632
654
|
{/* ── Bottom Tabs (hidden during chat/call/user-list/block-list) ── */}
|
|
633
655
|
{widgetConfig.status === 'ACTIVE' &&
|
|
656
|
+
!widgetConfig.viewerBlocked &&
|
|
634
657
|
screen !== 'chat' &&
|
|
635
658
|
screen !== 'call' &&
|
|
636
659
|
screen !== 'user-list' &&
|
|
@@ -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,6 +16,8 @@ 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';
|
package/src/types/index.ts
CHANGED
|
@@ -42,6 +42,18 @@ export interface WidgetConfig {
|
|
|
42
42
|
allowTranscriptDownload: boolean;
|
|
43
43
|
allowReport: boolean;
|
|
44
44
|
allowBlock: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* When `true` (set by the server if this viewer is spam-blocked or not in allowed user/ticket/chat lists),
|
|
47
|
+
* the widget hides all normal navigation and shows only the blocked-user screen with a re-enable request form.
|
|
48
|
+
*/
|
|
49
|
+
viewerBlocked?: boolean;
|
|
50
|
+
/** Optional override for the blocked message (default is a fixed spam notice). */
|
|
51
|
+
blockedViewerMessage?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Absolute URL for `POST` JSON re-enable requests. If omitted, the submit button explains that no endpoint is configured.
|
|
54
|
+
* @example https://api.example.com/widgets/reenable-request
|
|
55
|
+
*/
|
|
56
|
+
reenableRequestUrl?: string;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
export interface RemoteChatData {
|
|
@@ -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
|
+
}
|