Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@escalated-dev/escalated",
"version": "0.8.0",
"version": "0.9.0",
"description": "Vue 3 + Inertia.js UI components for Escalated \u00e2\u20ac\u201d the embeddable support ticket system",
"author": "Escalated Dev <hello@escalated.dev>",
"license": "MIT",
Expand Down
11 changes: 11 additions & 0 deletions src/components/EscalatedLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const isPanel = computed(() => isAdminSection.value || isAgentSection.value);
const isDark = computed(() => isPanel.value && panelConfig.mode !== 'light');
const showPoweredBy = computed(() => page.props.escalated?.show_powered_by !== false);
const kbEnabled = computed(() => page.props.escalated?.knowledge_base_enabled !== false);
const newslettersEnabled = computed(() => page.props.escalated?.features?.newsletters === true);
const kbPublic = computed(() => page.props.escalated?.knowledge_base_public !== false);
const chatEnabled = computed(() => page.props.escalated?.chat_enabled === true);
const activeChats = computed(() => page.props.escalated?.active_chats || []);
Expand Down Expand Up @@ -116,6 +117,16 @@ const adminLinks = computed(() => {
icon: 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z',
position: 80,
},
...(newslettersEnabled.value
? [
{
href: `${p}/admin/newsletters`,
label: 'Newsletters',
icon: 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
position: 81,
},
]
: []),
{
href: `${p}/admin/skills`,
label: 'Skills',
Expand Down
14 changes: 14 additions & 0 deletions src/components/admin/newsletters/AnalyticsTiles.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import AnalyticsTiles from './AnalyticsTiles.vue';

export default { title: 'Admin/Newsletters/AnalyticsTiles', component: AnalyticsTiles };

export const Default = {
args: {
summary: { total: 1000, sent: 990, opened: 400, clicked: 80, bounced: 10, complained: 1 },
topClicks: [
{ url: 'https://example.com/launch', clicks: 42 },
{ url: 'https://example.com/blog/post', clicks: 18 },
{ url: 'https://example.com/pricing', clicks: 7 },
],
},
};
55 changes: 55 additions & 0 deletions src/components/admin/newsletters/AnalyticsTiles.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<div class="analytics-tiles">
<SummaryTiles :summary="summary" />
<section v-if="topClicks?.length" class="analytics-tiles__top-clicks">
<h3>Top clicked URLs</h3>
<ol>
<li v-for="row in topClicks" :key="row.url">
<span class="analytics-tiles__url">{{ row.url }}</span>
<span class="analytics-tiles__count">{{ row.clicks }}</span>
</li>
</ol>
</section>
</div>
</template>

<script setup>
import SummaryTiles from './SummaryTiles.vue';

defineProps({
summary: { type: Object, required: true },
topClicks: { type: Array, default: () => [] },
});
</script>

<style scoped>
.analytics-tiles {
display: flex;
flex-direction: column;
gap: 24px;
}
.analytics-tiles__top-clicks h3 {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--escalated-text-muted, #64748b);
margin: 0 0 8px;
}
.analytics-tiles__top-clicks ol {
margin: 0;
padding-left: 20px;
}
.analytics-tiles__top-clicks li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
}
.analytics-tiles__url {
word-break: break-all;
}
.analytics-tiles__count {
font-variant-numeric: tabular-nums;
color: var(--escalated-text-muted, #64748b);
}
</style>
28 changes: 28 additions & 0 deletions src/components/admin/newsletters/DeliveriesTable.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import DeliveriesTable from './DeliveriesTable.vue';

export default { title: 'Admin/Newsletters/DeliveriesTable', component: DeliveriesTable };

export const Default = {
args: {
rows: [
{
id: 1,
contact: { name: 'Maria', email: 'maria@example.com' },
status: 'sent',
sent_at: '2026-05-19T12:00:00Z',
opened_at: '2026-05-19T13:00:00Z',
last_clicked_at: '2026-05-19T13:05:00Z',
bounce_reason: null,
},
{
id: 2,
contact: { name: null, email: 'x@example.com' },
status: 'bounced',
sent_at: '2026-05-19T12:00:00Z',
opened_at: null,
last_clicked_at: null,
bounce_reason: '550 mailbox not found',
},
],
},
};
104 changes: 104 additions & 0 deletions src/components/admin/newsletters/DeliveriesTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<div class="deliveries-table">
<div class="deliveries-table__toolbar">
<select
class="deliveries-table__filter"
:value="statusFilter"
@change="$emit('filter', $event.target.value)"
>
<option value="">All statuses</option>
<option v-for="s in statuses" :key="s" :value="s">{{ s }}</option>
</select>
<button type="button" class="deliveries-table__export" @click="$emit('export')">Export CSV</button>
</div>
<table>
<thead>
<tr>
<th>Contact</th>
<th>Status</th>
<th>Sent</th>
<th>Opened</th>
<th>Last clicked</th>
<th>Bounce reason</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<div>{{ row.contact.name ?? '—' }}</div>
<div class="deliveries-table__email">{{ row.contact.email }}</div>
</td>
<td>
<span :class="`status status--${row.status}`">{{ row.status }}</span>
</td>
<td>{{ row.sent_at ? new Date(row.sent_at).toLocaleString() : '—' }}</td>
<td>{{ row.opened_at ? new Date(row.opened_at).toLocaleString() : '—' }}</td>
<td>{{ row.last_clicked_at ? new Date(row.last_clicked_at).toLocaleString() : '—' }}</td>
<td>{{ row.bounce_reason ?? '—' }}</td>
</tr>
</tbody>
</table>
</div>
</template>

<script setup>
defineProps({
rows: { type: Array, required: true },
statusFilter: { type: String, default: '' },
});
defineEmits(['filter', 'export']);
const statuses = ['pending', 'queued', 'sent', 'bounced', 'complained', 'suppressed', 'failed'];
</script>

<style scoped>
.deliveries-table {
display: flex;
flex-direction: column;
gap: 12px;
}
.deliveries-table__toolbar {
display: flex;
gap: 8px;
align-items: center;
}
.deliveries-table table {
width: 100%;
border-collapse: collapse;
}
.deliveries-table th,
.deliveries-table td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--escalated-border, #e2e8f0);
font-size: 14px;
}
.deliveries-table__email {
color: var(--escalated-text-muted, #64748b);
font-size: 12px;
}
.status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.status--sent {
background: #e0f2fe;
}
.status--bounced {
background: #fee2e2;
}
.status--complained {
background: #fef3c7;
}
.status--pending,
.status--queued {
background: #f1f5f9;
}
.status--failed {
background: #fecaca;
}
.status--suppressed {
background: #ede9fe;
}
</style>
11 changes: 11 additions & 0 deletions src/components/admin/newsletters/DynamicFilterBuilder.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import DynamicFilterBuilder from './DynamicFilterBuilder.vue';

export default { title: 'Admin/Newsletters/DynamicFilterBuilder', component: DynamicFilterBuilder };

export const Empty = { args: { modelValue: { rules: [] }, matchCount: 0 } };
export const WithRules = {
args: {
modelValue: { rules: [{ field: 'tickets_count', op: '>=', value: 3 }] },
matchCount: 142,
},
};
53 changes: 53 additions & 0 deletions src/components/admin/newsletters/DynamicFilterBuilder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<div class="dynamic-filter-builder">
<textarea
class="dynamic-filter-builder__fallback"
:value="JSON.stringify(modelValue, null, 2)"
rows="10"
spellcheck="false"
@input="onInput"
/>
<p class="dynamic-filter-builder__help">
Edit the saved filter JSON. A visual builder is planned for a follow-up.
</p>
<div class="dynamic-filter-builder__count">{{ matchCount }} contacts match</div>
</div>
</template>

<script setup>
defineProps({
modelValue: { type: Object, required: true },
matchCount: { type: Number, default: 0 },
});

const emit = defineEmits(['update:modelValue']);

function onInput(event) {
try {
emit('update:modelValue', JSON.parse(event.target.value));
} catch {
// Invalid JSON during typing — ignore until it parses cleanly.
}
}
</script>

<style scoped>
.dynamic-filter-builder__fallback {
width: 100%;
font-family: ui-monospace, monospace;
font-size: 13px;
padding: 12px;
border: 1px solid var(--escalated-border, #e2e8f0);
border-radius: 6px;
}
.dynamic-filter-builder__help {
margin: 4px 0 0;
color: var(--escalated-text-muted, #64748b);
font-size: 12px;
}
.dynamic-filter-builder__count {
margin-top: 8px;
color: var(--escalated-text-muted, #64748b);
font-size: 13px;
}
</style>
12 changes: 12 additions & 0 deletions src/components/admin/newsletters/ListMemberTable.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import ListMemberTable from './ListMemberTable.vue';

export default { title: 'Admin/Newsletters/ListMemberTable', component: ListMemberTable };

export const Default = {
args: {
members: [
{ id: 1, contact: { id: 10, name: 'Maria', email: 'maria@example.com' }, added_at: '2026-05-10T00:00:00Z' },
{ id: 2, contact: { id: 11, name: null, email: 'noname@example.com' }, added_at: '2026-05-12T00:00:00Z' },
],
},
};
53 changes: 53 additions & 0 deletions src/components/admin/newsletters/ListMemberTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<table class="list-member-table">
<thead>
<tr>
<th>Contact</th>
<th>Added</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.id">
<td>
<div>{{ m.contact.name ?? '—' }}</div>
<div class="list-member-table__email">{{ m.contact.email }}</div>
</td>
<td>{{ new Date(m.added_at).toLocaleDateString() }}</td>
<td>
<button
type="button"
data-action="remove"
:data-contact-id="m.contact.id"
@click="$emit('remove', m.contact.id)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</template>

<script setup>
defineProps({ members: { type: Array, required: true } });
defineEmits(['remove']);
</script>

<style scoped>
.list-member-table {
width: 100%;
border-collapse: collapse;
}
.list-member-table th,
.list-member-table td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--escalated-border, #e2e8f0);
font-size: 14px;
}
.list-member-table__email {
color: var(--escalated-text-muted, #64748b);
font-size: 12px;
}
</style>
6 changes: 6 additions & 0 deletions src/components/admin/newsletters/MarkdownEditor.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import MarkdownEditor from './MarkdownEditor.vue';

export default { title: 'Admin/Newsletters/MarkdownEditor', component: MarkdownEditor };

export const Empty = { args: { modelValue: '' } };
export const WithContent = { args: { modelValue: '# Welcome\n\nThanks for being a customer.' } };
Loading