@escalated-dev/escalated 0.7.0 → 0.8.0

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.
Files changed (181) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +36 -1
  3. package/package.json +16 -7
  4. package/src/components/ActiveChatsPanel.vue +246 -0
  5. package/src/components/ActivityTimeline.stories.js +55 -55
  6. package/src/components/ActivityTimeline.vue +83 -83
  7. package/src/components/AdminDashboard.stories.js +1423 -1095
  8. package/src/components/AgentLoadIndicator.stories.js +37 -37
  9. package/src/components/AgentLoadIndicator.vue +49 -49
  10. package/src/components/AgentPerformanceCard.vue +44 -44
  11. package/src/components/ArticleEditor.vue +82 -82
  12. package/src/components/AssigneeSelect.vue +37 -37
  13. package/src/components/AttachmentList.vue +13 -1
  14. package/src/components/AuditLogEntry.vue +95 -95
  15. package/src/components/BulkActionBar.vue +166 -166
  16. package/src/components/CategoryTree.vue +89 -89
  17. package/src/components/ChartWidget.vue +46 -46
  18. package/src/components/ChatActionBar.vue +226 -0
  19. package/src/components/ChatBubble.vue +162 -0
  20. package/src/components/ChatComposer.vue +308 -0
  21. package/src/components/ChatQueue.vue +222 -0
  22. package/src/components/ChatThread.vue +197 -0
  23. package/src/components/CollisionWarning.stories.js +30 -30
  24. package/src/components/CollisionWarning.vue +68 -68
  25. package/src/components/ConditionalFieldRules.vue +135 -135
  26. package/src/components/ContextPanel.vue +66 -66
  27. package/src/components/ContextPanelSection.vue +65 -65
  28. package/src/components/CustomFieldRenderer.vue +159 -159
  29. package/src/components/EscalatedLayout.vue +35 -6
  30. package/src/components/FileDropzone.vue +57 -57
  31. package/src/components/FollowButton.stories.js +30 -30
  32. package/src/components/FollowButton.vue +88 -88
  33. package/src/components/KeyboardShortcutHelp.vue +213 -213
  34. package/src/components/KnowledgePanel.vue +122 -122
  35. package/src/components/KpiCard.stories.js +32 -32
  36. package/src/components/KpiCard.vue +88 -88
  37. package/src/components/MacroDropdown.vue +150 -150
  38. package/src/components/MentionDropdown.vue +154 -0
  39. package/src/components/MetricComparison.vue +89 -0
  40. package/src/components/PercentileChart.vue +67 -0
  41. package/src/components/PeriodSelector.vue +98 -0
  42. package/src/components/PermissionMatrix.vue +107 -107
  43. package/src/components/PinnedNotes.vue +129 -129
  44. package/src/components/PluginSlot.vue +68 -62
  45. package/src/components/PresenceIndicator.vue +46 -2
  46. package/src/components/PriorityBadge.stories.js +33 -33
  47. package/src/components/PriorityBadge.vue +46 -46
  48. package/src/components/ReplyComposer.vue +81 -18
  49. package/src/components/ReplyThread.vue +40 -2
  50. package/src/components/ReportChart.vue +395 -0
  51. package/src/components/ReportExportButton.vue +108 -0
  52. package/src/components/SatisfactionRating.vue +180 -180
  53. package/src/components/SaveViewDialog.vue +172 -0
  54. package/src/components/SavedViewSidebar.vue +208 -0
  55. package/src/components/ScheduleEditor.vue +104 -104
  56. package/src/components/SideConversation.vue +165 -165
  57. package/src/components/SideConversationList.vue +147 -147
  58. package/src/components/SkillTagManager.vue +83 -79
  59. package/src/components/SlaComplianceChart.vue +46 -53
  60. package/src/components/SlaTimer.stories.js +35 -35
  61. package/src/components/SlaTimer.vue +62 -62
  62. package/src/components/SnoozeButton.vue +247 -0
  63. package/src/components/StatsCard.stories.js +24 -24
  64. package/src/components/StatsCard.vue +58 -58
  65. package/src/components/StatusBadge.stories.js +53 -53
  66. package/src/components/StatusBadge.vue +78 -78
  67. package/src/components/TagSelect.vue +84 -84
  68. package/src/components/TicketFilters.vue +270 -225
  69. package/src/components/TicketLinkPanel.vue +193 -193
  70. package/src/components/TicketList.vue +655 -594
  71. package/src/components/TicketMergeDialog.vue +157 -157
  72. package/src/components/TicketSplitDialog.vue +98 -0
  73. package/src/components/TotpInput.stories.js +9 -9
  74. package/src/components/TotpInput.vue +81 -81
  75. package/src/components/TwoFactorSetup.vue +113 -113
  76. package/src/components/VariableTokenInput.vue +111 -0
  77. package/src/components/WorkflowActionConfig.vue +287 -0
  78. package/src/components/WorkflowActionList.vue +350 -0
  79. package/src/components/WorkflowConditionBuilder.vue +265 -0
  80. package/src/components/WorkflowTriggerSelector.vue +125 -0
  81. package/src/composables/useChat.js +187 -0
  82. package/src/composables/useI18n.js +30 -8
  83. package/src/composables/useKeyboardShortcuts.js +46 -46
  84. package/src/composables/useMentions.js +134 -0
  85. package/src/composables/usePluginExtensions.js +159 -159
  86. package/src/composables/usePluginHooks.js +65 -65
  87. package/src/composables/useRealtime.js +182 -0
  88. package/src/defineEscalatedPlugin.js +76 -76
  89. package/src/index.js +84 -72
  90. package/src/locales/README.md +13 -0
  91. package/src/locales/ar.json +727 -0
  92. package/src/locales/de.json +678 -430
  93. package/src/locales/en.json +717 -455
  94. package/src/locales/es.json +679 -430
  95. package/src/locales/fr.json +680 -431
  96. package/src/locales/index.js +12 -2
  97. package/src/locales/it.json +727 -0
  98. package/src/locales/ja.json +727 -0
  99. package/src/locales/ko.json +727 -0
  100. package/src/locales/nl.json +727 -0
  101. package/src/locales/pl.json +727 -0
  102. package/src/locales/pt-BR.json +727 -0
  103. package/src/locales/ru.json +727 -0
  104. package/src/locales/tr.json +727 -0
  105. package/src/locales/zh-CN.json +727 -0
  106. package/src/pages/Admin/ApiTokens/Index.vue +381 -381
  107. package/src/pages/Admin/AuditLog/Index.vue +154 -154
  108. package/src/pages/Admin/BusinessHours/Form.vue +172 -172
  109. package/src/pages/Admin/BusinessHours/Index.vue +113 -113
  110. package/src/pages/Admin/CannedResponses/Index.vue +127 -127
  111. package/src/pages/Admin/Capacity/Index.vue +134 -134
  112. package/src/pages/Admin/CustomFields/Form.vue +195 -195
  113. package/src/pages/Admin/CustomFields/Index.vue +134 -134
  114. package/src/pages/Admin/CustomObjects/Form.vue +202 -202
  115. package/src/pages/Admin/CustomObjects/Index.vue +127 -127
  116. package/src/pages/Admin/CustomObjects/Records.vue +228 -228
  117. package/src/pages/Admin/Departments/Form.vue +75 -75
  118. package/src/pages/Admin/Departments/Index.vue +110 -109
  119. package/src/pages/Admin/EscalationRules/Form.vue +157 -157
  120. package/src/pages/Admin/EscalationRules/Index.vue +178 -178
  121. package/src/pages/Admin/Import/Connect.vue +0 -1
  122. package/src/pages/Admin/Import/Index.vue +1 -1
  123. package/src/pages/Admin/Import/Progress.vue +1 -1
  124. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -142
  125. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -195
  126. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -193
  127. package/src/pages/Admin/Macros/Index.vue +448 -448
  128. package/src/pages/Admin/Plugins/Index.vue +327 -327
  129. package/src/pages/Admin/Reports/AgentDetail.vue +132 -0
  130. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -115
  131. package/src/pages/Admin/Reports/AgentRanking.vue +246 -0
  132. package/src/pages/Admin/Reports/Cohorts.vue +180 -0
  133. package/src/pages/Admin/Reports/Comparison.vue +141 -0
  134. package/src/pages/Admin/Reports/CsatReport.vue +127 -127
  135. package/src/pages/Admin/Reports/Dashboard.vue +36 -21
  136. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -0
  137. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -0
  138. package/src/pages/Admin/Reports/SlaReport.vue +162 -166
  139. package/src/pages/Admin/Reports/SlaTrends.vue +183 -0
  140. package/src/pages/Admin/Reports.vue +206 -206
  141. package/src/pages/Admin/Roles/Form.vue +71 -71
  142. package/src/pages/Admin/Roles/Index.vue +122 -122
  143. package/src/pages/Admin/Settings/CsatSettings.vue +165 -165
  144. package/src/pages/Admin/Settings/DataRetention.vue +182 -182
  145. package/src/pages/Admin/Settings/EmailSettings.vue +243 -243
  146. package/src/pages/Admin/Settings/Sandbox.vue +135 -135
  147. package/src/pages/Admin/Settings/SsoSettings.vue +218 -218
  148. package/src/pages/Admin/Settings/TwoFactor.vue +110 -110
  149. package/src/pages/Admin/Settings.vue +1008 -468
  150. package/src/pages/Admin/Skills/Form.vue +193 -53
  151. package/src/pages/Admin/Skills/Index.vue +105 -94
  152. package/src/pages/Admin/SlaPolicies/Form.vue +118 -118
  153. package/src/pages/Admin/SlaPolicies/Index.vue +121 -120
  154. package/src/pages/Admin/Statuses/Form.vue +114 -114
  155. package/src/pages/Admin/Statuses/Index.vue +153 -153
  156. package/src/pages/Admin/Tags/Index.vue +191 -191
  157. package/src/pages/Admin/Tickets/Index.vue +126 -91
  158. package/src/pages/Admin/Tickets/Show.vue +29 -0
  159. package/src/pages/Admin/Users/Index.vue +145 -0
  160. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -120
  161. package/src/pages/Admin/Webhooks/Form.vue +110 -110
  162. package/src/pages/Admin/Webhooks/Index.vue +136 -136
  163. package/src/pages/Admin/Workflows/Builder.vue +207 -0
  164. package/src/pages/Admin/Workflows/Index.vue +235 -0
  165. package/src/pages/Admin/Workflows/Logs.vue +286 -0
  166. package/src/pages/Agent/TicketIndex.vue +112 -88
  167. package/src/pages/Agent/TicketShow.vue +192 -60
  168. package/src/pages/Customer/Create.vue +1 -1
  169. package/src/pages/Customer/Index.vue +26 -1
  170. package/src/pages/Customer/KnowledgeBase/Article.vue +4 -2
  171. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -151
  172. package/src/pages/Customer/Show.vue +69 -63
  173. package/src/pages/Guest/Create.vue +40 -15
  174. package/src/pages/Guest/Show.vue +124 -93
  175. package/src/plugin.js +26 -1
  176. package/src/utils/mentionHighlight.js +24 -0
  177. package/src/utils/sanitizeHtml.js +78 -64
  178. package/src/widget/EscalatedWidget.vue +804 -0
  179. package/src/widget/index.js +254 -0
  180. package/src/pages/Admin/Automations/Form.vue +0 -184
  181. package/src/pages/Admin/Automations/Index.vue +0 -129
