keystone-design-bootstrap 1.0.85 → 1.0.86
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/dist/index.js +156 -37
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/design_system/chat/useRealtimeReplyOrchestrator.ts +127 -0
- package/src/design_system/components/ChatWidget.tsx +58 -37
- package/src/design_system/portal/MessageComposer.tsx +53 -1
- package/src/next/routes/chat.ts +57 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keystone-design-bootstrap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.86",
|
|
4
4
|
"description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@fontsource/montserrat": "^5.2.8",
|
|
65
65
|
"@fontsource/playfair-display": "^5.2.8",
|
|
66
66
|
"@fontsource/poppins": "^5.2.7",
|
|
67
|
+
"@rails/actioncable": "^8.1.300",
|
|
67
68
|
"@untitledui/file-icons": "^0.0.9",
|
|
68
69
|
"@untitledui/icons": "^0.0.20",
|
|
69
70
|
"clsx": "^2.1.1",
|
|
@@ -79,6 +80,7 @@
|
|
|
79
80
|
},
|
|
80
81
|
"devDependencies": {
|
|
81
82
|
"@types/node": "^20",
|
|
83
|
+
"@types/rails__actioncable": "^8.0.3",
|
|
82
84
|
"@types/react": "^19",
|
|
83
85
|
"@types/react-dom": "^19",
|
|
84
86
|
"eslint": "^9",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
import { createConsumer } from '@rails/actioncable';
|
|
5
|
+
|
|
6
|
+
export interface RealtimeSubscriptionData {
|
|
7
|
+
token: string;
|
|
8
|
+
contact_id: number;
|
|
9
|
+
cable_url: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UseRealtimeReplyOrchestratorOptions {
|
|
13
|
+
debugLabel: string;
|
|
14
|
+
fetchRealtimeData: () => Promise<RealtimeSubscriptionData | null>;
|
|
15
|
+
loadLatestHasReply: () => Promise<boolean>;
|
|
16
|
+
onReplyResolved: () => void;
|
|
17
|
+
onAgentThinking?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useRealtimeReplyOrchestrator({
|
|
21
|
+
debugLabel,
|
|
22
|
+
fetchRealtimeData,
|
|
23
|
+
loadLatestHasReply,
|
|
24
|
+
onReplyResolved,
|
|
25
|
+
onAgentThinking,
|
|
26
|
+
}: UseRealtimeReplyOrchestratorOptions) {
|
|
27
|
+
const cableRef = useRef<ReturnType<typeof createConsumer> | null>(null);
|
|
28
|
+
const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);
|
|
29
|
+
const subscribedContactRef = useRef<number | null>(null);
|
|
30
|
+
|
|
31
|
+
const clearRealtime = useCallback(() => {
|
|
32
|
+
if (subscriptionRef.current) {
|
|
33
|
+
subscriptionRef.current.unsubscribe();
|
|
34
|
+
subscriptionRef.current = null;
|
|
35
|
+
}
|
|
36
|
+
if (cableRef.current) {
|
|
37
|
+
cableRef.current.disconnect();
|
|
38
|
+
cableRef.current = null;
|
|
39
|
+
}
|
|
40
|
+
subscribedContactRef.current = null;
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const resolveReply = useCallback(() => {
|
|
44
|
+
onReplyResolved();
|
|
45
|
+
}, [onReplyResolved]);
|
|
46
|
+
|
|
47
|
+
const subscribeRealtime = useCallback((realtime: RealtimeSubscriptionData) => {
|
|
48
|
+
const token = realtime.token;
|
|
49
|
+
const contactIdForStream = realtime.contact_id;
|
|
50
|
+
const cableUrl = realtime.cable_url;
|
|
51
|
+
if (!token || !contactIdForStream || !cableUrl) return;
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
subscribedContactRef.current === contactIdForStream &&
|
|
55
|
+
subscriptionRef.current &&
|
|
56
|
+
cableRef.current
|
|
57
|
+
) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
clearRealtime();
|
|
62
|
+
|
|
63
|
+
const cable = createConsumer(`${cableUrl}?token=${encodeURIComponent(token)}`);
|
|
64
|
+
cableRef.current = cable;
|
|
65
|
+
subscribedContactRef.current = contactIdForStream;
|
|
66
|
+
subscriptionRef.current = cable.subscriptions.create(
|
|
67
|
+
{ channel: 'ContactCommunicationsChannel', contact_id: String(contactIdForStream) },
|
|
68
|
+
{
|
|
69
|
+
connected: () => {
|
|
70
|
+
console.info(`[${debugLabel}] realtime connected contact_id=${contactIdForStream}`);
|
|
71
|
+
},
|
|
72
|
+
disconnected: () => {
|
|
73
|
+
console.warn(`[${debugLabel}] realtime disconnected contact_id=${contactIdForStream}`);
|
|
74
|
+
},
|
|
75
|
+
rejected: () => {
|
|
76
|
+
console.warn(`[${debugLabel}] realtime rejected contact_id=${contactIdForStream}`);
|
|
77
|
+
},
|
|
78
|
+
received: async (data: { type?: string }) => {
|
|
79
|
+
console.info(`[${debugLabel}] realtime received type=${data?.type ?? 'unknown'} contact_id=${contactIdForStream}`);
|
|
80
|
+
if (data?.type === 'agent_thinking') {
|
|
81
|
+
onAgentThinking?.();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (data?.type !== 'new_communication') return;
|
|
85
|
+
try {
|
|
86
|
+
const hasReply = await loadLatestHasReply();
|
|
87
|
+
if (hasReply) {
|
|
88
|
+
resolveReply();
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`[${debugLabel}] realtime receive handling error`, error);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}, [clearRealtime, debugLabel, loadLatestHasReply, onAgentThinking, resolveReply]);
|
|
97
|
+
|
|
98
|
+
const ensureRealtimeSubscription = useCallback(async () => {
|
|
99
|
+
try {
|
|
100
|
+
const realtime = await fetchRealtimeData();
|
|
101
|
+
if (!realtime) return;
|
|
102
|
+
subscribeRealtime(realtime);
|
|
103
|
+
} catch {
|
|
104
|
+
// Realtime is best-effort; UI remains in waiting state until connection succeeds.
|
|
105
|
+
}
|
|
106
|
+
}, [fetchRealtimeData, subscribeRealtime]);
|
|
107
|
+
|
|
108
|
+
const beginReplyWait = useCallback((options?: { realtimeData?: RealtimeSubscriptionData | null }) => {
|
|
109
|
+
if (options?.realtimeData) {
|
|
110
|
+
subscribeRealtime(options.realtimeData);
|
|
111
|
+
} else {
|
|
112
|
+
void ensureRealtimeSubscription();
|
|
113
|
+
}
|
|
114
|
+
}, [ensureRealtimeSubscription, subscribeRealtime]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
clearRealtime();
|
|
119
|
+
};
|
|
120
|
+
}, [clearRealtime]);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
ensureRealtimeSubscription,
|
|
124
|
+
subscribeRealtime,
|
|
125
|
+
beginReplyWait,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -5,6 +5,7 @@ import { X, MessageChatSquare } from '@untitledui/icons';
|
|
|
5
5
|
import { Avatar } from '../elements/avatar/avatar';
|
|
6
6
|
import { cx } from '../../utils/cx';
|
|
7
7
|
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
|
+
import { useRealtimeReplyOrchestrator, type RealtimeSubscriptionData } from '../chat/useRealtimeReplyOrchestrator';
|
|
8
9
|
|
|
9
10
|
interface Message {
|
|
10
11
|
id: string;
|
|
@@ -126,47 +127,55 @@ export function ChatWidget({
|
|
|
126
127
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
127
128
|
}, [messages]);
|
|
128
129
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
clearInterval(pollIntervalRef.current);
|
|
137
|
-
}
|
|
138
|
-
};
|
|
130
|
+
const hasAgentReplyWithBody = useCallback((list: Message[]) => {
|
|
131
|
+
const latest = list[list.length - 1];
|
|
132
|
+
return (
|
|
133
|
+
latest?.sender_type === 'agent' &&
|
|
134
|
+
latest?.body != null &&
|
|
135
|
+
String(latest.body).trim() !== ''
|
|
136
|
+
);
|
|
139
137
|
}, []);
|
|
140
138
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
const fetchRealtimeData = useCallback(async (): Promise<RealtimeSubscriptionData | null> => {
|
|
140
|
+
if (!contactId && !sessionId) return null;
|
|
141
|
+
const query = contactId
|
|
142
|
+
? `contact_id=${encodeURIComponent(contactId)}`
|
|
143
|
+
: `identifier=${encodeURIComponent(sessionId)}`;
|
|
144
|
+
const response = await fetch(`/api/chat/?action=realtime_token&${query}`);
|
|
145
|
+
if (!response.ok) return null;
|
|
146
|
+
const result = await response.json();
|
|
147
|
+
const token = result?.data?.token as string | undefined;
|
|
148
|
+
const contactIdForStream = result?.data?.contact_id as number | undefined;
|
|
149
|
+
const cableUrl = result?.data?.cable_url as string | undefined;
|
|
150
|
+
if (!token || !contactIdForStream || !cableUrl) return null;
|
|
151
|
+
return { token, contact_id: contactIdForStream, cable_url: cableUrl };
|
|
152
|
+
}, [contactId, sessionId]);
|
|
149
153
|
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
const {
|
|
155
|
+
beginReplyWait,
|
|
156
|
+
ensureRealtimeSubscription,
|
|
157
|
+
} = useRealtimeReplyOrchestrator({
|
|
158
|
+
debugLabel: 'ChatWidget',
|
|
159
|
+
fetchRealtimeData,
|
|
160
|
+
loadLatestHasReply: async () => hasAgentReplyWithBody(await loadMessages()),
|
|
161
|
+
onReplyResolved: () => {
|
|
162
|
+
setWaitingForReply(false);
|
|
163
|
+
setIsLoading(false);
|
|
164
|
+
},
|
|
165
|
+
onAgentThinking: () => {
|
|
166
|
+
setWaitingForReply(true);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
152
169
|
|
|
153
|
-
|
|
154
|
-
const hasAgentReplyWithBody =
|
|
155
|
-
latest?.sender_type === 'agent' &&
|
|
156
|
-
latest?.body != null &&
|
|
157
|
-
String(latest.body).trim() !== '';
|
|
170
|
+
const hasPersistedMessages = messages.some((message) => !String(message.id).startsWith('temp_'));
|
|
158
171
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
console.error('[ChatWidget] Error polling for messages:', error);
|
|
167
|
-
}
|
|
168
|
-
}, 1000);
|
|
169
|
-
};
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!isOpen || (!contactId && !sessionId)) return;
|
|
174
|
+
// Anonymous sessions do not have a contact row until the first message is sent.
|
|
175
|
+
// Ignore optimistic temp rows to avoid pre-contact token probes (404 noise).
|
|
176
|
+
if (!contactId && !hasPersistedMessages) return;
|
|
177
|
+
ensureRealtimeSubscription();
|
|
178
|
+
}, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, sessionId]);
|
|
170
179
|
|
|
171
180
|
const sendMessage = async () => {
|
|
172
181
|
if (!inputValue.trim() || (!contactId && !sessionId)) return;
|
|
@@ -201,24 +210,36 @@ export function ChatWidget({
|
|
|
201
210
|
captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
|
|
202
211
|
|
|
203
212
|
if (result.data?.job_id) {
|
|
204
|
-
|
|
213
|
+
setWaitingForReply(true);
|
|
214
|
+
const realtimeFromSend = result.data?.realtime_token && result.data?.contact_id && result.data?.cable_url
|
|
215
|
+
? {
|
|
216
|
+
token: result.data.realtime_token,
|
|
217
|
+
contact_id: result.data.contact_id,
|
|
218
|
+
cable_url: result.data.cable_url,
|
|
219
|
+
}
|
|
220
|
+
: null;
|
|
221
|
+
beginReplyWait({ realtimeData: realtimeFromSend });
|
|
205
222
|
} else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
|
|
206
223
|
setIsLoading(false);
|
|
224
|
+
setWaitingForReply(false);
|
|
207
225
|
} else {
|
|
208
226
|
await loadMessages();
|
|
209
227
|
setIsLoading(false);
|
|
228
|
+
setWaitingForReply(false);
|
|
210
229
|
}
|
|
211
230
|
} else {
|
|
212
231
|
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
|
213
232
|
captureEvent('chat_message_failed', { error: 'send_failed' });
|
|
214
233
|
console.error('Failed to send message');
|
|
215
234
|
setIsLoading(false);
|
|
235
|
+
setWaitingForReply(false);
|
|
216
236
|
}
|
|
217
237
|
} catch (error) {
|
|
218
238
|
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
|
219
239
|
captureEvent('chat_message_failed', { error: 'network_error' });
|
|
220
240
|
console.error('Failed to send message:', error);
|
|
221
241
|
setIsLoading(false);
|
|
242
|
+
setWaitingForReply(false);
|
|
222
243
|
}
|
|
223
244
|
};
|
|
224
245
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useTransition, useRef, useEffect } from 'react';
|
|
3
|
+
import React, { useState, useTransition, useRef, useEffect, useCallback } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { useRealtimeReplyOrchestrator, type RealtimeSubscriptionData } from '../chat/useRealtimeReplyOrchestrator';
|
|
5
6
|
|
|
6
7
|
export function MessageComposer({ contactId }: { contactId: number }) {
|
|
7
8
|
const [body, setBody] = useState('');
|
|
@@ -10,6 +11,42 @@ export function MessageComposer({ contactId }: { contactId: number }) {
|
|
|
10
11
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
11
12
|
const router = useRouter();
|
|
12
13
|
|
|
14
|
+
const hasAgentReplyWithBody = useCallback(async () => {
|
|
15
|
+
const response = await fetch(`/api/chat/?contact_id=${encodeURIComponent(contactId)}`);
|
|
16
|
+
if (!response.ok) return false;
|
|
17
|
+
const result = await response.json();
|
|
18
|
+
const messages = (result?.data || []) as Array<{ sender_type?: string; body?: string | null }>;
|
|
19
|
+
const latest = messages[messages.length - 1];
|
|
20
|
+
return (
|
|
21
|
+
latest?.sender_type === 'agent' &&
|
|
22
|
+
latest?.body != null &&
|
|
23
|
+
String(latest.body).trim() !== ''
|
|
24
|
+
);
|
|
25
|
+
}, [contactId]);
|
|
26
|
+
|
|
27
|
+
const fetchRealtimeData = useCallback(async (): Promise<RealtimeSubscriptionData | null> => {
|
|
28
|
+
const response = await fetch(`/api/chat/?action=realtime_token&contact_id=${encodeURIComponent(contactId)}`);
|
|
29
|
+
if (!response.ok) return null;
|
|
30
|
+
const result = await response.json();
|
|
31
|
+
const token = result?.data?.token as string | undefined;
|
|
32
|
+
const streamContactId = result?.data?.contact_id as number | undefined;
|
|
33
|
+
const cableUrl = result?.data?.cable_url as string | undefined;
|
|
34
|
+
if (!token || !streamContactId || !cableUrl) return null;
|
|
35
|
+
return { token, contact_id: streamContactId, cable_url: cableUrl };
|
|
36
|
+
}, [contactId]);
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
beginReplyWait,
|
|
40
|
+
ensureRealtimeSubscription,
|
|
41
|
+
} = useRealtimeReplyOrchestrator({
|
|
42
|
+
debugLabel: 'MessageComposer',
|
|
43
|
+
fetchRealtimeData,
|
|
44
|
+
loadLatestHasReply: hasAgentReplyWithBody,
|
|
45
|
+
onReplyResolved: () => {
|
|
46
|
+
router.refresh();
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
13
50
|
// Auto-resize textarea
|
|
14
51
|
useEffect(() => {
|
|
15
52
|
const el = textareaRef.current;
|
|
@@ -18,6 +55,10 @@ export function MessageComposer({ contactId }: { contactId: number }) {
|
|
|
18
55
|
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
|
19
56
|
}, [body]);
|
|
20
57
|
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
void ensureRealtimeSubscription();
|
|
60
|
+
}, [ensureRealtimeSubscription]);
|
|
61
|
+
|
|
21
62
|
function submit() {
|
|
22
63
|
if (!body.trim() || isPending) return;
|
|
23
64
|
setError(null);
|
|
@@ -34,6 +75,17 @@ export function MessageComposer({ contactId }: { contactId: number }) {
|
|
|
34
75
|
} else {
|
|
35
76
|
setBody('');
|
|
36
77
|
router.refresh();
|
|
78
|
+
const hasBackgroundReplyJob = Boolean(result?.data?.job_id);
|
|
79
|
+
if (hasBackgroundReplyJob) {
|
|
80
|
+
const realtimeFromSend = result?.data?.realtime_token && result?.data?.contact_id && result?.data?.cable_url
|
|
81
|
+
? {
|
|
82
|
+
token: result.data.realtime_token,
|
|
83
|
+
contact_id: result.data.contact_id,
|
|
84
|
+
cable_url: result.data.cable_url,
|
|
85
|
+
}
|
|
86
|
+
: null;
|
|
87
|
+
beginReplyWait({ realtimeData: realtimeFromSend });
|
|
88
|
+
}
|
|
37
89
|
}
|
|
38
90
|
} catch {
|
|
39
91
|
setError('Failed to send message.');
|
package/src/next/routes/chat.ts
CHANGED
|
@@ -25,9 +25,22 @@ import { clientContextHeaders } from './proxy-headers';
|
|
|
25
25
|
|
|
26
26
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
27
27
|
const API_KEY = process.env.API_KEY || '';
|
|
28
|
+
const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
|
|
28
29
|
|
|
29
30
|
type JsonResponder = (body: unknown, init?: ResponseInit) => Response;
|
|
30
31
|
|
|
32
|
+
function cookieValue(cookieHeader: string | null, key: string): string | null {
|
|
33
|
+
if (!cookieHeader) return null;
|
|
34
|
+
const token = cookieHeader
|
|
35
|
+
.split(';')
|
|
36
|
+
.map((part) => part.trim())
|
|
37
|
+
.find((part) => part.startsWith(`${key}=`))
|
|
38
|
+
?.split('=')
|
|
39
|
+
.slice(1)
|
|
40
|
+
.join('=');
|
|
41
|
+
return token ? decodeURIComponent(token) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResponder } }) {
|
|
32
45
|
const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
|
|
33
46
|
|
|
@@ -65,7 +78,9 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
65
78
|
{ status: response.status }
|
|
66
79
|
);
|
|
67
80
|
}
|
|
68
|
-
|
|
81
|
+
const responseData = data.data || {};
|
|
82
|
+
const cableUrl = API_URL.replace(/\/api\/v1\/?$/, '').replace(/^http/, 'ws') + '/cable';
|
|
83
|
+
return json({ success: true, data: { ...responseData, cable_url: cableUrl } });
|
|
69
84
|
} catch (error) {
|
|
70
85
|
console.error('Chat message error:', error);
|
|
71
86
|
return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
|
|
@@ -76,9 +91,50 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
76
91
|
GET: async (request: Request): Promise<Response> => {
|
|
77
92
|
try {
|
|
78
93
|
const { searchParams } = new URL(request.url);
|
|
94
|
+
const action = searchParams.get('action');
|
|
79
95
|
const identifier = searchParams.get('identifier');
|
|
80
96
|
const contactId = searchParams.get('contact_id');
|
|
81
97
|
|
|
98
|
+
if (action === 'realtime_token') {
|
|
99
|
+
if (!identifier && !contactId) {
|
|
100
|
+
return json({ success: false, error: 'identifier or contact_id is required for realtime token.' }, { status: 400 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const response = contactId
|
|
104
|
+
? await (async () => {
|
|
105
|
+
const consumerToken = cookieValue(request.headers.get('cookie'), CONSUMER_TOKEN_COOKIE);
|
|
106
|
+
if (!consumerToken) {
|
|
107
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
108
|
+
status: 401,
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return fetch(
|
|
113
|
+
`${API_URL}/consumer/me/contacts/${encodeURIComponent(contactId)}/realtime_token`,
|
|
114
|
+
{
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${consumerToken}`,
|
|
117
|
+
...(API_KEY ? { 'X-API-Key': API_KEY } : {}),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
})()
|
|
122
|
+
: await fetch(
|
|
123
|
+
`${API_URL}/public/messages/realtime_token?identifier=${encodeURIComponent(identifier!)}`,
|
|
124
|
+
{ headers: { 'X-API-Key': API_KEY } }
|
|
125
|
+
);
|
|
126
|
+
const data = await response.json();
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
return json(
|
|
129
|
+
{ success: false, error: data.error || 'Failed to create realtime token.' },
|
|
130
|
+
{ status: response.status }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const cableUrl = API_URL.replace(/\/api\/v1\/?$/, '').replace(/^http/, 'ws') + '/cable';
|
|
135
|
+
return json({ success: true, data: { ...(data.data || {}), cable_url: cableUrl } });
|
|
136
|
+
}
|
|
137
|
+
|
|
82
138
|
if (!identifier && !contactId) {
|
|
83
139
|
return json({ success: false, error: 'identifier or contact_id is required.' }, { status: 400 });
|
|
84
140
|
}
|