@escalated-dev/escalated 0.4.0 → 0.6.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.
@@ -1,46 +1,46 @@
1
- <script setup>
2
- import { inject, computed } from 'vue';
3
-
4
- const props = defineProps({
5
- status: { type: String, required: true },
6
- });
7
-
8
- const escDark = inject('esc-dark', computed(() => false));
9
-
10
- const darkConfig = {
11
- open: { label: 'Open', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
12
- in_progress: { label: 'In Progress', color: 'bg-violet-500/10 text-violet-400 ring-1 ring-violet-500/20' },
13
- waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
- waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
- escalated: { label: 'Escalated', color: 'bg-rose-500/10 text-rose-400 ring-1 ring-rose-500/20' },
16
- resolved: { label: 'Resolved', color: 'bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20' },
17
- closed: { label: 'Closed', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
18
- reopened: { label: 'Reopened', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
19
- };
20
-
21
- const lightConfig = {
22
- open: { label: 'Open', color: 'bg-blue-100 text-blue-800' },
23
- in_progress: { label: 'In Progress', color: 'bg-purple-100 text-purple-800' },
24
- waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-yellow-100 text-yellow-800' },
25
- waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-100 text-orange-800' },
26
- escalated: { label: 'Escalated', color: 'bg-red-100 text-red-800' },
27
- resolved: { label: 'Resolved', color: 'bg-green-100 text-green-800' },
28
- closed: { label: 'Closed', color: 'bg-gray-100 text-gray-800' },
29
- reopened: { label: 'Reopened', color: 'bg-blue-100 text-blue-800' },
30
- };
31
-
32
- const fallback = { label: props.status, color: 'bg-gray-100 text-gray-800' };
33
- const darkFallback = { label: props.status, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' };
34
-
35
- const config = computed(() => {
36
- const map = escDark.value ? darkConfig : lightConfig;
37
- const fb = escDark.value ? darkFallback : fallback;
38
- return map[props.status] || fb;
39
- });
40
- </script>
41
-
42
- <template>
43
- <span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
44
- {{ config.label }}
45
- </span>
46
- </template>
1
+ <script setup>
2
+ import { inject, computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ status: { type: String, required: true },
6
+ });
7
+
8
+ const escDark = inject('esc-dark', computed(() => false));
9
+
10
+ const darkConfig = {
11
+ open: { label: 'Open', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
12
+ in_progress: { label: 'In Progress', color: 'bg-violet-500/10 text-violet-400 ring-1 ring-violet-500/20' },
13
+ waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
+ waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
+ escalated: { label: 'Escalated', color: 'bg-rose-500/10 text-rose-400 ring-1 ring-rose-500/20' },
16
+ resolved: { label: 'Resolved', color: 'bg-emerald-500/10 text-emerald-400 ring-1 ring-emerald-500/20' },
17
+ closed: { label: 'Closed', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
18
+ reopened: { label: 'Reopened', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
19
+ };
20
+
21
+ const lightConfig = {
22
+ open: { label: 'Open', color: 'bg-blue-100 text-blue-800' },
23
+ in_progress: { label: 'In Progress', color: 'bg-purple-100 text-purple-800' },
24
+ waiting_on_customer: { label: 'Waiting on Customer', color: 'bg-yellow-100 text-yellow-800' },
25
+ waiting_on_agent: { label: 'Waiting on Agent', color: 'bg-orange-100 text-orange-800' },
26
+ escalated: { label: 'Escalated', color: 'bg-red-100 text-red-800' },
27
+ resolved: { label: 'Resolved', color: 'bg-green-100 text-green-800' },
28
+ closed: { label: 'Closed', color: 'bg-gray-100 text-gray-800' },
29
+ reopened: { label: 'Reopened', color: 'bg-blue-100 text-blue-800' },
30
+ };
31
+
32
+ const fallback = { label: props.status, color: 'bg-gray-100 text-gray-800' };
33
+ const darkFallback = { label: props.status, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' };
34
+
35
+ const config = computed(() => {
36
+ const map = escDark.value ? darkConfig : lightConfig;
37
+ const fb = escDark.value ? darkFallback : fallback;
38
+ return map[props.status] || fb;
39
+ });
40
+ </script>
41
+
42
+ <template>
43
+ <span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
44
+ {{ config.label }}
45
+ </span>
46
+ </template>
@@ -1,53 +1,53 @@
1
- <script setup>
2
- import { ref, computed, inject } from 'vue';
3
-
4
- const props = defineProps({
5
- tags: { type: Array, required: true },
6
- modelValue: { type: Array, default: () => [] },
7
- });
8
-
9
- const emit = defineEmits(['update:modelValue']);
10
- const dark = inject('esc-dark', computed(() => false));
11
-
12
- const search = ref('');
13
-
14
- const filteredTags = computed(() => {
15
- if (!search.value) return props.tags;
16
- const q = search.value.toLowerCase();
17
- return props.tags.filter(t => t.name.toLowerCase().includes(q));
18
- });
19
-
20
- function toggle(tagId) {
21
- const current = [...props.modelValue];
22
- const index = current.indexOf(tagId);
23
- if (index >= 0) {
24
- current.splice(index, 1);
25
- } else {
26
- current.push(tagId);
27
- }
28
- emit('update:modelValue', current);
29
- }
30
-
31
- function isSelected(tagId) {
32
- return props.modelValue.includes(tagId);
33
- }
34
- </script>
35
-
36
- <template>
37
- <div>
38
- <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Tags</label>
39
- <input v-model="search" type="text" placeholder="Filter tags..."
40
- :class="['mb-2 w-full rounded-md border px-2 py-1 text-xs focus:outline-none',
41
- dark ? 'border-white/10 bg-neutral-950 text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']" />
42
- <div class="flex flex-wrap gap-1">
43
- <button v-for="tag in filteredTags" :key="tag.id" @click="toggle(tag.id)"
44
- :class="['rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
45
- dark
46
- ? (isSelected(tag.id) ? 'bg-white/[0.12] text-white ring-1 ring-white/20' : 'bg-white/[0.04] text-neutral-500 hover:bg-white/[0.08]')
47
- : (isSelected(tag.id) ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200')]"
48
- :style="tag.color ? { backgroundColor: isSelected(tag.id) ? tag.color + '33' : undefined, color: isSelected(tag.id) ? tag.color : undefined } : {}">
49
- {{ tag.name }}
50
- </button>
51
- </div>
52
- </div>
53
- </template>
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+
4
+ const props = defineProps({
5
+ tags: { type: Array, required: true },
6
+ modelValue: { type: Array, default: () => [] },
7
+ });
8
+
9
+ const emit = defineEmits(['update:modelValue']);
10
+ const dark = inject('esc-dark', computed(() => false));
11
+
12
+ const search = ref('');
13
+
14
+ const filteredTags = computed(() => {
15
+ if (!search.value) return props.tags;
16
+ const q = search.value.toLowerCase();
17
+ return props.tags.filter(t => t.name.toLowerCase().includes(q));
18
+ });
19
+
20
+ function toggle(tagId) {
21
+ const current = [...props.modelValue];
22
+ const index = current.indexOf(tagId);
23
+ if (index >= 0) {
24
+ current.splice(index, 1);
25
+ } else {
26
+ current.push(tagId);
27
+ }
28
+ emit('update:modelValue', current);
29
+ }
30
+
31
+ function isSelected(tagId) {
32
+ return props.modelValue.includes(tagId);
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div>
38
+ <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Tags</label>
39
+ <input v-model="search" type="text" placeholder="Filter tags..."
40
+ :class="['mb-2 w-full rounded-md border px-2 py-1 text-xs focus:outline-none',
41
+ dark ? 'border-white/10 bg-neutral-950 text-neutral-200 placeholder-neutral-600 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']" />
42
+ <div class="flex flex-wrap gap-1">
43
+ <button v-for="tag in filteredTags" :key="tag.id" @click="toggle(tag.id)"
44
+ :class="['rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
45
+ dark
46
+ ? (isSelected(tag.id) ? 'bg-white/[0.12] text-white ring-1 ring-white/20' : 'bg-white/[0.04] text-neutral-500 hover:bg-white/[0.08]')
47
+ : (isSelected(tag.id) ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200')]"
48
+ :style="tag.color ? { backgroundColor: isSelected(tag.id) ? tag.color + '33' : undefined, color: isSelected(tag.id) ? tag.color : undefined } : {}">
49
+ {{ tag.name }}
50
+ </button>
51
+ </div>
52
+ </div>
53
+ </template>
@@ -0,0 +1,80 @@
1
+ import { ref, computed } from 'vue';
2
+ import locales from '../locales/index.js';
3
+
4
+ const currentLocale = ref('en');
5
+ const customMessages = ref({});
6
+
7
+ function resolve(obj, path) {
8
+ return path.split('.').reduce((acc, key) => acc?.[key], obj);
9
+ }
10
+
11
+ function applyReplacements(str, replacements) {
12
+ if (!replacements) return str;
13
+ return Object.entries(replacements).reduce(
14
+ (result, [key, value]) => result.replace(new RegExp(`:${key}`, 'g'), value),
15
+ str,
16
+ );
17
+ }
18
+
19
+ export function setLocale(locale) {
20
+ currentLocale.value = locale;
21
+ }
22
+
23
+ export function getLocale() {
24
+ return currentLocale.value;
25
+ }
26
+
27
+ export function mergeMessages(locale, messages) {
28
+ customMessages.value = {
29
+ ...customMessages.value,
30
+ [locale]: deepMerge(customMessages.value[locale] || {}, messages),
31
+ };
32
+ }
33
+
34
+ function deepMerge(target, source) {
35
+ const result = { ...target };
36
+ for (const key of Object.keys(source)) {
37
+ if (
38
+ source[key] &&
39
+ typeof source[key] === 'object' &&
40
+ !Array.isArray(source[key]) &&
41
+ target[key] &&
42
+ typeof target[key] === 'object'
43
+ ) {
44
+ result[key] = deepMerge(target[key], source[key]);
45
+ } else {
46
+ result[key] = source[key];
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+
52
+ export function useI18n() {
53
+ const locale = computed(() => currentLocale.value);
54
+
55
+ function t(key, replacements) {
56
+ const lang = currentLocale.value;
57
+
58
+ // Try custom messages for current locale
59
+ const custom = resolve(customMessages.value[lang], key);
60
+ if (typeof custom === 'string') return applyReplacements(custom, replacements);
61
+
62
+ // Try built-in messages for current locale
63
+ const msg = resolve(locales[lang], key);
64
+ if (typeof msg === 'string') return applyReplacements(msg, replacements);
65
+
66
+ // Fallback to English
67
+ if (lang !== 'en') {
68
+ const customEn = resolve(customMessages.value.en, key);
69
+ if (typeof customEn === 'string') return applyReplacements(customEn, replacements);
70
+
71
+ const fallback = resolve(locales.en, key);
72
+ if (typeof fallback === 'string') return applyReplacements(fallback, replacements);
73
+ }
74
+
75
+ // Return raw key
76
+ return key;
77
+ }
78
+
79
+ return { t, locale };
80
+ }
@@ -0,0 +1,67 @@
1
+ import { computed } from 'vue';
2
+ import { usePage } from '@inertiajs/vue3';
3
+
4
+ /**
5
+ * Composable for reading plugin extensions from Inertia shared props.
6
+ *
7
+ * The Laravel backend (escalated-laravel) shares plugin UI extensions via
8
+ * `$page.props.escalated.plugins`, which contains:
9
+ * - menuItems: Array of navigation items injected by plugins
10
+ * - dashboardWidgets: Array of widget definitions for the agent dashboard
11
+ * - pageComponents: Object mapping slot names to arrays of component definitions
12
+ *
13
+ * @returns {Object} Plugin extension data and helper functions
14
+ */
15
+ export function usePluginExtensions() {
16
+ const page = usePage();
17
+
18
+ const pluginData = computed(() => page.props?.escalated?.plugins || {});
19
+
20
+ /**
21
+ * Menu items injected by plugins, sorted by position.
22
+ * Each item: { label, route, icon, position, permission, badge, submenu }
23
+ */
24
+ const menuItems = computed(() => {
25
+ const items = pluginData.value.menuItems || [];
26
+ return [...items].sort((a, b) => (a.position || 100) - (b.position || 100));
27
+ });
28
+
29
+ /**
30
+ * Dashboard widgets injected by plugins, sorted by position.
31
+ * Each widget: { id, title, component, plugin, data, position, width }
32
+ */
33
+ const dashboardWidgets = computed(() => {
34
+ const widgets = pluginData.value.dashboardWidgets || [];
35
+ return [...widgets].sort((a, b) => (a.position || 100) - (b.position || 100));
36
+ });
37
+
38
+ /**
39
+ * Get plugin components for a specific page slot, sorted by position.
40
+ *
41
+ * @param {string} pageName - The page name (e.g. 'dashboard', 'ticket.show')
42
+ * @param {string} slotName - The slot name (e.g. 'header', 'sidebar', 'footer')
43
+ * @returns {Array} Sorted array of component definitions for the given slot
44
+ */
45
+ function getPageComponents(pageName, slotName) {
46
+ const key = `${pageName}.${slotName}`;
47
+ const components = pluginData.value.pageComponents || {};
48
+ return [...(components[key] || [])].sort((a, b) => (a.position || 100) - (b.position || 100));
49
+ }
50
+
51
+ /**
52
+ * Check whether any plugins have registered extensions.
53
+ */
54
+ const hasPlugins = computed(() => {
55
+ return menuItems.value.length > 0
56
+ || dashboardWidgets.value.length > 0
57
+ || Object.keys(pluginData.value.pageComponents || {}).length > 0;
58
+ });
59
+
60
+ return {
61
+ pluginData,
62
+ menuItems,
63
+ dashboardWidgets,
64
+ getPageComponents,
65
+ hasPlugins,
66
+ };
67
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Helper for plugin authors to define an Escalated plugin.
3
+ *
4
+ * Returns a Vue plugin object that registers components globally when installed.
5
+ *
6
+ * Usage in a plugin's main entry file:
7
+ *
8
+ * import { defineEscalatedPlugin } from '@escalated-dev/escalated'
9
+ * import SalesWidget from './Components/SalesWidget.vue'
10
+ * import CustomSidebar from './Components/CustomSidebar.vue'
11
+ *
12
+ * export default defineEscalatedPlugin({
13
+ * name: 'my-plugin',
14
+ * components: {
15
+ * SalesWidget,
16
+ * CustomSidebar,
17
+ * },
18
+ * setup(app) {
19
+ * // Optional: run custom setup logic when the plugin is installed
20
+ * },
21
+ * })
22
+ *
23
+ * @param {Object} options
24
+ * @param {string} options.name - Plugin identifier
25
+ * @param {Object} [options.components] - Map of component name to component definition
26
+ * @param {Function} [options.setup] - Optional setup callback receiving the Vue app instance
27
+ * @returns {Object} A Vue plugin object with an install() method
28
+ */
29
+ export function defineEscalatedPlugin(options = {}) {
30
+ return {
31
+ install(app) {
32
+ // Register all provided components globally
33
+ if (options.components) {
34
+ Object.entries(options.components).forEach(([name, component]) => {
35
+ app.component(name, component);
36
+ });
37
+ }
38
+
39
+ // Run optional setup callback
40
+ if (typeof options.setup === 'function') {
41
+ options.setup(app);
42
+ }
43
+
44
+ // Provide the plugin name for debugging
45
+ if (options.name) {
46
+ app.provide(`escalated-plugin:${options.name}`, true);
47
+ }
48
+ },
49
+ };
50
+ }
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ export { default as FollowButton } from './components/FollowButton.vue'
12
12
  export { default as KeyboardShortcutHelp } from './components/KeyboardShortcutHelp.vue'
13
13
  export { default as MacroDropdown } from './components/MacroDropdown.vue'
14
14
  export { default as PinnedNotes } from './components/PinnedNotes.vue'
15
+ export { default as PluginSlot } from './components/PluginSlot.vue'
15
16
  export { default as PresenceIndicator } from './components/PresenceIndicator.vue'
16
17
  export { default as PriorityBadge } from './components/PriorityBadge.vue'
17
18
  export { default as QuickFilters } from './components/QuickFilters.vue'
@@ -28,3 +29,7 @@ export { default as TicketSidebar } from './components/TicketSidebar.vue'
28
29
 
29
30
  // Composables
30
31
  export { useKeyboardShortcuts } from './composables/useKeyboardShortcuts'
32
+ export { usePluginExtensions } from './composables/usePluginExtensions'
33
+
34
+ // Plugin authoring helper
35
+ export { defineEscalatedPlugin } from './defineEscalatedPlugin'