@escalated-dev/escalated 0.5.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.
package/README.md CHANGED
@@ -125,6 +125,16 @@ app.use(EscalatedPlugin, { layout: AppLayout })
125
125
 
126
126
  All the Vue 3 + Inertia.js components that power the Escalated UI. These are identical across Laravel, Rails, Django, and AdonisJS — the backend framework renders them via Inertia.
127
127
 
128
+ ## 📸 Screenshots
129
+
130
+ <p align="center">
131
+ <img src="docs/assets/escalated_admin_1.png" alt="Escalated Admin Screenshot 1" width="800" />
132
+ </p>
133
+
134
+ <p align="center">
135
+ <img src="docs/assets/escalated_admin_2.png" alt="Escalated Admin Screenshot 2" width="800" />
136
+ </p>
137
+
128
138
  ### Pages
129
139
 
130
140
  **Customer Portal** — Self-service ticket management
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escalated-dev/escalated",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
5
5
  "author": "Escalated Dev <hello@escalated.dev>",
6
6
  "license": "MIT",
@@ -21,24 +21,26 @@
21
21
  "exports": {
22
22
  "./components/*": "./src/components/*",
23
23
  "./pages/*": "./src/pages/*",
24
+ "./locales": "./src/locales/index.js",
25
+ "./locales/*": "./src/locales/*",
24
26
  ".": "./src/index.js"
25
27
  },
26
28
  "files": [
27
29
  "src"
28
30
  ],
29
31
  "peerDependencies": {
30
- "vue": "^3.3.0",
31
- "@inertiajs/vue3": "^1.0.0 || ^2.0.0"
32
+ "@inertiajs/vue3": "^1.0.0 || ^2.0.0",
33
+ "vue": "^3.3.0"
32
34
  },
33
35
  "scripts": {
34
36
  "test": "vitest run"
35
37
  },
36
38
  "devDependencies": {
37
- "vue": "^3.5.0",
38
39
  "@inertiajs/vue3": "^2.0.0",
39
40
  "@vitejs/plugin-vue": "^5.0.0",
40
41
  "@vue/test-utils": "^2.4.0",
41
42
  "happy-dom": "^15.0.0",
42
- "vitest": "^2.0.0"
43
+ "vitest": "^2.0.0",
44
+ "vue": "^3.5.0"
43
45
  }
44
46
  }
