@escalated-dev/escalated 0.6.0 → 0.7.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 (130) hide show
  1. package/README.md +94 -2
  2. package/package.json +31 -2
  3. package/src/components/ActivityTimeline.stories.js +55 -0
  4. package/src/components/ActivityTimeline.vue +24 -7
  5. package/src/components/AdminDashboard.stories.js +1095 -0
  6. package/src/components/AgentLoadIndicator.stories.js +37 -0
  7. package/src/components/AgentLoadIndicator.vue +49 -0
  8. package/src/components/AgentPerformanceCard.vue +44 -0
  9. package/src/components/ArticleEditor.vue +82 -0
  10. package/src/components/AssigneeSelect.vue +19 -5
  11. package/src/components/AttachmentList.vue +53 -37
  12. package/src/components/AuditLogEntry.vue +95 -0
  13. package/src/components/BulkActionBar.vue +99 -30
  14. package/src/components/CategoryTree.vue +89 -0
  15. package/src/components/ChartWidget.vue +46 -0
  16. package/src/components/CollisionWarning.stories.js +30 -0
  17. package/src/components/CollisionWarning.vue +68 -0
  18. package/src/components/ConditionalFieldRules.vue +135 -0
  19. package/src/components/ContextPanel.vue +66 -0
  20. package/src/components/ContextPanelSection.vue +65 -0
  21. package/src/components/CustomFieldRenderer.vue +159 -0
  22. package/src/components/EscalatedLayout.vue +400 -69
  23. package/src/components/FileDropzone.vue +26 -10
  24. package/src/components/FollowButton.stories.js +30 -0
  25. package/src/components/FollowButton.vue +53 -28
  26. package/src/components/KeyboardShortcutHelp.vue +129 -39
  27. package/src/components/KnowledgePanel.vue +122 -0
  28. package/src/components/KpiCard.stories.js +32 -0
  29. package/src/components/KpiCard.vue +88 -0
  30. package/src/components/MacroDropdown.vue +95 -35
  31. package/src/components/PermissionMatrix.vue +107 -0
  32. package/src/components/PinnedNotes.vue +70 -31
  33. package/src/components/PresenceIndicator.vue +93 -27
  34. package/src/components/PriorityBadge.stories.js +33 -0
  35. package/src/components/PriorityBadge.vue +9 -2
  36. package/src/components/QuickFilters.vue +65 -49
  37. package/src/components/ReplyComposer.vue +144 -30
  38. package/src/components/ReplyThread.vue +72 -20
  39. package/src/components/SatisfactionRating.vue +98 -46
  40. package/src/components/ScheduleEditor.vue +104 -0
  41. package/src/components/SideConversation.vue +165 -0
  42. package/src/components/SideConversationList.vue +147 -0
  43. package/src/components/SkillTagManager.vue +79 -0
  44. package/src/components/SlaComplianceChart.vue +53 -0
  45. package/src/components/SlaTimer.stories.js +35 -0
  46. package/src/components/SlaTimer.vue +17 -4
  47. package/src/components/StatsCard.stories.js +24 -0
  48. package/src/components/StatsCard.vue +58 -46
  49. package/src/components/StatusBadge.stories.js +53 -0
  50. package/src/components/StatusBadge.vue +36 -4
  51. package/src/components/TagSelect.vue +43 -12
  52. package/src/components/TicketFilters.vue +225 -69
  53. package/src/components/TicketLinkPanel.vue +193 -0
  54. package/src/components/TicketList.vue +594 -193
  55. package/src/components/TicketMergeDialog.vue +157 -0
  56. package/src/components/TicketSidebar.vue +167 -99
  57. package/src/components/TicketTypeSelector.vue +21 -0
  58. package/src/components/TotpInput.stories.js +9 -0
  59. package/src/components/TotpInput.vue +81 -0
  60. package/src/components/TwoFactorSetup.vue +113 -0
  61. package/src/composables/useDebouncedSearch.js +38 -0
  62. package/src/composables/usePluginExtensions.js +95 -3
  63. package/src/composables/usePluginHooks.js +65 -0
  64. package/src/defineEscalatedPlugin.js +34 -8
  65. package/src/index.js +72 -35
  66. package/src/locales/en.json +25 -0
  67. package/src/pages/Admin/ApiTokens/Index.vue +231 -67
  68. package/src/pages/Admin/AuditLog/Index.vue +154 -0
  69. package/src/pages/Admin/Automations/Form.vue +184 -0
  70. package/src/pages/Admin/Automations/Index.vue +129 -0
  71. package/src/pages/Admin/BusinessHours/Form.vue +172 -0
  72. package/src/pages/Admin/BusinessHours/Index.vue +113 -0
  73. package/src/pages/Admin/CannedResponses/Index.vue +127 -70
  74. package/src/pages/Admin/Capacity/Index.vue +134 -0
  75. package/src/pages/Admin/CustomFields/Form.vue +195 -0
  76. package/src/pages/Admin/CustomFields/Index.vue +134 -0
  77. package/src/pages/Admin/CustomObjects/Form.vue +202 -0
  78. package/src/pages/Admin/CustomObjects/Index.vue +127 -0
  79. package/src/pages/Admin/CustomObjects/Records.vue +228 -0
  80. package/src/pages/Admin/Departments/Form.vue +75 -51
  81. package/src/pages/Admin/Departments/Index.vue +109 -57
  82. package/src/pages/Admin/EscalationRules/Form.vue +157 -96
  83. package/src/pages/Admin/EscalationRules/Index.vue +178 -57
  84. package/src/pages/Admin/Import/Connect.vue +96 -0
  85. package/src/pages/Admin/Import/Index.vue +102 -0
  86. package/src/pages/Admin/Import/Mapping.vue +110 -0
  87. package/src/pages/Admin/Import/Progress.vue +153 -0
  88. package/src/pages/Admin/Import/Review.vue +76 -0
  89. package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
  90. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
  91. package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
  92. package/src/pages/Admin/Macros/Index.vue +250 -89
  93. package/src/pages/Admin/Plugins/Index.vue +205 -75
  94. package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
  95. package/src/pages/Admin/Reports/CsatReport.vue +127 -0
  96. package/src/pages/Admin/Reports/Dashboard.vue +211 -0
  97. package/src/pages/Admin/Reports/SlaReport.vue +166 -0
  98. package/src/pages/Admin/Reports.vue +139 -34
  99. package/src/pages/Admin/Roles/Form.vue +71 -0
  100. package/src/pages/Admin/Roles/Index.vue +122 -0
  101. package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
  102. package/src/pages/Admin/Settings/DataRetention.vue +182 -0
  103. package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
  104. package/src/pages/Admin/Settings/Sandbox.vue +135 -0
  105. package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
  106. package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
  107. package/src/pages/Admin/Settings.vue +468 -260
  108. package/src/pages/Admin/Skills/Form.vue +53 -0
  109. package/src/pages/Admin/Skills/Index.vue +94 -0
  110. package/src/pages/Admin/SlaPolicies/Form.vue +118 -75
  111. package/src/pages/Admin/SlaPolicies/Index.vue +120 -59
  112. package/src/pages/Admin/Statuses/Form.vue +114 -0
  113. package/src/pages/Admin/Statuses/Index.vue +153 -0
  114. package/src/pages/Admin/Tags/Index.vue +191 -105
  115. package/src/pages/Admin/Tickets/Index.vue +91 -39
  116. package/src/pages/Admin/Tickets/Show.vue +101 -37
  117. package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
  118. package/src/pages/Admin/Webhooks/Form.vue +110 -0
  119. package/src/pages/Admin/Webhooks/Index.vue +136 -0
  120. package/src/pages/Agent/Dashboard.vue +311 -186
  121. package/src/pages/Agent/TicketIndex.vue +88 -38
  122. package/src/pages/Agent/TicketShow.vue +258 -51
  123. package/src/pages/Customer/Create.vue +36 -9
  124. package/src/pages/Customer/Index.vue +31 -24
  125. package/src/pages/Customer/KnowledgeBase/Article.vue +127 -0
  126. package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
  127. package/src/plugin.js +86 -1
  128. package/src/utils/constants.js +30 -0
  129. package/src/utils/formatting.js +35 -0
  130. package/src/pages/Admin/Plugins.vue +0 -129
