@escalated-dev/escalated 0.8.0 → 0.9.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/LICENSE +21 -21
- package/README.md +380 -361
- package/package.json +85 -84
- package/src/components/ActiveChatsPanel.vue +246 -246
- package/src/components/AdminDashboard.stories.js +1423 -1423
- package/src/components/AttachmentList.vue +65 -65
- package/src/components/ChatActionBar.vue +226 -226
- package/src/components/ChatBubble.vue +162 -162
- package/src/components/ChatComposer.vue +308 -308
- package/src/components/ChatQueue.vue +222 -222
- package/src/components/ChatThread.vue +197 -197
- package/src/components/CustomTicketActions.stories.js +71 -0
- package/src/components/CustomTicketActions.vue +101 -0
- package/src/components/EscalatedLayout.vue +619 -604
- package/src/components/FollowButton.vue +20 -1
- package/src/components/MentionDropdown.vue +154 -154
- package/src/components/MetricComparison.vue +89 -89
- package/src/components/PercentileChart.vue +67 -67
- package/src/components/PeriodSelector.vue +98 -98
- package/src/components/PresenceIndicator.vue +228 -213
- package/src/components/ReplyComposer.vue +336 -336
- package/src/components/ReplyThread.vue +153 -153
- package/src/components/ReportChart.vue +395 -395
- package/src/components/ReportExportButton.vue +108 -108
- package/src/components/SaveViewDialog.vue +172 -172
- package/src/components/SavedViewSidebar.vue +208 -208
- package/src/components/SkillTagManager.vue +3 -0
- package/src/components/SnoozeButton.vue +247 -247
- package/src/components/TicketFilters.vue +270 -270
- package/src/components/TicketList.vue +655 -655
- package/src/components/TicketSidebar.vue +4 -0
- package/src/components/TicketSplitDialog.vue +98 -98
- package/src/components/TicketSubjects.stories.js +63 -0
- package/src/components/TicketSubjects.vue +107 -0
- package/src/components/TwoFactorSetup.vue +1 -37
- package/src/components/VariableTokenInput.vue +111 -111
- package/src/components/WorkflowActionConfig.vue +287 -287
- package/src/components/WorkflowActionList.vue +350 -350
- package/src/components/WorkflowConditionBuilder.vue +265 -265
- package/src/components/WorkflowTriggerSelector.vue +125 -125
- package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
- package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
- package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
- package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
- package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
- package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
- package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
- package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
- package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
- package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
- package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
- package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
- package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
- package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
- package/src/composables/useChat.js +187 -187
- package/src/composables/useI18n.js +102 -102
- package/src/composables/useMentions.js +134 -134
- package/src/composables/usePermissions.js +31 -0
- package/src/composables/useRealtime.js +182 -182
- package/src/index.js +85 -84
- package/src/locales/README.md +13 -13
- package/src/locales/ar.json +727 -727
- package/src/locales/de.json +690 -690
- package/src/locales/en.json +889 -729
- package/src/locales/es.json +691 -691
- package/src/locales/fr.json +691 -691
- package/src/locales/index.js +16 -16
- package/src/locales/it.json +727 -727
- package/src/locales/ja.json +727 -727
- package/src/locales/ko.json +727 -727
- package/src/locales/nl.json +727 -727
- package/src/locales/pl.json +727 -727
- package/src/locales/pt-BR.json +727 -727
- package/src/locales/ru.json +727 -727
- package/src/locales/tr.json +727 -727
- package/src/locales/zh-CN.json +727 -727
- package/src/pages/Admin/Newsletters/Compose.vue +192 -0
- package/src/pages/Admin/Newsletters/Edit.vue +24 -0
- package/src/pages/Admin/Newsletters/Index.vue +141 -0
- package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
- package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
- package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
- package/src/pages/Admin/Newsletters/Settings.vue +85 -0
- package/src/pages/Admin/Newsletters/Show.vue +140 -0
- package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
- package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
- package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
- package/src/pages/Admin/Newsletters/usePreview.js +44 -0
- package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
- package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
- package/src/pages/Admin/Reports/Cohorts.vue +180 -180
- package/src/pages/Admin/Reports/Comparison.vue +141 -141
- package/src/pages/Admin/Reports/Dashboard.vue +226 -226
- package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
- package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
- package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
- package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
- package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
- package/src/pages/Admin/Settings.vue +1008 -1008
- package/src/pages/Admin/Skills/Form.vue +30 -10
- package/src/pages/Admin/Tickets/Index.vue +126 -126
- package/src/pages/Admin/Tickets/Show.vue +254 -249
- package/src/pages/Admin/Users/Index.vue +145 -145
- package/src/pages/Admin/Workflows/Builder.vue +207 -207
- package/src/pages/Admin/Workflows/Index.vue +235 -235
- package/src/pages/Admin/Workflows/Logs.vue +286 -286
- package/src/pages/Agent/TicketIndex.vue +112 -112
- package/src/pages/Agent/TicketShow.vue +547 -494
- package/src/pages/Customer/Index.vue +56 -56
- package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
- package/src/plugin.js +175 -175
- package/src/utils/mentionHighlight.js +24 -24
- package/src/widget/EscalatedWidget.vue +804 -804
- package/src/widget/index.js +254 -254
|
@@ -1,197 +1,197 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { ref, computed, inject, watch, nextTick, onMounted } from 'vue';
|
|
3
|
-
import ChatBubble from './ChatBubble.vue';
|
|
4
|
-
import { useI18n } from '../composables/useI18n';
|
|
5
|
-
|
|
6
|
-
const props = defineProps({
|
|
7
|
-
messages: { type: Array, default: () => [] },
|
|
8
|
-
typingUser: { type: String, default: null },
|
|
9
|
-
currentUserId: { type: [Number, String], default: null },
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
const { t } = useI18n();
|
|
13
|
-
const escDark = inject(
|
|
14
|
-
'esc-dark',
|
|
15
|
-
computed(() => false),
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
const threadContainer = ref(null);
|
|
19
|
-
const isScrolledUp = ref(false);
|
|
20
|
-
const hasNewMessages = ref(false);
|
|
21
|
-
|
|
22
|
-
// Determine if user is near bottom of scroll
|
|
23
|
-
function isNearBottom() {
|
|
24
|
-
const el = threadContainer.value;
|
|
25
|
-
if (!el) return true;
|
|
26
|
-
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function onScroll() {
|
|
30
|
-
isScrolledUp.value = !isNearBottom();
|
|
31
|
-
if (!isScrolledUp.value) {
|
|
32
|
-
hasNewMessages.value = false;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function scrollToBottom() {
|
|
37
|
-
const el = threadContainer.value;
|
|
38
|
-
if (!el) return;
|
|
39
|
-
el.scrollTop = el.scrollHeight;
|
|
40
|
-
isScrolledUp.value = false;
|
|
41
|
-
hasNewMessages.value = false;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Auto-scroll on new messages
|
|
45
|
-
watch(
|
|
46
|
-
() => props.messages.length,
|
|
47
|
-
async () => {
|
|
48
|
-
await nextTick();
|
|
49
|
-
if (isNearBottom()) {
|
|
50
|
-
scrollToBottom();
|
|
51
|
-
} else {
|
|
52
|
-
hasNewMessages.value = true;
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
onMounted(() => {
|
|
58
|
-
nextTick(() => scrollToBottom());
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Determine whether to show avatar for a message:
|
|
63
|
-
* Show on the first message of a consecutive group by the same author.
|
|
64
|
-
*/
|
|
65
|
-
function shouldShowAvatar(index) {
|
|
66
|
-
if (index === 0) return true;
|
|
67
|
-
const prev = props.messages[index - 1];
|
|
68
|
-
const curr = props.messages[index];
|
|
69
|
-
return prev.author?.id !== curr.author?.id;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Determine whether to show a time separator between messages.
|
|
74
|
-
* Show when there's a >5 minute gap.
|
|
75
|
-
*/
|
|
76
|
-
function shouldShowTimeSeparator(index) {
|
|
77
|
-
if (index === 0) return true;
|
|
78
|
-
const prev = props.messages[index - 1];
|
|
79
|
-
const curr = props.messages[index];
|
|
80
|
-
if (!prev.created_at || !curr.created_at) return false;
|
|
81
|
-
const gap = new Date(curr.created_at) - new Date(prev.created_at);
|
|
82
|
-
return gap > 5 * 60 * 1000;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Show timestamp on the last message of a group or when there's a time gap.
|
|
87
|
-
*/
|
|
88
|
-
function shouldShowTimestamp(index) {
|
|
89
|
-
if (index === props.messages.length - 1) return true;
|
|
90
|
-
return shouldShowTimeSeparator(index + 1);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function formatTimeSeparator(dateStr) {
|
|
94
|
-
const date = new Date(dateStr);
|
|
95
|
-
const now = new Date();
|
|
96
|
-
const diffDays = Math.floor((now - date) / 86400000);
|
|
97
|
-
|
|
98
|
-
if (diffDays === 0) {
|
|
99
|
-
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
100
|
-
}
|
|
101
|
-
if (diffDays === 1) {
|
|
102
|
-
return 'Yesterday ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
103
|
-
}
|
|
104
|
-
return date.toLocaleDateString(undefined, {
|
|
105
|
-
month: 'short',
|
|
106
|
-
day: 'numeric',
|
|
107
|
-
hour: '2-digit',
|
|
108
|
-
minute: '2-digit',
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function isAgentMessage(message) {
|
|
113
|
-
if (message.is_agent !== undefined) return message.is_agent;
|
|
114
|
-
if (props.currentUserId && message.author?.id) {
|
|
115
|
-
return String(message.author.id) === String(props.currentUserId);
|
|
116
|
-
}
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
</script>
|
|
120
|
-
|
|
121
|
-
<template>
|
|
122
|
-
<div class="relative flex flex-col" style="min-height: 200px">
|
|
123
|
-
<div
|
|
124
|
-
ref="threadContainer"
|
|
125
|
-
class="flex-1 space-y-1 overflow-y-auto p-4"
|
|
126
|
-
style="max-height: 500px"
|
|
127
|
-
@scroll="onScroll"
|
|
128
|
-
>
|
|
129
|
-
<!-- Empty state -->
|
|
130
|
-
<div
|
|
131
|
-
v-if="!messages.length"
|
|
132
|
-
:class="['py-12 text-center text-sm', escDark ? 'text-neutral-600' : 'text-gray-400']"
|
|
133
|
-
>
|
|
134
|
-
{{ t('chat.empty_state') }}
|
|
135
|
-
</div>
|
|
136
|
-
|
|
137
|
-
<template v-for="(message, index) in messages" :key="message.id || index">
|
|
138
|
-
<!-- Time separator -->
|
|
139
|
-
<div
|
|
140
|
-
v-if="shouldShowTimeSeparator(index)"
|
|
141
|
-
:class="['flex items-center gap-3 py-2', escDark ? 'text-neutral-600' : 'text-gray-400']"
|
|
142
|
-
>
|
|
143
|
-
<div :class="['flex-1 border-t', escDark ? 'border-neutral-800' : 'border-gray-200']"></div>
|
|
144
|
-
<span class="text-[10px] font-medium">
|
|
145
|
-
{{ formatTimeSeparator(message.created_at) }}
|
|
146
|
-
</span>
|
|
147
|
-
<div :class="['flex-1 border-t', escDark ? 'border-neutral-800' : 'border-gray-200']"></div>
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
<!-- Chat bubble -->
|
|
151
|
-
<ChatBubble
|
|
152
|
-
:message="message"
|
|
153
|
-
:is-agent="isAgentMessage(message)"
|
|
154
|
-
:show-avatar="shouldShowAvatar(index)"
|
|
155
|
-
:show-timestamp="shouldShowTimestamp(index)"
|
|
156
|
-
/>
|
|
157
|
-
</template>
|
|
158
|
-
|
|
159
|
-
<!-- Typing indicator -->
|
|
160
|
-
<div v-if="typingUser" class="flex items-center gap-2 pl-10 pt-1">
|
|
161
|
-
<div
|
|
162
|
-
:class="[
|
|
163
|
-
'flex items-center gap-1 rounded-2xl px-4 py-2',
|
|
164
|
-
escDark ? 'bg-neutral-800' : 'bg-gray-100',
|
|
165
|
-
]"
|
|
166
|
-
>
|
|
167
|
-
<div class="flex gap-1">
|
|
168
|
-
<span
|
|
169
|
-
v-for="i in 3"
|
|
170
|
-
:key="i"
|
|
171
|
-
:class="[
|
|
172
|
-
'inline-block h-1.5 w-1.5 rounded-full animate-bounce',
|
|
173
|
-
escDark ? 'bg-neutral-500' : 'bg-gray-400',
|
|
174
|
-
]"
|
|
175
|
-
:style="{ animationDelay: `${(i - 1) * 150}ms` }"
|
|
176
|
-
></span>
|
|
177
|
-
</div>
|
|
178
|
-
<span :class="['ml-2 text-xs', escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
179
|
-
{{ t('chat.typing', { name: typingUser }) }}
|
|
180
|
-
</span>
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
|
-
|
|
185
|
-
<!-- New messages pill -->
|
|
186
|
-
<button
|
|
187
|
-
v-if="hasNewMessages"
|
|
188
|
-
:class="[
|
|
189
|
-
'absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full px-4 py-1.5 text-xs font-medium shadow-lg transition-all',
|
|
190
|
-
escDark ? 'bg-cyan-500 text-white hover:bg-cyan-400' : 'bg-blue-600 text-white hover:bg-blue-500',
|
|
191
|
-
]"
|
|
192
|
-
@click="scrollToBottom"
|
|
193
|
-
>
|
|
194
|
-
{{ t('chat.new_messages') }}
|
|
195
|
-
</button>
|
|
196
|
-
</div>
|
|
197
|
-
</template>
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject, watch, nextTick, onMounted } from 'vue';
|
|
3
|
+
import ChatBubble from './ChatBubble.vue';
|
|
4
|
+
import { useI18n } from '../composables/useI18n';
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
messages: { type: Array, default: () => [] },
|
|
8
|
+
typingUser: { type: String, default: null },
|
|
9
|
+
currentUserId: { type: [Number, String], default: null },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const { t } = useI18n();
|
|
13
|
+
const escDark = inject(
|
|
14
|
+
'esc-dark',
|
|
15
|
+
computed(() => false),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const threadContainer = ref(null);
|
|
19
|
+
const isScrolledUp = ref(false);
|
|
20
|
+
const hasNewMessages = ref(false);
|
|
21
|
+
|
|
22
|
+
// Determine if user is near bottom of scroll
|
|
23
|
+
function isNearBottom() {
|
|
24
|
+
const el = threadContainer.value;
|
|
25
|
+
if (!el) return true;
|
|
26
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function onScroll() {
|
|
30
|
+
isScrolledUp.value = !isNearBottom();
|
|
31
|
+
if (!isScrolledUp.value) {
|
|
32
|
+
hasNewMessages.value = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scrollToBottom() {
|
|
37
|
+
const el = threadContainer.value;
|
|
38
|
+
if (!el) return;
|
|
39
|
+
el.scrollTop = el.scrollHeight;
|
|
40
|
+
isScrolledUp.value = false;
|
|
41
|
+
hasNewMessages.value = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Auto-scroll on new messages
|
|
45
|
+
watch(
|
|
46
|
+
() => props.messages.length,
|
|
47
|
+
async () => {
|
|
48
|
+
await nextTick();
|
|
49
|
+
if (isNearBottom()) {
|
|
50
|
+
scrollToBottom();
|
|
51
|
+
} else {
|
|
52
|
+
hasNewMessages.value = true;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
onMounted(() => {
|
|
58
|
+
nextTick(() => scrollToBottom());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Determine whether to show avatar for a message:
|
|
63
|
+
* Show on the first message of a consecutive group by the same author.
|
|
64
|
+
*/
|
|
65
|
+
function shouldShowAvatar(index) {
|
|
66
|
+
if (index === 0) return true;
|
|
67
|
+
const prev = props.messages[index - 1];
|
|
68
|
+
const curr = props.messages[index];
|
|
69
|
+
return prev.author?.id !== curr.author?.id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Determine whether to show a time separator between messages.
|
|
74
|
+
* Show when there's a >5 minute gap.
|
|
75
|
+
*/
|
|
76
|
+
function shouldShowTimeSeparator(index) {
|
|
77
|
+
if (index === 0) return true;
|
|
78
|
+
const prev = props.messages[index - 1];
|
|
79
|
+
const curr = props.messages[index];
|
|
80
|
+
if (!prev.created_at || !curr.created_at) return false;
|
|
81
|
+
const gap = new Date(curr.created_at) - new Date(prev.created_at);
|
|
82
|
+
return gap > 5 * 60 * 1000;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Show timestamp on the last message of a group or when there's a time gap.
|
|
87
|
+
*/
|
|
88
|
+
function shouldShowTimestamp(index) {
|
|
89
|
+
if (index === props.messages.length - 1) return true;
|
|
90
|
+
return shouldShowTimeSeparator(index + 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatTimeSeparator(dateStr) {
|
|
94
|
+
const date = new Date(dateStr);
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const diffDays = Math.floor((now - date) / 86400000);
|
|
97
|
+
|
|
98
|
+
if (diffDays === 0) {
|
|
99
|
+
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
100
|
+
}
|
|
101
|
+
if (diffDays === 1) {
|
|
102
|
+
return 'Yesterday ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
103
|
+
}
|
|
104
|
+
return date.toLocaleDateString(undefined, {
|
|
105
|
+
month: 'short',
|
|
106
|
+
day: 'numeric',
|
|
107
|
+
hour: '2-digit',
|
|
108
|
+
minute: '2-digit',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isAgentMessage(message) {
|
|
113
|
+
if (message.is_agent !== undefined) return message.is_agent;
|
|
114
|
+
if (props.currentUserId && message.author?.id) {
|
|
115
|
+
return String(message.author.id) === String(props.currentUserId);
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<div class="relative flex flex-col" style="min-height: 200px">
|
|
123
|
+
<div
|
|
124
|
+
ref="threadContainer"
|
|
125
|
+
class="flex-1 space-y-1 overflow-y-auto p-4"
|
|
126
|
+
style="max-height: 500px"
|
|
127
|
+
@scroll="onScroll"
|
|
128
|
+
>
|
|
129
|
+
<!-- Empty state -->
|
|
130
|
+
<div
|
|
131
|
+
v-if="!messages.length"
|
|
132
|
+
:class="['py-12 text-center text-sm', escDark ? 'text-neutral-600' : 'text-gray-400']"
|
|
133
|
+
>
|
|
134
|
+
{{ t('chat.empty_state') }}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<template v-for="(message, index) in messages" :key="message.id || index">
|
|
138
|
+
<!-- Time separator -->
|
|
139
|
+
<div
|
|
140
|
+
v-if="shouldShowTimeSeparator(index)"
|
|
141
|
+
:class="['flex items-center gap-3 py-2', escDark ? 'text-neutral-600' : 'text-gray-400']"
|
|
142
|
+
>
|
|
143
|
+
<div :class="['flex-1 border-t', escDark ? 'border-neutral-800' : 'border-gray-200']"></div>
|
|
144
|
+
<span class="text-[10px] font-medium">
|
|
145
|
+
{{ formatTimeSeparator(message.created_at) }}
|
|
146
|
+
</span>
|
|
147
|
+
<div :class="['flex-1 border-t', escDark ? 'border-neutral-800' : 'border-gray-200']"></div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<!-- Chat bubble -->
|
|
151
|
+
<ChatBubble
|
|
152
|
+
:message="message"
|
|
153
|
+
:is-agent="isAgentMessage(message)"
|
|
154
|
+
:show-avatar="shouldShowAvatar(index)"
|
|
155
|
+
:show-timestamp="shouldShowTimestamp(index)"
|
|
156
|
+
/>
|
|
157
|
+
</template>
|
|
158
|
+
|
|
159
|
+
<!-- Typing indicator -->
|
|
160
|
+
<div v-if="typingUser" class="flex items-center gap-2 pl-10 pt-1">
|
|
161
|
+
<div
|
|
162
|
+
:class="[
|
|
163
|
+
'flex items-center gap-1 rounded-2xl px-4 py-2',
|
|
164
|
+
escDark ? 'bg-neutral-800' : 'bg-gray-100',
|
|
165
|
+
]"
|
|
166
|
+
>
|
|
167
|
+
<div class="flex gap-1">
|
|
168
|
+
<span
|
|
169
|
+
v-for="i in 3"
|
|
170
|
+
:key="i"
|
|
171
|
+
:class="[
|
|
172
|
+
'inline-block h-1.5 w-1.5 rounded-full animate-bounce',
|
|
173
|
+
escDark ? 'bg-neutral-500' : 'bg-gray-400',
|
|
174
|
+
]"
|
|
175
|
+
:style="{ animationDelay: `${(i - 1) * 150}ms` }"
|
|
176
|
+
></span>
|
|
177
|
+
</div>
|
|
178
|
+
<span :class="['ml-2 text-xs', escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
179
|
+
{{ t('chat.typing', { name: typingUser }) }}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- New messages pill -->
|
|
186
|
+
<button
|
|
187
|
+
v-if="hasNewMessages"
|
|
188
|
+
:class="[
|
|
189
|
+
'absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full px-4 py-1.5 text-xs font-medium shadow-lg transition-all',
|
|
190
|
+
escDark ? 'bg-cyan-500 text-white hover:bg-cyan-400' : 'bg-blue-600 text-white hover:bg-blue-500',
|
|
191
|
+
]"
|
|
192
|
+
@click="scrollToBottom"
|
|
193
|
+
>
|
|
194
|
+
{{ t('chat.new_messages') }}
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import CustomTicketActions from './CustomTicketActions.vue';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
title: 'Components/CustomTicketActions',
|
|
5
|
+
component: CustomTicketActions,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const baseAction = {
|
|
9
|
+
confirmation: null,
|
|
10
|
+
disabled: false,
|
|
11
|
+
metadata: {},
|
|
12
|
+
method: 'post',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const Default = {
|
|
16
|
+
args: {
|
|
17
|
+
actions: [
|
|
18
|
+
{
|
|
19
|
+
...baseAction,
|
|
20
|
+
key: 'sync-crm',
|
|
21
|
+
label: 'Sync CRM',
|
|
22
|
+
variant: 'primary',
|
|
23
|
+
metadata: { icon: 'refresh-cw' },
|
|
24
|
+
url: '#',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
...baseAction,
|
|
28
|
+
key: 'export',
|
|
29
|
+
label: 'Export',
|
|
30
|
+
variant: 'secondary',
|
|
31
|
+
url: '#',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const WithConfirmationAndDanger = {
|
|
38
|
+
args: {
|
|
39
|
+
actions: [
|
|
40
|
+
{
|
|
41
|
+
...baseAction,
|
|
42
|
+
key: 'archive',
|
|
43
|
+
label: 'Archive',
|
|
44
|
+
variant: 'danger',
|
|
45
|
+
confirmation: 'Archive this ticket?',
|
|
46
|
+
url: '#',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Disabled = {
|
|
53
|
+
args: {
|
|
54
|
+
actions: [
|
|
55
|
+
{
|
|
56
|
+
...baseAction,
|
|
57
|
+
key: 'locked',
|
|
58
|
+
label: 'Already synced',
|
|
59
|
+
variant: 'secondary',
|
|
60
|
+
disabled: true,
|
|
61
|
+
url: '#',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Empty = {
|
|
68
|
+
args: {
|
|
69
|
+
actions: [],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders host-application defined action buttons on the agent ticket screen.
|
|
7
|
+
*
|
|
8
|
+
* Each action is provided by the backend's ticket action registry and exposed
|
|
9
|
+
* on the ticket show props as `customActions`. Clicking a button (after an
|
|
10
|
+
* optional confirmation) POSTs to the action's `url`, which dispatches the
|
|
11
|
+
* backend `TicketCustomActionTriggered` event so the host app can handle it.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {Object} CustomAction
|
|
14
|
+
* @property {string} key - Unique identifier for the action.
|
|
15
|
+
* @property {string} label - Pre-localized button label from the backend.
|
|
16
|
+
* @property {string} variant - Button style: 'primary' | 'secondary' | 'danger'.
|
|
17
|
+
* @property {string|null} confirmation - Optional pre-localized confirmation prompt.
|
|
18
|
+
* @property {boolean} disabled - Whether the button is non-interactive.
|
|
19
|
+
* @property {Object} metadata - Extra data from the action (e.g. `icon`).
|
|
20
|
+
* @property {string} url - Endpoint the action POSTs to.
|
|
21
|
+
* @property {string} method - HTTP method, currently always 'post'.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
defineProps({
|
|
25
|
+
actions: { type: Array, default: () => [] },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const escDark = inject(
|
|
29
|
+
'esc-dark',
|
|
30
|
+
computed(() => false),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Key of the action currently submitting, so we only disable the clicked button.
|
|
34
|
+
const processingKey = ref(null);
|
|
35
|
+
|
|
36
|
+
function variantClasses(variant) {
|
|
37
|
+
switch (variant) {
|
|
38
|
+
case 'primary':
|
|
39
|
+
return 'bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-black/20 hover:from-cyan-400 hover:to-violet-400';
|
|
40
|
+
case 'danger':
|
|
41
|
+
return escDark.value
|
|
42
|
+
? 'bg-rose-500/15 text-rose-400 ring-1 ring-rose-500/20 hover:bg-rose-500/25'
|
|
43
|
+
: 'bg-rose-50 text-rose-600 ring-1 ring-rose-200 hover:bg-rose-100';
|
|
44
|
+
case 'secondary':
|
|
45
|
+
default:
|
|
46
|
+
return escDark.value
|
|
47
|
+
? 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-secondary)] hover:bg-[var(--esc-panel-hover)]'
|
|
48
|
+
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function trigger(action) {
|
|
53
|
+
if (action.disabled || processingKey.value || !action.url) return;
|
|
54
|
+
if (action.confirmation && !window.confirm(action.confirmation)) return;
|
|
55
|
+
|
|
56
|
+
processingKey.value = action.key;
|
|
57
|
+
router.post(
|
|
58
|
+
action.url,
|
|
59
|
+
{},
|
|
60
|
+
{
|
|
61
|
+
preserveScroll: true,
|
|
62
|
+
onFinish: () => {
|
|
63
|
+
processingKey.value = null;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<button
|
|
72
|
+
v-for="action in actions"
|
|
73
|
+
:key="action.key"
|
|
74
|
+
type="button"
|
|
75
|
+
:disabled="action.disabled || processingKey === action.key"
|
|
76
|
+
:data-action-key="action.key"
|
|
77
|
+
:class="[
|
|
78
|
+
'inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-all',
|
|
79
|
+
variantClasses(action.variant),
|
|
80
|
+
(action.disabled || processingKey === action.key) && 'cursor-not-allowed opacity-50',
|
|
81
|
+
]"
|
|
82
|
+
@click="trigger(action)"
|
|
83
|
+
>
|
|
84
|
+
<svg
|
|
85
|
+
v-if="action.metadata && action.metadata.icon"
|
|
86
|
+
class="h-4 w-4"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
stroke-width="1.5"
|
|
90
|
+
viewBox="0 0 24 24"
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
>
|
|
93
|
+
<path
|
|
94
|
+
stroke-linecap="round"
|
|
95
|
+
stroke-linejoin="round"
|
|
96
|
+
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
|
|
97
|
+
/>
|
|
98
|
+
</svg>
|
|
99
|
+
<span>{{ action.label }}</span>
|
|
100
|
+
</button>
|
|
101
|
+
</template>
|