@@ -0,0 +1,125 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ modelValue: { type: String, default: '' },
6
+ });
7
+
8
+ const emit = defineEmits(['update:modelValue']);
9
+
10
+ const triggers = [
11
+ {
12
+ value: 'ticket_created',
13
+ label: 'Ticket Created',
14
+ description: 'When a new ticket is submitted',
15
+ icon: 'M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z',
16
+ },
17
+ {
18
+ value: 'ticket_updated',
19
+ label: 'Ticket Updated',
20
+ description: 'When any ticket field is modified',
21
+ icon: 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182',
22
+ },
23
+ {
24
+ value: 'reply_added',
25
+ label: 'Reply Added',
26
+ description: 'When a reply is posted on a ticket',
27
+ icon: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z',
28
+ },
29
+ {
30
+ value: 'status_changed',
31
+ label: 'Status Changed',
32
+ description: 'When the ticket status is updated',
33
+ icon: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
34
+ },
35
+ {
36
+ value: 'ticket_assigned',
37
+ label: 'Ticket Assigned',
38
+ description: 'When a ticket is assigned to an agent',
39
+ icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z',
40
+ },
41
+ {
42
+ value: 'ticket_escalated',
43
+ label: 'Ticket Escalated',
44
+ description: 'When a ticket is escalated',
45
+ icon: 'M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12',
46
+ },
47
+ {
48
+ value: 'sla_breached',
49
+ label: 'SLA Breached',
50
+ description: 'When an SLA target is breached',
51
+ icon: 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z',
52
+ },
53
+ {
54
+ value: 'sla_warning',
55
+ label: 'SLA Warning',
56
+ description: 'When an SLA target is approaching',
57
+ icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
58
+ },
59
+ {
60
+ value: 'chat_started',
61
+ label: 'Chat Started',
62
+ description: 'When a live chat session begins',
63
+ icon: 'M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155',
64
+ },
65
+ {
66
+ value: 'chat_ended',
67
+ label: 'Chat Ended',
68
+ description: 'When a live chat session ends',
69
+ icon: 'M15 11.25l-3-3m0 0l-3 3m3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
70
+ },
71
+ ];
72
+
73
+ const selected = computed(() => props.modelValue);
74
+
75
+ function select(value) {
76
+ emit('update:modelValue', value);
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
82
+ <button
83
+ v-for="trigger in triggers"
84
+ :key="trigger.value"
85
+ type="button"
86
+ class="group relative flex flex-col items-center rounded-xl border-2 p-4 text-center transition-all"
87
+ :class="
88
+ selected === trigger.value
89
+ ? 'border-blue-500 bg-blue-500/10 shadow-lg shadow-blue-500/10'
90
+ : 'border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] hover:border-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)]'
91
+ "
92
+ @click="select(trigger.value)"
93
+ >
94
+ <div
95
+ class="mb-2 flex h-10 w-10 items-center justify-center rounded-lg"
96
+ :class="
97
+ selected === trigger.value
98
+ ? 'bg-blue-500/20 text-blue-400'
99
+ : 'bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-muted)] group-hover:text-[var(--esc-panel-text-secondary)]'
100
+ "
101
+ >
102
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
103
+ <path stroke-linecap="round" stroke-linejoin="round" :d="trigger.icon" />
104
+ </svg>
105
+ </div>
106
+ <span
107
+ class="text-xs font-semibold"
108
+ :class="selected === trigger.value ? 'text-blue-400' : 'text-[var(--esc-panel-text-secondary)]'"
109
+ >
110
+ {{ trigger.label }}
111
+ </span>
112
+ <span class="mt-1 text-[10px] leading-tight text-[var(--esc-panel-text-muted)]">
113
+ {{ trigger.description }}
114
+ </span>
115
+ <div
116
+ v-if="selected === trigger.value"
117
+ class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-blue-500 text-white"
118
+ >
119
+ <svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
120
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
121
+ </svg>
122
+ </div>
123
+ </button>
124
+ </div>
125
+ </template>
@@ -0,0 +1,187 @@
1
+ import { ref, readonly } from 'vue';
2
+ import { useRealtime } from './useRealtime';
3
+
4
+ /**
5
+ * Composable for live chat functionality.
6
+ *
7
+ * Provides methods to start, send messages, end, and rate chat sessions,
8
+ * plus real-time subscription via useRealtime.
9
+ *
10
+ * @param {Object} [options]
11
+ * @param {string} [options.widgetPath] - Path prefix under which the host
12
+ * framework mounts widget routes. Defaults to `/support/widget` which
13
+ * matches every host-adapter plugin (Laravel / Rails / Django / Adonis /
14
+ * WordPress / Filament / Symfony / .NET / Go / Spring / Phoenix). On a
15
+ * NestJS backend, pass `/escalated/widget` (or read from whatever
16
+ * configuration channel the host app exposes to the client).
17
+ */
18
+ export function useChat(options = {}) {
19
+ const widgetPath = options.widgetPath ?? '/support/widget';
20
+ const connectionState = ref('disconnected'); // disconnected | connecting | connected
21
+ const messageBuffer = ref([]);
22
+
23
+ const { echoAvailable, listen } = useRealtime();
24
+
25
+ function csrfToken() {
26
+ return typeof document !== 'undefined' ? document.querySelector('meta[name="csrf-token"]')?.content || '' : '';
27
+ }
28
+
29
+ async function apiRequest(method, path, body = null) {
30
+ const opts = {
31
+ method,
32
+ headers: {
33
+ Accept: 'application/json',
34
+ 'Content-Type': 'application/json',
35
+ 'X-Requested-With': 'XMLHttpRequest',
36
+ 'X-CSRF-TOKEN': csrfToken(),
37
+ },
38
+ };
39
+ if (body) opts.body = JSON.stringify(body);
40
+ const res = await fetch(path, opts);
41
+ const data = await res.json();
42
+ if (!res.ok) throw { status: res.status, data };
43
+ return data;
44
+ }
45
+
46
+ /**
47
+ * Start a new chat session.
48
+ * @param {Object} data - { name, email, department_id, message }
49
+ * @returns {Promise<Object>} The created chat session
50
+ */
51
+ async function startChat(data) {
52
+ return apiRequest('POST', `${widgetPath}/chat/start`, data);
53
+ }
54
+
55
+ /**
56
+ * Send a message in a chat session.
57
+ * @param {string|number} sessionId
58
+ * @param {Object} body - { body, is_internal_note?, attachments? }
59
+ * @returns {Promise<Object>}
60
+ */
61
+ async function sendMessage(sessionId, body) {
62
+ const message = await apiRequest('POST', `${widgetPath}/chat/${sessionId}/messages`, body);
63
+ return message;
64
+ }
65
+
66
+ /**
67
+ * Send typing indicator (debounced by caller).
68
+ * @param {string|number} sessionId
69
+ * @returns {Promise<void>}
70
+ */
71
+ let lastTypingSent = 0;
72
+ async function sendTyping(sessionId) {
73
+ const now = Date.now();
74
+ if (now - lastTypingSent < 3000) return;
75
+ lastTypingSent = now;
76
+ try {
77
+ await apiRequest('POST', `${widgetPath}/chat/${sessionId}/typing`);
78
+ } catch {
79
+ // silently ignore typing failures
80
+ }
81
+ }
82
+
83
+ /**
84
+ * End a chat session.
85
+ * @param {string|number} sessionId
86
+ * @returns {Promise<Object>}
87
+ */
88
+ async function endChat(sessionId) {
89
+ return apiRequest('POST', `${widgetPath}/chat/${sessionId}/end`);
90
+ }
91
+
92
+ /**
93
+ * Rate a completed chat session.
94
+ * @param {string|number} sessionId
95
+ * @param {number} rating - 1-5 star rating
96
+ * @param {string} [comment]
97
+ * @returns {Promise<Object>}
98
+ */
99
+ async function rateChat(sessionId, rating, comment = '') {
100
+ return apiRequest('POST', `${widgetPath}/chat/${sessionId}/rate`, { rating, comment });
101
+ }
102
+
103
+ /**
104
+ * Check chat availability for a department.
105
+ * @param {string|number} [departmentId]
106
+ * @returns {Promise<Object>} { available, message, estimated_wait }
107
+ */
108
+ async function checkAvailability(departmentId = null) {
109
+ const query = departmentId ? `?department_id=${departmentId}` : '';
110
+ return apiRequest('GET', `${widgetPath}/chat/availability${query}`);
111
+ }
112
+
113
+ /**
114
+ * Subscribe to real-time chat events for a session.
115
+ * @param {string|number} sessionId
116
+ * @param {Object} callbacks
117
+ * @param {Function} [callbacks.onMessage]
118
+ * @param {Function} [callbacks.onTyping]
119
+ * @param {Function} [callbacks.onEnded]
120
+ * @param {Function} [callbacks.onAssigned]
121
+ * @returns {Function} Unsubscribe function
122
+ */
123
+ function subscribeToChat(sessionId, callbacks = {}) {
124
+ if (!echoAvailable.value) return () => {};
125
+
126
+ const channelName = `escalated.chat.${sessionId}`;
127
+ const unsubs = [];
128
+
129
+ if (callbacks.onMessage) {
130
+ unsubs.push(listen(channelName, '.chat.message', callbacks.onMessage));
131
+ }
132
+ if (callbacks.onTyping) {
133
+ unsubs.push(listen(channelName, '.chat.typing', callbacks.onTyping));
134
+ }
135
+ if (callbacks.onEnded) {
136
+ unsubs.push(listen(channelName, '.chat.ended', callbacks.onEnded));
137
+ }
138
+ if (callbacks.onAssigned) {
139
+ unsubs.push(listen(channelName, '.chat.assigned', callbacks.onAssigned));
140
+ }
141
+
142
+ connectionState.value = 'connected';
143
+
144
+ return () => {
145
+ unsubs.forEach((fn) => fn());
146
+ connectionState.value = 'disconnected';
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Subscribe to the chat queue channel for new incoming chats.
152
+ * @param {Object} callbacks
153
+ * @param {Function} [callbacks.onNewChat]
154
+ * @param {Function} [callbacks.onChatAccepted]
155
+ * @returns {Function} Unsubscribe function
156
+ */
157
+ function subscribeToChatQueue(callbacks = {}) {
158
+ if (!echoAvailable.value) return () => {};
159
+
160
+ const channelName = 'escalated.chat.queue';
161
+ const unsubs = [];
162
+
163
+ if (callbacks.onNewChat) {
164
+ unsubs.push(listen(channelName, '.chat.new', callbacks.onNewChat));
165
+ }
166
+ if (callbacks.onChatAccepted) {
167
+ unsubs.push(listen(channelName, '.chat.accepted', callbacks.onChatAccepted));
168
+ }
169
+
170
+ return () => {
171
+ unsubs.forEach((fn) => fn());
172
+ };
173
+ }
174
+
175
+ return {
176
+ connectionState: readonly(connectionState),
177
+ messageBuffer: readonly(messageBuffer),
178
+ startChat,
179
+ sendMessage,
180
+ sendTyping,
181
+ endChat,
182
+ rateChat,
183
+ checkAvailability,
184
+ subscribeToChat,
185
+ subscribeToChatQueue,
186
+ };
187
+ }
@@ -1,5 +1,17 @@
1
1
  import { ref, computed } from 'vue';
2
- import locales from '../locales/index.js';
2
+ import overrideLocales from '../locales/index.js';
3
+
4
+ // Central translations published from escalated-dev/escalated-locale.
5
+ // This is the BASE layer. Local src/locales/*.json files in this repo are
6
+ // loaded as OVERRIDES and win over the central source for any matching key.
7
+ //
8
+ // Until @escalated-dev/locale v0.1.0 ships its consolidated strings, this
9
+ // import resolves to a stub object with the same shape. Local overrides
10
+ // (src/locales/) preserve the current behavior so components keep rendering
11
+ // the strings they always rendered. After Codex finishes consolidating
12
+ // translations into the central package, the local override files can be
13
+ // trimmed (or deleted entirely) and the central source becomes authoritative.
14
+ import centralLocales from '@escalated-dev/locale';
3
15
 
4
16
  const currentLocale = ref('en');
5
17
  const customMessages = ref({});
@@ -55,21 +67,31 @@ export function useI18n() {
55
67
  function t(key, replacements) {
56
68
  const lang = currentLocale.value;
57
69
 
58
- // Try custom messages for current locale
70
+ // 1. Host-app supplied messages for the current locale (highest priority).
59
71
  const custom = resolve(customMessages.value[lang], key);
60
72
  if (typeof custom === 'string') return applyReplacements(custom, replacements);
61
73
 
62
- // Try built-in messages for current locale
63
- const msg = resolve(locales[lang], key);
64
- if (typeof msg === 'string') return applyReplacements(msg, replacements);
74
+ // 2. Local plugin overrides for the current locale (src/locales/*.json).
75
+ // These exist to let this repo win over the central source for any
76
+ // string the plugin needs to phrase differently.
77
+ const override = resolve(overrideLocales[lang], key);
78
+ if (typeof override === 'string') return applyReplacements(override, replacements);
79
+
80
+ // 3. Central source for the current locale (@escalated-dev/locale).
81
+ // This is the canonical base layer.
82
+ const central = resolve(centralLocales?.[lang], key);
83
+ if (typeof central === 'string') return applyReplacements(central, replacements);
65
84
 
66
- // Fallback to English
85
+ // 4. English fallbacks, in the same priority order.
67
86
  if (lang !== 'en') {
68
87
  const customEn = resolve(customMessages.value.en, key);
69
88
  if (typeof customEn === 'string') return applyReplacements(customEn, replacements);
70
89
 
71
- const fallback = resolve(locales.en, key);
72
- if (typeof fallback === 'string') return applyReplacements(fallback, replacements);
90
+ const overrideEn = resolve(overrideLocales.en, key);
91
+ if (typeof overrideEn === 'string') return applyReplacements(overrideEn, replacements);
92
+
93
+ const centralEn = resolve(centralLocales?.en, key);
94
+ if (typeof centralEn === 'string') return applyReplacements(centralEn, replacements);
73
95
  }
74
96
 
75
97
  // Return raw key
@@ -1,46 +1,46 @@
1
- import { onMounted, onUnmounted, unref } from 'vue';
2
-
3
- /**
4
- * Composable for registering keyboard shortcuts.
5
- *
6
- * @param {Object} shortcuts - Map of key to handler function, e.g. { 'r': () => {}, 'n': () => {} }
7
- * @param {Object} options - Optional configuration
8
- * @param {import('vue').Ref<boolean>} options.enabled - Ref controlling whether shortcuts are active (default: true)
9
- */
10
- export function useKeyboardShortcuts(shortcuts, options = {}) {
11
- const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
12
-
13
- function handleKeydown(event) {
14
- // Check if shortcuts are enabled
15
- if (options.enabled !== undefined && !unref(options.enabled)) {
16
- return;
17
- }
18
-
19
- // Skip when focus is inside form elements or contenteditable
20
- const target = event.target;
21
- if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
22
- return;
23
- }
24
-
25
- // Skip if modifier keys are held (allow shift for ? and similar)
26
- if (event.ctrlKey || event.metaKey || event.altKey) {
27
- return;
28
- }
29
-
30
- const key = event.key;
31
- const handler = shortcuts[key];
32
-
33
- if (handler && typeof handler === 'function') {
34
- event.preventDefault();
35
- handler(event);
36
- }
37
- }
38
-
39
- onMounted(() => {
40
- document.addEventListener('keydown', handleKeydown);
41
- });
42
-
43
- onUnmounted(() => {
44
- document.removeEventListener('keydown', handleKeydown);
45
- });
46
- }
1
+ import { onMounted, onUnmounted, unref } from 'vue';
2
+
3
+ /**
4
+ * Composable for registering keyboard shortcuts.
5
+ *
6
+ * @param {Object} shortcuts - Map of key to handler function, e.g. { 'r': () => {}, 'n': () => {} }
7
+ * @param {Object} options - Optional configuration
8
+ * @param {import('vue').Ref<boolean>} options.enabled - Ref controlling whether shortcuts are active (default: true)
9
+ */
10
+ export function useKeyboardShortcuts(shortcuts, options = {}) {
11
+ const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
12
+
13
+ function handleKeydown(event) {
14
+ // Check if shortcuts are enabled
15
+ if (options.enabled !== undefined && !unref(options.enabled)) {
16
+ return;
17
+ }
18
+
19
+ // Skip when focus is inside form elements or contenteditable
20
+ const target = event.target;
21
+ if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
22
+ return;
23
+ }
24
+
25
+ // Skip if modifier keys are held (allow shift for ? and similar)
26
+ if (event.ctrlKey || event.metaKey || event.altKey) {
27
+ return;
28
+ }
29
+
30
+ const key = event.key;
31
+ const handler = shortcuts[key];
32
+
33
+ if (handler && typeof handler === 'function') {
34
+ event.preventDefault();
35
+ handler(event);
36
+ }
37
+ }
38
+
39
+ onMounted(() => {
40
+ document.addEventListener('keydown', handleKeydown);
41
+ });
42
+
43
+ onUnmounted(() => {
44
+ document.removeEventListener('keydown', handleKeydown);
45
+ });
46
+ }
@@ -0,0 +1,134 @@
1
+ import { ref, onUnmounted } from 'vue';
2
+
3
+ /**
4
+ * Composable that detects `@` typed in a text input/textarea,
5
+ * fetches agent suggestions from the API (debounced), and provides
6
+ * an insert function to replace the mention token.
7
+ *
8
+ * @param {Object} options
9
+ * @param {string} [options.searchEndpoint] - API endpoint for agent search (default: route-based)
10
+ * @param {number} [options.debounce=250] - Debounce delay in ms
11
+ * @returns {Object}
12
+ */
13
+ export function useMentions(options = {}) {
14
+ const { debounce = 250 } = options;
15
+
16
+ const suggestions = ref([]);
17
+ const isActive = ref(false);
18
+ const query = ref('');
19
+ const mentionStart = ref(null); // cursor position where `@` was typed
20
+ const loading = ref(false);
21
+
22
+ let timer = null;
23
+
24
+ function clearTimer() {
25
+ if (timer !== null) {
26
+ window.clearTimeout(timer);
27
+ timer = null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Call this on every input/keyup event with the textarea element
33
+ * and the current text value.
34
+ */
35
+ function onTextChange(textareaEl, text) {
36
+ if (!textareaEl) return;
37
+
38
+ const cursorPos = textareaEl.selectionStart;
39
+ const textBeforeCursor = text.slice(0, cursorPos);
40
+
41
+ // Find the last `@` that starts a mention (preceded by whitespace or start of string)
42
+ const mentionMatch = textBeforeCursor.match(/(?:^|[\s(])@([a-zA-Z0-9_ ]*)$/);
43
+
44
+ if (mentionMatch) {
45
+ const searchQuery = mentionMatch[1];
46
+ // The @ is at cursorPos minus the matched group minus 1 for the @
47
+ mentionStart.value = cursorPos - searchQuery.length - 1;
48
+ query.value = searchQuery;
49
+ isActive.value = true;
50
+ debouncedFetch(searchQuery);
51
+ } else {
52
+ close();
53
+ }
54
+ }
55
+
56
+ function debouncedFetch(searchQuery) {
57
+ clearTimer();
58
+
59
+ if (searchQuery.length < 1) {
60
+ fetchSuggestions('');
61
+ return;
62
+ }
63
+
64
+ timer = window.setTimeout(() => {
65
+ fetchSuggestions(searchQuery);
66
+ }, debounce);
67
+ }
68
+
69
+ async function fetchSuggestions(searchQuery) {
70
+ loading.value = true;
71
+ try {
72
+ const endpoint = options.searchEndpoint || '/support/admin/agents/search';
73
+ const url = `${endpoint}?q=${encodeURIComponent(searchQuery)}`;
74
+ const response = await fetch(url, {
75
+ headers: {
76
+ Accept: 'application/json',
77
+ 'X-Requested-With': 'XMLHttpRequest',
78
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
79
+ },
80
+ });
81
+ if (response.ok) {
82
+ suggestions.value = await response.json();
83
+ } else {
84
+ suggestions.value = [];
85
+ }
86
+ } catch {
87
+ suggestions.value = [];
88
+ } finally {
89
+ loading.value = false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Insert the selected agent mention into the text.
95
+ * Returns the new text value with the mention inserted.
96
+ *
97
+ * @param {string} currentText - Current textarea value
98
+ * @param {Object} agent - Agent object with `name` property
99
+ * @returns {string} Updated text
100
+ */
101
+ function insertMention(currentText, agent) {
102
+ if (mentionStart.value === null) return currentText;
103
+
104
+ const before = currentText.slice(0, mentionStart.value);
105
+ const cursorPos = mentionStart.value + 1 + query.value.length;
106
+ const after = currentText.slice(cursorPos);
107
+
108
+ const mentionText = `@{${agent.name}} `;
109
+ const newText = before + mentionText + after;
110
+
111
+ close();
112
+ return newText;
113
+ }
114
+
115
+ function close() {
116
+ isActive.value = false;
117
+ suggestions.value = [];
118
+ query.value = '';
119
+ mentionStart.value = null;
120
+ clearTimer();
121
+ }
122
+
123
+ onUnmounted(clearTimer);
124
+
125
+ return {
126
+ suggestions,
127
+ isActive,
128
+ query,
129
+ loading,
130
+ onTextChange,
131
+ insertMention,
132
+ close,
133
+ };
134
+ }