@escalated-dev/escalated 0.9.0 → 0.11.1

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
@@ -15,19 +15,23 @@
15
15
  <a href="docs/translations/README.zh-CN.md">简体中文</a>
16
16
  </p>
17
17
 
18
- <h1>
18
+ <h1 align="center">
19
19
  <img src="https://escalated.dev/apple-touch-icon.png" width="28" style="vertical-align:middle;" />
20
20
  Escalated
21
21
  </h1>
22
22
 
23
- [![Tests](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml/badge.svg)](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml)
24
- [![FOSSA Status](https://app.fossa.com/api/projects/custom%2B62107%2Fgithub.com%2Fescalated-dev%2Fescalated.svg?type=shield)](https://app.fossa.com/projects/custom%2B62107%2Fgithub.com%2Fescalated-dev%2Fescalated?ref=badge_shield)
25
- [![npm](https://img.shields.io/npm/v/@escalated-dev/escalated)](https://www.npmjs.com/package/@escalated-dev/escalated)
23
+ <div align="center">
24
+
25
+ [![Tests](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml/badge.svg)](https://github.com/escalated-dev/escalated/actions/workflows/run-tests.yml)[![npm](https://img.shields.io/npm/v/@escalated-dev/escalated)](https://www.npmjs.com/package/@escalated-dev/escalated)
26
26
  [![Vue 3](https://img.shields.io/badge/vue-3.x-4FC08D?logo=vue.js&logoColor=white)](https://vuejs.org/)
27
27
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
28
 
29
+ </div>
30
+
29
31
  <p align="center">
30
- <img src=".github/profile/demo.gif" alt="Escalated demo: filing a ticket and replying from the agent inbox" width="800" />
32
+ <a href="https://escalated.dev">
33
+ <img src=".github/og.png" alt="Escalated — support tickets, built into your app" width="840" />
34
+ </a>
31
35
  </p>
32
36
 
33
37
  Escalated is an embeddable support ticket system with SLA tracking, escalation rules, agent workflows, and a customer portal. This repo contains all the shared frontend assets (Vue 3 + Inertia.js) used across every supported backend framework.
@@ -36,6 +40,10 @@ Escalated is an embeddable support ticket system with SLA tracking, escalation r
36
40
 
37
41
  **You don't install this package directly.** Start with the backend package for your framework — it handles everything including pulling in these frontend assets.
38
42
 
43
+ <p align="center">
44
+ <img src=".github/profile/demo.gif" alt="Escalated demo: filing a ticket and replying from the agent inbox" width="800" />
45
+ </p>
46
+
39
47
  ## Features
40
48
 
41
49
  - **Ticket splitting** — Split a reply into a new standalone ticket while preserving context
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@escalated-dev/escalated",
3
- "version": "0.9.0",
4
- "description": "Vue 3 + Inertia.js UI components for Escalated \u00e2\u20ac\u201d the embeddable support ticket system",
3
+ "version": "0.11.1",
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",
7
7
  "homepage": "https://github.com/escalated-dev/escalated",
@@ -33,7 +33,7 @@
33
33
  "vue": "^3.3.0"
34
34
  },
35
35
  "dependencies": {
36
- "@escalated-dev/locale": "github:escalated-dev/escalated-locale#v0.1.8"
36
+ "@escalated-dev/locale": "^0.1.8"
37
37
  },
38
38
  "scripts": {
39
39
  "test": "vitest run",
@@ -57,29 +57,29 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@eslint/js": "^10.0.1",
60
- "@inertiajs/vue3": "^3.2.0",
61
- "@playwright/test": "^1.50.0",
62
- "@storybook/addon-a11y": "^10.2.12",
63
- "@storybook/addon-docs": "^10.2.12",
64
- "@storybook/vue3-vite": "^10.2.12",
65
- "@tailwindcss/postcss": "^4.3.0",
66
- "@vitejs/plugin-vue": "^6.0.6",
67
- "@vue/test-utils": "^2.4.0",
68
- "autoprefixer": "^10.4.20",
69
- "concurrently": "^9.0.0",
70
- "eslint": "^10.0.1",
71
- "eslint-plugin-storybook": "^10.2.12",
72
- "eslint-plugin-vue": "^10.8.0",
73
- "happy-dom": "^20.9.0",
60
+ "@inertiajs/vue3": "^3.7.0",
61
+ "@playwright/test": "^1.62.1",
62
+ "@storybook/addon-a11y": "^10.6.0",
63
+ "@storybook/addon-docs": "^10.6.0",
64
+ "@storybook/vue3-vite": "^10.6.0",
65
+ "@tailwindcss/postcss": "^4.3.3",
66
+ "@vitejs/plugin-vue": "^6.0.8",
67
+ "@vue/test-utils": "^2.5.0",
68
+ "autoprefixer": "^10.5.4",
69
+ "concurrently": "^10.0.5",
70
+ "eslint": "^10.9.1",
71
+ "eslint-plugin-storybook": "^10.5.10",
72
+ "eslint-plugin-vue": "^10.10.0",
73
+ "happy-dom": "^20.14.0",
74
74
  "husky": "^9.1.7",
75
- "lint-staged": "^17.0.5",
76
- "playwright": "^1.50.0",
77
- "postcss": "^8.5.0",
78
- "prettier": "^3.8.1",
79
- "storybook": "^10.2.12",
80
- "tailwindcss": "^4.3.0",
81
- "vitest": "^4.1.6",
82
- "vue": "^3.5.0",
83
- "wait-on": "^9.0.5"
75
+ "lint-staged": "^17.4.1",
76
+ "playwright": "^1.62.1",
77
+ "postcss": "^8.5.26",
78
+ "prettier": "^3.9.6",
79
+ "storybook": "^10.5.10",
80
+ "tailwindcss": "^4.3.3",
81
+ "vitest": "^5.0.0",
82
+ "vue": "^3.5.42",
83
+ "wait-on": "^9.1.0"
84
84
  }
85
85
  }
@@ -85,6 +85,15 @@ const adminLinks = computed(() => {
85
85
  icon: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
86
86
  position: 53,
87
87
  },
88
+ {
89
+ href: `${p}/admin/automations`,
90
+ label: 'Automations',
91
+ // Time-based rules, next to the event-driven ones. The two are
92
+ // separate surfaces and always have been; this entry was simply
93
+ // never added, so the screen the backends render had no way in.
94
+ icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
95
+ position: 53.5,
96
+ },
88
97
  {
89
98
  href: `${p}/admin/webhooks`,
90
99
  label: 'Webhooks',
@@ -0,0 +1,280 @@
1
+ <script setup>
2
+ import EscalatedLayout from '../../../components/EscalatedLayout.vue';
3
+ import { Link, useForm } from '@inertiajs/vue3';
4
+
5
+ /**
6
+ * Create and edit a time-based automation.
7
+ *
8
+ * The vocabulary here is the runner's: every field, operator and action type
9
+ * below is one the backend actually evaluates. Offering anything else would
10
+ * produce a rule that saves cleanly and then silently never matches.
11
+ */
12
+ const props = defineProps({
13
+ automation: { type: Object, default: null },
14
+ });
15
+
16
+ /** Fields that count hours take an operator and a number; the rest take a value. */
17
+ const TIME_FIELDS = ['hours_since_created', 'hours_since_updated', 'hours_since_assigned'];
18
+
19
+ const FIELDS = [
20
+ { value: 'hours_since_created', label: 'Hours since created' },
21
+ { value: 'hours_since_updated', label: 'Hours since last update' },
22
+ { value: 'hours_since_assigned', label: 'Hours since assigned' },
23
+ { value: 'status', label: 'Status' },
24
+ { value: 'priority', label: 'Priority' },
25
+ { value: 'assigned', label: 'Assignment' },
26
+ { value: 'ticket_type', label: 'Ticket type' },
27
+ { value: 'subject_contains', label: 'Subject contains' },
28
+ ];
29
+
30
+ const OPERATORS = [
31
+ { value: '>', label: 'more than' },
32
+ { value: '>=', label: 'at least' },
33
+ { value: '<', label: 'fewer than' },
34
+ { value: '<=', label: 'at most' },
35
+ { value: '=', label: 'exactly' },
36
+ ];
37
+
38
+ const ACTIONS = [
39
+ { value: 'change_status', label: 'Change status' },
40
+ { value: 'change_priority', label: 'Change priority' },
41
+ { value: 'assign', label: 'Assign to agent' },
42
+ { value: 'add_tag', label: 'Add tag' },
43
+ { value: 'set_ticket_type', label: 'Set ticket type' },
44
+ { value: 'add_note', label: 'Add internal note' },
45
+ ];
46
+
47
+ const ASSIGNMENT_VALUES = [
48
+ { value: 'assigned', label: 'Assigned' },
49
+ { value: 'unassigned', label: 'Unassigned' },
50
+ ];
51
+
52
+ const form = useForm({
53
+ name: props.automation?.name ?? '',
54
+ conditions: (props.automation?.conditions ?? []).map((c) => ({
55
+ field: c.field ?? 'hours_since_updated',
56
+ operator: c.operator ?? '>',
57
+ value: c.value ?? '',
58
+ })),
59
+ actions: (props.automation?.actions ?? []).map((a) => ({
60
+ type: a.type ?? 'change_status',
61
+ value: a.value ?? '',
62
+ })),
63
+ active: props.automation?.active ?? true,
64
+ });
65
+
66
+ // A new automation starts with one of each, because an empty one cannot be
67
+ // saved: the backend requires at least one condition and one action.
68
+ if (!form.conditions.length) {
69
+ form.conditions.push({ field: 'hours_since_updated', operator: '>', value: 24 });
70
+ }
71
+
72
+ if (!form.actions.length) {
73
+ form.actions.push({ type: 'change_status', value: '' });
74
+ }
75
+
76
+ function isTimeField(field) {
77
+ return TIME_FIELDS.includes(field);
78
+ }
79
+
80
+ function addCondition() {
81
+ form.conditions.push({ field: 'hours_since_updated', operator: '>', value: 24 });
82
+ }
83
+
84
+ function removeCondition(index) {
85
+ form.conditions.splice(index, 1);
86
+ }
87
+
88
+ function addAction() {
89
+ form.actions.push({ type: 'change_status', value: '' });
90
+ }
91
+
92
+ function removeAction(index) {
93
+ form.actions.splice(index, 1);
94
+ }
95
+
96
+ function submit() {
97
+ if (props.automation?.id) {
98
+ form.put(route('escalated.admin.automations.update', props.automation.id));
99
+ } else {
100
+ form.post(route('escalated.admin.automations.store'));
101
+ }
102
+ }
103
+ </script>
104
+
105
+ <template>
106
+ <EscalatedLayout :title="automation ? 'Edit Automation' : 'Create Automation'">
107
+ <div class="mb-4">
108
+ <Link
109
+ :href="route('escalated.admin.automations.index')"
110
+ class="text-xs text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-accent)]"
111
+ >
112
+ &larr; Back to automations
113
+ </Link>
114
+ <h2 class="mt-2 text-lg font-semibold text-[var(--esc-panel-text-secondary)]">
115
+ {{ automation ? 'Edit Automation' : 'Create Automation' }}
116
+ </h2>
117
+ <p class="text-xs text-[var(--esc-panel-text-muted)]">
118
+ Runs on a schedule against open tickets. Every condition must match for the actions to apply.
119
+ </p>
120
+ </div>
121
+
122
+ <form class="space-y-6" @submit.prevent="submit">
123
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4">
124
+ <label class="block text-xs font-medium text-[var(--esc-panel-text-muted)]" for="automation-name">
125
+ Name
126
+ </label>
127
+ <input
128
+ id="automation-name"
129
+ v-model="form.name"
130
+ type="text"
131
+ required
132
+ maxlength="255"
133
+ placeholder="Escalate tickets untouched for a day"
134
+ class="mt-1 w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-accent)] focus:outline-none"
135
+ />
136
+ <p v-if="form.errors.name" class="mt-1 text-xs text-red-400">{{ form.errors.name }}</p>
137
+
138
+ <label class="mt-4 flex items-center gap-2 text-sm text-[var(--esc-panel-text-secondary)]">
139
+ <input v-model="form.active" type="checkbox" class="rounded border-[var(--esc-panel-border)]" />
140
+ Active
141
+ </label>
142
+ </div>
143
+
144
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4">
145
+ <div class="mb-3 flex items-center justify-between">
146
+ <div>
147
+ <h3 class="text-sm font-semibold text-[var(--esc-panel-text-secondary)]">When</h3>
148
+ <p class="text-xs text-[var(--esc-panel-text-muted)]">All conditions must match</p>
149
+ </div>
150
+ <button
151
+ type="button"
152
+ class="rounded-lg border border-[var(--esc-panel-border)] px-3 py-1.5 text-xs font-medium text-[var(--esc-panel-text-secondary)] transition-colors hover:bg-[var(--esc-panel-hover)]"
153
+ @click="addCondition"
154
+ >
155
+ Add condition
156
+ </button>
157
+ </div>
158
+
159
+ <div v-for="(condition, i) in form.conditions" :key="`condition-${i}`" class="mb-2 flex gap-2">
160
+ <select
161
+ v-model="condition.field"
162
+ :aria-label="`Condition ${i + 1} field`"
163
+ class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
164
+ >
165
+ <option v-for="field in FIELDS" :key="field.value" :value="field.value">
166
+ {{ field.label }}
167
+ </option>
168
+ </select>
169
+
170
+ <select
171
+ v-if="isTimeField(condition.field)"
172
+ v-model="condition.operator"
173
+ :aria-label="`Condition ${i + 1} operator`"
174
+ class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
175
+ >
176
+ <option v-for="operator in OPERATORS" :key="operator.value" :value="operator.value">
177
+ {{ operator.label }}
178
+ </option>
179
+ </select>
180
+
181
+ <select
182
+ v-if="condition.field === 'assigned'"
183
+ v-model="condition.value"
184
+ :aria-label="`Condition ${i + 1} value`"
185
+ class="flex-1 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
186
+ >
187
+ <option v-for="option in ASSIGNMENT_VALUES" :key="option.value" :value="option.value">
188
+ {{ option.label }}
189
+ </option>
190
+ </select>
191
+ <input
192
+ v-else
193
+ v-model="condition.value"
194
+ :type="isTimeField(condition.field) ? 'number' : 'text'"
195
+ :min="isTimeField(condition.field) ? 0 : undefined"
196
+ :aria-label="`Condition ${i + 1} value`"
197
+ :placeholder="isTimeField(condition.field) ? 'hours' : 'value'"
198
+ class="flex-1 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
199
+ />
200
+
201
+ <button
202
+ type="button"
203
+ :aria-label="`Remove condition ${i + 1}`"
204
+ class="rounded-lg px-2 text-xs text-[var(--esc-panel-text-muted)] transition-colors hover:text-red-400"
205
+ :disabled="form.conditions.length === 1"
206
+ @click="removeCondition(i)"
207
+ >
208
+ Remove
209
+ </button>
210
+ </div>
211
+
212
+ <p v-if="form.errors.conditions" class="mt-1 text-xs text-red-400">{{ form.errors.conditions }}</p>
213
+ </div>
214
+
215
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-4">
216
+ <div class="mb-3 flex items-center justify-between">
217
+ <div>
218
+ <h3 class="text-sm font-semibold text-[var(--esc-panel-text-secondary)]">Then</h3>
219
+ <p class="text-xs text-[var(--esc-panel-text-muted)]">Applied to every matching ticket</p>
220
+ </div>
221
+ <button
222
+ type="button"
223
+ class="rounded-lg border border-[var(--esc-panel-border)] px-3 py-1.5 text-xs font-medium text-[var(--esc-panel-text-secondary)] transition-colors hover:bg-[var(--esc-panel-hover)]"
224
+ @click="addAction"
225
+ >
226
+ Add action
227
+ </button>
228
+ </div>
229
+
230
+ <div v-for="(action, i) in form.actions" :key="`action-${i}`" class="mb-2 flex gap-2">
231
+ <select
232
+ v-model="action.type"
233
+ :aria-label="`Action ${i + 1} type`"
234
+ class="rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
235
+ >
236
+ <option v-for="option in ACTIONS" :key="option.value" :value="option.value">
237
+ {{ option.label }}
238
+ </option>
239
+ </select>
240
+
241
+ <input
242
+ v-model="action.value"
243
+ type="text"
244
+ :aria-label="`Action ${i + 1} value`"
245
+ placeholder="value"
246
+ class="flex-1 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-bg)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)]"
247
+ />
248
+
249
+ <button
250
+ type="button"
251
+ :aria-label="`Remove action ${i + 1}`"
252
+ class="rounded-lg px-2 text-xs text-[var(--esc-panel-text-muted)] transition-colors hover:text-red-400"
253
+ :disabled="form.actions.length === 1"
254
+ @click="removeAction(i)"
255
+ >
256
+ Remove
257
+ </button>
258
+ </div>
259
+
260
+ <p v-if="form.errors.actions" class="mt-1 text-xs text-red-400">{{ form.errors.actions }}</p>
261
+ </div>
262
+
263
+ <div class="flex items-center gap-3">
264
+ <button
265
+ type="submit"
266
+ :disabled="form.processing"
267
+ class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-4 py-2 text-sm font-medium text-white transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)] disabled:opacity-50"
268
+ >
269
+ {{ automation ? 'Save changes' : 'Create automation' }}
270
+ </button>
271
+ <Link
272
+ :href="route('escalated.admin.automations.index')"
273
+ class="text-sm text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-text-secondary)]"
274
+ >
275
+ Cancel
276
+ </Link>
277
+ </div>
278
+ </form>
279
+ </EscalatedLayout>
280
+ </template>
@@ -0,0 +1,230 @@
1
+ <script setup>
2
+ import EscalatedLayout from '../../../components/EscalatedLayout.vue';
3
+ import { Link, router } from '@inertiajs/vue3';
4
+
5
+ /**
6
+ * Automations are the time-based half of the admin automation surface: a cron
7
+ * scan matches open tickets on how long they have been sitting and applies
8
+ * actions. Workflows are the event-driven half, and Macros are the agent's
9
+ * manual one. The three co-exist; none subsumes the others.
10
+ */
11
+ defineProps({
12
+ automations: { type: Array, default: () => [] },
13
+ });
14
+
15
+ const FIELD_LABELS = {
16
+ hours_since_created: 'Hours since created',
17
+ hours_since_updated: 'Hours since updated',
18
+ hours_since_assigned: 'Hours since assigned',
19
+ status: 'Status',
20
+ priority: 'Priority',
21
+ assigned: 'Assignment',
22
+ ticket_type: 'Type',
23
+ subject_contains: 'Subject contains',
24
+ };
25
+
26
+ const ACTION_LABELS = {
27
+ change_status: 'Change status',
28
+ assign: 'Assign to',
29
+ add_tag: 'Add tag',
30
+ change_priority: 'Change priority',
31
+ add_note: 'Add internal note',
32
+ set_ticket_type: 'Set type',
33
+ };
34
+
35
+ function destroy(id) {
36
+ if (confirm('Delete this automation? This cannot be undone.')) {
37
+ router.delete(route('escalated.admin.automations.destroy', id));
38
+ }
39
+ }
40
+
41
+ function toggleActive(automation) {
42
+ router.put(route('escalated.admin.automations.update', automation.id), {
43
+ name: automation.name,
44
+ conditions: automation.conditions ?? [],
45
+ actions: automation.actions ?? [],
46
+ active: !automation.active,
47
+ });
48
+ }
49
+
50
+ /** A condition in the words an admin would use, not the words it is stored in. */
51
+ function conditionSummary(condition) {
52
+ const label = FIELD_LABELS[condition?.field] ?? condition?.field ?? '';
53
+
54
+ if (!label) {
55
+ return '';
56
+ }
57
+
58
+ if (String(condition.field ?? '').startsWith('hours_since_')) {
59
+ return `${label} ${condition.operator ?? '>'} ${condition.value}`;
60
+ }
61
+
62
+ return `${label} is ${condition.value}`;
63
+ }
64
+
65
+ function actionSummary(action) {
66
+ const label = ACTION_LABELS[action?.type] ?? action?.type ?? '';
67
+
68
+ if (!label) {
69
+ return '';
70
+ }
71
+
72
+ return action.value === undefined || action.value === null || action.value === ''
73
+ ? label
74
+ : `${label}: ${action.value}`;
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <EscalatedLayout title="Automations">
80
+ <div class="mb-4 flex items-center justify-between">
81
+ <div>
82
+ <h2 class="text-lg font-semibold text-[var(--esc-panel-text-secondary)]">Automations</h2>
83
+ <p class="text-xs text-[var(--esc-panel-text-muted)]">
84
+ Time-based rules. A scheduled scan matches open tickets on how long they have been waiting and
85
+ applies actions — for events as they happen, use Workflows.
86
+ </p>
87
+ </div>
88
+ <Link
89
+ :href="route('escalated.admin.automations.create')"
90
+ class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[var(--esc-panel-bg)]/20 transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)]"
91
+ >
92
+ Create Automation
93
+ </Link>
94
+ </div>
95
+
96
+ <div class="overflow-hidden rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)]">
97
+ <table class="min-w-full divide-y divide-[var(--esc-panel-border)]">
98
+ <thead>
99
+ <tr class="bg-[var(--esc-panel-hover)]">
100
+ <th
101
+ class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
102
+ >
103
+ Name
104
+ </th>
105
+ <th
106
+ class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
107
+ >
108
+ When
109
+ </th>
110
+ <th
111
+ class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
112
+ >
113
+ Then
114
+ </th>
115
+ <th
116
+ class="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
117
+ >
118
+ Status
119
+ </th>
120
+ <th
121
+ class="px-4 py-3 text-right text-[11px] font-semibold uppercase tracking-wider text-[var(--esc-panel-text-muted)]"
122
+ >
123
+ Actions
124
+ </th>
125
+ </tr>
126
+ </thead>
127
+ <tbody class="divide-y divide-[var(--esc-panel-border)]">
128
+ <tr v-if="!automations?.length">
129
+ <td colspan="5" class="px-4 py-12 text-center">
130
+ <svg
131
+ class="mx-auto mb-3 h-8 w-8 text-[var(--esc-panel-text-muted)]"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ stroke-width="1.5"
135
+ viewBox="0 0 24 24"
136
+ >
137
+ <path
138
+ stroke-linecap="round"
139
+ stroke-linejoin="round"
140
+ d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
141
+ />
142
+ </svg>
143
+ <p class="text-sm text-[var(--esc-panel-text-muted)]">No automations yet</p>
144
+ <p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
145
+ Chase tickets that have gone quiet — escalate after 24 hours, close after a week
146
+ </p>
147
+ </td>
148
+ </tr>
149
+ <tr
150
+ v-for="automation in automations"
151
+ :key="automation.id"
152
+ class="transition-colors hover:bg-[var(--esc-panel-hover)]"
153
+ >
154
+ <td class="px-4 py-3">
155
+ <Link
156
+ :href="route('escalated.admin.automations.edit', automation.id)"
157
+ class="text-sm font-medium text-[var(--esc-panel-text-secondary)] hover:text-[var(--esc-panel-accent)]"
158
+ >
159
+ {{ automation.name }}
160
+ </Link>
161
+ </td>
162
+ <td class="px-4 py-3">
163
+ <div class="flex flex-wrap gap-1">
164
+ <span
165
+ v-for="(condition, i) in automation.conditions ?? []"
166
+ :key="i"
167
+ class="rounded bg-[var(--esc-panel-hover)] px-2 py-0.5 text-[11px] text-[var(--esc-panel-text-muted)]"
168
+ >
169
+ {{ conditionSummary(condition) }}
170
+ </span>
171
+ <span
172
+ v-if="!(automation.conditions ?? []).length"
173
+ class="text-[11px] text-[var(--esc-panel-text-muted)]"
174
+ >
175
+ —
176
+ </span>
177
+ </div>
178
+ </td>
179
+ <td class="px-4 py-3">
180
+ <div class="flex flex-wrap gap-1">
181
+ <span
182
+ v-for="(action, i) in automation.actions ?? []"
183
+ :key="i"
184
+ class="rounded bg-[var(--esc-panel-accent)]/10 px-2 py-0.5 text-[11px] text-[var(--esc-panel-accent)]"
185
+ >
186
+ {{ actionSummary(action) }}
187
+ </span>
188
+ <span
189
+ v-if="!(automation.actions ?? []).length"
190
+ class="text-[11px] text-[var(--esc-panel-text-muted)]"
191
+ >
192
+ —
193
+ </span>
194
+ </div>
195
+ </td>
196
+ <td class="px-4 py-3">
197
+ <button
198
+ type="button"
199
+ class="rounded px-2 py-0.5 text-[11px] font-medium transition-colors"
200
+ :class="
201
+ automation.active
202
+ ? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
203
+ : 'bg-gray-500/10 text-gray-400 hover:bg-gray-500/20'
204
+ "
205
+ @click="toggleActive(automation)"
206
+ >
207
+ {{ automation.active ? 'Active' : 'Paused' }}
208
+ </button>
209
+ </td>
210
+ <td class="px-4 py-3 text-right">
211
+ <Link
212
+ :href="route('escalated.admin.automations.edit', automation.id)"
213
+ class="text-xs font-medium text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-accent)]"
214
+ >
215
+ Edit
216
+ </Link>
217
+ <button
218
+ type="button"
219
+ class="ml-3 text-xs font-medium text-[var(--esc-panel-text-muted)] transition-colors hover:text-red-400"
220
+ @click="destroy(automation.id)"
221
+ >
222
+ Delete
223
+ </button>
224
+ </td>
225
+ </tr>
226
+ </tbody>
227
+ </table>
228
+ </div>
229
+ </EscalatedLayout>
230
+ </template>
@@ -5,6 +5,10 @@ import { ref, computed } from 'vue';
5
5
 
6
6
  const props = defineProps({
7
7
  plugins: { type: Array, default: () => [] },
8
+ // Backends that load plugins from uploaded ZIPs (e.g. Laravel) support the
9
+ // upload flow; backends where plugins are compiled host modules registered
10
+ // in config (e.g. Phoenix) pass can_upload: false to hide it.
11
+ can_upload: { type: Boolean, default: true },
8
12
  });
9
13
 
10
14
  const confirmingDelete = ref(null);
@@ -93,7 +97,7 @@ function statusLabel(plugin) {
93
97
  {{ activePlugins.length }} active, {{ inactivePlugins.length }} inactive
94
98
  </p>
95
99
  </div>
96
- <div>
100
+ <div v-if="can_upload">
97
101
  <input ref="fileInput" type="file" accept=".zip" class="hidden" @change="handleUpload" />
98
102
  <button
99
103
  :disabled="uploading"
@@ -144,9 +148,12 @@ function statusLabel(plugin) {
144
148
  />
145
149
  </svg>
146
150
  <h3 class="text-sm font-medium text-[var(--esc-panel-text-secondary)]">No plugins installed</h3>
147
- <p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
151
+ <p v-if="can_upload" class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
148
152
  Upload a plugin ZIP to extend Escalated with custom functionality.
149
153
  </p>
154
+ <p v-else class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
155
+ Register a plugin in the host application to extend Escalated with custom functionality.
156
+ </p>
150
157
  </div>
151
158
 
152
159
  <!-- Plugin cards -->
@@ -234,6 +241,7 @@ function statusLabel(plugin) {
234
241
  </svg>
235
242
  Installed {{ plugin.installed_at }}
236
243
  </span>
244
+ <span v-if="plugin.module" class="font-mono">{{ plugin.module }}</span>
237
245
  </div>
238
246
 
239
247
  <!-- Provided extensions summary -->
@@ -0,0 +1,228 @@
1
+ <script setup>
2
+ import EscalatedLayout from '../../../components/EscalatedLayout.vue';
3
+ import { useForm, router } from '@inertiajs/vue3';
4
+ import { computed, ref } from 'vue';
5
+
6
+ /**
7
+ * Which database Escalated's own tables live on.
8
+ *
9
+ * Two things about this screen are deliberate and worth not undoing.
10
+ *
11
+ * It refuses a switch to a database with no Escalated tables. That case does
12
+ * not error at runtime — the panel simply comes up with no tickets, no
13
+ * departments and no settings, which reads exactly like data loss. The server
14
+ * refuses it, and the option is disabled here so the mistake is hard to make
15
+ * in the first place.
16
+ *
17
+ * And when `escalated.connection` is set in config or .env, the whole form is
18
+ * read-only. That value is deployed infrastructure; a web form must not be
19
+ * able to quietly disagree with it.
20
+ */
21
+ const props = defineProps({
22
+ connections: { type: Array, default: () => [] },
23
+ current: { type: String, default: null },
24
+ hostDefault: { type: String, default: null },
25
+ pinnedByConfig: { type: Boolean, default: false },
26
+ tablePrefix: { type: String, default: 'escalated_' },
27
+ });
28
+
29
+ const form = useForm({
30
+ connection: props.current ?? '',
31
+ });
32
+
33
+ const testing = ref(null);
34
+
35
+ const active = computed(() => props.connections.find((c) => c.is_current) ?? null);
36
+
37
+ const selected = computed(
38
+ () => props.connections.find((c) => c.name === (form.connection || props.hostDefault)) ?? null,
39
+ );
40
+
41
+ const isChanging = computed(() => (form.connection || null) !== (props.current || null));
42
+
43
+ function submit() {
44
+ if (props.pinnedByConfig) return;
45
+ form.post(route('escalated.admin.settings.database.update'), { preserveScroll: true });
46
+ }
47
+
48
+ function test(name) {
49
+ testing.value = name;
50
+
51
+ router.post(
52
+ route('escalated.admin.settings.database.test'),
53
+ { connection: name },
54
+ {
55
+ preserveScroll: true,
56
+ preserveState: false,
57
+ onFinish: () => {
58
+ testing.value = null;
59
+ },
60
+ },
61
+ );
62
+ }
63
+
64
+ function statusLabel(connection) {
65
+ if (!connection.reachable) return 'Unreachable';
66
+ if (!connection.migrated) return 'Not migrated';
67
+ return 'Ready';
68
+ }
69
+
70
+ function statusClass(connection) {
71
+ if (!connection.reachable) return 'text-[var(--esc-danger)]';
72
+ if (!connection.migrated) return 'text-[var(--esc-warning)]';
73
+ return 'text-[var(--esc-success)]';
74
+ }
75
+
76
+ function formatCount(value) {
77
+ return value === null || value === undefined ? null : value.toLocaleString();
78
+ }
79
+ </script>
80
+
81
+ <template>
82
+ <EscalatedLayout title="Database Connection">
83
+ <div class="mx-auto max-w-3xl space-y-6">
84
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6">
85
+ <h3 class="mb-1 text-sm font-semibold text-[var(--esc-panel-text)]">Current connection</h3>
86
+ <p class="mb-5 text-xs text-[var(--esc-panel-text-muted)]">
87
+ Where Escalated's own tables (<code>{{ tablePrefix }}*</code>) are read and written. Your users
88
+ table is not affected — it stays wherever your application keeps it.
89
+ </p>
90
+
91
+ <dl v-if="active" class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
92
+ <dt class="text-[var(--esc-panel-text-muted)]">Connection</dt>
93
+ <dd class="font-medium text-[var(--esc-panel-text)]">
94
+ {{ active.name }}
95
+ <span v-if="active.is_host_default" class="text-xs text-[var(--esc-panel-text-muted)]">
96
+ (application default)
97
+ </span>
98
+ </dd>
99
+
100
+ <dt class="text-[var(--esc-panel-text-muted)]">Driver</dt>
101
+ <dd class="text-[var(--esc-panel-text-secondary)]">{{ active.driver ?? '—' }}</dd>
102
+
103
+ <dt class="text-[var(--esc-panel-text-muted)]">Database</dt>
104
+ <dd class="break-all text-[var(--esc-panel-text-secondary)]">{{ active.database ?? '—' }}</dd>
105
+
106
+ <dt class="text-[var(--esc-panel-text-muted)]">Tickets stored</dt>
107
+ <dd class="text-[var(--esc-panel-text-secondary)]">
108
+ {{ formatCount(active.ticket_count) ?? '—' }}
109
+ </dd>
110
+ </dl>
111
+ </div>
112
+
113
+ <div
114
+ v-if="pinnedByConfig"
115
+ class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface-alt)] p-4 text-sm text-[var(--esc-panel-text-secondary)]"
116
+ >
117
+ This connection is set in your application config (<code>escalated.connection</code> /
118
+ <code>ESCALATED_DB_CONNECTION</code>) and cannot be changed from here. Edit it where it is deployed, so
119
+ the panel and your configuration cannot disagree.
120
+ </div>
121
+
122
+ <form v-else class="space-y-6" @submit.prevent="submit">
123
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6">
124
+ <h3 class="mb-1 text-sm font-semibold text-[var(--esc-panel-text)]">Change connection</h3>
125
+ <p class="mb-5 text-xs text-[var(--esc-panel-text-muted)]">
126
+ Only connections already carrying Escalated's tables can be selected. Run the package migrations
127
+ against a database before pointing Escalated at it.
128
+ </p>
129
+
130
+ <div class="space-y-2">
131
+ <label
132
+ v-for="connection in connections"
133
+ :key="connection.name"
134
+ :class="[
135
+ 'flex items-start gap-3 rounded-lg border p-3 transition',
136
+ connection.migrated
137
+ ? 'cursor-pointer border-[var(--esc-panel-border)] hover:bg-[var(--esc-panel-surface-alt)]'
138
+ : 'cursor-not-allowed border-[var(--esc-panel-border)] opacity-60',
139
+ ]"
140
+ >
141
+ <input
142
+ v-model="form.connection"
143
+ type="radio"
144
+ :value="connection.is_host_default ? '' : connection.name"
145
+ :disabled="!connection.migrated"
146
+ class="mt-1"
147
+ />
148
+ <span class="min-w-0 flex-1">
149
+ <span class="flex flex-wrap items-center gap-2">
150
+ <span class="text-sm font-medium text-[var(--esc-panel-text)]">
151
+ {{ connection.name }}
152
+ </span>
153
+ <span
154
+ v-if="connection.is_host_default"
155
+ class="text-xs text-[var(--esc-panel-text-muted)]"
156
+ >
157
+ application default
158
+ </span>
159
+ <span :class="['text-xs font-medium', statusClass(connection)]">
160
+ {{ statusLabel(connection) }}
161
+ </span>
162
+ </span>
163
+ <span class="mt-0.5 block text-xs text-[var(--esc-panel-text-muted)]">
164
+ {{ connection.driver ?? 'unknown driver' }} ·
165
+ {{ connection.database ?? 'no database configured' }}
166
+ <template v-if="connection.ticket_count !== null">
167
+ · {{ formatCount(connection.ticket_count) }} tickets
168
+ </template>
169
+ </span>
170
+ <span
171
+ v-if="connection.error"
172
+ class="mt-1 block break-all text-xs text-[var(--esc-danger)]"
173
+ >
174
+ {{ connection.error }}
175
+ </span>
176
+ <span
177
+ v-else-if="!connection.migrated"
178
+ class="mt-1 block text-xs text-[var(--esc-panel-text-muted)]"
179
+ >
180
+ No Escalated tables here yet. Switching would show an empty panel, so it is
181
+ disabled.
182
+ </span>
183
+ </span>
184
+ <button
185
+ type="button"
186
+ class="shrink-0 rounded-md border border-[var(--esc-panel-border-input)] px-2 py-1 text-xs text-[var(--esc-panel-text-secondary)]"
187
+ :disabled="testing === connection.name"
188
+ @click.prevent="test(connection.name)"
189
+ >
190
+ {{ testing === connection.name ? 'Testing…' : 'Test' }}
191
+ </button>
192
+ </label>
193
+ </div>
194
+
195
+ <p v-if="form.errors.connection" class="mt-3 text-xs text-[var(--esc-danger)]">
196
+ {{ form.errors.connection }}
197
+ </p>
198
+ </div>
199
+
200
+ <div
201
+ v-if="isChanging"
202
+ class="rounded-xl border border-[var(--esc-warning)] bg-[var(--esc-panel-surface-alt)] p-4 text-sm text-[var(--esc-panel-text-secondary)]"
203
+ >
204
+ <p class="font-medium text-[var(--esc-panel-text)]">This does not move any data.</p>
205
+ <p class="mt-1 text-xs">
206
+ Escalated will start reading and writing
207
+ <strong>{{ selected?.name ?? hostDefault }}</strong
208
+ >, which currently holds
209
+ <strong>{{ formatCount(selected?.ticket_count) ?? 'an unknown number of' }}</strong> tickets.
210
+ The <strong>{{ formatCount(active?.ticket_count) ?? 'existing' }}</strong> tickets on
211
+ <strong>{{ active?.name }}</strong> stay where they are and will no longer be visible in the
212
+ panel.
213
+ </p>
214
+ </div>
215
+
216
+ <div class="flex justify-end">
217
+ <button
218
+ type="submit"
219
+ :disabled="form.processing || !isChanging"
220
+ class="rounded-lg bg-[var(--esc-accent)] px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
221
+ >
222
+ {{ form.processing ? 'Saving…' : 'Use this connection' }}
223
+ </button>
224
+ </div>
225
+ </form>
226
+ </div>
227
+ </EscalatedLayout>
228
+ </template>
@@ -0,0 +1,163 @@
1
+ <script setup>
2
+ import EscalatedLayout from '../../../components/EscalatedLayout.vue';
3
+ import { useForm, Link } from '@inertiajs/vue3';
4
+ import { computed } from 'vue';
5
+
6
+ /**
7
+ * What happens to a ticket raised by someone who is not signed in.
8
+ *
9
+ * The three modes are the guest policy: leave it unattached, attach it to a
10
+ * standing guest account, or ask the person to sign up before it is accepted.
11
+ * Each backend stores these as three settings, and the second and third each
12
+ * need one value alongside the mode.
13
+ */
14
+ const props = defineProps({
15
+ settings: { type: Object, default: () => ({}) },
16
+ });
17
+
18
+ const MODES = [
19
+ {
20
+ value: 'unassigned',
21
+ label: 'Leave unattached',
22
+ description: 'The ticket is stored with the guest name and email and no user behind it.',
23
+ },
24
+ {
25
+ value: 'guest_user',
26
+ label: 'Attach to a guest account',
27
+ description: 'Every guest ticket is owned by one standing account, so agents see a consistent requester.',
28
+ },
29
+ {
30
+ value: 'prompt_signup',
31
+ label: 'Ask the guest to sign up',
32
+ description: 'The guest is sent to your sign-up page before the ticket is accepted.',
33
+ },
34
+ ];
35
+
36
+ const form = useForm({
37
+ guest_policy_mode: props.settings?.guest_policy_mode ?? 'unassigned',
38
+ guest_policy_user_id: props.settings?.guest_policy_user_id ?? '',
39
+ guest_policy_signup_url_template: props.settings?.guest_policy_signup_url_template ?? '',
40
+ });
41
+
42
+ const needsUserId = computed(() => form.guest_policy_mode === 'guest_user');
43
+ const needsSignupUrl = computed(() => form.guest_policy_mode === 'prompt_signup');
44
+
45
+ function submit() {
46
+ form.post(route('escalated.admin.settings.public-tickets.update'));
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <EscalatedLayout title="Public Tickets">
52
+ <div class="mx-auto max-w-2xl">
53
+ <div class="mb-6">
54
+ <Link
55
+ :href="route('escalated.admin.settings')"
56
+ class="text-sm text-[var(--esc-panel-text-muted)] hover:text-[var(--esc-panel-text-secondary)]"
57
+ >
58
+ &larr; Back to Settings
59
+ </Link>
60
+ </div>
61
+
62
+ <div class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6">
63
+ <h2 class="text-lg font-semibold text-[var(--esc-panel-text-secondary)]">Public Tickets</h2>
64
+ <p class="mt-1 text-sm text-[var(--esc-panel-text-muted)]">
65
+ How a ticket raised by someone who is not signed in is recorded.
66
+ </p>
67
+
68
+ <form class="mt-6 space-y-6" @submit.prevent="submit">
69
+ <fieldset class="space-y-3">
70
+ <legend class="mb-1.5 text-sm font-medium text-[var(--esc-panel-text-secondary)]">
71
+ Guest policy
72
+ </legend>
73
+
74
+ <label
75
+ v-for="mode in MODES"
76
+ :key="mode.value"
77
+ class="flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors"
78
+ :class="
79
+ form.guest_policy_mode === mode.value
80
+ ? 'border-[var(--esc-panel-accent)]/40 bg-[var(--esc-panel-hover)]'
81
+ : 'border-[var(--esc-panel-border)] hover:bg-[var(--esc-panel-hover)]'
82
+ "
83
+ >
84
+ <input
85
+ v-model="form.guest_policy_mode"
86
+ type="radio"
87
+ name="guest_policy_mode"
88
+ :value="mode.value"
89
+ class="mt-1"
90
+ />
91
+ <span>
92
+ <span class="block text-sm text-[var(--esc-panel-text-secondary)]">
93
+ {{ mode.label }}
94
+ </span>
95
+ <span class="block text-xs text-[var(--esc-panel-text-muted)]">
96
+ {{ mode.description }}
97
+ </span>
98
+ </span>
99
+ </label>
100
+
101
+ <p v-if="form.errors.guest_policy_mode" class="text-xs text-red-400">
102
+ {{ form.errors.guest_policy_mode }}
103
+ </p>
104
+ </fieldset>
105
+
106
+ <div v-if="needsUserId">
107
+ <label
108
+ class="mb-1.5 block text-sm font-medium text-[var(--esc-panel-text-secondary)]"
109
+ for="guest-user-id"
110
+ >
111
+ Guest account user ID
112
+ </label>
113
+ <input
114
+ id="guest-user-id"
115
+ v-model="form.guest_policy_user_id"
116
+ type="number"
117
+ min="1"
118
+ required
119
+ class="w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-hover)] px-3 py-2.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-accent)]/40 focus:outline-none"
120
+ />
121
+ <p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
122
+ The id of an existing user in your application. Every guest ticket is attached to it.
123
+ </p>
124
+ <p v-if="form.errors.guest_policy_user_id" class="mt-1 text-xs text-red-400">
125
+ {{ form.errors.guest_policy_user_id }}
126
+ </p>
127
+ </div>
128
+
129
+ <div v-if="needsSignupUrl">
130
+ <label
131
+ class="mb-1.5 block text-sm font-medium text-[var(--esc-panel-text-secondary)]"
132
+ for="signup-url"
133
+ >
134
+ Sign-up URL
135
+ </label>
136
+ <input
137
+ id="signup-url"
138
+ v-model="form.guest_policy_signup_url_template"
139
+ type="text"
140
+ maxlength="500"
141
+ placeholder="/register?email={email}"
142
+ class="w-full rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-hover)] px-3 py-2.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-accent)]/40 focus:outline-none"
143
+ />
144
+ <p class="mt-1 text-xs text-[var(--esc-panel-text-muted)]">
145
+ <code>{email}</code> is replaced with the address the guest entered.
146
+ </p>
147
+ <p v-if="form.errors.guest_policy_signup_url_template" class="mt-1 text-xs text-red-400">
148
+ {{ form.errors.guest_policy_signup_url_template }}
149
+ </p>
150
+ </div>
151
+
152
+ <button
153
+ type="submit"
154
+ :disabled="form.processing"
155
+ class="rounded-lg bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)] px-4 py-2 text-sm font-medium text-white transition-all hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)] disabled:opacity-50"
156
+ >
157
+ Save settings
158
+ </button>
159
+ </form>
160
+ </div>
161
+ </div>
162
+ </EscalatedLayout>
163
+ </template>
@@ -108,8 +108,7 @@ function togglePayload(id) {
108
108
  <td colspan="5" class="bg-[var(--esc-panel-hover)] px-4 py-3">
109
109
  <pre
110
110
  class="max-h-40 overflow-auto rounded-lg bg-[var(--esc-panel-surface-alt)] p-3 text-xs text-[var(--esc-panel-text-tertiary)]"
111
- >{{ JSON.stringify(delivery.payload, null, 2) }}</pre
112
- >
111
+ >{{ JSON.stringify(delivery.payload, null, 2) }}</pre>
113
112
  </td>
114
113
  </tr>
115
114
  </template>
@@ -0,0 +1,24 @@
1
+ <script setup>
2
+ import Builder from './Builder.vue';
3
+
4
+ /**
5
+ * The workflow editor, under the name the backends render.
6
+ *
7
+ * Builder.vue is the editor itself and takes exactly these props. Several
8
+ * backends render `Escalated/Admin/Workflows/Form` for both create and edit,
9
+ * and a page name with no component behind it is not an error in Inertia --
10
+ * the panel simply comes up blank. This is that name.
11
+ */
12
+ defineProps({
13
+ workflow: { type: Object, default: null },
14
+
15
+ // Passed by the backends alongside the workflow. Builder derives its own
16
+ // trigger list, so this is accepted rather than forwarded; declaring it
17
+ // keeps it out of the fallthrough attributes.
18
+ triggerEvents: { type: [Array, Object], default: null },
19
+ });
20
+ </script>
21
+
22
+ <template>
23
+ <Builder :workflow="workflow" />
24
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup>
2
+ import EscalatedLayout from '../../components/EscalatedLayout.vue';
3
+ import { computed, resolveComponent } from 'vue';
4
+
5
+ /**
6
+ * A full page registered by a plugin.
7
+ *
8
+ * A plugin declares a route on the backend and a component on the frontend;
9
+ * the backend renders this page with the component's name, and this is where
10
+ * the two meet. `defineEscalatedPlugin` registers those components globally on
11
+ * the app, so the name is resolved from the global registry rather than
12
+ * imported.
13
+ *
14
+ * Two backends render `Escalated/Plugin/Page`, and there was no component
15
+ * behind the name — so every plugin page was blank, whether or not the plugin
16
+ * had registered its component correctly. A plugin that has not been installed
17
+ * on the frontend now says so instead.
18
+ */
19
+ const props = defineProps({
20
+ plugin: { type: String, default: '' },
21
+ component: { type: String, default: '' },
22
+ layout: { type: String, default: 'admin' },
23
+ props: { type: Object, default: () => ({}) },
24
+ });
25
+
26
+ const resolved = computed(() => {
27
+ if (!props.component) {
28
+ return null;
29
+ }
30
+
31
+ const found = resolveComponent(props.component);
32
+
33
+ // resolveComponent hands back the name as a string when nothing is
34
+ // registered under it, which would render as a literal <ComponentName>
35
+ // element rather than as anything useful.
36
+ return typeof found === 'string' ? null : found;
37
+ });
38
+
39
+ const title = computed(() => props.component || props.plugin || 'Plugin');
40
+ </script>
41
+
42
+ <template>
43
+ <EscalatedLayout :title="title">
44
+ <component :is="resolved" v-if="resolved" v-bind="props.props" />
45
+
46
+ <div
47
+ v-else
48
+ class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6 text-center"
49
+ >
50
+ <p class="text-sm text-[var(--esc-panel-text-secondary)]">
51
+ The <strong>{{ plugin || 'plugin' }}</strong> plugin registered a page here, but no component named
52
+ <code class="rounded bg-[var(--esc-panel-hover)] px-1">{{ component || '(unnamed)' }}</code> is
53
+ available.
54
+ </p>
55
+ <p class="mt-2 text-xs text-[var(--esc-panel-text-muted)]">
56
+ Register it on the frontend with <code>defineEscalatedPlugin</code> — the backend route exists, so only
57
+ the component is missing.
58
+ </p>
59
+ </div>
60
+ </EscalatedLayout>
61
+ </template>