Implementing a link tracking and user click logging system.Implementing a link tracking and user click logging system.
This commit is contained in:
@@ -301,6 +301,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/SmsCampaignsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/sms-campaigns/:id/report',
|
||||
name: 'sms-campaign-report',
|
||||
component: () => import('@/views/SmsCampaignReportView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/custom-fields',
|
||||
name: 'custom-fields',
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsCampaignReportDto } from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const report = ref<SmsCampaignReportDto | null>(null);
|
||||
|
||||
const id = computed(() => String(route.params.id || ''));
|
||||
|
||||
function maskMobile(m: string | null) {
|
||||
if (!m) return '—';
|
||||
const digits = m.replace(/\D/g, '');
|
||||
if (digits.length < 6) return m;
|
||||
return `${digits.slice(0, 4)}…${digits.slice(-2)}`;
|
||||
}
|
||||
|
||||
function clickAt(iso: string | null) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return `${formatJalaliNumeric(d)} ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
function openContact(row: SmsCampaignReportDto['clickedUsers'][number]) {
|
||||
if (row.personId) {
|
||||
void router.push({ name: 'person-detail', params: { id: row.personId } });
|
||||
return;
|
||||
}
|
||||
if (row.leadId) {
|
||||
void router.push({ name: 'lead-detail', params: { id: row.leadId } });
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<SmsCampaignReportDto>(
|
||||
`/sms-campaigns/${id.value}/report`,
|
||||
);
|
||||
report.value = data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول پیامک گروهی برای این محیط فعال نیست.';
|
||||
} else if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
error.value = 'کمپین پیدا نشد.';
|
||||
} else {
|
||||
error.value = 'بارگذاری گزارش ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
:title="report?.campaign.name || 'گزارش کمپین'"
|
||||
description="آمار ارسال و کلیک لینکهای ردیابیشده"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
@click="router.push({ name: 'sms-campaigns' })"
|
||||
>
|
||||
بازگشت
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<template v-if="report && !loading">
|
||||
<p class="body muted">{{ report.campaign.body }}</p>
|
||||
|
||||
<div class="stats">
|
||||
<UiCard title="ارسالشده">
|
||||
<p class="stat">{{ toFaDigits(report.totalCount) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="ارسال موفق">
|
||||
<p class="stat">{{ toFaDigits(report.sentCount) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="کلیک یکتا">
|
||||
<p class="stat">{{ toFaDigits(report.uniqueClickers) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="نرخ کلیک">
|
||||
<p class="stat">{{ toFaDigits(report.clickRate) }}٪</p>
|
||||
</UiCard>
|
||||
<UiCard title="کل کلیک">
|
||||
<p class="stat">{{ toFaDigits(report.totalClicks) }}</p>
|
||||
</UiCard>
|
||||
<UiCard title="چندبار کلیک">
|
||||
<p class="stat">{{ toFaDigits(report.multiClickers) }}</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard title="کلیککنندگان">
|
||||
<UiEmptyState
|
||||
v-if="!report.clickedUsers.length"
|
||||
title="هنوز کلیکی ثبت نشده"
|
||||
description="وقتی مخاطب روی لینک داخل پیامک بزند، اینجا دیده میشود."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>مخاطب</th>
|
||||
<th>نوع</th>
|
||||
<th>شماره</th>
|
||||
<th>اولین کلیک</th>
|
||||
<th>آخرین کلیک</th>
|
||||
<th>تعداد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, idx) in report.clickedUsers"
|
||||
:key="`${row.contactType}-${row.personId || row.leadId}-${idx}`"
|
||||
class="row"
|
||||
@click="openContact(row)"
|
||||
>
|
||||
<td>
|
||||
<strong>{{ row.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ row.contactType === 'lead' ? 'Lead' : 'Customer' }}
|
||||
</td>
|
||||
<td>{{ maskMobile(row.mobile) }}</td>
|
||||
<td>{{ clickAt(row.firstClickedAt) }}</td>
|
||||
<td>{{ clickAt(row.lastClickedAt) }}</td>
|
||||
<td>{{ toFaDigits(row.clickCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.body {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stat {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: #0f766e;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: right;
|
||||
padding: 0.55rem 0.4rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.table th {
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const peopleLoading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -192,7 +194,7 @@ onMounted(() => {
|
||||
<UiTextarea
|
||||
v-model="body"
|
||||
:rows="4"
|
||||
placeholder="متن پیامک را بنویسید…"
|
||||
placeholder="متن پیامک را بنویسید… در صورت وجود لینک http(s)، برای هر مخاطب لینک یکتا ساخته میشود."
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
@@ -245,6 +247,12 @@ onMounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
@click="router.push({ name: 'sms-campaign-report', params: { id: c.id } })"
|
||||
>
|
||||
گزارش
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="c.status === 'draft' || c.status === 'cancelled'"
|
||||
variant="secondary"
|
||||
|
||||
Reference in New Issue
Block a user