Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to `@escalated-dev/escalated` will be documented in this file.

## [Unreleased]

### Fixed
- Widget's API endpoint path is now configurable via `data-widget-path` (on the script tag) / `widgetPath` option (on `createEscalated`). Default stays `/support/widget` for backward compatibility. Unblocks NestJS hosts where the base path isn't `/support`.
- `useChat()` threads the resolved `widgetPath` through all six chat API endpoints; Agent `TicketShow`, `ActiveChatsPanel`, `ChatQueue` read `page.props.escalated?.prefix` to build the right path on the agent side.

## [0.7.0] - 2026-04-05

### Added
Expand Down
10 changes: 8 additions & 2 deletions src/components/ActiveChatsPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, inject, onMounted, onUnmounted } from 'vue';
import { usePage } from '@inertiajs/vue3';
import { useI18n } from '../composables/useI18n';
import { useChat } from '../composables/useChat';
import { timeAgo } from '../utils/formatting';
Expand Down Expand Up @@ -88,8 +89,13 @@ function chatInitials(chat) {
.slice(0, 2);
}

// Real-time: listen for new chats assigned to this agent
const { subscribeToChatQueue } = useChat();
// Real-time: listen for new chats assigned to this agent. Read the
// host framework's route prefix from Inertia page props (default
// 'support') so chat API calls resolve correctly on NestJS backends
// that use '/escalated/widget' instead of '/support/widget'.
const page = usePage();
const routePrefix = page.props.escalated?.prefix || 'support';
const { subscribeToChatQueue } = useChat({ widgetPath: `/${routePrefix}/widget` });