@@ -1,66 +1,66 @@
1
- <script setup>
2
- import { computed, inject } from 'vue';
3
-
4
- defineProps({
5
- activities: { type: Array, required: true },
6
- });
7
-
8
- const dark = inject('esc-dark', computed(() => false));
9
-
10
- const typeLabels = {
11
- status_changed: 'changed status',
12
- assigned: 'assigned ticket',
13
- unassigned: 'unassigned ticket',
14
- priority_changed: 'changed priority',
15
- tag_added: 'added tag',
16
- tag_removed: 'removed tag',
17
- escalated: 'escalated ticket',
18
- sla_breached: 'SLA breached',
19
- replied: 'replied',
20
- note_added: 'added note',
21
- department_changed: 'changed department',
22
- reopened: 'reopened ticket',
23
- resolved: 'resolved ticket',
24
- closed: 'closed ticket',
25
- };
26
-
27
- function formatDate(date) {
28
- return new Date(date).toLocaleString();
29
- }
30
-
31
- function describeActivity(activity) {
32
- const label = typeLabels[activity.type] || activity.type;
33
- const who = activity.causer?.name || 'System';
34
- const props = activity.properties || {};
35
-
36
- if (activity.type === 'status_changed' && props.to) {
37
- return `${who} ${label} to ${props.to}`;
38
- }
39
- if (activity.type === 'assigned' && props.agent_name) {
40
- return `${who} assigned to ${props.agent_name}`;
41
- }
42
- if (activity.type === 'priority_changed' && props.to) {
43
- return `${who} ${label} to ${props.to}`;
44
- }
45
- if ((activity.type === 'tag_added' || activity.type === 'tag_removed') && props.tag) {
46
- return `${who} ${label} "${props.tag}"`;
47
- }
48
- if (activity.type === 'department_changed' && props.department) {
49
- return `${who} ${label} to ${props.department}`;
50
- }
51
- return `${who} ${label}`;
52
- }
53
- </script>
54
-
55
- <template>
56
- <div class="space-y-3">
57
- <div v-for="activity in activities" :key="activity.id" class="flex gap-3 text-sm">
58
- <div :class="['mt-1 h-2 w-2 flex-shrink-0 rounded-full', dark ? 'bg-neutral-600' : 'bg-gray-400']"></div>
59
- <div class="flex-1">
60
- <p :class="dark ? 'text-neutral-300' : 'text-gray-700'">{{ describeActivity(activity) }}</p>
61
- <p :class="['text-xs', dark ? 'text-neutral-600' : 'text-gray-400']">{{ formatDate(activity.created_at) }}</p>
62
- </div>
63
- </div>
64
- <div v-if="!activities?.length" :class="['py-4 text-center text-sm', dark ? 'text-neutral-600' : 'text-gray-500']">No activity yet.</div>
65
- </div>
66
- </template>
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ defineProps({
5
+ activities: { type: Array, required: true },
6
+ });
7
+
8
+ const dark = inject('esc-dark', computed(() => false));
9
+
10
+ const typeLabels = {
11
+ status_changed: 'changed status',
12
+ assigned: 'assigned ticket',
13
+ unassigned: 'unassigned ticket',
14
+ priority_changed: 'changed priority',
15
+ tag_added: 'added tag',
16
+ tag_removed: 'removed tag',
17
+ escalated: 'escalated ticket',
18
+ sla_breached: 'SLA breached',
19
+ replied: 'replied',
20
+ note_added: 'added note',
21
+ department_changed: 'changed department',
22
+ reopened: 'reopened ticket',
23
+ resolved: 'resolved ticket',
24
+ closed: 'closed ticket',
25
+ };
26
+
27
+ function formatDate(date) {
28
+ return new Date(date).toLocaleString();
29
+ }
30
+
31
+ function describeActivity(activity) {
32
+ const label = typeLabels[activity.type] || activity.type;
33
+ const who = activity.causer?.name || 'System';
34
+ const props = activity.properties || {};
35
+
36
+ if (activity.type === 'status_changed' && props.to) {
37
+ return `${who} ${label} to ${props.to}`;
38
+ }
39
+ if (activity.type === 'assigned' && props.agent_name) {
40
+ return `${who} assigned to ${props.agent_name}`;
41
+ }
42
+ if (activity.type === 'priority_changed' && props.to) {
43
+ return `${who} ${label} to ${props.to}`;
44
+ }
45
+ if ((activity.type === 'tag_added' || activity.type === 'tag_removed') && props.tag) {
46
+ return `${who} ${label} "${props.tag}"`;
47
+ }
48
+ if (activity.type === 'department_changed' && props.department) {
49
+ return `${who} ${label} to ${props.department}`;
50
+ }
51
+ return `${who} ${label}`;
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="space-y-3">
57
+ <div v-for="activity in activities" :key="activity.id" class="flex gap-3 text-sm">
58
+ <div :class="['mt-1 h-2 w-2 flex-shrink-0 rounded-full', dark ? 'bg-neutral-600' : 'bg-gray-400']"></div>
59
+ <div class="flex-1">
60
+ <p :class="dark ? 'text-neutral-300' : 'text-gray-700'">{{ describeActivity(activity) }}</p>
61
+ <p :class="['text-xs', dark ? 'text-neutral-600' : 'text-gray-400']">{{ formatDate(activity.created_at) }}</p>
62
+ </div>
63
+ </div>
64
+ <div v-if="!activities?.length" :class="['py-4 text-center text-sm', dark ? 'text-neutral-600' : 'text-gray-500']">No activity yet.</div>
65
+ </div>
66
+ </template>
@@ -1,23 +1,23 @@
1
- <script setup>
2
- import { computed, inject } from 'vue';
3
-
4
- defineProps({
5
- agents: { type: Array, required: true },
6
- modelValue: { type: [Number, String], default: null },
7
- });
8
-
9
- const emit = defineEmits(['update:modelValue']);
10
- const dark = inject('esc-dark', computed(() => false));
11
- </script>
12
-
13
- <template>
14
- <div>
15
- <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Assigned To</label>
16
- <select :value="modelValue" @change="emit('update:modelValue', $event.target.value || null)"
17
- :class="['w-full rounded-md border px-2 py-1.5 text-sm focus:outline-none',
18
- dark ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']">
19
- <option value="">Unassigned</option>
20
- <option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
21
- </select>
22
- </div>
23
- </template>
1
+ <script setup>
2
+ import { computed, inject } from 'vue';
3
+
4
+ defineProps({
5
+ agents: { type: Array, required: true },
6
+ modelValue: { type: [Number, String], default: null },
7
+ });
8
+
9
+ const emit = defineEmits(['update:modelValue']);
10
+ const dark = inject('esc-dark', computed(() => false));
11
+ </script>
12
+
13
+ <template>
14
+ <div>
15
+ <label :class="['mb-1 block text-xs font-medium', dark ? 'text-neutral-500' : 'text-gray-600']">Assigned To</label>
16
+ <select :value="modelValue" @change="emit('update:modelValue', $event.target.value || null)"
17
+ :class="['w-full rounded-md border px-2 py-1.5 text-sm focus:outline-none',
18
+ dark ? 'border-white/10 bg-neutral-950 text-neutral-200 focus:border-white/20 focus:ring-1 focus:ring-white/10' : 'border-gray-300 focus:border-blue-500']">
19
+ <option value="">Unassigned</option>
20
+ <option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
21
+ </select>
22
+ </div>
23
+ </template>
@@ -1,41 +1,41 @@
1
- <script setup>
2
- import { ref, computed, inject } from 'vue';
3
-
4
- const emit = defineEmits(['files']);
5
- const dark = inject('esc-dark', computed(() => false));
6
-
7
- const dragging = ref(false);
8
- const fileInput = ref(null);
9
-
10
- function onDrop(e) {
11
- dragging.value = false;
12
- if (e.dataTransfer?.files?.length) {
13
- emit('files', Array.from(e.dataTransfer.files));
14
- }
15
- }
16
-
17
- function onFileSelect(e) {
18
- if (e.target.files?.length) {
19
- emit('files', Array.from(e.target.files));
20
- e.target.value = '';
21
- }
22
- }
23
-
24
- function browse() {
25
- fileInput.value?.click();
26
- }
27
- </script>
28
-
29
- <template>
30
- <div @dragover.prevent="dragging = true" @dragleave="dragging = false" @drop.prevent="onDrop"
31
- :class="['cursor-pointer rounded-lg border-2 border-dashed px-4 py-3 text-center text-xs transition-colors',
32
- dark
33
- ? (dragging ? 'border-white/20 bg-white/[0.04]' : 'border-white/[0.08] hover:border-white/[0.12]')
34
- : (dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400')]"
35
- @click="browse">
36
- <p :class="dark ? 'text-neutral-500' : 'text-gray-500'">
37
- Drop files here or <span :class="['font-medium', dark ? 'text-white' : 'text-blue-600']">browse</span>
38
- </p>
39
- <input ref="fileInput" type="file" multiple class="hidden" @change="onFileSelect" />
40
- </div>
41
- </template>
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+
4
+ const emit = defineEmits(['files']);
5
+ const dark = inject('esc-dark', computed(() => false));
6
+
7
+ const dragging = ref(false);
8
+ const fileInput = ref(null);
9
+
10
+ function onDrop(e) {
11
+ dragging.value = false;
12
+ if (e.dataTransfer?.files?.length) {
13
+ emit('files', Array.from(e.dataTransfer.files));
14
+ }
15
+ }
16
+
17
+ function onFileSelect(e) {
18
+ if (e.target.files?.length) {
19
+ emit('files', Array.from(e.target.files));
20
+ e.target.value = '';
21
+ }
22
+ }
23
+
24
+ function browse() {
25
+ fileInput.value?.click();
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <div @dragover.prevent="dragging = true" @dragleave="dragging = false" @drop.prevent="onDrop"
31
+ :class="['cursor-pointer rounded-lg border-2 border-dashed px-4 py-3 text-center text-xs transition-colors',
32
+ dark
33
+ ? (dragging ? 'border-white/20 bg-white/[0.04]' : 'border-white/[0.08] hover:border-white/[0.12]')
34
+ : (dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400')]"
35
+ @click="browse">
36
+ <p :class="dark ? 'text-neutral-500' : 'text-gray-500'">
37
+ Drop files here or <span :class="['font-medium', dark ? 'text-white' : 'text-blue-600']">browse</span>
38
+ </p>
39
+ <input ref="fileInput" type="file" multiple class="hidden" @change="onFileSelect" />
40
+ </div>
41
+ </template>
@@ -1,6 +1,8 @@
1
1
  <script setup>
2
2
  import { ref, computed, inject } from 'vue';
3
3
  import { router } from '@inertiajs/vue3';
4
+ import { sanitizeHtml } from '../utils/sanitizeHtml';
5
+ import { useI18n } from '../composables/useI18n';
4
6
 
5
7
  const props = defineProps({
6
8
  notes: { type: Array, default: () => [] },
@@ -9,6 +11,7 @@ const props = defineProps({
9
11
  });
10
12
 
11
13
  const escDark = inject('esc-dark', computed(() => false));
14
+ const { t } = useI18n();
12
15
  const processingId = ref(null);
13
16
 
14
17
  function unpinNote(note) {
@@ -49,7 +52,7 @@ function formatDate(dateStr) {
49
52
  </svg>
50
53
  <span :class="['text-xs font-semibold uppercase tracking-wider',
51
54
  escDark ? 'text-amber-400' : 'text-amber-700']">
52
- Pinned Notes
55
+ {{ t('pinned_notes.title') }}
53
56
  </span>
54
57
  </div>
55
58
 
@@ -61,7 +64,7 @@ function formatDate(dateStr) {
61
64
  ? 'border-amber-500/10 bg-amber-500/[0.04]'
62
65
  : 'border-amber-200 bg-white']">
63
66
  <!-- Note body -->
64
- <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="note.body"></div>
67
+ <div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="sanitizeHtml(note.body)"></div>
65
68
 
66
69
  <!-- Meta row -->
67
70
  <div class="mt-2 flex items-center justify-between">
@@ -78,7 +81,7 @@ function formatDate(dateStr) {
78
81
  ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
79
82
  : 'text-amber-600 hover:bg-amber-100 hover:text-amber-700',
80
83
  processingId === note.id && 'opacity-50 cursor-not-allowed']">
81
- Unpin
84
+ {{ t('pinned_notes.unpin') }}
82
85
  </button>
83
86
  </div>
84
87
  </div>
@@ -1,39 +1,39 @@
1
- <script setup>
2
- import { inject, computed } from 'vue';
3
-
4
- const props = defineProps({
5
- priority: { type: String, required: true },
6
- });
7
-
8
- const escDark = inject('esc-dark', computed(() => false));
9
-
10
- const darkConfig = {
11
- low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
12
- medium: { label: 'Medium', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
13
- high: { label: 'High', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
- urgent: { label: 'Urgent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
- critical: { label: 'Critical', color: 'bg-rose-500/10 text-rose-300 ring-1 ring-rose-500/20' },
16
- };
17
-
18
- const lightConfig = {
19
- low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
20
- medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
21
- high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
22
- urgent: { label: 'Urgent', color: 'bg-orange-100 text-orange-800' },
23
- critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
24
- };
25
-
26
- const config = computed(() => {
27
- const map = escDark.value ? darkConfig : lightConfig;
28
- const fallback = escDark.value
29
- ? { label: props.priority, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' }
30
- : { label: props.priority, color: 'bg-gray-100 text-gray-800' };
31
- return map[props.priority] || fallback;
32
- });
33
- </script>
34
-
35
- <template>
36
- <span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
37
- {{ config.label }}
38
- </span>
39
- </template>
1
+ <script setup>
2
+ import { inject, computed } from 'vue';
3
+
4
+ const props = defineProps({
5
+ priority: { type: String, required: true },
6
+ });
7
+
8
+ const escDark = inject('esc-dark', computed(() => false));
9
+
10
+ const darkConfig = {
11
+ low: { label: 'Low', color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' },
12
+ medium: { label: 'Medium', color: 'bg-cyan-500/10 text-white ring-1 ring-cyan-500/20' },
13
+ high: { label: 'High', color: 'bg-amber-500/10 text-amber-400 ring-1 ring-amber-500/20' },
14
+ urgent: { label: 'Urgent', color: 'bg-orange-500/10 text-orange-400 ring-1 ring-orange-500/20' },
15
+ critical: { label: 'Critical', color: 'bg-rose-500/10 text-rose-300 ring-1 ring-rose-500/20' },
16
+ };
17
+
18
+ const lightConfig = {
19
+ low: { label: 'Low', color: 'bg-gray-100 text-gray-800' },
20
+ medium: { label: 'Medium', color: 'bg-blue-100 text-blue-800' },
21
+ high: { label: 'High', color: 'bg-yellow-100 text-yellow-800' },
22
+ urgent: { label: 'Urgent', color: 'bg-orange-100 text-orange-800' },
23
+ critical: { label: 'Critical', color: 'bg-red-100 text-red-800' },
24
+ };
25
+
26
+ const config = computed(() => {
27
+ const map = escDark.value ? darkConfig : lightConfig;
28
+ const fallback = escDark.value
29
+ ? { label: props.priority, color: 'bg-gray-500/10 text-gray-400 ring-1 ring-gray-500/20' }
30
+ : { label: props.priority, color: 'bg-gray-100 text-gray-800' };
31
+ return map[props.priority] || fallback;
32
+ });
33
+ </script>
34
+
35
+ <template>
36
+ <span :class="['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', config.color]">
37
+ {{ config.label }}
38
+ </span>
39
+ </template>