@escalated-dev/escalated 0.7.1 → 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 -357
- package/package.json +85 -81
- 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 -598
- 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 +29 -22
- 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 +30 -8
- 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 -0
- 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 +164 -4
- package/src/pages/Admin/Skills/Index.vue +14 -3
- 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 -0
- 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 +26 -1
- 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,98 +1,98 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { ref, computed } from 'vue';
|
|
3
|
-
|
|
4
|
-
const props = defineProps({
|
|
5
|
-
modelValue: { type: Number, default: 30 },
|
|
6
|
-
options: { type: Array, default: () => [7, 30, 90, 365] },
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
const emit = defineEmits(['update:modelValue']);
|
|
10
|
-
|
|
11
|
-
const showCustom = ref(false);
|
|
12
|
-
const customStart = ref('');
|
|
13
|
-
const customEnd = ref('');
|
|
14
|
-
|
|
15
|
-
const isCustom = computed(() => !props.options.includes(props.modelValue));
|
|
16
|
-
|
|
17
|
-
function selectPeriod(days) {
|
|
18
|
-
showCustom.value = false;
|
|
19
|
-
emit('update:modelValue', days);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function applyCustom() {
|
|
23
|
-
if (customStart.value && customEnd.value) {
|
|
24
|
-
const start = new Date(customStart.value);
|
|
25
|
-
const end = new Date(customEnd.value);
|
|
26
|
-
const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
|
|
27
|
-
if (days > 0) {
|
|
28
|
-
emit('update:modelValue', days);
|
|
29
|
-
showCustom.value = false;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function formatLabel(days) {
|
|
35
|
-
if (days === 365) return '1y';
|
|
36
|
-
return `${days}d`;
|
|
37
|
-
}
|
|
38
|
-
</script>
|
|
39
|
-
|
|
40
|
-
<template>
|
|
41
|
-
<div class="flex items-center gap-2">
|
|
42
|
-
<button
|
|
43
|
-
v-for="d in options"
|
|
44
|
-
:key="d"
|
|
45
|
-
:class="[
|
|
46
|
-
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
|
|
47
|
-
modelValue === d
|
|
48
|
-
? 'bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] text-white shadow-lg shadow-[var(--esc-panel-bg)]/20'
|
|
49
|
-
: 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
|
|
50
|
-
]"
|
|
51
|
-
@click="selectPeriod(d)"
|
|
52
|
-
>
|
|
53
|
-
{{ formatLabel(d) }}
|
|
54
|
-
</button>
|
|
55
|
-
<div class="relative">
|
|
56
|
-
<button
|
|
57
|
-
:class="[
|
|
58
|
-
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
|
|
59
|
-
isCustom
|
|
60
|
-
? 'bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] text-white shadow-lg shadow-[var(--esc-panel-bg)]/20'
|
|
61
|
-
: 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
|
|
62
|
-
]"
|
|
63
|
-
@click="showCustom = !showCustom"
|
|
64
|
-
>
|
|
65
|
-
Custom
|
|
66
|
-
</button>
|
|
67
|
-
<div
|
|
68
|
-
v-if="showCustom"
|
|
69
|
-
class="absolute right-0 top-full z-10 mt-2 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4 shadow-xl"
|
|
70
|
-
>
|
|
71
|
-
<div class="flex flex-col gap-3">
|
|
72
|
-
<label class="text-xs font-medium text-[var(--esc-panel-text-muted)]">
|
|
73
|
-
Start
|
|
74
|
-
<input
|
|
75
|
-
v-model="customStart"
|
|
76
|
-
type="date"
|
|
77
|
-
class="mt-1 block w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3 py-1.5 text-sm text-[var(--esc-panel-text)]"
|
|
78
|
-
/>
|
|
79
|
-
</label>
|
|
80
|
-
<label class="text-xs font-medium text-[var(--esc-panel-text-muted)]">
|
|
81
|
-
End
|
|
82
|
-
<input
|
|
83
|
-
v-model="customEnd"
|
|
84
|
-
type="date"
|
|
85
|
-
class="mt-1 block w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3 py-1.5 text-sm text-[var(--esc-panel-text)]"
|
|
86
|
-
/>
|
|
87
|
-
</label>
|
|
88
|
-
<button
|
|
89
|
-
class="rounded-lg bg-[var(--esc-panel-accent)] px-4 py-1.5 text-sm font-medium text-white transition-all hover:opacity-90"
|
|
90
|
-
@click="applyCustom"
|
|
91
|
-
>
|
|
92
|
-
Apply
|
|
93
|
-
</button>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</template>
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
modelValue: { type: Number, default: 30 },
|
|
6
|
+
options: { type: Array, default: () => [7, 30, 90, 365] },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits(['update:modelValue']);
|
|
10
|
+
|
|
11
|
+
const showCustom = ref(false);
|
|
12
|
+
const customStart = ref('');
|
|
13
|
+
const customEnd = ref('');
|
|
14
|
+
|
|
15
|
+
const isCustom = computed(() => !props.options.includes(props.modelValue));
|
|
16
|
+
|
|
17
|
+
function selectPeriod(days) {
|
|
18
|
+
showCustom.value = false;
|
|
19
|
+
emit('update:modelValue', days);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyCustom() {
|
|
23
|
+
if (customStart.value && customEnd.value) {
|
|
24
|
+
const start = new Date(customStart.value);
|
|
25
|
+
const end = new Date(customEnd.value);
|
|
26
|
+
const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
|
|
27
|
+
if (days > 0) {
|
|
28
|
+
emit('update:modelValue', days);
|
|
29
|
+
showCustom.value = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatLabel(days) {
|
|
35
|
+
if (days === 365) return '1y';
|
|
36
|
+
return `${days}d`;
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="flex items-center gap-2">
|
|
42
|
+
<button
|
|
43
|
+
v-for="d in options"
|
|
44
|
+
:key="d"
|
|
45
|
+
:class="[
|
|
46
|
+
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
|
|
47
|
+
modelValue === d
|
|
48
|
+
? 'bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] text-white shadow-lg shadow-[var(--esc-panel-bg)]/20'
|
|
49
|
+
: 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
|
|
50
|
+
]"
|
|
51
|
+
@click="selectPeriod(d)"
|
|
52
|
+
>
|
|
53
|
+
{{ formatLabel(d) }}
|
|
54
|
+
</button>
|
|
55
|
+
<div class="relative">
|
|
56
|
+
<button
|
|
57
|
+
:class="[
|
|
58
|
+
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
|
|
59
|
+
isCustom
|
|
60
|
+
? 'bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] text-white shadow-lg shadow-[var(--esc-panel-bg)]/20'
|
|
61
|
+
: 'border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] text-[var(--esc-panel-text-tertiary)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
|
|
62
|
+
]"
|
|
63
|
+
@click="showCustom = !showCustom"
|
|
64
|
+
>
|
|
65
|
+
Custom
|
|
66
|
+
</button>
|
|
67
|
+
<div
|
|
68
|
+
v-if="showCustom"
|
|
69
|
+
class="absolute right-0 top-full z-10 mt-2 rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4 shadow-xl"
|
|
70
|
+
>
|
|
71
|
+
<div class="flex flex-col gap-3">
|
|
72
|
+
<label class="text-xs font-medium text-[var(--esc-panel-text-muted)]">
|
|
73
|
+
Start
|
|
74
|
+
<input
|
|
75
|
+
v-model="customStart"
|
|
76
|
+
type="date"
|
|
77
|
+
class="mt-1 block w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3 py-1.5 text-sm text-[var(--esc-panel-text)]"
|
|
78
|
+
/>
|
|
79
|
+
</label>
|
|
80
|
+
<label class="text-xs font-medium text-[var(--esc-panel-text-muted)]">
|
|
81
|
+
End
|
|
82
|
+
<input
|
|
83
|
+
v-model="customEnd"
|
|
84
|
+
type="date"
|
|
85
|
+
class="mt-1 block w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-hover)] px-3 py-1.5 text-sm text-[var(--esc-panel-text)]"
|
|
86
|
+
/>
|
|
87
|
+
</label>
|
|
88
|
+
<button
|
|
89
|
+
class="rounded-lg bg-[var(--esc-panel-accent)] px-4 py-1.5 text-sm font-medium text-white transition-all hover:opacity-90"
|
|
90
|
+
@click="applyCustom"
|
|
91
|
+
>
|
|
92
|
+
Apply
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</template>
|
|
@@ -1,213 +1,228 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { ref, computed, inject,
|
|
3
|
-
|
|
4
|
-
const props = defineProps({
|
|
5
|
-
ticketReference: { type: String, required: true },
|
|
6
|
-
ticketId: { type: [Number, String], default: null },
|
|
7
|
-
routePrefix: { type: String, required: true },
|
|
8
|
-
pollInterval: { type: Number, default: 30000 },
|
|
9
|
-
showLabel: { type: Boolean, default: false },
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
const effectiveInterval = computed(() => Math.max(props.pollInterval, 5000));
|
|
13
|
-
const echoAvailable = typeof window !== 'undefined' && !!window.Echo;
|
|
14
|
-
|
|
15
|
-
const escDark = inject(
|
|
16
|
-
'esc-dark',
|
|
17
|
-
computed(() => false),
|
|
18
|
-
);
|
|
19
|
-
const viewers = ref([]);
|
|
20
|
-
const hovering = ref(false);
|
|
21
|
-
let intervalId = null;
|
|
22
|
-
let presenceChannel = null;
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
'
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'bg-
|
|
93
|
-
'bg-
|
|
94
|
-
'bg-
|
|
95
|
-
'bg-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject, onUnmounted, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
ticketReference: { type: String, required: true },
|
|
6
|
+
ticketId: { type: [Number, String], default: null },
|
|
7
|
+
routePrefix: { type: String, required: true },
|
|
8
|
+
pollInterval: { type: Number, default: 30000 },
|
|
9
|
+
showLabel: { type: Boolean, default: false },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const effectiveInterval = computed(() => Math.max(props.pollInterval, 5000));
|
|
13
|
+
const echoAvailable = typeof window !== 'undefined' && !!window.Echo;
|
|
14
|
+
|
|
15
|
+
const escDark = inject(
|
|
16
|
+
'esc-dark',
|
|
17
|
+
computed(() => false),
|
|
18
|
+
);
|
|
19
|
+
const viewers = ref([]);
|
|
20
|
+
const hovering = ref(false);
|
|
21
|
+
let intervalId = null;
|
|
22
|
+
let presenceChannel = null;
|
|
23
|
+
// Track the exact channel name we joined so we leave the RIGHT channel when the
|
|
24
|
+
// ticket changes — props.ticketId would already hold the new id by then.
|
|
25
|
+
let joinedChannelName = null;
|
|
26
|
+
|
|
27
|
+
async function fetchPresence() {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(route(`${props.routePrefix}.tickets.presence`, props.ticketReference), {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
Accept: 'application/json',
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
35
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (response.ok) {
|
|
39
|
+
const data = await response.json();
|
|
40
|
+
viewers.value = data.viewers || data || [];
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// silently ignore network errors
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Use Echo presence channel for real-time viewer tracking when available.
|
|
49
|
+
* Falls back to polling when Echo is not configured.
|
|
50
|
+
*/
|
|
51
|
+
function initPresenceChannel() {
|
|
52
|
+
if (!echoAvailable || !props.ticketId) return false;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
joinedChannelName = `escalated.tickets.${props.ticketId}`;
|
|
56
|
+
presenceChannel = window.Echo.join(joinedChannelName);
|
|
57
|
+
|
|
58
|
+
presenceChannel
|
|
59
|
+
.here((users) => {
|
|
60
|
+
viewers.value = users;
|
|
61
|
+
})
|
|
62
|
+
.joining((user) => {
|
|
63
|
+
if (!viewers.value.find((v) => v.id === user.id)) {
|
|
64
|
+
viewers.value = [...viewers.value, user];
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.leaving((user) => {
|
|
68
|
+
viewers.value = viewers.value.filter((v) => v.id !== user.id);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tooltipText = computed(() => {
|
|
78
|
+
if (!viewers.value.length) return '';
|
|
79
|
+
const names = viewers.value.map((v) => v.name);
|
|
80
|
+
return 'Also viewing: ' + names.join(', ');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const maxVisible = 4;
|
|
84
|
+
const visibleViewers = computed(() => viewers.value.slice(0, maxVisible));
|
|
85
|
+
const overflowCount = computed(() => Math.max(0, viewers.value.length - maxVisible));
|
|
86
|
+
|
|
87
|
+
function getInitial(viewer) {
|
|
88
|
+
return (viewer.name || '?').charAt(0).toUpperCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const colors = [
|
|
92
|
+
'bg-cyan-500',
|
|
93
|
+
'bg-violet-500',
|
|
94
|
+
'bg-amber-500',
|
|
95
|
+
'bg-emerald-500',
|
|
96
|
+
'bg-rose-500',
|
|
97
|
+
'bg-blue-500',
|
|
98
|
+
'bg-pink-500',
|
|
99
|
+
'bg-teal-500',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function getColor(index) {
|
|
103
|
+
return colors[index % colors.length];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function teardown() {
|
|
107
|
+
if (intervalId) {
|
|
108
|
+
clearInterval(intervalId);
|
|
109
|
+
intervalId = null;
|
|
110
|
+
}
|
|
111
|
+
if (presenceChannel) {
|
|
112
|
+
try {
|
|
113
|
+
window.Echo.leave(joinedChannelName);
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
presenceChannel = null;
|
|
118
|
+
joinedChannelName = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function start() {
|
|
123
|
+
// Tear down any subscription for the previously viewed ticket first. Inertia
|
|
124
|
+
// reuses this component across ticket navigation, so without re-initialising
|
|
125
|
+
// we would keep showing the old ticket's viewers and leak its channel.
|
|
126
|
+
teardown();
|
|
127
|
+
viewers.value = [];
|
|
128
|
+
// Prefer presence channels via Echo; fall back to polling
|
|
129
|
+
const usingEcho = initPresenceChannel();
|
|
130
|
+
if (!usingEcho) {
|
|
131
|
+
fetchPresence();
|
|
132
|
+
intervalId = setInterval(fetchPresence, effectiveInterval.value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// (Re)initialise whenever the ticket identity changes; `immediate` covers mount.
|
|
137
|
+
watch(() => [props.ticketId, props.ticketReference], start, { immediate: true });
|
|
138
|
+
|
|
139
|
+
onUnmounted(teardown);
|
|
140
|
+
</script>
|
|
141
|
+
|
|
142
|
+
<template>
|
|
143
|
+
<div
|
|
144
|
+
v-if="viewers.length > 0"
|
|
145
|
+
class="relative flex items-center"
|
|
146
|
+
@mouseenter="hovering = true"
|
|
147
|
+
@mouseleave="hovering = false"
|
|
148
|
+
>
|
|
149
|
+
<!-- Avatar stack -->
|
|
150
|
+
<TransitionGroup
|
|
151
|
+
tag="div"
|
|
152
|
+
class="flex -space-x-2"
|
|
153
|
+
enter-active-class="transition-all duration-300 ease-out"
|
|
154
|
+
enter-from-class="opacity-0 scale-75 -ml-7"
|
|
155
|
+
enter-to-class="opacity-100 scale-100 ml-0"
|
|
156
|
+
leave-active-class="transition-all duration-200 ease-in"
|
|
157
|
+
leave-from-class="opacity-100 scale-100"
|
|
158
|
+
leave-to-class="opacity-0 scale-75 -ml-7"
|
|
159
|
+
>
|
|
160
|
+
<div
|
|
161
|
+
v-for="(viewer, index) in visibleViewers"
|
|
162
|
+
:key="viewer.id || viewer.name"
|
|
163
|
+
:aria-label="viewer.name"
|
|
164
|
+
:class="[
|
|
165
|
+
'relative flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white ring-2',
|
|
166
|
+
getColor(index),
|
|
167
|
+
escDark ? 'ring-neutral-900' : 'ring-white',
|
|
168
|
+
]"
|
|
169
|
+
>
|
|
170
|
+
{{ getInitial(viewer) }}
|
|
171
|
+
<!-- Pulsing live indicator on first avatar -->
|
|
172
|
+
<span v-if="index === 0" class="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
|
|
173
|
+
<span
|
|
174
|
+
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"
|
|
175
|
+
></span>
|
|
176
|
+
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500"></span>
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div
|
|
180
|
+
v-if="overflowCount > 0"
|
|
181
|
+
:key="'overflow'"
|
|
182
|
+
:class="[
|
|
183
|
+
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2',
|
|
184
|
+
escDark
|
|
185
|
+
? 'bg-white/[0.08] text-neutral-300 ring-neutral-900'
|
|
186
|
+
: 'bg-gray-200 text-gray-600 ring-white',
|
|
187
|
+
]"
|
|
188
|
+
>
|
|
189
|
+
+{{ overflowCount }}
|
|
190
|
+
</div>
|
|
191
|
+
</TransitionGroup>
|
|
192
|
+
|
|
193
|
+
<!-- Viewer count label -->
|
|
194
|
+
<span
|
|
195
|
+
v-if="showLabel"
|
|
196
|
+
aria-live="polite"
|
|
197
|
+
:class="['ml-2 text-xs font-medium', escDark ? 'text-neutral-400' : 'text-gray-500']"
|
|
198
|
+
>
|
|
199
|
+
{{ viewers.length }} viewing
|
|
200
|
+
</span>
|
|
201
|
+
|
|
202
|
+
<!-- Tooltip -->
|
|
203
|
+
<Transition
|
|
204
|
+
enter-active-class="transition duration-100 ease-out"
|
|
205
|
+
enter-from-class="opacity-0 translate-y-1"
|
|
206
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
207
|
+
leave-active-class="transition duration-75 ease-in"
|
|
208
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
209
|
+
leave-to-class="opacity-0 translate-y-1"
|
|
210
|
+
>
|
|
211
|
+
<div
|
|
212
|
+
v-if="hovering"
|
|
213
|
+
:class="[
|
|
214
|
+
'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',
|
|
215
|
+
escDark ? 'border border-white/[0.06] bg-neutral-800 text-neutral-200' : 'bg-gray-900 text-white',
|
|
216
|
+
]"
|
|
217
|
+
>
|
|
218
|
+
{{ tooltipText }}
|
|
219
|
+
<div
|
|
220
|
+
:class="[
|
|
221
|
+
'absolute -top-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45',
|
|
222
|
+
escDark ? 'bg-neutral-800' : 'bg-gray-900',
|
|
223
|
+
]"
|
|
224
|
+
></div>
|
|
225
|
+
</div>
|
|
226
|
+
</Transition>
|
|
227
|
+
</div>
|
|
228
|
+
</template>
|