onMounted(() => {
subscribeToChatQueue({
Expand Down
10 changes: 8 additions & 2 deletions src/components/ChatQueue.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, inject, onMounted, onUnmounted } from 'vue';
import { usePage } from '@inertiajs/vue3';
import { useI18n } from '../composables/useI18n';
import { useChat } from '../composables/useChat';

Expand Down Expand Up @@ -66,8 +67,13 @@ async function acceptChat(session) {
}
}

// Subscribe to queue for real-time updates
const { subscribeToChatQueue } = useChat();
// Subscribe to queue for real-time updates. Read the host framework's
// route prefix from Inertia page props so chat API calls resolve
// correctly on NestJS backends ('/escalated/widget') as well as every
// other framework ('/support/widget').
const page = usePage();
const routePrefix = page.props.escalated?.prefix || 'support';
const { subscribeToChatQueue } = useChat({ widgetPath: `/${routePrefix}/widget` });

onMounted(() => {
subscribeToChatQueue({
Expand Down
23 changes: 16 additions & 7 deletions src/composables/useChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ import { useRealtime } from './useRealtime';
*
* Provides methods to start, send messages, end, and rate chat sessions,
* plus real-time subscription via useRealtime.
*
* @param {Object} [options]
* @param {string} [options.widgetPath] - Path prefix under which the host
* framework mounts widget routes. Defaults to `/support/widget` which
* matches every host-adapter plugin (Laravel / Rails / Django / Adonis /
* WordPress / Filament / Symfony / .NET / Go / Spring / Phoenix). On a
* NestJS backend, pass `/escalated/widget` (or read from whatever
* configuration channel the host app exposes to the client).
*/
export function useChat() {
export function useChat(options = {}) {
const widgetPath = options.widgetPath ?? '/support/widget';
const connectionState = ref('disconnected'); // disconnected | connecting | connected
const messageBuffer = ref([]);

Expand Down Expand Up @@ -40,7 +49,7 @@ export function useChat() {
* @returns {Promise<Object>} The created chat session
*/
async function startChat(data) {
return apiRequest('POST', '/support/widget/chat/start', data);
return apiRequest('POST', `${widgetPath}/chat/start`, data);
}

/**
Expand All @@ -50,7 +59,7 @@ export function useChat() {
* @returns {Promise<Object>}
*/
async function sendMessage(sessionId, body) {
const message = await apiRequest('POST', `/support/widget/chat/${sessionId}/messages`, body);
const message = await apiRequest('POST', `${widgetPath}/chat/${sessionId}/messages`, body);
return message;
}

Expand All @@ -65,7 +74,7 @@ export function useChat() {
if (now - lastTypingSent < 3000) return;
lastTypingSent = now;
try {
await apiRequest('POST', `/support/widget/chat/${sessionId}/typing`);
await apiRequest('POST', `${widgetPath}/chat/${sessionId}/typing`);
} catch {
// silently ignore typing failures
}
Expand All @@ -77,7 +86,7 @@ export function useChat() {
* @returns {Promise<Object>}
*/
async function endChat(sessionId) {
return apiRequest('POST', `/support/widget/chat/${sessionId}/end`);
return apiRequest('POST', `${widgetPath}/chat/${sessionId}/end`);
}

/**
Expand All @@ -88,7 +97,7 @@ export function useChat() {
* @returns {Promise<Object>}
*/
async function rateChat(sessionId, rating, comment = '') {
return apiRequest('POST', `/support/widget/chat/${sessionId}/rate`, { rating, comment });
return apiRequest('POST', `${widgetPath}/chat/${sessionId}/rate`, { rating, comment });
}

/**
Expand All @@ -98,7 +107,7 @@ export function useChat() {
*/
async function checkAvailability(departmentId = null) {
const query = departmentId ? `?department_id=${departmentId}` : '';
return apiRequest('GET', `/support/widget/chat/availability${query}`);
return apiRequest('GET', `${widgetPath}/chat/availability${query}`);
}

/**
Expand Down
14 changes: 10 additions & 4 deletions src/pages/Agent/TicketShow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const props = defineProps({

const page = usePage();
const isLightAgent = computed(() => page.props.escalated?.agent_type === 'light');
// Route prefix the host framework mounts Escalated under. Defaults to
// `support`, matching every host-adapter plugin. NestJS uses `escalated`.
const routePrefix = computed(() => page.props.escalated?.prefix || 'support');
const widgetPath = computed(() => `/${routePrefix.value}/widget`);
const activeTab = ref(isLightAgent.value ? 'note' : 'reply');
const showShortcutHelp = ref(false);
const replyComposerRef = ref(null);
Expand Down Expand Up @@ -78,8 +82,10 @@ const isChatMode = computed(() => props.ticket.channel === 'chat' && props.ticke
const chatMessages = ref(props.ticket.chat_messages || []);
const chatTypingUser = ref(null);

// Subscribe to chat channel for real-time messages
const { subscribeToChat } = useChat();
// Subscribe to chat channel for real-time messages. Pass widgetPath so
// useChat's API calls resolve against whatever prefix the host framework
// serves Escalated under (config on NestJS, default /support elsewhere).
const { subscribeToChat } = useChat({ widgetPath: widgetPath.value });
let chatTypingTimer = null;

onMounted(() => {
Expand Down Expand Up @@ -324,10 +330,10 @@ onMounted(() => {
<ChatComposer
:session-id="ticket.chat_session_id"
:send-endpoint="
ticket.chat_session_id ? `/support/widget/chat/${ticket.chat_session_id}/messages` : ''
ticket.chat_session_id ? `${widgetPath}/chat/${ticket.chat_session_id}/messages` : ''
"
:typing-endpoint="
ticket.chat_session_id ? `/support/widget/chat/${ticket.chat_session_id}/typing` : ''
ticket.chat_session_id ? `${widgetPath}/chat/${ticket.chat_session_id}/typing` : ''
"
allow-notes
@send="onChatSend"
Expand Down
9 changes: 8 additions & 1 deletion src/widget/EscalatedWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { sanitizeHtml } from '../utils/sanitizeHtml';

const props = defineProps({
baseUrl: { type: String, default: '' },
// Path prefix the host framework mounts widget routes under. Defaults
// to /support/widget which matches every host-adapter plugin
// (Laravel / Rails / Django / Adonis / WordPress / Filament /
// Symfony / .NET / Go / Spring / Phoenix). The NestJS reference
// mounts at /escalated/widget, so embedders on a NestJS backend
// should override this with data-widget-path="/escalated/widget".
widgetPath: { type: String, default: '/support/widget' },
initialColor: { type: String, default: '#4F46E5' },
initialPosition: { type: String, default: 'bottom-right' },
});
Expand Down Expand Up @@ -64,7 +71,7 @@ const statusResult = ref(null);
let searchTimer = null;

async function api(method, apiPath, body = null) {
const url = `${props.baseUrl}/support/widget${apiPath}`;
const url = `${props.baseUrl}${props.widgetPath}${apiPath}`;
const opts = {
method,
headers: {
Expand Down
5 changes: 5 additions & 0 deletions src/widget/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import EscalatedWidget from './EscalatedWidget.vue';

const config = {
baseUrl: scriptTag?.getAttribute('data-base-url') || globalConfig.baseUrl || '',
// Host-framework-specific path prefix. Every plugin except the NestJS
// reference mounts at /support/widget; NestJS mounts at
// /escalated/widget. Set via data-widget-path on NestJS backends.
widgetPath: scriptTag?.getAttribute('data-widget-path') || globalConfig.widgetPath || '/support/widget',
color: scriptTag?.getAttribute('data-color') || globalConfig.color || '#4F46E5',
position: scriptTag?.getAttribute('data-position') || globalConfig.position || 'bottom-right',
};
Expand All @@ -29,6 +33,7 @@ import EscalatedWidget from './EscalatedWidget.vue';
render() {
return h(EscalatedWidget, {
baseUrl: config.baseUrl,
widgetPath: config.widgetPath,
initialColor: config.color,
initialPosition: config.position,
});
Expand Down
4 changes: 4 additions & 0 deletions tests/components/ActiveChatsPanel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ vi.mock('../../src/composables/useChat', () => ({
}),
}));

vi.mock('@inertiajs/vue3', () => ({
usePage: () => ({ props: { escalated: { prefix: 'support' } } }),
}));

function mountPanel(props = {}, dark = false) {
return mount(ActiveChatsPanel, {
props: {
Expand Down
Loading