keystone-design-bootstrap 1.0.84 → 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/design_system/logo/keystone-logo.js +102 -31
- package/dist/design_system/logo/keystone-logo.js.map +1 -1
- 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/logo/assets/keystone-logo.svg +17 -0
- package/src/design_system/logo/assets/keystone-wordmark.svg +22 -0
- package/src/design_system/logo/keystone-brand-lockup.tsx +25 -0
- package/src/design_system/logo/keystone-logo-minimal.tsx +26 -87
- package/src/design_system/logo/keystone-logo.tsx +3 -9
- package/src/design_system/logo/keystone-wordmark.tsx +49 -0
- package/src/design_system/portal/LoginModalController.tsx +12 -6
- package/src/design_system/portal/MessageComposer.tsx +53 -1
- package/src/design_system/portal/PortalPage.tsx +16 -5
- package/src/next/routes/chat.ts +57 -1
|
@@ -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.');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import Link from 'next/link';
|
|
3
3
|
import { cookies } from 'next/headers';
|
|
4
|
-
import {
|
|
4
|
+
import { KeystoneBrandLockup } from '../logo/keystone-brand-lockup';
|
|
5
|
+
import { KEYSTONE_APP_URL } from '../logo/keystone-logo-minimal';
|
|
5
6
|
import { LogoutButton } from './LogoutButton';
|
|
6
7
|
import { LoginModalController } from './LoginModalController';
|
|
7
8
|
import { MessageComposer } from './MessageComposer';
|
|
@@ -287,7 +288,7 @@ function ServicesPanel({
|
|
|
287
288
|
<PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} tab="services" />
|
|
288
289
|
<div className="divide-y divide-tertiary rounded-component border border-secondary bg-primary overflow-hidden">
|
|
289
290
|
{activeServices.map((service) => (
|
|
290
|
-
<details key={service.id} className="group">
|
|
291
|
+
<details key={service.id} className="group" open>
|
|
291
292
|
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-4 hover:bg-secondary transition-colors">
|
|
292
293
|
<div>
|
|
293
294
|
<span className="font-medium text-primary">{service.name}</span>
|
|
@@ -715,9 +716,19 @@ export async function PortalPage({
|
|
|
715
716
|
</button>
|
|
716
717
|
)}
|
|
717
718
|
|
|
718
|
-
<div className="
|
|
719
|
-
<
|
|
720
|
-
|
|
719
|
+
<div className="border-l border-tertiary pl-3">
|
|
720
|
+
<a
|
|
721
|
+
href={KEYSTONE_APP_URL}
|
|
722
|
+
target="_blank"
|
|
723
|
+
rel="noopener noreferrer"
|
|
724
|
+
aria-label="Visit Keystone website"
|
|
725
|
+
>
|
|
726
|
+
<KeystoneBrandLockup
|
|
727
|
+
className="gap-2"
|
|
728
|
+
iconClassName="h-3.5"
|
|
729
|
+
wordmarkClassName="hidden sm:block h-2.5"
|
|
730
|
+
/>
|
|
731
|
+
</a>
|
|
721
732
|
</div>
|
|
722
733
|
</div>
|
|
723
734
|
</div>
|
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
|
}
|