@escalated-dev/escalated 0.9.0 → 0.11.2

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.
@@ -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>