@@ -4,9 +4,16 @@ import { ref, computed, inject, onMounted, onUnmounted } from 'vue';
4
4
  const props = defineProps({
5
5
  ticketReference: { type: String, required: true },
6
6
  routePrefix: { type: String, required: true },
7
+ pollInterval: { type: Number, default: 30000 },
8
+ showLabel: { type: Boolean, default: false },
7
9
  });
8
10
 
9
- const escDark = inject('esc-dark', computed(() => false));
11
+ const effectiveInterval = computed(() => Math.max(props.pollInterval, 5000));
12
+
13
+ const escDark = inject(
14
+ 'esc-dark',
15
+ computed(() => false),
16
+ );
10
17
  const viewers = ref([]);
11
18
  const hovering = ref(false);
12
19
  let intervalId = null;
@@ -16,7 +23,7 @@ async function fetchPresence() {
16
23
  const response = await fetch(route(`${props.routePrefix}.tickets.presence`, props.ticketReference), {
17
24
  method: 'POST',
18
25
  headers: {
19
- 'Accept': 'application/json',
26
+ Accept: 'application/json',
20
27
  'Content-Type': 'application/json',
21
28
  'X-Requested-With': 'XMLHttpRequest',
22
29
  'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
@@ -33,7 +40,7 @@ async function fetchPresence() {
33
40
 
34
41
  const tooltipText = computed(() => {
35
42
  if (!viewers.value.length) return '';
36
- const names = viewers.value.map(v => v.name);
43
+ const names = viewers.value.map((v) => v.name);
37
44
  return 'Also viewing: ' + names.join(', ');
38
45
  });
39
46
 
@@ -46,8 +53,14 @@ function getInitial(viewer) {
46
53
  }
47
54
 
48
55
  const colors = [
49
- 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500', 'bg-emerald-500',
50
- 'bg-rose-500', 'bg-blue-500', 'bg-pink-500', 'bg-teal-500',
56
+ 'bg-cyan-500',
57
+ 'bg-violet-500',
58
+ 'bg-amber-500',
59
+ 'bg-emerald-500',
60
+ 'bg-rose-500',
61
+ 'bg-blue-500',
62
+ 'bg-pink-500',
63
+ 'bg-teal-500',
51
64
  ];
52
65
 
53
66
  function getColor(index) {
@@ -56,7 +69,7 @@ function getColor(index) {
56
69
 
57
70
  onMounted(() => {
58
71
  fetchPresence();
59
- intervalId = setInterval(fetchPresence, 30000);
72
+ intervalId = setInterval(fetchPresence, effectiveInterval.value);
60
73
  });
61
74
 
62
75
  onUnmounted(() => {
@@ -68,35 +81,88 @@ onUnmounted(() => {
68
81
  </script>
69
82
 
70
83
  <template>
71
- <div v-if="viewers.length > 0" class="relative" @mouseenter="hovering = true" @mouseleave="hovering = false">
84
+ <div
85
+ v-if="viewers.length > 0"
86
+ class="relative flex items-center"
87
+ @mouseenter="hovering = true"
88
+ @mouseleave="hovering = false"
89
+ >
72
90
  <!-- Avatar stack -->
73
- <div class="flex -space-x-2">
74
- <div v-for="(viewer, index) in visibleViewers" :key="viewer.id || index"
75
- :class="['flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white ring-2',
76
- getColor(index),
77
- escDark ? 'ring-neutral-900' : 'ring-white']">
91
+ <TransitionGroup
92
+ tag="div"
93
+ class="flex -space-x-2"
94
+ enter-active-class="transition-all duration-300 ease-out"
95
+ enter-from-class="opacity-0 scale-75 -ml-7"
96
+ enter-to-class="opacity-100 scale-100 ml-0"
97
+ leave-active-class="transition-all duration-200 ease-in"
98
+ leave-from-class="opacity-100 scale-100"
99
+ leave-to-class="opacity-0 scale-75 -ml-7"
100
+ >
101
+ <div
102
+ v-for="(viewer, index) in visibleViewers"
103
+ :key="viewer.id || viewer.name"
104
+ :aria-label="viewer.name"
105
+ :class="[
106
+ 'relative flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white ring-2',
107
+ getColor(index),
108
+ escDark ? 'ring-neutral-900' : 'ring-white',
109
+ ]"
110
+ >
78
111
  {{ getInitial(viewer) }}
112
+ <!-- Pulsing live indicator on first avatar -->
113
+ <span v-if="index === 0" class="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
114
+ <span
115
+ class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"
116
+ ></span>
117
+ <span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500"></span>
118
+ </span>
79
119
  </div>
80
- <div v-if="overflowCount > 0"
81
- :class="['flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2',
82
- escDark
83
- ? 'bg-white/[0.08] text-neutral-300 ring-neutral-900'
84
- : 'bg-gray-200 text-gray-600 ring-white']">
120
+ <div
121
+ v-if="overflowCount > 0"
122
+ :key="'overflow'"
123
+ :class="[
124
+ 'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2',
125
+ escDark
126
+ ? 'bg-white/[0.08] text-neutral-300 ring-neutral-900'
127
+ : 'bg-gray-200 text-gray-600 ring-white',
128
+ ]"
129
+ >
85
130
  +{{ overflowCount }}
86
131
  </div>
87
- </div>
132
+ </TransitionGroup>
133
+
134
+ <!-- Viewer count label -->
135
+ <span
136
+ v-if="showLabel"
137
+ aria-live="polite"
138
+ :class="['ml-2 text-xs font-medium', escDark ? 'text-neutral-400' : 'text-gray-500']"
139
+ >
140
+ {{ viewers.length }} viewing
141
+ </span>
88
142
 
89
143
  <!-- Tooltip -->
90
- <Transition enter-active-class="transition duration-100 ease-out" enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
91
- leave-active-class="transition duration-75 ease-in" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-1">
92
- <div v-if="hovering"
93
- :class="['absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium shadow-lg',
94
- escDark
95
- ? 'border border-white/[0.06] bg-neutral-800 text-neutral-200'
96
- : 'bg-gray-900 text-white']">
144
+ <Transition
145
+ enter-active-class="transition duration-100 ease-out"
146
+ enter-from-class="opacity-0 translate-y-1"
147
+ enter-to-class="opacity-100 translate-y-0"
148
+ leave-active-class="transition duration-75 ease-in"
149
+ leave-from-class="opacity-100 translate-y-0"
150
+ leave-to-class="opacity-0 translate-y-1"
151
+ >
152
+ <div
153
+ v-if="hovering"
154
+ :class="[
155
+ 'absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium shadow-lg',
156
+ escDark ? 'border border-white/[0.06] bg-neutral-800 text-neutral-200' : 'bg-gray-900 text-white',
157
+ ]"
158
+ >
97
159
  {{ tooltipText }}
98
- <div :class="['absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45',
99
- escDark ? 'bg-neutral-800' : 'bg-gray-900']"></div>
160
+ <div
161
+ :class="[
162
+ 'absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45',
163
+ escDark ? 'bg-neutral-800' : 'bg-gray-900',
164
+ ]"
165
+ ></div>
100
166
  </div>
101
167
  </Transition>
102
168
  </div>
@@ -0,0 +1,33 @@
1
+ import PriorityBadge from './PriorityBadge.vue';
2
+
3
+ export default {
4
+ title: 'Components/PriorityBadge',
5
+ component: PriorityBadge,
6
+ argTypes: {
7
+ priority: {
8
+ control: 'select',
9
+ options: ['low', 'medium', 'high', 'urgent', 'critical'],
10
+ },
11
+ },
12
+ };
13
+
14
+ export const Low = { args: { priority: 'low' } };
15
+ export const Medium = { args: { priority: 'medium' } };
16
+ export const High = { args: { priority: 'high' } };
17
+ export const Urgent = { args: { priority: 'urgent' } };
18
+ export const Critical = { args: { priority: 'critical' } };
19
+
20
+ export const AllPriorities = {
21
+ render: () => ({
22
+ components: { PriorityBadge },
23
+ template: `
24
+ <div class="flex flex-wrap items-center gap-2">
25
+ <PriorityBadge priority="low" />
26
+ <PriorityBadge priority="medium" />
27
+ <PriorityBadge priority="high" />
28
+ <PriorityBadge priority="urgent" />
29
+ <PriorityBadge priority="critical" />
30
+ </div>
31
+ `,
32
+ }),
33
+ };
@@ -5,7 +5,10 @@ const props = defineProps({
5
5
  priority: { type: String, required: true },
6
6
  });
7
7
 
8
- const escDark = inject('esc-dark', computed(() => false));
8
+ const escDark = inject(
9
+ 'esc-dark',
10
+ computed(() => false),
11
+ );
9
12
 
10
13
  const darkConfig = {
11
14
  low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
@@ -33,7 +36,11 @@ const config = computed(() => {
33
36
  </script>
34
37
 
35
38
  <template>
36
- <span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
39
+ <span
40
+ role="status"
41
+ :aria-label="'Priority: ' + config.label"
42
+ :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]"
43
+ >
37
44
  {{ config.label }}
38
45
  </span>
39
46
  </template>
@@ -1,49 +1,65 @@
1
- <script setup>
2
- import { ref, computed, inject } from 'vue';
3
-
4
- const props = defineProps({
5
- currentUserId: { type: [Number, String], default: null },
6
- });
7
-
8
- const emit = defineEmits(['filter']);
9
- const escDark = inject('esc-dark', computed(() => false));
10
- const activeChip = ref(null);
11
-
12
- const chips = computed(() => {
13
- const list = [
14
- { key: 'my_tickets', label: 'My Tickets', filter: { assigned_to: props.currentUserId } },
15
- { key: 'unassigned', label: 'Unassigned', filter: { unassigned: true } },
16
- { key: 'urgent', label: 'Urgent+', filter: { priority: 'urgent' } },
17
- { key: 'sla_breaching', label: 'SLA Breaching', filter: { sla_breached: true } },
18
- { key: 'following', label: 'Following', filter: { following: true } },
19
- ];
20
- if (props.currentUserId) return list;
21
- return list.filter(c => c.key !== 'my_tickets');
22
- });
23
-
24
- function toggle(chip) {
25
- if (activeChip.value === chip.key) {
26
- activeChip.value = null;
27
- emit('filter', {});
28
- } else {
29
- activeChip.value = chip.key;
30
- emit('filter', chip.filter);
31
- }
32
- }
33
- </script>
34
-
35
- <template>
36
- <div class="flex flex-wrap items-center gap-2">
37
- <button v-for="chip in chips" :key="chip.key" @click="toggle(chip)"
38
- :class="['rounded-full px-3 py-1.5 text-xs font-medium transition-all',
39
- activeChip === chip.key
40
- ? (escDark
41
- ? 'bg-gradient-to-r from-cyan-500/20 to-violet-500/20 text-white ring-1 ring-cyan-500/30'
42
- : 'bg-gradient-to-r from-blue-50 to-indigo-50 text-blue-700 ring-1 ring-blue-200')
43
- : (escDark
44
- ? 'bg-white/[0.04] text-neutral-400 ring-1 ring-white/[0.06] hover:bg-white/[0.06] hover:text-neutral-300'
45
- : 'bg-gray-50 text-gray-600 ring-1 ring-gray-200 hover:bg-gray-100')]">
46
- {{ chip.label }}
47
- </button>
48
- </div>
49
- </template>
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+
4
+ const props = defineProps({
5
+ currentUserId: { type: [Number, String], default: null },
6
+ });
7
+
8
+ const emit = defineEmits(['filter']);
9
+ const escDark = inject(
10
+ 'esc-dark',
11
+ computed(() => false),
12
+ );
13
+ const activeTab = ref(null);
14
+
15
+ const tabs = computed(() => {
16
+ const list = [
17
+ { key: null, label: 'All Tickets', filter: {} },
18
+ { key: 'assigned_to_me', label: 'Assigned to Me', filter: { assigned_to: props.currentUserId } },
19
+ { key: 'unassigned', label: 'Unassigned', filter: { unassigned: true } },
20
+ { key: 'urgent', label: 'Urgent', filter: { priority: 'urgent' } },
21
+ { key: 'sla_breaching', label: 'SLA Breaching', filter: { sla_breached: true } },
22
+ { key: 'following', label: 'Following', filter: { following: true } },
23
+ ];
24
+ if (props.currentUserId) return list;
25
+ return list.filter((t) => t.key !== 'assigned_to_me');
26
+ });
27
+
28
+ function selectTab(tab) {
29
+ activeTab.value = tab.key;
30
+ emit('filter', tab.filter);
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div
36
+ :class="['flex items-center gap-0 border-b', escDark ? 'border-white/[0.08]' : 'border-gray-200']"
37
+ role="tablist"
38
+ aria-label="Ticket view tabs"
39
+ >
40
+ <button
41
+ v-for="tab in tabs"
42
+ :key="tab.key"
43
+ role="tab"
44
+ :aria-selected="activeTab === tab.key"
45
+ :class="[
46
+ 'relative px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap',
47
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
48
+ activeTab === tab.key
49
+ ? escDark
50
+ ? 'text-cyan-400 focus-visible:ring-cyan-500/40'
51
+ : 'text-blue-600 focus-visible:ring-blue-500/40'
52
+ : escDark
53
+ ? 'text-neutral-500 hover:text-neutral-300 focus-visible:ring-cyan-500/40'
54
+ : 'text-gray-500 hover:text-gray-700 focus-visible:ring-blue-500/40',
55
+ ]"
56
+ @click="selectTab(tab)"
57
+ >
58
+ {{ tab.label }}
59
+ <span
60
+ v-if="activeTab === tab.key"
61
+ :class="['absolute bottom-0 left-0 right-0 h-0.5 rounded-t', escDark ? 'bg-cyan-400' : 'bg-blue-600']"
62
+ ></span>
63
+ </button>
64
+ </div>
65
+ </template>
@@ -9,16 +9,59 @@ const props = defineProps({
9
9
  allowNotes: { type: Boolean, default: false },
10
10
  placeholder: { type: String, default: 'Type your reply...' },
11
11
  submitLabel: { type: String, default: null },
12
+ typingEndpoint: { type: String, default: '' },
12
13
  });
13
14
 
14
15
  const emit = defineEmits(['submit']);
15
- const escDark = inject('esc-dark', computed(() => false));
16
+ const escDark = inject(
17
+ 'esc-dark',
18
+ computed(() => false),
19
+ );
16
20
 
17
21
  const body = ref('');
18
22
  const isNote = ref(false);
19
23
  const files = ref([]);
20
24
  const submitting = ref(false);
21
25
 
26
+ // Typing indicator: debounced POST every 5 seconds while typing
27
+ let lastTypingSent = 0;
28
+ let typingTimer = null;
29
+
30
+ function onTyping() {
31
+ if (!props.typingEndpoint) return;
32
+ const now = Date.now();
33
+ if (now - lastTypingSent < 5000) return;
34
+ lastTypingSent = now;
35
+ sendTypingIndicator();
36
+
37
+ // Clear typing after 6 seconds of inactivity
38
+ window.clearTimeout(typingTimer);
39
+ typingTimer = window.setTimeout(() => {
40
+ lastTypingSent = 0;
41
+ }, 6000);
42
+ }
43
+
44
+ async function sendTypingIndicator() {
45
+ try {
46
+ await fetch(props.typingEndpoint, {
47
+ method: 'POST',
48
+ headers: {
49
+ Accept: 'application/json',
50
+ 'Content-Type': 'application/json',
51
+ 'X-Requested-With': 'XMLHttpRequest',
52
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
53
+ },
54
+ });
55
+ } catch {
56
+ // silently ignore
57
+ }
58
+ }
59
+
60
+ function onBlur() {
61
+ window.clearTimeout(typingTimer);
62
+ lastTypingSent = 0;
63
+ }
64
+
22
65
  function insertCanned(response) {
23
66
  body.value += response.body;
24
67
  }
@@ -34,6 +77,8 @@ function removeFile(index) {
34
77
  function submit() {
35
78
  if (!body.value.trim() || submitting.value) return;
36
79
  submitting.value = true;
80
+ window.clearTimeout(typingTimer);
81
+ lastTypingSent = 0;
37
82
 
38
83
  const payload = {
39
84
  body: body.value,
@@ -66,45 +111,84 @@ const buttonLabel = computed(() => {
66
111
  <!-- Dark mode -->
67
112
  <div v-if="escDark" class="rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4">
68
113
  <div v-if="allowNotes" class="mb-3 flex gap-2">
69
- <button @click="isNote = false"
70
- :class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', !isNote ? 'bg-cyan-500/15 text-white ring-1 ring-cyan-500/20' : 'text-gray-400 hover:bg-white/[0.04]']">
114
+ <button
115
+ :class="[
116
+ 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
117
+ !isNote
118
+ ? 'bg-cyan-500/15 text-white ring-1 ring-cyan-500/20'
119
+ : 'text-gray-400 hover:bg-white/[0.04]',
120
+ ]"
121
+ @click="isNote = false"
122
+ >
71
123
  Reply
72
124
  </button>
73
- <button @click="isNote = true"
74
- :class="['rounded-lg px-3 py-1.5 text-sm font-medium transition-colors', isNote ? 'bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20' : 'text-gray-400 hover:bg-white/[0.04]']">
125
+ <button
126
+ :class="[
127
+ 'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
128
+ isNote
129
+ ? 'bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/20'
130
+ : 'text-gray-400 hover:bg-white/[0.04]',
131
+ ]"
132
+ aria-label="Internal note"
133
+ @click="isNote = true"
134
+ >
75
135
  Internal Note
76
136
  </button>
77
137
  </div>
78
138
 
79
- <div v-if="isNote" class="mb-2 rounded-lg bg-amber-500/10 px-3 py-1.5 text-xs text-amber-400 ring-1 ring-amber-500/20">
139
+ <div
140
+ v-if="isNote"
141
+ class="mb-2 rounded-lg bg-amber-500/10 px-3 py-1.5 text-xs text-amber-400 ring-1 ring-amber-500/20"
142
+ >
80
143
  This note is only visible to agents.
81
144
  </div>
82
145
 
83
- <textarea v-model="body" rows="4" :placeholder="placeholder"
84
- class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"></textarea>
146
+ <textarea
147
+ v-model="body"
148
+ rows="4"
149
+ :placeholder="placeholder"
150
+ aria-label="Reply message"
151
+ class="w-full rounded-lg border border-white/10 bg-neutral-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
152
+ @keydown="onTyping"
153
+ @blur="onBlur"
154
+ ></textarea>
85
155
 
86
- <FileDropzone @files="handleFiles" class="mt-2" />
156
+ <FileDropzone class="mt-2" @files="handleFiles" />
87
157
 
88
158
  <div v-if="files.length" class="mt-2 space-y-1">
89
159
  <div v-for="(file, i) in files" :key="i" class="flex items-center gap-2 text-sm text-gray-400">
90
160
  <span>{{ file.name }}</span>
91
- <button @click="removeFile(i)" class="text-rose-400 hover:text-rose-300">&times;</button>
161
+ <button aria-label="Remove attachment" class="text-rose-400 hover:text-rose-300" @click="removeFile(i)">
162
+ &times;
163
+ </button>
92
164
  </div>
93
165
  </div>
94
166
 
95
167
  <div class="mt-3 flex items-center justify-between">
96
168
  <div v-if="cannedResponses.length">
97
- <select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
98
- class="rounded-lg border border-white/10 bg-gray-900 px-2 py-1 text-xs text-gray-400">
169
+ <select
170
+ class="rounded-lg border border-white/10 bg-gray-900 px-2 py-1 text-xs text-gray-400"
171
+ @change="
172
+ insertCanned(cannedResponses[$event.target.value]);
173
+ $event.target.value = '';
174
+ "
175
+ >
99
176
  <option value="">Canned responses...</option>
100
177
  <option v-for="(cr, i) in cannedResponses" :key="cr.id" :value="i">{{ cr.title }}</option>
101
178
  </select>
102
179
  </div>
103
180
  <div v-else></div>
104
- <button @click="submit" :disabled="!body.trim() || submitting"
105
- :class="['rounded-lg px-4 py-2 text-sm font-medium text-white transition-all',
106
- isNote ? 'bg-amber-500 hover:bg-amber-400' : 'bg-gradient-to-r from-cyan-500 to-violet-500 hover:from-cyan-400 hover:to-violet-400',
107
- (!body.trim() || submitting) && 'cursor-not-allowed opacity-40']">
181
+ <button
182
+ :disabled="!body.trim() || submitting"
183
+ :class="[
184
+ 'rounded-lg px-4 py-2 text-sm font-medium text-white transition-all',
185
+ isNote
186
+ ? 'bg-amber-500 hover:bg-amber-400'
187
+ : 'bg-gradient-to-r from-cyan-500 to-violet-500 hover:from-cyan-400 hover:to-violet-400',
188
+ (!body.trim() || submitting) && 'cursor-not-allowed opacity-40',
189
+ ]"
190
+ @click="submit"
191
+ >
108
192
  {{ buttonLabel }}
109
193
  </button>
110
194
  </div>
@@ -113,12 +197,23 @@ const buttonLabel = computed(() => {
113
197
  <!-- Light mode -->
114
198
  <div v-else class="rounded-lg border border-gray-200 bg-white p-4">
115
199
  <div v-if="allowNotes" class="mb-3 flex gap-2">
116
- <button @click="isNote = false"
117
- :class="['rounded-md px-3 py-1 text-sm font-medium', !isNote ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-100']">
200
+ <button
201
+ :class="[
202
+ 'rounded-md px-3 py-1 text-sm font-medium',
203
+ !isNote ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-100',
204
+ ]"
205
+ @click="isNote = false"
206
+ >
118
207
  Reply
119
208
  </button>
120
- <button @click="isNote = true"
121
- :class="['rounded-md px-3 py-1 text-sm font-medium', isNote ? 'bg-yellow-100 text-yellow-700' : 'text-gray-500 hover:bg-gray-100']">
209
+ <button
210
+ :class="[
211
+ 'rounded-md px-3 py-1 text-sm font-medium',
212
+ isNote ? 'bg-yellow-100 text-yellow-700' : 'text-gray-500 hover:bg-gray-100',
213
+ ]"
214
+ aria-label="Internal note"
215
+ @click="isNote = true"
216
+ >
122
217
  Internal Note
123
218
  </button>
124
219
  </div>
@@ -127,31 +222,50 @@ const buttonLabel = computed(() => {
127
222
  This note is only visible to agents.
128
223
  </div>
129
224
 
130
- <textarea v-model="body" rows="4" :placeholder="placeholder"
131
- class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
225
+ <textarea
226
+ v-model="body"
227
+ rows="4"
228
+ :placeholder="placeholder"
229
+ aria-label="Reply message"
230
+ class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
231
+ @keydown="onTyping"
232
+ @blur="onBlur"
233
+ ></textarea>
132
234
 
133
- <FileDropzone @files="handleFiles" class="mt-2" />
235
+ <FileDropzone class="mt-2" @files="handleFiles" />
134
236
 
135
237
  <div v-if="files.length" class="mt-2 space-y-1">
136
238
  <div v-for="(file, i) in files" :key="i" class="flex items-center gap-2 text-sm text-gray-600">
137
239
  <span>{{ file.name }}</span>
138
- <button @click="removeFile(i)" class="text-red-500 hover:text-red-700">&times;</button>
240
+ <button aria-label="Remove attachment" class="text-red-500 hover:text-red-700" @click="removeFile(i)">
241
+ &times;
242
+ </button>
139
243
  </div>
140
244
  </div>
141
245
 
142
246
  <div class="mt-3 flex items-center justify-between">
143
247
  <div v-if="cannedResponses.length">
144
- <select @change="insertCanned(cannedResponses[$event.target.value]); $event.target.value = ''"
145
- class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600">
248
+ <select
249
+ class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600"
250
+ @change="
251
+ insertCanned(cannedResponses[$event.target.value]);
252
+ $event.target.value = '';
253
+ "
254
+ >
146
255
  <option value="">Canned responses...</option>
147
256
  <option v-for="(cr, i) in cannedResponses" :key="cr.id" :value="i">{{ cr.title }}</option>
148
257
  </select>
149
258
  </div>
150
259
  <div v-else></div>
151
- <button @click="submit" :disabled="!body.trim() || submitting"
152
- :class="['rounded-md px-4 py-2 text-sm font-medium text-white',
153
- isNote ? 'bg-yellow-500 hover:bg-yellow-600' : 'bg-blue-600 hover:bg-blue-700',
154
- (!body.trim() || submitting) && 'cursor-not-allowed opacity-50']">
260
+ <button
261
+ :disabled="!body.trim() || submitting"
262
+ :class="[
263
+ 'rounded-md px-4 py-2 text-sm font-medium text-white',
264
+ isNote ? 'bg-yellow-500 hover:bg-yellow-600' : 'bg-blue-600 hover:bg-blue-700',
265
+ (!body.trim() || submitting) && 'cursor-not-allowed opacity-50',
266
+ ]"
267
+ @click="submit"
268
+ >
155
269
  {{ buttonLabel }}
156
270
  </button>
157
271
  </div>