@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.
Files changed (116) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +380 -361
  3. package/package.json +85 -84
  4. package/src/components/ActiveChatsPanel.vue +246 -246
  5. package/src/components/AdminDashboard.stories.js +1423 -1423
  6. package/src/components/AttachmentList.vue +65 -65
  7. package/src/components/ChatActionBar.vue +226 -226
  8. package/src/components/ChatBubble.vue +162 -162
  9. package/src/components/ChatComposer.vue +308 -308
  10. package/src/components/ChatQueue.vue +222 -222
  11. package/src/components/ChatThread.vue +197 -197
  12. package/src/components/CustomTicketActions.stories.js +71 -0
  13. package/src/components/CustomTicketActions.vue +101 -0
  14. package/src/components/EscalatedLayout.vue +619 -604
  15. package/src/components/FollowButton.vue +20 -1
  16. package/src/components/MentionDropdown.vue +154 -154
  17. package/src/components/MetricComparison.vue +89 -89
  18. package/src/components/PercentileChart.vue +67 -67
  19. package/src/components/PeriodSelector.vue +98 -98
  20. package/src/components/PresenceIndicator.vue +228 -213
  21. package/src/components/ReplyComposer.vue +336 -336
  22. package/src/components/ReplyThread.vue +153 -153
  23. package/src/components/ReportChart.vue +395 -395
  24. package/src/components/ReportExportButton.vue +108 -108
  25. package/src/components/SaveViewDialog.vue +172 -172
  26. package/src/components/SavedViewSidebar.vue +208 -208
  27. package/src/components/SkillTagManager.vue +3 -0
  28. package/src/components/SnoozeButton.vue +247 -247
  29. package/src/components/TicketFilters.vue +270 -270
  30. package/src/components/TicketList.vue +655 -655
  31. package/src/components/TicketSidebar.vue +4 -0
  32. package/src/components/TicketSplitDialog.vue +98 -98
  33. package/src/components/TicketSubjects.stories.js +63 -0
  34. package/src/components/TicketSubjects.vue +107 -0
  35. package/src/components/TwoFactorSetup.vue +1 -37
  36. package/src/components/VariableTokenInput.vue +111 -111
  37. package/src/components/WorkflowActionConfig.vue +287 -287
  38. package/src/components/WorkflowActionList.vue +350 -350
  39. package/src/components/WorkflowConditionBuilder.vue +265 -265
  40. package/src/components/WorkflowTriggerSelector.vue +125 -125
  41. package/src/components/admin/newsletters/AnalyticsTiles.stories.js +14 -0
  42. package/src/components/admin/newsletters/AnalyticsTiles.vue +55 -0
  43. package/src/components/admin/newsletters/DeliveriesTable.stories.js +28 -0
  44. package/src/components/admin/newsletters/DeliveriesTable.vue +104 -0
  45. package/src/components/admin/newsletters/DynamicFilterBuilder.stories.js +11 -0
  46. package/src/components/admin/newsletters/DynamicFilterBuilder.vue +53 -0
  47. package/src/components/admin/newsletters/ListMemberTable.stories.js +12 -0
  48. package/src/components/admin/newsletters/ListMemberTable.vue +53 -0
  49. package/src/components/admin/newsletters/MarkdownEditor.stories.js +6 -0
  50. package/src/components/admin/newsletters/MarkdownEditor.vue +53 -0
  51. package/src/components/admin/newsletters/MergeFieldDropdown.stories.js +6 -0
  52. package/src/components/admin/newsletters/MergeFieldDropdown.vue +84 -0
  53. package/src/components/admin/newsletters/PreviewIframe.stories.js +7 -0
  54. package/src/components/admin/newsletters/PreviewIframe.vue +40 -0
  55. package/src/components/admin/newsletters/SummaryTiles.stories.js +8 -0
  56. package/src/components/admin/newsletters/SummaryTiles.vue +63 -0
  57. package/src/composables/useChat.js +187 -187
  58. package/src/composables/useI18n.js +102 -102
  59. package/src/composables/useMentions.js +134 -134
  60. package/src/composables/usePermissions.js +31 -0
  61. package/src/composables/useRealtime.js +182 -182
  62. package/src/index.js +85 -84
  63. package/src/locales/README.md +13 -13
  64. package/src/locales/ar.json +727 -727
  65. package/src/locales/de.json +690 -690
  66. package/src/locales/en.json +889 -729
  67. package/src/locales/es.json +691 -691
  68. package/src/locales/fr.json +691 -691
  69. package/src/locales/index.js +16 -16
  70. package/src/locales/it.json +727 -727
  71. package/src/locales/ja.json +727 -727
  72. package/src/locales/ko.json +727 -727
  73. package/src/locales/nl.json +727 -727
  74. package/src/locales/pl.json +727 -727
  75. package/src/locales/pt-BR.json +727 -727
  76. package/src/locales/ru.json +727 -727
  77. package/src/locales/tr.json +727 -727
  78. package/src/locales/zh-CN.json +727 -727
  79. package/src/pages/Admin/Newsletters/Compose.vue +192 -0
  80. package/src/pages/Admin/Newsletters/Edit.vue +24 -0
  81. package/src/pages/Admin/Newsletters/Index.vue +141 -0
  82. package/src/pages/Admin/Newsletters/Lists/Create.vue +64 -0
  83. package/src/pages/Admin/Newsletters/Lists/Index.vue +59 -0
  84. package/src/pages/Admin/Newsletters/Lists/Show.vue +53 -0
  85. package/src/pages/Admin/Newsletters/Settings.vue +85 -0
  86. package/src/pages/Admin/Newsletters/Show.vue +140 -0
  87. package/src/pages/Admin/Newsletters/Templates/Create.vue +11 -0
  88. package/src/pages/Admin/Newsletters/Templates/Index.vue +54 -0
  89. package/src/pages/Admin/Newsletters/Templates/Show.vue +65 -0
  90. package/src/pages/Admin/Newsletters/usePreview.js +44 -0
  91. package/src/pages/Admin/Reports/AgentDetail.vue +132 -132
  92. package/src/pages/Admin/Reports/AgentRanking.vue +246 -246
  93. package/src/pages/Admin/Reports/Cohorts.vue +180 -180
  94. package/src/pages/Admin/Reports/Comparison.vue +141 -141
  95. package/src/pages/Admin/Reports/Dashboard.vue +226 -226
  96. package/src/pages/Admin/Reports/ResolutionTimes.vue +164 -164
  97. package/src/pages/Admin/Reports/ResponseTimes.vue +164 -164
  98. package/src/pages/Admin/Reports/SlaTrends.vue +183 -183
  99. package/src/pages/Admin/Settings/SsoSettings.vue +157 -4
  100. package/src/pages/Admin/Settings/TwoFactor.vue +32 -6
  101. package/src/pages/Admin/Settings.vue +1008 -1008
  102. package/src/pages/Admin/Skills/Form.vue +30 -10
  103. package/src/pages/Admin/Tickets/Index.vue +126 -126
  104. package/src/pages/Admin/Tickets/Show.vue +254 -249
  105. package/src/pages/Admin/Users/Index.vue +145 -145
  106. package/src/pages/Admin/Workflows/Builder.vue +207 -207
  107. package/src/pages/Admin/Workflows/Index.vue +235 -235
  108. package/src/pages/Admin/Workflows/Logs.vue +286 -286
  109. package/src/pages/Agent/TicketIndex.vue +112 -112
  110. package/src/pages/Agent/TicketShow.vue +547 -494
  111. package/src/pages/Customer/Index.vue +56 -56
  112. package/src/pages/Customer/KnowledgeBase/Article.vue +129 -129
  113. package/src/plugin.js +175 -175
  114. package/src/utils/mentionHighlight.js +24 -24
  115. package/src/widget/EscalatedWidget.vue +804 -804
  116. 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>