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 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
- }, [(_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]);
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 = (_d = data === null || data === void 0 ? void 0 : data.developers.filter(d => d.uid !== viewerUid)) !== null && _d !== void 0 ? _d : [];
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: (_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 => {
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,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,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';
@@ -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.11",
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",
@@ -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
  {
@@ -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';
@@ -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
+ }