@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.
- package/README.md +94 -2
- package/package.json +31 -2
- package/src/components/ActivityTimeline.stories.js +55 -0
- package/src/components/ActivityTimeline.vue +24 -7
- package/src/components/AdminDashboard.stories.js +1095 -0
- package/src/components/AgentLoadIndicator.stories.js +37 -0
- package/src/components/AgentLoadIndicator.vue +49 -0
- package/src/components/AgentPerformanceCard.vue +44 -0
- package/src/components/ArticleEditor.vue +82 -0
- package/src/components/AssigneeSelect.vue +19 -5
- package/src/components/AttachmentList.vue +53 -37
- package/src/components/AuditLogEntry.vue +95 -0
- package/src/components/BulkActionBar.vue +99 -30
- package/src/components/CategoryTree.vue +89 -0
- package/src/components/ChartWidget.vue +46 -0
- package/src/components/CollisionWarning.stories.js +30 -0
- package/src/components/CollisionWarning.vue +68 -0
- package/src/components/ConditionalFieldRules.vue +135 -0
- package/src/components/ContextPanel.vue +66 -0
- package/src/components/ContextPanelSection.vue +65 -0
- package/src/components/CustomFieldRenderer.vue +159 -0
- package/src/components/EscalatedLayout.vue +400 -69
- package/src/components/FileDropzone.vue +26 -10
- package/src/components/FollowButton.stories.js +30 -0
- package/src/components/FollowButton.vue +53 -28
- package/src/components/KeyboardShortcutHelp.vue +129 -39
- package/src/components/KnowledgePanel.vue +122 -0
- package/src/components/KpiCard.stories.js +32 -0
- package/src/components/KpiCard.vue +88 -0
- package/src/components/MacroDropdown.vue +95 -35
- package/src/components/PermissionMatrix.vue +107 -0
- package/src/components/PinnedNotes.vue +70 -31
- package/src/components/PresenceIndicator.vue +93 -27
- package/src/components/PriorityBadge.stories.js +33 -0
- package/src/components/PriorityBadge.vue +9 -2
- package/src/components/QuickFilters.vue +65 -49
- package/src/components/ReplyComposer.vue +144 -30
- package/src/components/ReplyThread.vue +72 -20
- package/src/components/SatisfactionRating.vue +98 -46
- package/src/components/ScheduleEditor.vue +104 -0
- package/src/components/SideConversation.vue +165 -0
- package/src/components/SideConversationList.vue +147 -0
- package/src/components/SkillTagManager.vue +79 -0
- package/src/components/SlaComplianceChart.vue +53 -0
- package/src/components/SlaTimer.stories.js +35 -0
- package/src/components/SlaTimer.vue +17 -4
- package/src/components/StatsCard.stories.js +24 -0
- package/src/components/StatsCard.vue +58 -46
- package/src/components/StatusBadge.stories.js +53 -0
- package/src/components/StatusBadge.vue +36 -4
- package/src/components/TagSelect.vue +43 -12
- package/src/components/TicketFilters.vue +225 -69
- package/src/components/TicketLinkPanel.vue +193 -0
- package/src/components/TicketList.vue +594 -193
- package/src/components/TicketMergeDialog.vue +157 -0
- package/src/components/TicketSidebar.vue +167 -99
- package/src/components/TicketTypeSelector.vue +21 -0
- package/src/components/TotpInput.stories.js +9 -0
- package/src/components/TotpInput.vue +81 -0
- package/src/components/TwoFactorSetup.vue +113 -0
- package/src/composables/useDebouncedSearch.js +38 -0
- package/src/composables/usePluginExtensions.js +95 -3
- package/src/composables/usePluginHooks.js +65 -0
- package/src/defineEscalatedPlugin.js +34 -8
- package/src/index.js +72 -35
- package/src/locales/en.json +25 -0
- package/src/pages/Admin/ApiTokens/Index.vue +231 -67
- package/src/pages/Admin/AuditLog/Index.vue +154 -0
- package/src/pages/Admin/Automations/Form.vue +184 -0
- package/src/pages/Admin/Automations/Index.vue +129 -0
- package/src/pages/Admin/BusinessHours/Form.vue +172 -0
- package/src/pages/Admin/BusinessHours/Index.vue +113 -0
- package/src/pages/Admin/CannedResponses/Index.vue +127 -70
- package/src/pages/Admin/Capacity/Index.vue +134 -0
- package/src/pages/Admin/CustomFields/Form.vue +195 -0
- package/src/pages/Admin/CustomFields/Index.vue +134 -0
- package/src/pages/Admin/CustomObjects/Form.vue +202 -0
- package/src/pages/Admin/CustomObjects/Index.vue +127 -0
- package/src/pages/Admin/CustomObjects/Records.vue +228 -0
- package/src/pages/Admin/Departments/Form.vue +75 -51
- package/src/pages/Admin/Departments/Index.vue +109 -57
- package/src/pages/Admin/EscalationRules/Form.vue +157 -96
- package/src/pages/Admin/EscalationRules/Index.vue +178 -57
- package/src/pages/Admin/Import/Connect.vue +96 -0
- package/src/pages/Admin/Import/Index.vue +102 -0
- package/src/pages/Admin/Import/Mapping.vue +110 -0
- package/src/pages/Admin/Import/Progress.vue +153 -0
- package/src/pages/Admin/Import/Review.vue +76 -0
- package/src/pages/Admin/KnowledgeBase/Articles/Form.vue +142 -0
- package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +195 -0
- package/src/pages/Admin/KnowledgeBase/Categories/Index.vue +193 -0
- package/src/pages/Admin/Macros/Index.vue +250 -89
- package/src/pages/Admin/Plugins/Index.vue +205 -75
- package/src/pages/Admin/Reports/AgentMetrics.vue +115 -0
- package/src/pages/Admin/Reports/CsatReport.vue +127 -0
- package/src/pages/Admin/Reports/Dashboard.vue +211 -0
- package/src/pages/Admin/Reports/SlaReport.vue +166 -0
- package/src/pages/Admin/Reports.vue +139 -34
- package/src/pages/Admin/Roles/Form.vue +71 -0
- package/src/pages/Admin/Roles/Index.vue +122 -0
- package/src/pages/Admin/Settings/CsatSettings.vue +165 -0
- package/src/pages/Admin/Settings/DataRetention.vue +182 -0
- package/src/pages/Admin/Settings/EmailSettings.vue +243 -0
- package/src/pages/Admin/Settings/Sandbox.vue +135 -0
- package/src/pages/Admin/Settings/SsoSettings.vue +218 -0
- package/src/pages/Admin/Settings/TwoFactor.vue +110 -0
- package/src/pages/Admin/Settings.vue +468 -260
- package/src/pages/Admin/Skills/Form.vue +53 -0
- package/src/pages/Admin/Skills/Index.vue +94 -0
- package/src/pages/Admin/SlaPolicies/Form.vue +118 -75
- package/src/pages/Admin/SlaPolicies/Index.vue +120 -59
- package/src/pages/Admin/Statuses/Form.vue +114 -0
- package/src/pages/Admin/Statuses/Index.vue +153 -0
- package/src/pages/Admin/Tags/Index.vue +191 -105
- package/src/pages/Admin/Tickets/Index.vue +91 -39
- package/src/pages/Admin/Tickets/Show.vue +101 -37
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +120 -0
- package/src/pages/Admin/Webhooks/Form.vue +110 -0
- package/src/pages/Admin/Webhooks/Index.vue +136 -0
- package/src/pages/Agent/Dashboard.vue +311 -186
- package/src/pages/Agent/TicketIndex.vue +88 -38
- package/src/pages/Agent/TicketShow.vue +258 -51
- package/src/pages/Customer/Create.vue +36 -9
- package/src/pages/Customer/Index.vue +31 -24
- package/src/pages/Customer/KnowledgeBase/Article.vue +127 -0
- package/src/pages/Customer/KnowledgeBase/Index.vue +151 -0
- package/src/plugin.js +86 -1
- package/src/utils/constants.js +30 -0
- package/src/utils/formatting.js +35 -0
- 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
|
|
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
|
-
|
|
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',
|
|
50
|
-
'bg-
|
|
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,
|
|
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
|
|
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
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
</
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
99
|
-
|
|
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(
|
|
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
|
|
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(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{ key:
|
|
18
|
-
{ key: '
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
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
|
|
70
|
-
|
|
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
|
|
74
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
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
|
|
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
|
|
161
|
+
<button aria-label="Remove attachment" class="text-rose-400 hover:text-rose-300" @click="removeFile(i)">
|
|
162
|
+
×
|
|
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
|
|
98
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
121
|
-
|
|
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
|
|
131
|
-
|
|
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
|
|
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
|
|
240
|
+
<button aria-label="Remove attachment" class="text-red-500 hover:text-red-700" @click="removeFile(i)">
|
|
241
|
+
×
|
|
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
|
|
145
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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>
|