mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
new selfhosted version
This commit is contained in:
181
dashboard/components/complex/ActionableChart.vue
Normal file
181
dashboard/components/complex/ActionableChart.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script lang="ts" setup>
|
||||
import DateService, { type Slice } from '~/shared/services/DateService';
|
||||
import ChartCard from './actionable-chart/ChartCard.vue';
|
||||
import ChartTooltip, { type TooltipData } from './actionable-chart/ChartTooltip.vue';
|
||||
import MainChart, { type ActionableChartData } from './actionable-chart/MainChart.vue';
|
||||
import { LoaderCircle, Sparkles } from 'lucide-vue-next';
|
||||
import type { TooltipModel } from 'chart.js';
|
||||
|
||||
const snapshotStore = useSnapshotStore();
|
||||
|
||||
const slices: Slice[] = ['hour', 'day', 'month'];
|
||||
|
||||
const { isShared, sharedSlice } = useShared();
|
||||
|
||||
const showViews = ref<boolean>(true);
|
||||
const showVisitors = ref<boolean>(true);
|
||||
const showEvents = ref<boolean>(true);
|
||||
|
||||
const allowedSlices = computed(() => {
|
||||
const days = snapshotStore.duration;
|
||||
return slices.filter(e => days > DateService.sliceAvailabilityMap[e][0] && days < DateService.sliceAvailabilityMap[e][1]);
|
||||
});
|
||||
|
||||
const currentSlice = ref<Slice>(allowedSlices.value[0]);
|
||||
|
||||
watch(snapshotStore, () => {
|
||||
currentSlice.value = allowedSlices.value[0];
|
||||
})
|
||||
|
||||
type ResultType = { _id: string, count: number }
|
||||
|
||||
const { data: visits, status: visitsStatus } = useAuthFetch<ResultType[]>('/api/timeline/visits', {
|
||||
headers: { 'x-slice': currentSlice }, lazy: true, key: 'actionable:visits'
|
||||
});
|
||||
|
||||
const { data: sessions, status: sessionsStatus } = useAuthFetch<ResultType[]>('/api/timeline/sessions', {
|
||||
headers: { 'x-slice': currentSlice }, lazy: true, key: 'actionable:sessions'
|
||||
});
|
||||
|
||||
|
||||
const { data: events, status: eventsStatus } = useAuthFetch<ResultType[]>('/api/timeline/events', {
|
||||
headers: { 'x-slice': currentSlice }, lazy: true, key: 'actionable:events'
|
||||
});
|
||||
|
||||
const ready = computed(() => {
|
||||
return visitsStatus.value === 'success' && sessionsStatus.value === 'success' && eventsStatus.value === 'success';
|
||||
});
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
if (!visits.value) return -1;
|
||||
const index = visits.value.findIndex(e => new Date(e._id).getTime() >= (Date.now()));
|
||||
return index;
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
if (!visits.value || !sessions.value || !events.value) return {
|
||||
labels: [],
|
||||
visits: [], sessions: [], events: [],
|
||||
todayIndex: todayIndex.value,
|
||||
slice: 'month'
|
||||
} as ActionableChartData;
|
||||
|
||||
const maxChartY = Math.max(...visits.value.map(e => e.count), ...sessions.value.map(e => e.count));
|
||||
const maxEventSize = Math.max(...events.value.map(e => e.count));
|
||||
|
||||
const result: ActionableChartData = {
|
||||
labels: visits.value.map(e => DateService.getChartLabelFromISO(new Date(e._id).getTime(), isShared.value ? sharedSlice.value : currentSlice.value)),
|
||||
visits: visits.value.map(e => e.count),
|
||||
sessions: sessions.value.map(e => Math.round(e.count)),
|
||||
events: events.value.map(e => {
|
||||
const rValue = 20 / maxEventSize * e.count;
|
||||
return { x: 0, y: maxChartY + 60, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
}),
|
||||
todayIndex: todayIndex.value,
|
||||
slice: currentSlice.value,
|
||||
tooltipHandler: externalTooltipHandler,
|
||||
showViews: showViews.value,
|
||||
showVisitors: showVisitors.value,
|
||||
showEvents: showEvents.value,
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
})
|
||||
|
||||
const tooltipElement = ref<HTMLDivElement>();
|
||||
|
||||
|
||||
const tooltipData = ref<TooltipData>({
|
||||
date: '',
|
||||
events: 0,
|
||||
sessions: 0,
|
||||
visits: 0
|
||||
});
|
||||
|
||||
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
|
||||
const { chart, tooltip } = context;
|
||||
|
||||
if (!tooltipElement.value) {
|
||||
const elem = document.getElementById('external-tooltip');
|
||||
if (!elem) return;
|
||||
tooltipElement.value = elem as HTMLDivElement;
|
||||
}
|
||||
|
||||
const tooltipEl = tooltipElement.value;
|
||||
if (!tooltipEl) return;
|
||||
|
||||
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||
|
||||
if (todayIndex.value >= 0) {
|
||||
if (currentIndex > todayIndex.value - 1) {
|
||||
return tooltipEl.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
tooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||
tooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||
tooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any)?.r2.count as number;
|
||||
|
||||
const dateIndex = tooltip.dataPoints[0].dataIndex;
|
||||
const targetLabel = visits.value ? visits.value[dateIndex] : { _id: 0 };
|
||||
|
||||
tooltipData.value.date = new Date(targetLabel._id).toLocaleString();
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
const xSwap = tooltip.caretX > (window.innerWidth * 0.5) ? -250 : 50;
|
||||
|
||||
tooltipEl.style.opacity = '1';
|
||||
|
||||
tooltipEl.style.left = (tooltip.caretX + xSwap) + 'px';
|
||||
|
||||
tooltipEl.style.top = (tooltip.caretY - 75) + 'px';
|
||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||
|
||||
}
|
||||
|
||||
const chartColor = useChartColor();
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard v-model="currentSlice">
|
||||
<div class="flex flex-col">
|
||||
<div v-if="!isShared" class="mb-4 flex justify-between">
|
||||
<NuxtLink v-if="!isSelfhosted()" to="/ai">
|
||||
<Button size="sm" variant="outline">
|
||||
<Sparkles class="text-yellow-500" /> Ask AI
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
<div class="flex gap-4">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="showViews"></Checkbox>
|
||||
<Label> Views </Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="showVisitors">
|
||||
</Checkbox>
|
||||
<Label> Visitors </Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="showEvents"></Checkbox>
|
||||
<Label> Events </Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[25rem] flex items-center justify-center relative">
|
||||
<LoaderCircle v-if="!ready" class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
<MainChart v-if="ready" :data="data"></MainChart>
|
||||
<ChartTooltip class="opacity-0" :data="tooltipData" id='external-tooltip'>
|
||||
</ChartTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</template>
|
||||
114
dashboard/components/complex/EventDoughnutChart.vue
Normal file
114
dashboard/components/complex/EventDoughnutChart.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { Chart, registerables, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { LoaderCircle } from 'lucide-vue-next';
|
||||
import { DoughnutChart, useDoughnutChart } from 'vue-chart-3';
|
||||
|
||||
const { data: events, status } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/events', {
|
||||
headers: { 'x-limit': '5' }, lazy: true, key: 'doughnut:events'
|
||||
});
|
||||
|
||||
watch(status, () => {
|
||||
if (status.value === 'success') {
|
||||
chartData.value = getChartData();
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const chartOptions = shallowRef<ChartOptions<'doughnut'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
x: {
|
||||
ticks: { display: false },
|
||||
grid: { display: false, drawBorder: false },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
align: 'center',
|
||||
labels: {
|
||||
font: {
|
||||
family: 'Poppins',
|
||||
size: 14
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = shallowRef<ChartData<'doughnut'>>(getChartData());
|
||||
|
||||
function getChartData(): ChartData<'doughnut'> {
|
||||
|
||||
const result: ChartData<'doughnut'> = {
|
||||
labels: events.value?.map(e => e._id) ?? [],
|
||||
datasets: [
|
||||
{
|
||||
rotation: 1,
|
||||
data: events.value?.map(e => e.count) ?? [],
|
||||
backgroundColor: [
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6"
|
||||
],
|
||||
borderColor: ['#1d1d1f'],
|
||||
borderWidth: 2
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
const { doughnutChartProps, doughnutChartRef } = useDoughnutChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Top 5 events
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Displays key events.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="h-full">
|
||||
<div v-if="status !== 'success'" class="flex items-center justify-center h-full">
|
||||
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
</div>
|
||||
<DoughnutChart v-if="status === 'success'" v-bind="doughnutChartProps"> </DoughnutChart>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
198
dashboard/components/complex/EventsFunnelChart.vue
Normal file
198
dashboard/components/complex/EventsFunnelChart.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { type ChartData, type ChartOptions } from 'chart.js';
|
||||
import { defineChartComponent } from 'vue-chart-3';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
|
||||
const FunnelChart = defineChartComponent('funnel', 'funnel');
|
||||
|
||||
|
||||
const enabledEvents = ref<string[]>([]);
|
||||
|
||||
const eventsData = useAuthFetch(`/api/data/events`, {
|
||||
headers: {
|
||||
'x-limit': "999999"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const totalEventsCount = computed(() => {
|
||||
let count = 0;
|
||||
for (const key in eventsData.data.value) {
|
||||
count += eventsData.data.value[key as any].count;
|
||||
}
|
||||
return count;
|
||||
})
|
||||
|
||||
const chartOptions = ref<ChartOptions<'funnel'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
color: '#FFFFFF',
|
||||
formatter(value, context) {
|
||||
return ((totalEventsCount.value ?? 0) / 100 * value).toFixed(2) + '%';
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const chartData = ref<ChartData<'funnel'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#5680F877',
|
||||
"#6bbbe377",
|
||||
"#a6d5cb77",
|
||||
"#fae0b977",
|
||||
"#f28e8e77",
|
||||
"#e3a7e477",
|
||||
"#c4a8e177",
|
||||
"#8cc1d877",
|
||||
"#f9c2cd77",
|
||||
"#b4e3b277",
|
||||
"#ffdfba77",
|
||||
"#e9c3b577",
|
||||
"#d5b8d677",
|
||||
"#add7f677",
|
||||
"#ffd1dc77",
|
||||
"#ffe7a177",
|
||||
"#a8e6cf77",
|
||||
"#d4a5a577",
|
||||
"#f3d6e477",
|
||||
"#c3aed677"
|
||||
],
|
||||
// borderColor: '#0000CC',
|
||||
// borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#26262677',
|
||||
// hoverBorderColor: 'white',
|
||||
// hoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
// const c = document.createElement('canvas');
|
||||
// const ctx = c.getContext("2d");
|
||||
// let gradient: any = `${'#0000CC'}22`;
|
||||
// if (ctx) {
|
||||
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
// gradient.addColorStop(0, `${'#0000CC'}99`);
|
||||
// gradient.addColorStop(0.35, `${'#0000CC'}66`);
|
||||
// gradient.addColorStop(1, `${'#0000CC'}22`);
|
||||
// } else {
|
||||
// console.warn('Cannot get context for gradient');
|
||||
// }
|
||||
|
||||
// chartData.value.datasets[0].backgroundColor = [gradient];
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function onEventCheck(eventName: string) {
|
||||
const index = enabledEvents.value.indexOf(eventName);
|
||||
if (index == -1) {
|
||||
enabledEvents.value.push(eventName);
|
||||
} else {
|
||||
enabledEvents.value.splice(index, 1);
|
||||
}
|
||||
|
||||
|
||||
chartData.value.labels = enabledEvents.value;
|
||||
chartData.value.datasets[0].data = [];
|
||||
|
||||
for (const enabledEvent of enabledEvents.value) {
|
||||
const target = (eventsData.data.value ?? []).find(e => e._id == enabledEvent);
|
||||
chartData.value.datasets[0].data.push(target?.count || 0);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Funnel
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Monitor and analyze the actions your users are performing on your platform to gain insights into their
|
||||
behavior and optimize the user experience
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex gap-2 justify-between lg:flex-row flex-col">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="min-w-[20rem] text-lyx-text-darker">
|
||||
Select two or more events
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-for="event of eventsData.data.value">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Checkbox :model-value="enabledEvents.includes(event._id)"
|
||||
@update:model-value="onEventCheck(event._id)"></Checkbox>
|
||||
<Label>{{ event._id }}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<FunnelChart :chart-data="chartData" :options="chartOptions"> </FunnelChart>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
111
dashboard/components/complex/EventsMetadataAnalyzer.vue
Normal file
111
dashboard/components/complex/EventsMetadataAnalyzer.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { LoaderCircle } from 'lucide-vue-next';
|
||||
|
||||
const result = ref<any>();
|
||||
const analyzing = ref<boolean>(false);
|
||||
|
||||
const selectedEvent = ref<string>();
|
||||
const selectedEventField = ref<string>();
|
||||
|
||||
const total = ref<number>(0);
|
||||
|
||||
const { data: events, status: eventsStatus } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/events', {
|
||||
headers: { 'x-limit': '1000' }, lazy: true, key: 'list:events'
|
||||
});
|
||||
|
||||
const { data: eventFields, status: eventFieldsStatus } = useAuthFetch<string[]>(() => `/api/data/event_metadata_fields?event_name=${selectedEvent?.value ?? 'null'}`, {
|
||||
lazy: true, immediate: false
|
||||
});
|
||||
|
||||
watch(selectedEventField, () => {
|
||||
if (!selectedEventField.value) return;
|
||||
analyzeMetadata();
|
||||
})
|
||||
|
||||
|
||||
async function analyzeMetadata() {
|
||||
if (!selectedEvent.value) return;
|
||||
if (!selectedEventField.value) return;
|
||||
analyzing.value = true;
|
||||
const res = await useAuthFetchSync<{ _id: string, count: number }[]>(`/api/data/event_metadata_analyze?event_name=${selectedEvent.value}&field_name=${selectedEventField.value}`);
|
||||
// const count = res.reduce((a, e) => a + e.count, 0);
|
||||
// result.value = res.map(e => ({ ...e, count: 100 / count * e.count })).toSorted((a, b) => b.count - a.count);
|
||||
total.value = res.reduce((a, e) => a + e.count, 0);
|
||||
result.value = res;
|
||||
analyzing.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle> Analyze event metadata </CardTitle>
|
||||
<CardDescription>
|
||||
Filter events metadata fields to analyze them
|
||||
</CardDescription>
|
||||
<CardContent class="p-0 mt-6">
|
||||
|
||||
<div v-if="eventsStatus !== 'success'" class="flex items-center justify-center h-[10rem]">
|
||||
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<Select v-if="eventsStatus === 'success'" v-model="selectedEvent">
|
||||
<SelectTrigger>
|
||||
<SelectValue class="w-[15rem]" placeholder="Select an event">
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="event of events" :value="event._id">
|
||||
{{ event._id }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-if="eventFieldsStatus === 'success'" v-model="selectedEventField">
|
||||
<SelectTrigger>
|
||||
<SelectValue class="w-[15rem]" placeholder="Select an event">
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="field of eventFields" :value="field">
|
||||
{{ field }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div v-if="!analyzing && result" class="flex flex-col gap-2">
|
||||
|
||||
<div class="relative h-8 px-4 flex items-center bg-[#1b1b1d] rounded-lg text-[.9rem] poppins"
|
||||
v-for="item of result">
|
||||
<div class="z-[5]"> {{ item._id }} </div>
|
||||
<div class="grow"></div>
|
||||
<div class="z-[5]">{{ item.count }}</div>
|
||||
<div :style="`width: ${Math.floor(100 / total * item.count)}%`"
|
||||
class="absolute bg-[#7537F340] rounded-lg top-0 left-0 h-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="analyzing" class="flex flex-col gap-2">
|
||||
<Skeleton class="h-8 w-full"></Skeleton>
|
||||
<Skeleton class="h-8 w-full"></Skeleton>
|
||||
<Skeleton class="h-8 w-full"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</template>
|
||||
132
dashboard/components/complex/EventsStackedChart.vue
Normal file
132
dashboard/components/complex/EventsStackedChart.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import DateService, { type Slice } from '~/shared/services/DateService';
|
||||
import ChartCard from './events-stacked-chart/ChartCard.vue';
|
||||
import MainChart from './events-stacked-chart/MainChart.vue';
|
||||
import { LoaderCircle } from 'lucide-vue-next';
|
||||
import type { EventsStackedChartData } from './events-stacked-chart/MainChart.vue';
|
||||
import type { TooltipModel } from 'chart.js';
|
||||
import type { TooltipDataEventsStacked } from './events-stacked-chart/ChartTooltip.vue';
|
||||
import ChartTooltip from './events-stacked-chart/ChartTooltip.vue';
|
||||
|
||||
const snapshotStore = useSnapshotStore();
|
||||
|
||||
const slices: Slice[] = ['hour', 'day', 'month'];
|
||||
|
||||
const allowedSlices = computed(() => {
|
||||
const days = snapshotStore.duration;
|
||||
return slices.filter(e => days > DateService.sliceAvailabilityMap[e][0] && days < DateService.sliceAvailabilityMap[e][1]);
|
||||
});
|
||||
|
||||
const currentSlice = ref<Slice>(allowedSlices.value[0]);
|
||||
|
||||
watch(snapshotStore, () => {
|
||||
currentSlice.value = allowedSlices.value[0];
|
||||
})
|
||||
|
||||
type ResultType = { _id: string, events: { name: string, count: number }[] }
|
||||
|
||||
const { data: events, status: eventsStatus, error: eventsError } = useAuthFetch<ResultType[]>('/api/timeline/events_stacked', {
|
||||
headers: { 'x-slice': currentSlice }, lazy: true
|
||||
});
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
if (!events.value) return -1;
|
||||
const index = events.value.findIndex(e => new Date(e._id).getTime() >= (Date.now()));
|
||||
return index;
|
||||
});
|
||||
|
||||
const data = computed(() => {
|
||||
if (!events.value) return {
|
||||
data: [],
|
||||
labels: [],
|
||||
slice: 'month',
|
||||
todayIndex: todayIndex.value
|
||||
} as EventsStackedChartData;
|
||||
|
||||
const result: EventsStackedChartData = {
|
||||
labels: events.value.map(e => DateService.getChartLabelFromISO(new Date(e._id).getTime(), currentSlice.value)),
|
||||
data: events.value.map(e => e.events),
|
||||
slice: currentSlice.value,
|
||||
todayIndex: todayIndex.value,
|
||||
tooltipHandler: externalTooltipHandler
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
})
|
||||
|
||||
const tooltipElement = ref<HTMLDivElement>();
|
||||
|
||||
|
||||
const tooltipData = ref<TooltipDataEventsStacked>({
|
||||
date: '',
|
||||
items: []
|
||||
});
|
||||
|
||||
function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'line' | 'bar'> }) {
|
||||
const { chart, tooltip } = context;
|
||||
|
||||
if (!tooltipElement.value) {
|
||||
const elem = document.getElementById('external-tooltip-events-stacked');
|
||||
if (!elem) return;
|
||||
tooltipElement.value = elem as HTMLDivElement;
|
||||
}
|
||||
|
||||
const tooltipEl = tooltipElement.value;
|
||||
if (!tooltipEl) return;
|
||||
|
||||
const currentIndex = tooltip.dataPoints[0].parsed.x;
|
||||
|
||||
if (todayIndex.value >= 0) {
|
||||
if (currentIndex > todayIndex.value - 1) {
|
||||
return tooltipEl.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// tooltipData.value.visits = (tooltip.dataPoints.find(e => e.datasetIndex == 0)?.raw) as number;
|
||||
// tooltipData.value.sessions = (tooltip.dataPoints.find(e => e.datasetIndex == 1)?.raw) as number;
|
||||
// tooltipData.value.events = ((tooltip.dataPoints.find(e => e.datasetIndex == 2)?.raw) as any).r2.count as number;
|
||||
|
||||
const result = tooltip.dataPoints.map(e => {
|
||||
return { label: e.dataset.label, value: e.raw as number, color: e.dataset.backgroundColor }
|
||||
}).filter(e => e.value > 0);
|
||||
|
||||
tooltipData.value.items = result;
|
||||
|
||||
const dateIndex = tooltip.dataPoints[0].dataIndex;
|
||||
const targetLabel = events.value ? events.value[dateIndex] : { _id: 0 };
|
||||
tooltipData.value.date = new Date(targetLabel._id).toLocaleString();
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
const xSwap = tooltip.caretX > (window.innerWidth * 0.5) ? -250 : 50;
|
||||
|
||||
tooltipEl.style.opacity = '1';
|
||||
|
||||
tooltipEl.style.left = (tooltip.caretX + xSwap) + 'px';
|
||||
|
||||
tooltipEl.style.top = (tooltip.caretY - 75) + 'px';
|
||||
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard v-model="currentSlice">
|
||||
<div class="min-h-[25rem] flex items-center justify-center relative">
|
||||
<LoaderCircle v-if="eventsStatus !== 'success' && eventsStatus !== 'error'"
|
||||
class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
<MainChart class="w-full" v-if="eventsStatus === 'success'" :data="data"></MainChart>
|
||||
<ChartTooltip class="opacity-0" :data="tooltipData" id='external-tooltip-events-stacked'>
|
||||
</ChartTooltip>
|
||||
<div v-if="eventsError">
|
||||
{{ eventsError.data.message ?? eventsError }}
|
||||
</div>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</template>
|
||||
84
dashboard/components/complex/EventsUserFlow.vue
Normal file
84
dashboard/components/complex/EventsUserFlow.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { LoaderCircle } from 'lucide-vue-next';
|
||||
|
||||
const { data: events, status: eventsStatus } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/events', {
|
||||
headers: { 'x-limit': '1000' }, lazy: true, key: 'list:events'
|
||||
});
|
||||
|
||||
const result = ref<any>();
|
||||
const analyzing = ref<boolean>(false);
|
||||
const selectedEvent = ref<string>();
|
||||
|
||||
watch(selectedEvent, () => {
|
||||
if (!selectedEvent.value) return;
|
||||
analyzeEvents();
|
||||
})
|
||||
|
||||
async function analyzeEvents() {
|
||||
if (!selectedEvent.value) return;
|
||||
analyzing.value = true;
|
||||
const res = await useAuthFetchSync<{ _id: string, count: number }[]>(`/api/data/event_user_flow?event_name=${selectedEvent.value}`);
|
||||
const count = res.reduce((a, e) => a + e.count, 0);
|
||||
result.value = res.map(e => ({ ...e, count: 100 / count * e.count })).toSorted((a, b) => b.count - a.count);
|
||||
analyzing.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle> Events User Flow </CardTitle>
|
||||
<CardDescription>
|
||||
Track your user's journey from external links to in-app events, maintaining a complete view of their
|
||||
path from entry to engagement.
|
||||
</CardDescription>
|
||||
<CardContent class="p-0 mt-6">
|
||||
|
||||
<div v-if="eventsStatus !== 'success'" class="flex items-center justify-center h-[10rem]">
|
||||
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
</div>
|
||||
|
||||
<Select v-if="eventsStatus === 'success'" v-model="selectedEvent">
|
||||
<SelectTrigger>
|
||||
<SelectValue class="w-[15rem]" placeholder="Select an event">
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="event of events" :value="event._id">
|
||||
{{ event._id }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="mt-8">
|
||||
<div v-if="!analyzing && result" class="flex flex-col gap-2">
|
||||
|
||||
<div class="relative h-8 px-4 flex items-center bg-[#1b1b1d] rounded-lg text-[.9rem] poppins"
|
||||
v-for="item of result">
|
||||
<div class="z-[5]"> {{ item._id }} </div>
|
||||
<div class="grow"></div>
|
||||
<div class="z-[5]">{{ item.count.toFixed(2) }} %</div>
|
||||
|
||||
<div :style="`width: ${Math.floor(item.count)}%`" class="absolute bg-[#7537F340] rounded-lg top-0 left-0 h-full">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="analyzing" class="flex flex-col gap-2">
|
||||
<Skeleton class="h-8 w-full"></Skeleton>
|
||||
<Skeleton class="h-8 w-full"></Skeleton>
|
||||
<Skeleton class="h-8 w-full"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</template>
|
||||
177
dashboard/components/complex/FirstInteraction.vue
Normal file
177
dashboard/components/complex/FirstInteraction.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
import { CopyIcon } from 'lucide-vue-next';
|
||||
import { toast } from 'vue-sonner';
|
||||
import GuidedSetup from './GuidedSetup.vue';
|
||||
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
const scriptValue = [
|
||||
{ text: '<', color: '#35a4f1' },
|
||||
{ text: 'script ', color: '#f07178' },
|
||||
{ text: 'defer ', color: '#c792ea' },
|
||||
{ text: 'data-workspace', color: '#c792ea' },
|
||||
{ text: '=', color: '#35a4f1' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: projectStore.activeProject?._id.toString(), color: '#b9e87f' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: " src", color: '#c792ea' },
|
||||
{ text: '=', color: '#35a4f1' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: "https://cdn.jsdelivr.net/gh/litlyx/litlyx-js@latest/browser/litlyx.js", color: '#b9e87f' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
{ text: '</', color: '#35a4f1' },
|
||||
{ text: 'script', color: '#f07178' },
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
]
|
||||
|
||||
function copyScript() {
|
||||
if (!navigator.clipboard) return toast('Error', { position: 'top-right', description: 'Error copying' });
|
||||
navigator.clipboard.writeText(scriptValue.map(e => e.text).join(''));
|
||||
return toast('Success', { position: 'top-right', description: 'Project script is in the clipboard' });
|
||||
}
|
||||
|
||||
function copyProjectId() {
|
||||
if (!navigator.clipboard) return toast('Error', { position: 'top-right', description: 'Error copying' });
|
||||
navigator.clipboard.writeText(projectStore.activeProject?._id.toString() ?? 'ERROR_COPYING_PROJECT');
|
||||
return toast('Success', { position: 'top-right', description: 'Project id is in the clipboard' });
|
||||
}
|
||||
|
||||
|
||||
const techs = [
|
||||
{ name: 'Wordpress', link: 'https://docs.litlyx.com/techs/wordpress', icon: 'logos:wordpress-icon' },
|
||||
{ name: 'Shopify', link: 'https://docs.litlyx.com/techs/shopify', icon: 'logos:shopify' },
|
||||
{ name: 'Google Tag Manager', link: 'https://docs.litlyx.com/techs/google-tag-manager', icon: 'logos:google-tag-manager' },
|
||||
{ name: 'Javascript', link: 'https://docs.litlyx.com/techs/js', icon: 'logos:javascript' },
|
||||
{ name: 'Nuxt', link: 'https://docs.litlyx.com/techs/nuxt', icon: 'logos:nuxt-icon' },
|
||||
{ name: 'Next', link: 'https://docs.litlyx.com/techs/next', icon: 'logos:nextjs-icon' },
|
||||
{ name: 'React', link: 'https://docs.litlyx.com/techs/0react', icon: 'logos:react' },
|
||||
{ name: 'Vue', link: 'https://docs.litlyx.com/techs/vue', icon: 'logos:vue' },
|
||||
{ name: 'Angular', link: 'https://docs.litlyx.com/techs/angular', icon: 'logos:angular-icon' },
|
||||
{ name: 'Python', link: 'https://docs.litlyx.com/techs/py', icon: 'logos:python' },
|
||||
{ name: 'Serverless', link: 'https://docs.litlyx.com/techs/serverless', icon: 'logos:serverless' }
|
||||
|
||||
]
|
||||
|
||||
const setupGuidato = ref(true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="setupGuidato">
|
||||
<GuidedSetup v-model:active="setupGuidato" />
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
|
||||
<div class="flex flex-col gap-4 poppins">
|
||||
<div class="bg-gradient-to-r from-violet-500/20 to-transparent rounded-md">
|
||||
<div class=" m-[1px] p-4 rounded-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex flex-row">
|
||||
<Loader class="h-6" />
|
||||
<p class="pl-2 font-medium text-md">Waiting for your first visit..</p>
|
||||
</span>
|
||||
<Button @click="setupGuidato = true">Guided Setup</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 sm:grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Tag script
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Start tracking web analytics in one line.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted-foreground ">Place it in your <span
|
||||
class="text-muted-foreground dark:text-white font-medium">{{ `<head>` }}
|
||||
</span> or just before closing
|
||||
<span class="text-muted-foreground dark:text-white font-medium">{{ `<body>` }}
|
||||
</span> tag</p>
|
||||
<div class="bg-gray-700 dark:bg-accent/50 p-4 rounded-md relative">
|
||||
<div @click="copyScript()"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-muted-foreground cursor-pointer">
|
||||
<CopyIcon class="size-4"></CopyIcon>
|
||||
</div>
|
||||
<span v-for="e of scriptValue" :style="`color: ${e.color};`" class="text-[13px]">
|
||||
{{ e.text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label class="text-sm">
|
||||
<span class="pr-2">Workspace id:</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon name="lucide:info" class="align-middle" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="max-w-100">
|
||||
<p>If you are using a framework like <b>React</b>, <b>Vue</b>, or <b>Next</b>,
|
||||
copy the following ID into your <code
|
||||
class="text-violet-800">Lit.init("workspace_id")</code> function.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</label>
|
||||
<div class="bg-gray-700 dark:bg-accent/50 p-4 rounded-md relative">
|
||||
<div @click="copyProjectId()"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-muted-foreground cursor-pointer">
|
||||
<CopyIcon class="size-4"></CopyIcon>
|
||||
</div>
|
||||
<span class="text-[13px] text-white">{{ projectStore.pid ?? '' }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Integrations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Get started with your favourite integration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap place-content-center gap-4">
|
||||
<TooltipProvider v-for="e of techs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<NuxtLink :to="e.link" target="_blank">
|
||||
<div
|
||||
class="border-solid border-[1px] rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-transparent hover:dark:bg-gray-100/5 flex justify-center">
|
||||
<Icon class="size-6 m-[1.5rem]" :name="e.icon" mode="svg"></Icon>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-100">
|
||||
{{ e.name }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div class="bg-violet-500/20 p-4 rounded-md flex justify-between items-center">
|
||||
<div class="flex flex-col">
|
||||
<label>Need Help?</label>
|
||||
<p class="text-[13px]">visit the docs or contact us at <span
|
||||
class="font-medium">help@litlyx.com</span>.</p>
|
||||
</div>
|
||||
|
||||
<NavLink to="/docs">
|
||||
<Button>Visit Docs</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
16
dashboard/components/complex/GradientBorder.vue
Normal file
16
dashboard/components/complex/GradientBorder.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <div class="bg-gradient-to-b from-violet-300 dark:from-[#7533F3] to-border rounded-md">
|
||||
<div class="dark:bg-radial from-[50%] from-[#24114b] to-sidebar m-[1px] p-4 rounded-md">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="bg-gradient-to-b from-violet-300 dark:from-[#7533F3]/40 to-border rounded-md">
|
||||
<div class="dark:bg-linear-to-br from-[20%] from-[#24114b] to-sidebar m-[1px] p-4 rounded-md">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
480
dashboard/components/complex/GuidedSetup.vue
Normal file
480
dashboard/components/complex/GuidedSetup.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
import { BookUser, CreditCard, Truck, Check, TriangleAlert, CopyIcon, Trash } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
active: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:active'])
|
||||
|
||||
function close() {
|
||||
emit('update:active', false)
|
||||
}
|
||||
|
||||
//STEPS
|
||||
const currentStep = ref(1)
|
||||
|
||||
const steps = ref<{ step: number; title: string; icon: any; done: boolean; }[]>([
|
||||
{
|
||||
step: 1,
|
||||
title: 'Add Website Info',
|
||||
icon: BookUser,
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Install Litlyx',
|
||||
icon: Truck,
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'Verify Installation',
|
||||
icon: CreditCard,
|
||||
done: false,
|
||||
},
|
||||
])
|
||||
|
||||
//STEP 1 - Install Litlyx
|
||||
const installDomain = ref<string>('')
|
||||
const autoInstallDomain = ref<boolean>(false)
|
||||
|
||||
const checkDomain = computed(() => {
|
||||
return autoInstallDomain.value || installDomain.value.trim() !== ''
|
||||
})
|
||||
|
||||
const { data: domains, refresh: domainsRefresh } = useAuthFetch('/api/shields/domains/list');
|
||||
|
||||
watch(() => domains.value, (newDomains) => {
|
||||
if (Array.isArray(newDomains) && newDomains.length >= 1) {
|
||||
currentStep.value = 2;
|
||||
installDomain.value = newDomains[0];
|
||||
steps.value[0].done = true;
|
||||
} else {
|
||||
currentStep.value = 1;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
//Remove Domain
|
||||
async function removeInstallDomain() {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error deleting domain',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/shields/domains/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: installDomain.value })
|
||||
})
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
showToast('Domain deleted', { description: 'Domain deleted successfully', position: 'top-right' });
|
||||
domainsRefresh();
|
||||
steps.value[0].done = false;
|
||||
installDomain.value = '';
|
||||
},
|
||||
})
|
||||
}
|
||||
//Tasto proseguimento
|
||||
async function endSetup(step: number) {
|
||||
if (step === 1) {
|
||||
if (!checkDomain.value && (!domains.value || domains.value.length === 0)) {
|
||||
return;
|
||||
}
|
||||
if (autoInstallDomain.value === false) {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error adding domain',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/shields/domains/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: installDomain.value })
|
||||
})
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
showToast('Domain added', { description: 'Domain added successfully', position: 'top-right' });
|
||||
domainsRefresh();
|
||||
steps.value[0].done = true;
|
||||
currentStep.value = 2;
|
||||
},
|
||||
})
|
||||
} else {
|
||||
toast.info('Info', { description: 'Domain will be auto detected in verify installation', position: 'top-right' });
|
||||
steps.value[0].done = true;
|
||||
currentStep.value = 2;
|
||||
}
|
||||
|
||||
} else if (step === 2) {
|
||||
steps.value[1].done = true;
|
||||
currentStep.value = 3;
|
||||
await new Promise(e => setTimeout(e, 3000));
|
||||
await projectStore.fetchFirstInteraction();
|
||||
if (!projectStore.firstInteraction) {
|
||||
steps.value[1].done = false;
|
||||
currentStep.value = 2;
|
||||
toast.error('Domain verification', { description: 'Cannot verify your domain, try again', position: 'top-right' });
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Scripts
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
const litlyxScript = [
|
||||
{ text: '<', color: '#35a4f1' },
|
||||
{ text: 'script ', color: '#f07178' },
|
||||
{ text: 'defer ', color: '#c792ea' },
|
||||
{ text: 'data-workspace', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: projectStore.activeProject?._id?.toString() ?? '', color: '#b9e87f' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: " \nsrc", color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: "https://cdn.jsdelivr.net/npm/litlyx-js@latest/browser/litlyx.js", color: '#b9e87f' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
{ text: '</', color: '#35a4f1' },
|
||||
{ text: 'script', color: '#f07178' },
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
]
|
||||
|
||||
const googleTagScript = [
|
||||
{ text: '<', color: '#35a4f1' },
|
||||
{ text: 'script', color: '#f07178' },
|
||||
{ text: '>\n', color: '#35a4f1' },
|
||||
{ text: 'var', color: '#c792ea' },
|
||||
{ text: ' script', color: '#8ac1e7' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: "document.", color: '#8ac1e7' },
|
||||
{ text: "createElement('script');\n", color: '#8ac1e7' },
|
||||
|
||||
{ text: 'script.defer', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: "true\n", color: '#8ac1e7' },
|
||||
{ text: 'script.dataset.project', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: projectStore.activeProject?._id?.toString() ?? '', color: '#b9e87f' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: "\nscript.src", color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
{ text: "https://cdn.jsdelivr.net/npm/litlyx-js@latest/browser/litlyx.js", color: '#b9e87f' },
|
||||
{ text: "\"", color: '#b9e87f' },
|
||||
|
||||
{ text: `\ndocument.getElementsByTagName('head')[0].appendChild(script);\n`, color: '#c792ea' },
|
||||
{ text: '</', color: '#35a4f1' },
|
||||
{ text: 'script', color: '#f07178' },
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
]
|
||||
|
||||
const inHouseScript = [
|
||||
{ text: '<', color: '#35a4f1' },
|
||||
{ text: 'script ', color: '#f07178' },
|
||||
|
||||
// src
|
||||
{ text: 'src', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
{ text: 'https://cdn.jsdelivr.net/npm/litlyx-js@latest/browser/litlyx.js', color: '#b9e87f' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
|
||||
// data-workspace
|
||||
{ text: '\n data-workspace', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
{ text: projectStore.activeProject?._id?.toString() ?? '', color: '#b9e87f' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
|
||||
// data-host
|
||||
{ text: '\n data-host', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
{ text: 'your-host', color: '#b9e87f' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
|
||||
// data-port
|
||||
{ text: '\n data-port', color: '#c792ea' },
|
||||
{ text: ' = ', color: '#35a4f1' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
{ text: 'your-port', color: '#b9e87f' },
|
||||
{ text: '"', color: '#b9e87f' },
|
||||
|
||||
// chiusura tag
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
{ text: '</', color: '#35a4f1' },
|
||||
{ text: 'script', color: '#f07178' },
|
||||
{ text: '>', color: '#35a4f1' },
|
||||
];
|
||||
|
||||
|
||||
function copyScript(name: { text: string; color: string }[]) {
|
||||
if (!navigator.clipboard) return toast.error('Error', { position: 'top-right', description: 'Error copying' });
|
||||
navigator.clipboard.writeText(name.map(e => e.text).join(''));
|
||||
return toast.success('Success', { position: 'top-right', description: 'The workspace script has been copied to your clipboard' });
|
||||
}
|
||||
|
||||
function copyProjId() {
|
||||
navigator.clipboard.writeText(projectStore.activeProject?._id?.toString() ?? '')
|
||||
toast.success('Success', { position: 'top-right', description: 'The workspace id has been copied to your clipboard' });
|
||||
}
|
||||
const techs = [
|
||||
{ name: 'Wordpress', link: 'https://docs.litlyx.com/techs/wordpress', icon: 'logos:wordpress-icon' },
|
||||
{ name: 'Shopify', link: 'https://docs.litlyx.com/techs/shopify', icon: 'logos:shopify' },
|
||||
{ name: 'Google Tag Manager', link: 'https://docs.litlyx.com/techs/google-tag-manager', icon: 'logos:google-tag-manager' },
|
||||
{ name: 'Javascript', link: 'https://docs.litlyx.com/techs/js', icon: 'logos:javascript' },
|
||||
{ name: 'Nuxt', link: 'https://docs.litlyx.com/techs/nuxt', icon: 'logos:nuxt-icon' },
|
||||
{ name: 'Next', link: 'https://docs.litlyx.com/techs/next', icon: 'logos:nextjs-icon' },
|
||||
{ name: 'React', link: 'https://docs.litlyx.com/techs/0react', icon: 'logos:react' },
|
||||
{ name: 'Vue', link: 'https://docs.litlyx.com/techs/vue', icon: 'logos:vue' },
|
||||
{ name: 'Angular', link: 'https://docs.litlyx.com/techs/angular', icon: 'logos:angular-icon' },
|
||||
{ name: 'Python', link: 'https://docs.litlyx.com/techs/py', icon: 'logos:python' },
|
||||
{ name: 'Serverless', link: 'https://docs.litlyx.com/techs/serverless', icon: 'logos:serverless' }
|
||||
|
||||
]
|
||||
|
||||
//Timezone
|
||||
function getUserTimezoneLabel(): string {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const offsetMinutes = -new Date().getTimezoneOffset(); // invertito perché getTimezoneOffset è negativo per UTC+
|
||||
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const minutes = (Math.abs(offsetMinutes) % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
return `(GMT${sign}${hours}:${minutes}) ${timeZone}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<Unauthorized v-if="!projectStore.isOwner" authorization="Guest user limitation in Setup">
|
||||
</Unauthorized>
|
||||
<div v-else class="flex flex-col gap-12 p-4 text-white poppins">
|
||||
<div class="flex justify-center gap-2 items-center">
|
||||
<template v-for="(step, index) in steps" :key="step.step">
|
||||
<!-- STEP -->
|
||||
<div @click="(steps[index].done || steps[index - 1]?.done) && (currentStep = step.step)"
|
||||
class="flex flex-col text-center lg:flex-row lg:text-start items-center gap-2 cursor-pointer">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold" :class="{
|
||||
'bg-gray-800 text-white dark:bg-white dark:text-muted': currentStep === step.step,
|
||||
'bg-violet-500 dark:bg-violet-400/50 text-white': step.done && currentStep !== step.step,
|
||||
'bg-muted-foreground text-muted': !step.done && currentStep !== step.step
|
||||
}">
|
||||
<Check v-if="step.done" class="size-4" />
|
||||
<span v-else>{{ step.step }}</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="{
|
||||
'text-gray-500 dark:text-gray-200': currentStep === step.step,
|
||||
'text-gray-400 ': !step.done && currentStep !== step.step,
|
||||
'text-gray-800 dark:text-white': step.done && currentStep !== step.step
|
||||
}">
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- SEPARATOR (solo se non è l'ultimo) -->
|
||||
<div v-if="index < steps.length - 1" class="h-0.5 w-10 bg-sidebar-accent mx-2"></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<!-- Contenuto dello step selezionato -->
|
||||
<Card class="max-w-[80dvw] md:max-w-[40dvw] min-w-[40dvw] ">
|
||||
<div v-if="currentStep === 1">
|
||||
<CardHeader>
|
||||
<CardTitle>Add Website Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-8">
|
||||
|
||||
<Alert class="mt-4 border-yellow-500">
|
||||
<TriangleAlert class="size-4 !text-yellow-500" />
|
||||
<AlertTitle>Before start</AlertTitle>
|
||||
<AlertDescription>
|
||||
When you create your first workspace, your account will enter in a 30 days free trial period.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between gap-2 items-center">
|
||||
<h1 class="text-[16px] font-semibold lg:text-lg">Domain</h1>
|
||||
<span class="text-sm items-center flex gap-2">{{ autoInstallDomain ? 'Auto detect' : 'Manual mode' }}
|
||||
<Switch v-model="autoInstallDomain" />
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<div v-if="autoInstallDomain">
|
||||
<PageHeader description="Domain will be automatically detected" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<PageHeader description="Just the naked domain or subdomain without 'www', 'https' etc." />
|
||||
<div class="flex gap-4">
|
||||
<Input placeholder="example.com" v-model="installDomain"
|
||||
:disabled="(domains && domains.length >= 1)" />
|
||||
<Button v-if="domains && domains.length >= 1" @click="removeInstallDomain()" size="icon">
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">We store this in <strong>Shields</strong>, and only this
|
||||
domain is
|
||||
authorized to collect data.</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<PageHeader title="Timezone" description="Litlyx find your Timezone automatically." />
|
||||
<div class="rounded-md p-2 w-full border text-sm text-gray-950/50 dark:text-gray-50/50 select-none">
|
||||
{{ getUserTimezoneLabel() }}
|
||||
</div>
|
||||
</div>
|
||||
<Button :disabled="!checkDomain || (domains && domains.length >= 1)" @click="endSetup(1)">{{ domains &&
|
||||
(domains && domains.length >= 1) ? 'Domain Added' : 'Install Litlyx' }}</Button>
|
||||
</CardContent>
|
||||
</div>
|
||||
<div v-else-if="currentStep === 2">
|
||||
<CardHeader>
|
||||
<CardTitle>Install Litlyx</CardTitle>
|
||||
<CardDescription>Paste this snippet into the
|
||||
<strong>
|
||||
<span v-pre><head></span>
|
||||
</strong>
|
||||
or at the end of <strong><span v-pre></body></span></strong> tag section of your website.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-8">
|
||||
|
||||
<div class="flex justify-start ">
|
||||
<Tabs default-value="manual" class="mt-4 w-full">
|
||||
<TabsList class="grid grid-cols-3 w-full">
|
||||
<TabsTrigger value="manual" class="truncate">
|
||||
Manual
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="googletm" class="truncate">
|
||||
Google Tag Manager
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="in-house">
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="in-house" class="flex flex-col gap-4">
|
||||
<div class="bg-gray-700 dark:bg-accent/50 p-4 rounded-md relative">
|
||||
<div @click="copyScript(inHouseScript)"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-muted-foreground cursor-pointer">
|
||||
<CopyIcon class="size-4"></CopyIcon>
|
||||
</div>
|
||||
<span v-for="e of inHouseScript" :style="`color: ${e.color};`" class="text-[13px] whitespace-pre">
|
||||
{{ e.text }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Litlyx lets you integrate JSON data responses into your
|
||||
in-house
|
||||
services, providing seamless data transfer and easy synchronization with your existing workflows.
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="manual" class="flex flex-col gap-4">
|
||||
<div class="bg-gray-700 dark:bg-accent/50 p-4 rounded-md relative">
|
||||
<div @click="copyScript(litlyxScript)"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-muted-foreground cursor-pointer">
|
||||
<CopyIcon class="size-4"></CopyIcon>
|
||||
</div>
|
||||
<span v-for="e of litlyxScript" :style="`color: ${e.color};`" class="text-[13px] whitespace-pre">
|
||||
{{ e.text }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Litlyx works everywhere! From Vibe Coding tools like Cursor
|
||||
to
|
||||
frameworks like Nuxt or Vue, site builders like Framer or Wordpress and even Shopify.</p>
|
||||
<div class="flex flex-wrap place-content-center gap-2">
|
||||
<TooltipProvider v-for="e of techs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<NuxtLink :to="e.link" target="_blank">
|
||||
<div
|
||||
class="border-solid border-[1px] rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-transparent hover:dark:bg-gray-100/5 flex justify-center">
|
||||
<Icon class="size-8 m-4" :name="e.icon" mode="svg"></Icon>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-100">
|
||||
{{ e.name }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="googletm">
|
||||
<div class="bg-gray-700 dark:bg-accent/50 p-4 rounded-md relative">
|
||||
<div @click="copyScript(googleTagScript)"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-muted-foreground cursor-pointer">
|
||||
<CopyIcon class="size-4"></CopyIcon>
|
||||
</div>
|
||||
<span v-for="e of googleTagScript" :style="`color: ${e.color};`"
|
||||
class="text-[13px] whitespace-pre ">
|
||||
{{ e.text }}
|
||||
</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Workspace Id</Label>
|
||||
<div class="bg-gray-700 dark:bg-accent/50 p-4 rounded-md relative mt-2">
|
||||
<div @click="copyProjId()"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-muted-foreground cursor-pointer">
|
||||
<CopyIcon class="size-4"></CopyIcon>
|
||||
</div>
|
||||
<span class="text-[13px] text-white whitespace-pre ">
|
||||
{{ projectStore.activeProject?._id?.toString() ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Visit our <NuxtLink to="https://docs.litlyx.com/quickstart" alt="Quick Start Litlyx"
|
||||
class="text-black dark:text-white underline underline-offset-2">Quick Start</NuxtLink> in our
|
||||
documentation.
|
||||
</span>
|
||||
<Button @click="endSetup(2)">Verify Installation</Button>
|
||||
</CardContent>
|
||||
|
||||
</div>
|
||||
<div v-else-if="currentStep === 3">
|
||||
<CardContent class="my-8">
|
||||
<div class="flex items-center justify-center gap-4 ">
|
||||
|
||||
<div class="bg-muted rounded-full w-8 h-8 flex items-center justify-center">
|
||||
<div class="bg-violet-500 rounded-full size-2 animate-pulse"></div>
|
||||
</div>
|
||||
<PageHeader title="Verifying your installation.."
|
||||
description="We're checking everything is working fine!" />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</div>
|
||||
<CardFooter>
|
||||
<div class="text-xs">
|
||||
<p>If you have any problems, we are here to help you and assist your installation.</p>
|
||||
<p>Contact us on <strong>help@litlyx.com</strong>.</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
106
dashboard/components/complex/LineDataNew.vue
Normal file
106
dashboard/components/complex/LineDataNew.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Browsers from './line-data/Browsers.vue';
|
||||
|
||||
import Cities from './line-data/Cities.vue';
|
||||
import Regions from './line-data/Regions.vue';
|
||||
import Countries from './line-data/Countries.vue';
|
||||
import Continents from './line-data/Continents.vue';
|
||||
|
||||
import Devices from './line-data/Devices.vue';
|
||||
import Events from './line-data/Events.vue';
|
||||
import Oss from './line-data/Oss.vue';
|
||||
|
||||
import Pages from './line-data/Pages.vue';
|
||||
import EntryPages from './line-data/EntryPages.vue';
|
||||
import ExitPages from './line-data/ExitPages.vue';
|
||||
|
||||
import Referrers from './line-data/Referrers.vue';
|
||||
|
||||
|
||||
import Utm_generic from './line-data/UtmGeneric.vue';
|
||||
|
||||
import SelectCountry from './line-data/selectors/SelectCountry.vue';
|
||||
import SelectDevice from './line-data/selectors/SelectDevice.vue';
|
||||
import SelectPage from './line-data/selectors/SelectPage.vue';
|
||||
import SelectRefer from './line-data/selectors/SelectRefer.vue';
|
||||
|
||||
import ShowMoreDialog, { type ShowMoreDialogProps } from './line-data/ShowMoreDialog.vue';
|
||||
import type { LineDataProps } from './line-data/LineDataTemplate.vue';
|
||||
import { RefreshCwIcon } from 'lucide-vue-next';
|
||||
|
||||
type LineDataType = 'referrers' | 'utm_generic' | 'pages' | 'pages_entries' | 'pages_exits' | 'countries' | 'cities' | 'continents' | 'regions' | 'devices' | 'browsers' | 'oss' | 'events';
|
||||
type LineDataTypeSelectable = 'referrers' | 'devices' | 'countries' | 'pages';
|
||||
|
||||
const props = defineProps<{
|
||||
type: LineDataType,
|
||||
select?: boolean,
|
||||
sharedLink?: string
|
||||
}>();
|
||||
|
||||
const selected = ref<string>(props.type)
|
||||
|
||||
const selectMap: Record<LineDataTypeSelectable, Component> = {
|
||||
referrers: SelectRefer,
|
||||
devices: SelectDevice,
|
||||
countries: SelectCountry,
|
||||
pages: SelectPage,
|
||||
}
|
||||
|
||||
const selectedComponent = computed(() => {
|
||||
if (!selected.value) return;
|
||||
if (!selected.value.startsWith('utm_')) return componentsMap[selected.value as LineDataTypeSelectable];
|
||||
return componentsMap.utm_generic;
|
||||
});
|
||||
|
||||
const componentsMap: Record<LineDataType, Component> = {
|
||||
referrers: Referrers,
|
||||
utm_generic: Utm_generic,
|
||||
pages: Pages,
|
||||
pages_entries: EntryPages,
|
||||
pages_exits: ExitPages,
|
||||
continents: Continents,
|
||||
countries: Countries,
|
||||
regions: Regions,
|
||||
cities: Cities,
|
||||
devices: Devices,
|
||||
browsers: Browsers,
|
||||
oss: Oss,
|
||||
events: Events,
|
||||
}
|
||||
|
||||
const currentData = ref<LineDataProps>();
|
||||
|
||||
function onChildInit(data: LineDataProps) {
|
||||
currentData.value = data;
|
||||
}
|
||||
|
||||
const refreshToken = ref(0);
|
||||
|
||||
async function refreshData() {
|
||||
refreshToken.value++;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle v-if="currentData" class="flex gap-2">
|
||||
<div class="capitalize"> {{ currentData.title }} </div>
|
||||
<RefreshCwIcon @click="refreshData" class="size-4 hover:rotate-90 cursor-pointer transition-all">
|
||||
</RefreshCwIcon>
|
||||
</CardTitle>
|
||||
<CardDescription v-if="currentData"> {{ currentData.sub }} </CardDescription>
|
||||
<CardAction class="flex gap-2">
|
||||
<component v-if="props.select" :is="selectMap[(props.type as LineDataTypeSelectable)] ?? undefined"
|
||||
v-model="selected" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent v-if="selectedComponent">
|
||||
<component :shared-link="sharedLink" :refresh-token="refreshToken" @init="onChildInit" class="h-full" :is="selectedComponent"
|
||||
:advanced_data="{ raw_selected: selected }"></component>
|
||||
<!-- componente con all'interno il @click="emits('showMore')" -->
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
91
dashboard/components/complex/actionable-chart/ChartCard.vue
Normal file
91
dashboard/components/complex/actionable-chart/ChartCard.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Slice } from '~/shared/services/DateService';
|
||||
import ChartSliceSelector from './ChartSliceSelector.vue';
|
||||
import { Table } from 'lucide-vue-next'
|
||||
|
||||
useHead({
|
||||
meta: [{ name: 'robots', content: 'noindex, nofollow' }]
|
||||
});
|
||||
|
||||
const { isShared } = useShared();
|
||||
|
||||
const props = defineProps<{ modelValue: string | undefined }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', slice: Slice): void
|
||||
}>();
|
||||
|
||||
const exporting = ref<boolean>(false);
|
||||
|
||||
async function exportEvents() {
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
const result = await useAuthFetchSync(`/api/raw/export_events`);
|
||||
const blob = new Blob([result as any], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportEvents.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
exporting.value = false;
|
||||
}
|
||||
|
||||
async function exportVisits() {
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
const result = await useAuthFetchSync(`/api/raw/export_visits`);
|
||||
const blob = new Blob([result as any], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ReportVisits.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
exporting.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="gap-2">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Trend chart
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Easily match Visits, Unique sessions and Events trends.
|
||||
</CardDescription>
|
||||
<CardAction class="flex items-center h-full gap-4 flex-col md:flex-row">
|
||||
|
||||
<div v-if="!isShared" class="flex gap-4">
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button variant="ghost">
|
||||
<Table class="size-4" /> Raw Data
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="flex flex-col gap-2 w-[12rem] px-4">
|
||||
<NuxtLink to="/raw_visits"><Button variant="outline" class="w-full">Visits</Button>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/raw_events"><Button variant="outline" class="w-full">Events</Button>
|
||||
</NuxtLink>
|
||||
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<ChartSliceSelector v-if="props.modelValue" :model-value="props.modelValue"
|
||||
@update:model-value="emit('update:modelValue', $event)"></ChartSliceSelector>
|
||||
</div>
|
||||
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<slot></slot>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from 'lucide-vue-next';
|
||||
import DateService, { type Slice } from '~/shared/services/DateService';
|
||||
|
||||
const slices: Slice[] = ['hour', 'day', 'month'];
|
||||
|
||||
const props = defineProps<{ modelValue: string }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', slice: Slice): void
|
||||
}>();
|
||||
|
||||
const snapshotStore = useSnapshotStore();
|
||||
|
||||
const availabilityMap = DateService.sliceAvailabilityMap;
|
||||
|
||||
const allowedSlices = computed(() => {
|
||||
const days = snapshotStore.duration;
|
||||
return slices.filter(e => days > availabilityMap[e][0] && days < availabilityMap[e][1]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="group cursor-pointer">
|
||||
<div class="flex gap-1 items-center w-fit">
|
||||
<div class="group-data-[state=open]:opacity-80"> {{ modelValue }} </div>
|
||||
<ChevronDown
|
||||
class="w-5 mt-[1px] transition-transform duration-400 group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-[10rem] rounded-lg" align="start"
|
||||
side="bottom" :side-offset="16">
|
||||
<DropdownMenuLabel class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Slice
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem v-for="item in allowedSlices" :key="item"
|
||||
:class="{ 'text-accent-foreground': modelValue === item }" class="gap-2 p-2"
|
||||
@click="emit('update:modelValue', item)">
|
||||
{{ item }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type TooltipData = {
|
||||
visits: number,
|
||||
events: number,
|
||||
sessions: number,
|
||||
date: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: any }>();
|
||||
const colors = useChartColor();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="z-[400] absolute pointer-events-none transition-all duration-300">
|
||||
|
||||
<Card class="py-2 px-3 flex flex-col gap-2 !border-violet-500/20">
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> Date: </div>
|
||||
<div v-if="data"> {{ data.date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2 items-center bg-muted dark:bg-muted/20 px-2 py-1 rounded">
|
||||
<div class="size-3 rounded-full" :style="`background-color: ${colors.visits};`">
|
||||
</div>
|
||||
<div> Visits: </div>
|
||||
<div class="text-muted-foreground">{{ props.data.visits }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center bg-muted dark:bg-muted/20 px-2 py-1 rounded">
|
||||
<div class="size-3 rounded-full" :style="`background-color: ${colors.sessions};`">
|
||||
</div>
|
||||
<div> Unique Visitors: </div>
|
||||
<div class="text-muted-foreground">{{ props.data.sessions }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center bg-muted dark:bg-muted/20 px-2 py-1 rounded">
|
||||
<div class="size-3 rounded-full" :style="`background-color: yellow;`">
|
||||
</div>
|
||||
<div> Events: </div>
|
||||
<div class="text-muted-foreground">{{ props.data.events }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
161
dashboard/components/complex/actionable-chart/MainChart.vue
Normal file
161
dashboard/components/complex/actionable-chart/MainChart.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
import { type Slice } from '~/shared/services/DateService';
|
||||
|
||||
export type ActionableChartData = {
|
||||
labels: string[],
|
||||
visits: number[],
|
||||
sessions: number[],
|
||||
events: { x: number, y: number, r: number, r2: any }[],
|
||||
slice: Slice,
|
||||
todayIndex: number,
|
||||
tooltipHandler?: any,
|
||||
showViews?: boolean,
|
||||
showVisitors?: boolean,
|
||||
showEvents?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: ActionableChartData }>();
|
||||
|
||||
const chartColor = useChartColor();
|
||||
|
||||
const chartOptions = shallowRef<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
position: 'nearest',
|
||||
external: props.data.tooltipHandler
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = shallowRef<ChartData<'line' | 'bar' | 'bubble'>>(getChartData());
|
||||
|
||||
function getChartData(): ChartData<'line' | 'bar' | 'bubble'> {
|
||||
|
||||
return {
|
||||
labels: props.data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Visits',
|
||||
data: props.data.visits,
|
||||
backgroundColor: [`${chartColor.visits}`],
|
||||
borderColor: `${chartColor.visits}`,
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: `${chartColor.visits}`,
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
hidden: props.data.showViews != true,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = props.data.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return `${chartColor.visits}`;
|
||||
if (ctx.p1DataIndex > todayIndex - 1) return `${chartColor.visits}00`;
|
||||
return `${chartColor.visits}`
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = props.data.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = props.data.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return `${chartColor.visits}00`;
|
||||
if (ctx.p1DataIndex >= todayIndex) return `${chartColor.visits}00`;
|
||||
return `${chartColor.visits}00`;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique visitors',
|
||||
data: props.data.sessions,
|
||||
backgroundColor: props.data.sessions.map((e, i) => {
|
||||
const todayIndex = props.data.todayIndex;
|
||||
if (i == todayIndex - 1) return `${chartColor.sessions}22`;
|
||||
return `${chartColor.sessions}00`;
|
||||
}),
|
||||
borderColor: `${chartColor.sessions}`,
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: `${chartColor.sessions}22`,
|
||||
hoverBorderColor: `${chartColor.sessions}`,
|
||||
hoverBorderWidth: 2,
|
||||
hidden: props.data.showVisitors != true,
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: props.data.sessions.map((e, i) => {
|
||||
const todayIndex = props.data.todayIndex;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: props.data.events,
|
||||
backgroundColor: props.data.sessions.map((e, i) => {
|
||||
const todayIndex = props.data.todayIndex;
|
||||
if (i == todayIndex - 1) return `#fbbf2422`;
|
||||
return `#fbbf2400`;
|
||||
}),
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf2444',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
hidden: props.data.showEvents != true,
|
||||
type: 'bubble',
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
watch(props, () => {
|
||||
chartData.value = getChartData();
|
||||
})
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineChart v-if="chartData" ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
88
dashboard/components/complex/ai/AiChat.vue
Normal file
88
dashboard/components/complex/ai/AiChat.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { ReadableChatMessage } from '~/pages/ai.vue';
|
||||
import AssistantMessage from './AssistantMessage.vue';
|
||||
import { CircleAlert } from 'lucide-vue-next';
|
||||
|
||||
const ai_chats_component = useTemplateRef<HTMLDivElement>('ai_chats');
|
||||
|
||||
const props = defineProps<{
|
||||
messages?: ReadableChatMessage[],
|
||||
status?: string,
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'downvoted', message_index: number): void;
|
||||
(event: 'chatdeleted'): void;
|
||||
}>();
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
ai_chats_component.value?.scrollTo({ top: 999999, behavior: 'smooth' });
|
||||
}, 150);
|
||||
}
|
||||
watch(props, async () => {
|
||||
scrollToBottom();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 overflow-y-auto overflow-x-hidden" ref="ai_chats">
|
||||
|
||||
|
||||
|
||||
<div v-for="(message, index) of messages" class="flex flex-col relative">
|
||||
|
||||
<div class="w-full flex justify-end" v-if="message.role === 'user'">
|
||||
<div class="border rounded-md p-2 flex flex-col gap-2 flex-end w-[70%] bg-white dark:bg-black">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Label> {{ message.name ?? 'User' }} </Label>
|
||||
<Label class="text-sm text-muted-foreground" v-if="message.created_at">{{ new
|
||||
Date(message.created_at).toLocaleString() }}</Label>
|
||||
</div>
|
||||
<div>
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssistantMessage v-if="message.role === 'assistant'" @messageRendered="scrollToBottom()"
|
||||
@downvoted="emits('downvoted', $event)" :message="message" :message_index="index">
|
||||
</AssistantMessage>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="status?.startsWith('THINKING')" class="text-sm flex items-center gap-2">
|
||||
<Loader class="!size-3"></Loader>
|
||||
{{ status.split(':')[1] }} is thinking...
|
||||
</div>
|
||||
|
||||
<div v-if="status?.startsWith('FUNCTION')" class="text-sm flex items-center gap-2">
|
||||
<Loader class="!size-3"></Loader>
|
||||
{{ status.split(':')[1] }} is calling a function...
|
||||
</div>
|
||||
|
||||
<div v-if="status?.startsWith('FINDING_AGENT')" class="text-sm flex items-center gap-2">
|
||||
<Loader class="!size-3"></Loader>
|
||||
Finding best agents...
|
||||
</div>
|
||||
|
||||
<div v-if="status?.startsWith('ERRORED')" class="flex items-center gap-2">
|
||||
<CircleAlert class="text-orange-300 size-4"></CircleAlert>
|
||||
<div v-if="messages && messages.length < 100"> An error occurred. Please use another chat. </div>
|
||||
<div v-else> Context limit reached </div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<DevOnly>
|
||||
<div class="flex items-center gap-1 text-muted-foreground overflow-hidden">
|
||||
<Icon name="gg:debug" size="20"></Icon>
|
||||
<div v-if="status"> {{ status }} </div>
|
||||
<div v-else> No Status </div>
|
||||
</div>
|
||||
</DevOnly>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
292
dashboard/components/complex/ai/AssistantMessage.vue
Normal file
292
dashboard/components/complex/ai/AssistantMessage.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MDCNode, MDCParserResult, MDCRoot } from '@nuxtjs/mdc';
|
||||
import { InfoIcon, ThumbsDown, ThumbsUp } from 'lucide-vue-next';
|
||||
import type { ReadableChatMessage } from '~/pages/ai.vue';
|
||||
import AiChart from '~/components/complex/ai/Chart.vue'
|
||||
|
||||
const props = defineProps<{ message: ReadableChatMessage, message_index: number }>();
|
||||
|
||||
const parsedMessage = ref<MDCParserResult>();
|
||||
|
||||
const hidden = ref<boolean>(props.message.downvoted ?? false);
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'messageRendered'): void;
|
||||
(event: 'downvoted', index: number): void;
|
||||
}>();
|
||||
|
||||
|
||||
function removeEmbedImages(data: MDCRoot | MDCNode) {
|
||||
if (data.type !== 'root' && data.type !== 'element') return;
|
||||
if (!data.children) return;
|
||||
const imgChilds = data.children.filter(e => e.type === 'element' && e.tag === 'img');
|
||||
if (imgChilds.length == 0) return data.children.forEach(e => removeEmbedImages(e));
|
||||
for (let i = 0; i < imgChilds.length; i++) {
|
||||
const index = data.children.indexOf(imgChilds[i]);
|
||||
console.log('Index', index)
|
||||
if (index == -1) continue;
|
||||
data.children.splice(index, 1);
|
||||
}
|
||||
return data.children.forEach(e => removeEmbedImages(e));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.message.content) return;
|
||||
const parsed = await parseMarkdown(props.message.content);
|
||||
await new Promise(e => setTimeout(e, 200));
|
||||
parsedMessage.value = parsed;
|
||||
removeEmbedImages(parsed.body);
|
||||
emits('messageRendered');
|
||||
})
|
||||
|
||||
const AI_MAP: Record<string, { img: string, color: string }> = {
|
||||
GrowthAgent: { img: '/ai/growth.png', color: '#ff861755' },
|
||||
MarketingAgent: { img: '/ai/marketing.png', color: '#bf7fff55' },
|
||||
ProductAgent: { img: '/ai/product.png', color: '#00f33955' },
|
||||
}
|
||||
|
||||
const messageStyle = computed(() => {
|
||||
if (!props.message.name) return;
|
||||
const target = AI_MAP[props.message.name];
|
||||
if (!target) return '';
|
||||
return `background-color: ${target.color};`
|
||||
});
|
||||
|
||||
const isContentMessage = computed(() => !props.message.tool_calls && props.message.content && !hidden.value);
|
||||
const isHiddenMessage = computed(() => !props.message.tool_calls && props.message.content && hidden.value);
|
||||
const isToolMessage = computed(() => props.message.tool_calls);
|
||||
|
||||
function downvoteMessage() {
|
||||
emits('downvoted', props.message_index)
|
||||
hidden.value = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
|
||||
<div class="w-full flex justify-start ml-4">
|
||||
|
||||
|
||||
<div v-if="isToolMessage" class="flex flex-col w-[70%] gap-3">
|
||||
<div class="flex flex-col gap-2 flex-end">
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger class="w-fit">
|
||||
<div class="flex gap-1 items-center text-sm w-fit">
|
||||
<InfoIcon class="size-4"></InfoIcon>
|
||||
<div> The ai will use some functions </div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div class="font-semibold" v-for="tool of message.tool_calls">
|
||||
{{ tool.function.name }}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="isToolMessage && message.tool_calls?.[0].function.name === 'createChart'"
|
||||
class="flex flex-col gap-2 flex-end">
|
||||
<AiChart :data="JSON.parse(message.tool_calls[0].function.arguments)"></AiChart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div v-if="isContentMessage" :style="messageStyle"
|
||||
class="border rounded-md p-2 flex flex-col gap-2 flex-end w-[70%] relative agent-message-with-content border-accent-foreground/20">
|
||||
|
||||
<div class="absolute left-[-1rem] top-[-1rem] rotate-[-15deg]">
|
||||
<img v-if="message.name && AI_MAP[message.name]" class="h-[3rem]" :src="AI_MAP[message.name].img">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<img class="w-5 h-auto" :src="'/ai/pixel-boy.png'">
|
||||
<Label> {{ message.name ?? 'AI' }} </Label>
|
||||
<Label class="text-sm text-muted-foreground" v-if="message.created_at">
|
||||
{{ new Date(message.created_at).toLocaleString() }}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<MDCRenderer class="md-content !text-gray-800 dark:!text-white" v-if="parsedMessage" :body="parsedMessage.body"
|
||||
:data="parsedMessage.data" />
|
||||
<Skeleton v-if="!parsedMessage" class="w-full h-[5rem]"></Skeleton>
|
||||
</div>
|
||||
|
||||
<div v-if="isHiddenMessage" :style="messageStyle"
|
||||
class="border rounded-md p-2 flex flex-col gap-2 flex-end w-[70%] relative">
|
||||
<div class="absolute left-[-1rem] top-[-1rem] rotate-[-15deg]">
|
||||
<img v-if="message.name && AI_MAP[message.name]" class="h-[3rem]" :src="AI_MAP[message.name].img">
|
||||
</div>
|
||||
<div class="flex gap-2 items-center ml-6">
|
||||
<Label> {{ message.name ?? 'AI' }} </Label>
|
||||
<Label class="text-sm text-muted-foreground" v-if="message.created_at">
|
||||
{{ new Date(message.created_at).toLocaleString() }}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Message deleted becouse downvoted
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isContentMessage" class="flex ml-2 items-end gap-2">
|
||||
<ThumbsDown @click="downvoteMessage()" :class="{ 'text-red-400': message.downvoted }" class="size-4">
|
||||
</ThumbsDown>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agent-message-with-content .md-content {
|
||||
|
||||
&:deep() {
|
||||
font-family: system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 2rem 0 1rem;
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
// Paragraphs
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
// Links
|
||||
a {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Lists
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
|
||||
li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
border-left: 4px solid #ccc;
|
||||
background-color: #f9f9f9;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
pre {
|
||||
background: #1e1e1e;
|
||||
color: #dcdcdc;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f3f3f3;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Tables
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #0000006c;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #ffffff23;
|
||||
}
|
||||
}
|
||||
|
||||
// Images
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
76
dashboard/components/complex/ai/Chart.vue
Normal file
76
dashboard/components/complex/ai/Chart.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
|
||||
export type AiChartData = {
|
||||
labels: string[],
|
||||
title: string,
|
||||
datasets: {
|
||||
chartType: 'line' | 'bar',
|
||||
points: number[],
|
||||
color: string,
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: AiChartData }>();
|
||||
|
||||
const chartColor = useChartColor();
|
||||
|
||||
const chartOptions = shallowRef<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false }
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = shallowRef<ChartData<'line' | 'bar'>>({
|
||||
labels: props.data.labels,
|
||||
datasets: props.data.datasets.map(e => {
|
||||
return {
|
||||
label: e.name,
|
||||
data: e.points,
|
||||
borderColor: e.color ?? '#0000CC',
|
||||
type: e.chartType,
|
||||
backgroundColor: [e.color ?? '#0000CC']
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineChart v-if="chartData" ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||
</template>
|
||||
65
dashboard/components/complex/ai/ChatsList.vue
Normal file
65
dashboard/components/complex/ai/ChatsList.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { AlertCircle, TrashIcon } from 'lucide-vue-next';
|
||||
import type { TAiNewChatSchema } from '~/shared/schema/ai/AiNewChatSchema';
|
||||
|
||||
const props = defineProps<{ chats: TAiNewChatSchema[] }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'selectChat', chat_id: string): void;
|
||||
(event: 'deleteAllChats'): void;
|
||||
(event: 'deleteChat', chat_id: string): void;
|
||||
}>();
|
||||
|
||||
const separatorIndex = props.chats.toReversed().findIndex(e => new Date(e.updated_at).getUTCDay() < new Date().getUTCDay());
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 overflow-hidden h-full">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button @click="emits('deleteAllChats')" size="sm" class="w-full" variant="destructive">
|
||||
Delete all
|
||||
</Button>
|
||||
|
||||
<Button @click="emits('selectChat', 'null')" size="sm" class="w-full" variant="secondary">
|
||||
New chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 overflow-y-auto h-full pr-2 pb-[10rem]">
|
||||
|
||||
<div v-for="(chat, index) of chats.toReversed()">
|
||||
|
||||
<div v-if="separatorIndex === index" class="flex flex-col items-center mt-2 mb-2">
|
||||
<Label class="text-muted-foreground"> Older chats </Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-md border p-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="700">
|
||||
<TooltipTrigger class="grow cursor-pointer flex gap-2 items-center"
|
||||
@click="emits('selectChat', chat._id.toString())">
|
||||
<AlertCircle v-if="chat.status === 'ERRORED'" class="size-4 shrink-0 text-orange-300">
|
||||
</AlertCircle>
|
||||
<div class="text-ellipsis line-clamp-1 text-left">
|
||||
{{ chat.title }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{{ chat.status === 'ERRORED' ? '[ERROR]' : '' }} {{ chat.title }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div class="shrink-0 cursor-pointer hover:text-red-400">
|
||||
<TrashIcon @click="emits('deleteChat', chat._id.toString())" class="size-4"></TrashIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
163
dashboard/components/complex/ai/EmptyAiChat.vue
Normal file
163
dashboard/components/complex/ai/EmptyAiChat.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts" setup>
|
||||
import { ArrowUp, Flame, List, MessageSquareText, TriangleAlert } from 'lucide-vue-next'
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'sendprompt', message: string): void;
|
||||
(event: 'open-sheet'): void;
|
||||
}>();
|
||||
|
||||
const prompts = [
|
||||
'What traffic sources brought the most visitors last week?',
|
||||
'Show me the user retention rate for the past month',
|
||||
"How many users visited the website yesterday?",
|
||||
"Did our traffic increase compared to last month?",
|
||||
"Which page had the most views yesterday?",
|
||||
"Did users spend more time on site this week than last?",
|
||||
"Are desktop users staying longer than mobile users?",
|
||||
"Did our top 5 countries change this month?",
|
||||
"How many users visited the website yesterday?",
|
||||
|
||||
]
|
||||
|
||||
const input = ref('')
|
||||
const toggleSet = ref('')
|
||||
function onKeyPress(e: any) {
|
||||
if (e.key === 'Enter') emits('sendprompt', input.value);
|
||||
}
|
||||
|
||||
const checkInput = computed(() => input.value.trim().length > 0)
|
||||
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!input.value.trim()) return
|
||||
console.log('Inviato:', input.value)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
//Effetto macchina da scrivere desiderato da fratello antonio
|
||||
const baseText = 'Ask me about... '
|
||||
const placeholder_texts = ['your Month over Month growth in visits', 'your top traffic source last week', 'how long visitors stick around', 'how can I help you', 'to turn your visitor data into a bar chart']
|
||||
|
||||
const placeholder = ref('')
|
||||
|
||||
const typingSpeed = 35
|
||||
const pauseAfterTyping = 800
|
||||
const pauseAfterDeleting = 400
|
||||
|
||||
let index = 0
|
||||
let charIndex = 0
|
||||
let isDeleting = false
|
||||
let typingTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function startTyping() {
|
||||
const current = placeholder_texts[index]
|
||||
|
||||
placeholder.value = baseText + current.substring(0, charIndex)
|
||||
|
||||
if (!isDeleting) {
|
||||
if (charIndex < current.length) {
|
||||
charIndex++
|
||||
typingTimeout = setTimeout(startTyping, typingSpeed)
|
||||
} else {
|
||||
typingTimeout = setTimeout(() => {
|
||||
isDeleting = true
|
||||
startTyping()
|
||||
}, pauseAfterTyping)
|
||||
}
|
||||
} else {
|
||||
if (charIndex > 0) {
|
||||
charIndex--
|
||||
typingTimeout = setTimeout(startTyping, typingSpeed)
|
||||
} else {
|
||||
isDeleting = false
|
||||
index = (index + 1) % placeholder_texts.length
|
||||
typingTimeout = setTimeout(startTyping, pauseAfterDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetTyping() {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
index = 0
|
||||
charIndex = 0
|
||||
isDeleting = false
|
||||
startTyping()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startTyping()
|
||||
})
|
||||
|
||||
watch(input, (newValue) => {
|
||||
if (newValue === '') {
|
||||
resetTyping()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-dvh flex items-center justify-center poppins">
|
||||
<div class="w-full max-w-2xl space-y-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="text-2xl font-medium dark:text-white text-violet-500 tracking-tight">
|
||||
AI Assistant
|
||||
</h1>
|
||||
<p class="text-sm text-gray-400 dark:text-zinc-400 mt-1">
|
||||
A dedicated team of smart AI experts on Marketing, Growth and Product.
|
||||
</p>
|
||||
</div>
|
||||
<!-- <Alert class="border-yellow-500">
|
||||
<TriangleAlert class="size-4 !text-yellow-500"/>
|
||||
<AlertTitle>Our AI is still in development… we know it’s scrappy.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Using it helps us learn what you really need. Got feedback? We’d love to hear it!
|
||||
</AlertDescription>
|
||||
</Alert> -->
|
||||
</div>
|
||||
<!-- Input container -->
|
||||
<div class="relative bg-gray-200 dark:bg-zinc-800 rounded-2xl p-4 shadow-md flex flex-col gap-4">
|
||||
<div
|
||||
class="absolute z-0 border-2 animate-pulse border-violet-500 w-full h-full top-0 left-0 rounded-[14px]">
|
||||
</div>
|
||||
|
||||
<div class="w-full relative z-10">
|
||||
<Input v-model="input" :placeholder="placeholder"
|
||||
class="pl-0 !bg-transparent !border-none shadow-none text-gray-600 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 !outline-none !ring-0"
|
||||
@keypress="onKeyPress" />
|
||||
|
||||
</div>
|
||||
<div class="flex justify-between items-center gap-2 relative z-10">
|
||||
<ToggleGroup type="single" variant="outline" v-model="toggleSet">
|
||||
|
||||
<ToggleGroupItem value="prompts" aria-label="Toggle italic">
|
||||
<span class="text-sm font-normal items-center flex gap-2">
|
||||
<List class="size-4" /> Prompts
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div class="flex gap-2">
|
||||
<Button size="icon" @click="emits('open-sheet')" variant="ghost">
|
||||
<MessageSquareText class="size-4" />
|
||||
</Button>
|
||||
<Button size="icon" @click="emits('sendprompt', input)" :disabled="!checkInput">
|
||||
<ArrowUp class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden transition-all duration-300"
|
||||
:class="toggleSet === 'prompts' ? 'max-h-40 opacity-100 overflow-y-auto' : 'max-h-0 opacity-0'">
|
||||
<div class="rounded-md flex flex-col gap-2">
|
||||
<Button v-for="p of prompts" variant="outline" @click="emits('sendprompt', p)" class="truncate">{{ p
|
||||
}}</Button>
|
||||
<!-- <NuxtLink to="#">
|
||||
<Button variant="link">View complete list</Button>
|
||||
</NuxtLink> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Slice } from '~/shared/services/DateService';
|
||||
import ChartSliceSelector from '../actionable-chart/ChartSliceSelector.vue';
|
||||
|
||||
const props = defineProps<{ modelValue: string }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', slice: Slice): void
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Events
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Events stacked bar chart.
|
||||
</CardDescription>
|
||||
<CardAction class="flex items-center h-full">
|
||||
<ChartSliceSelector :model-value="props.modelValue"
|
||||
@update:model-value="emit('update:modelValue', $event)"></ChartSliceSelector>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent class="h-full">
|
||||
<slot></slot>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
|
||||
export type TooltipDataEventsStacked = {
|
||||
date: string,
|
||||
items: any[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: TooltipDataEventsStacked }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="z-[400] absolute pointer-events-none">
|
||||
|
||||
<Card class="py-2 px-3 flex flex-col gap-2">
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div> Date: </div>
|
||||
<div v-if="data"> {{ data.date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div v-for="item of props.data.items" class="flex gap-2 items-center">
|
||||
<div class="size-3 rounded-full" :style="`background-color: ${item.color};`">
|
||||
</div>
|
||||
<div class="text-ellipsis truncate max-w-[75%]"> {{ item.label }} </div>
|
||||
<div>{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
225
dashboard/components/complex/events-stacked-chart/MainChart.vue
Normal file
225
dashboard/components/complex/events-stacked-chart/MainChart.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||
import { useBarChart, BarChart } from 'vue-chart-3';
|
||||
import { type Slice } from '~/shared/services/DateService';
|
||||
|
||||
export type EventsStackedChartData = {
|
||||
data: ({ name: string, count: number }[])[],
|
||||
labels: string[],
|
||||
slice: Slice,
|
||||
todayIndex: number,
|
||||
tooltipHandler?: any
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: EventsStackedChartData }>();
|
||||
|
||||
const chartOptions = shallowRef<ChartOptions<'bar'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
position: 'nearest',
|
||||
external: props.data.tooltipHandler
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'bar'>>(getChartData());
|
||||
|
||||
function getChartJsDataset() {
|
||||
const eventMap: Record<string, number[]> = {};
|
||||
|
||||
props.data.data.forEach((dailyEvents, dayIndex) => {
|
||||
const nameCountMap: Record<string, number> = {};
|
||||
|
||||
if (!dailyEvents) return;
|
||||
|
||||
dailyEvents.forEach(event => {
|
||||
nameCountMap[event.name] = event.count;
|
||||
});
|
||||
|
||||
for (const name in eventMap) {
|
||||
eventMap[name].push(nameCountMap[name] || 0);
|
||||
}
|
||||
|
||||
for (const name in nameCountMap) {
|
||||
if (!eventMap[name]) {
|
||||
eventMap[name] = Array(dayIndex).fill(0);
|
||||
}
|
||||
eventMap[name].push(nameCountMap[name]);
|
||||
}
|
||||
});
|
||||
|
||||
const datasets = Object.entries(eventMap).map(([name, data]) => ({ label: name, data }));
|
||||
|
||||
return datasets;
|
||||
}
|
||||
|
||||
const backgroundColors = getBackgroundColors();
|
||||
|
||||
function getBackgroundColors() {
|
||||
const backgroundColors = [
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6",
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6",
|
||||
"#5655d0",
|
||||
"#6bbbe3",
|
||||
"#a6d5cb",
|
||||
"#fae0b9",
|
||||
"#f28e8e",
|
||||
"#e3a7e4",
|
||||
"#c4a8e1",
|
||||
"#8cc1d8",
|
||||
"#f9c2cd",
|
||||
"#b4e3b2",
|
||||
"#ffdfba",
|
||||
"#e9c3b5",
|
||||
"#d5b8d6",
|
||||
"#add7f6",
|
||||
"#ffd1dc",
|
||||
"#ffe7a1",
|
||||
"#a8e6cf",
|
||||
"#d4a5a5",
|
||||
"#f3d6e4",
|
||||
"#c3aed6"
|
||||
]
|
||||
|
||||
return backgroundColors;
|
||||
}
|
||||
|
||||
function getChartData(): ChartData<'bar'> {
|
||||
|
||||
const backgroundColors = getBackgroundColors();
|
||||
|
||||
return {
|
||||
labels: props.data.labels,
|
||||
datasets: getChartJsDataset().map((e, i) => {
|
||||
return {
|
||||
...e,
|
||||
backgroundColor: backgroundColors[i],
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
}
|
||||
})
|
||||
// props.data.data.map(e => {
|
||||
// return {
|
||||
// data: e.map(e => e.count),
|
||||
// label: 'CACCA',
|
||||
// backgroundColor: ['#FF0000'],
|
||||
// borderWidth: 0,
|
||||
// borderRadius: 0
|
||||
// }
|
||||
// })
|
||||
}
|
||||
}
|
||||
|
||||
watch(props, () => {
|
||||
chartData.value = getChartData();
|
||||
})
|
||||
|
||||
function toggleDataset(dataset: ChartDataset<'bar'>) {
|
||||
dataset.hidden = !dataset.hidden;
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
for (const dataset of chartData.value.datasets) {
|
||||
dataset.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function enableAll() {
|
||||
for (const dataset of chartData.value.datasets) {
|
||||
dataset.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { barChartProps, barChartRef } = useBarChart({ chartData: chartData as any, options: chartOptions });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<BarChart v-if="props.data.data.length > 0" class="w-full h-full" v-bind="barChartProps"> </BarChart>
|
||||
<div v-if="props.data.data.length > 0" class="flex flex-wrap gap-x-4 gap-y-2 mt-6">
|
||||
<div v-for="(dataset, index) of chartData.datasets" @click="toggleDataset(dataset as any)"
|
||||
:class="{ 'line-through': dataset.hidden }"
|
||||
class="flex items-center gap-2 border-solid border-[1px] px-3 py-[.3rem] rounded-lg hover:bg-accent cursor-pointer">
|
||||
<div :style="`background-color: ${backgroundColors[index]}`" class="size-3 rounded-lg"></div>
|
||||
<div>{{ dataset.label }}</div>
|
||||
</div>
|
||||
<Button @click="disableAll()"> Disable all </Button>
|
||||
<Button @click="enableAll()"> Enable all </Button>
|
||||
</div>
|
||||
<div class="font-medium" v-if="props.data.data.length == 0">
|
||||
No data yet
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
78
dashboard/components/complex/line-data/Browsers.vue
Normal file
78
dashboard/components/complex/line-data/Browsers.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const NO_BROWSER_INFO_TOOLTIP_TEXT = 'Browsers -> "Others" means the visitor used a rare or unidentified browser we couldn\'t clearly classify.';
|
||||
|
||||
const { data: browsers, status, refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/browsers', {
|
||||
headers: { 'x-limit': '10' }, lazy: true,
|
||||
transform: (data) => {
|
||||
return data.map(e => e._id === 'NO_BROWSER' ? { ...e, info: NO_BROWSER_INFO_TOOLTIP_TEXT } : e);
|
||||
}
|
||||
});
|
||||
|
||||
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||
let name = e._id.toLowerCase().replace(/ /g, '-');
|
||||
|
||||
if (name === 'mobile-safari') name = 'safari';
|
||||
if (name === 'chrome-headless') name = 'chrome'
|
||||
if (name === 'chrome-webview') name = 'chrome'
|
||||
|
||||
return [
|
||||
'img',
|
||||
`https://github.com/alrra/browser-logos/blob/main/src/${name}/${name}_256x256.png?raw=true`
|
||||
]
|
||||
}
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Browsers',
|
||||
sub: 'The browsers most used to search your website.',
|
||||
data: browsers.value ?? [],
|
||||
iconProvider,
|
||||
iconStyle: 'width: 1.3rem; height: auto;',
|
||||
elementTextTransformer(text) {
|
||||
if (text === 'NO_BROWSER') return 'Others';
|
||||
return text;
|
||||
},
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/browsers', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
66
dashboard/components/complex/line-data/Cities.vue
Normal file
66
dashboard/components/complex/line-data/Cities.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { CircleHelp } from 'lucide-vue-next';
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
|
||||
const { data: cities, status, refresh } = useAuthFetch<{ _id: any, count: number }[]>('/api/data/cities', {
|
||||
headers: { 'x-limit': '10' }, lazy: true,
|
||||
transform: (data) => {
|
||||
const res = data.filter(e => e._id !== '??' && getCityFromISO(e._id.city, e._id.region, e._id.country)).map(e => ({
|
||||
...e, flag: e._id,
|
||||
_id: e._id ? (getCityFromISO(e._id.city, e._id.region, e._id.country) ?? `NO_CITY`) : 'NO_CITY'
|
||||
}));
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Cities',
|
||||
sub: 'Lists the cities where users access your website.',
|
||||
data: cities.value ?? [],
|
||||
iconStyle: 'width: 1.8rem; padding: 1px;',
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: any, count: number }[]>('/api/data/cities', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data.filter(e => e._id !== '??' && getCityFromISO(e._id.city, e._id.region, e._id.country)).map(e => ({
|
||||
...e, flag: e._id,
|
||||
_id: e._id ? (getCityFromISO(e._id.city, e._id.region, e._id.country) ?? `NO_CITY`) : 'NO_CITY'
|
||||
}));
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
58
dashboard/components/complex/line-data/Continents.vue
Normal file
58
dashboard/components/complex/line-data/Continents.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { CircleHelp } from 'lucide-vue-next';
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
|
||||
const { data: continents, status, refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/continents', {
|
||||
headers: { 'x-limit': '10' }, lazy: true,
|
||||
transform: (data) => {
|
||||
return data.filter(e => e._id !== '??').map(e => ({ ...e, flag: e._id, _id: e._id ? (getContinentFromISO(e._id) ?? e._id) : 'NO_CONTINENT' }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Continents',
|
||||
sub: 'Lists the continents where users access your website.',
|
||||
data: continents.value ?? [],
|
||||
iconStyle: 'width: 1.8rem; padding: 1px;',
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/continents', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data.filter(e => e._id !== '??').map(e => ({ ...e, flag: e._id, _id: e._id ? (getContinentFromISO(e._id) ?? e._id) : 'NO_CONTINENT' }));
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
67
dashboard/components/complex/line-data/Countries.vue
Normal file
67
dashboard/components/complex/line-data/Countries.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { CircleHelp } from 'lucide-vue-next';
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number,sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
|
||||
if (!e.flag) return ['component', CircleHelp]
|
||||
return [
|
||||
'img',
|
||||
`https://raw.githubusercontent.com/hampusborgos/country-flags/refs/heads/main/svg/${e.flag.toLowerCase()}.svg`
|
||||
]
|
||||
}
|
||||
|
||||
const { data: countries, status, refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/countries', {
|
||||
headers: { 'x-limit': '10' }, lazy: true,
|
||||
transform: (data) => {
|
||||
return data.map(e => ({ ...e, flag: e._id, _id: e._id ? (getCountryFromISO(e._id) ?? e._id) : 'NO_COUNTRY' }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Countries',
|
||||
sub: 'Lists the countries where users access your website.',
|
||||
data: countries.value ?? [],
|
||||
iconProvider,
|
||||
iconStyle: 'width: 1.8rem; padding: 1px;',
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/countries', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data.map(e => ({ ...e, flag: e._id, _id: e._id ? (getCountryFromISO(e._id) ?? e._id) : 'NO_COUNTRY' }));
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
86
dashboard/components/complex/line-data/Devices.vue
Normal file
86
dashboard/components/complex/line-data/Devices.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { CircleHelp, Gamepad2, Monitor, Smartphone, Tablet, Tv } from 'lucide-vue-next';
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||
if (e._id === 'desktop') return ['component', Monitor];
|
||||
if (e._id === 'tablet') return ['component', Tablet];
|
||||
if (e._id === 'mobile') return ['component', Smartphone];
|
||||
if (e._id === 'smarttv') return ['component', Tv];
|
||||
if (e._id === 'console') return ['component', Gamepad2];
|
||||
return ['component', CircleHelp]
|
||||
}
|
||||
|
||||
const OTHERS_INFO_TOOLTIP_TEXT = 'Device -> "Others" means the device used isn’t clearly a phone, tablet, or desktop... like smart TVs, game consoles, or unknown devices.';
|
||||
|
||||
|
||||
const { data: devices, status, refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/devices', {
|
||||
headers: { 'x-limit': '10' }, lazy: true,
|
||||
transform: (data) => {
|
||||
return data.map(e => {
|
||||
if (e._id === 'mobile') return e;
|
||||
if (e._id === 'desktop') return e;
|
||||
if (e._id === 'tablet') return e;
|
||||
if (e._id === 'console') return e;
|
||||
if (e._id === 'smarttv') return e;
|
||||
return { ...e, info: OTHERS_INFO_TOOLTIP_TEXT };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Devices',
|
||||
sub: 'The devices most used to access your website.',
|
||||
data: devices.value ?? [],
|
||||
iconProvider,
|
||||
elementTextTransformer(text) {
|
||||
if (!text) return 'Others';
|
||||
if (text === 'mobile') return 'Mobile';
|
||||
if (text === 'desktop') return 'Desktop';
|
||||
if (text === 'tablet') return 'Tablet';
|
||||
if (text === 'console') return 'Console';
|
||||
if (text === 'smarttv') return 'Smart Tv';
|
||||
return text;
|
||||
},
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/devices', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
53
dashboard/components/complex/line-data/EntryPages.vue
Normal file
53
dashboard/components/complex/line-data/EntryPages.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import LineDataTemplate, { type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const { data: pages, status, refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/entry_pages', {
|
||||
headers: { 'x-limit': '10' }, lazy: true
|
||||
});
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Entry pages',
|
||||
sub: 'First page a user lands on.',
|
||||
data: pages.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
},
|
||||
actionProps: { to: '/raw_visits' }
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/entry_pages', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
51
dashboard/components/complex/line-data/Events.vue
Normal file
51
dashboard/components/complex/line-data/Events.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import LineDataTemplate, { type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const { data: events,status,refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/events', {
|
||||
headers: { 'x-limit': '9' }, lazy: true
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
const props = defineProps<{ refreshToken: number }>();
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Top events',
|
||||
sub: 'Most frequent user events triggered in this project.',
|
||||
data: events.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
},
|
||||
actionProps: { to: '/raw_events' }
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/events', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
52
dashboard/components/complex/line-data/ExitPages.vue
Normal file
52
dashboard/components/complex/line-data/ExitPages.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import LineDataTemplate, { type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const { data: pages, status,refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/exit_pages', {
|
||||
headers: { 'x-limit': '10' }, lazy: true
|
||||
});
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Exit pages',
|
||||
sub: 'Last page a user visits before leaving.',
|
||||
data: pages.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
},
|
||||
actionProps: { to: '/raw_visits' }
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/exit_pages', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
33
dashboard/components/complex/line-data/LineDataCard.vue
Normal file
33
dashboard/components/complex/line-data/LineDataCard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
export type LineDataCardProps = {
|
||||
title: string,
|
||||
sub: string,
|
||||
action?: Component,
|
||||
actionProps?: Record<string, any>
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: LineDataCardProps }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'showMore'): void
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{{ data.title }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ data.sub }}
|
||||
</CardDescription>
|
||||
<CardAction v-if="data.action" class="flex items-center">
|
||||
<component :data="data.actionProps" :is="data.action"></component>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent class="h-full">
|
||||
<slot></slot>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
160
dashboard/components/complex/line-data/LineDataTemplate.vue
Normal file
160
dashboard/components/complex/line-data/LineDataTemplate.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { CircleHelp, InfoIcon, Link } from 'lucide-vue-next';
|
||||
import LineDataCard, { type LineDataCardProps } from './LineDataCard.vue';
|
||||
import ShowMoreDialog, { type ShowMoreDialogProps } from './ShowMoreDialog.vue';
|
||||
|
||||
export type IconProvider<T = any> = (e: { _id: string, count: string } & T) => ['img', string] | ['component', Component] | undefined;
|
||||
|
||||
|
||||
export type LineDataProps = {
|
||||
title: string,
|
||||
sub: string,
|
||||
loading: boolean,
|
||||
data: { _id: string, count: number, info?: string, avgSeconds?: number }[],
|
||||
iconProvider?: IconProvider<any>,
|
||||
iconStyle?: string,
|
||||
elementTextTransformer?: (text: string) => string,
|
||||
hasLink?: boolean,
|
||||
showMoreData: {
|
||||
items: { _id: string, count: number }[],
|
||||
loading: boolean
|
||||
},
|
||||
action?: Component,
|
||||
actionProps?: Record<string, any>
|
||||
}
|
||||
|
||||
|
||||
const props = defineProps<{ data: LineDataProps }>();
|
||||
|
||||
const total = computed(() => props.data.data.reduce((a, e) => a + e.count, 0));
|
||||
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'showMore'): void
|
||||
}>();
|
||||
|
||||
const maxData = computed(() => props.data.data.reduce((a, e) => a + e.count, 0));
|
||||
|
||||
function openExternalLink(link: string) {
|
||||
if (link === 'self') return;
|
||||
return window.open('https://' + link, '_blank');
|
||||
}
|
||||
|
||||
const showMoreDialogData = computed<ShowMoreDialogProps>(() => {
|
||||
return {
|
||||
title: props.data.title,
|
||||
sub: props.data.sub,
|
||||
items: props.data.showMoreData.items,
|
||||
total: props.data.data.reduce((a, e) => a + e.count, 0),
|
||||
loading: props.data.showMoreData.loading,
|
||||
iconProvider: props.data.iconProvider,
|
||||
iconStyle: props.data.iconStyle
|
||||
}
|
||||
})
|
||||
|
||||
const iconsErrored = ref<number[]>([]);
|
||||
|
||||
function onIconError(index: number) {
|
||||
iconsErrored.value.push(index);
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-2 h-full">
|
||||
<div class="w-full flex flex-col gap-1">
|
||||
<div class="flex justify-between text-sm font-medium text-muted-foreground pb-2">
|
||||
<p>Source</p>
|
||||
<div class="flex gap-2">
|
||||
<p v-if="props.data.data.at(0)?.avgSeconds" class="w-[6rem] text-right">Time Spent</p>
|
||||
<p class="w-16 text-right">Count</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex justify-between items-center" v-if="data.data && data.data.length > 0 && !data.loading"
|
||||
v-for="(element, index) of props.data.data">
|
||||
|
||||
<div class="flex items-center gap-2 w-10/12 relative">
|
||||
|
||||
<div v-if="data.hasLink">
|
||||
<Link @click="openExternalLink(element._id)"
|
||||
class="size-4 cursor-pointer hover:text-muted-foreground">
|
||||
</Link>
|
||||
<i @click="openExternalLink(element._id)"
|
||||
class="fas fa-link text-gray-300 hover:text-gray-400 cursor-pointer"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
|
||||
<div class="absolute rounded-sm w-full h-full bg-accent"
|
||||
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
|
||||
|
||||
<div class="flex px-2 py-1 relative items-center gap-4">
|
||||
<div v-if="data.iconProvider && data.iconProvider(element) != undefined"
|
||||
class="flex items-center h-[1.3rem]">
|
||||
|
||||
<img v-if="!iconsErrored.includes(index) && data.iconProvider(element)?.[0] === 'img'"
|
||||
class="h-full" @error="onIconError(index)" :style="data.iconStyle"
|
||||
:src="(data.iconProvider(element)?.[1] as string)">
|
||||
|
||||
<CircleHelp v-if="iconsErrored.includes(index)"></CircleHelp>
|
||||
|
||||
<component v-if="data.iconProvider(element)?.[0] == 'component'" class="size-5"
|
||||
:is="data.iconProvider(element)?.[1]">
|
||||
</component>
|
||||
</div>
|
||||
<span
|
||||
class=" line-clamp-1 ui-font z-[19] text-[.95rem] max-w-56 md:max-w-64 lg:max-w-96 overflow-x-auto">
|
||||
{{ data.elementTextTransformer?.(element._id) || element._id }}
|
||||
</span>
|
||||
<span v-if="element.info">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<InfoIcon class="size-4"></InfoIcon>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ element.info }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-center w-[6rem] text-[.8rem] text-muted-foreground" v-if="element.avgSeconds">
|
||||
{{ formatTime(element.avgSeconds * 1000, false) }}
|
||||
</span>
|
||||
<div
|
||||
class="cursor-default w-16 group text-muted-foreground items-center justify-end font-medium text-[.9rem] md:text-[1rem] poppins flex gap-2">
|
||||
<span class="group-hover:hidden flex">
|
||||
{{ formatNumberK(element.count) }}
|
||||
</span>
|
||||
<span class="hidden group-hover:flex "> {{ (100 / total * element.count).toFixed(1) }}% </span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.data.length > 0" class="grow"> </div>
|
||||
<Loader v-if="data.loading" />
|
||||
|
||||
<ShowMoreDialog v-if="data.data.length > 0" :data="showMoreDialogData">
|
||||
<Button v-if="!data.loading" @click="emits('showMore')" variant="ghost" class="w-full shrink-0">
|
||||
Show more
|
||||
</Button>
|
||||
</ShowMoreDialog>
|
||||
|
||||
|
||||
<div class="font-medium" v-if="data.title === 'Top pages' && data.data.length == 0 && !data.loading">
|
||||
You need at least 2 views
|
||||
</div>
|
||||
<div class="font-medium" v-else-if="data.data.length == 0 && !data.loading">
|
||||
No data yet
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
52
dashboard/components/complex/line-data/Oss.vue
Normal file
52
dashboard/components/complex/line-data/Oss.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import LineDataTemplate, { type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const { data: oss,status,refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/oss', {
|
||||
headers: { 'x-limit': '10' }, lazy: true
|
||||
});
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'OS',
|
||||
sub: 'The operating systems most commonly used by your website\'s visitors..',
|
||||
data: oss.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/oss', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
55
dashboard/components/complex/line-data/Pages.vue
Normal file
55
dashboard/components/complex/line-data/Pages.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import LineDataTemplate, { type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const { data: pages, status, refresh } = useAuthFetch<{ _id: string, count: number, avgSeconds: number }[]>('/api/data/pages_durations', {
|
||||
headers: { 'x-limit': '10' }, lazy: true
|
||||
});
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number, avgSeconds: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Top pages',
|
||||
sub: 'Most visited pages.',
|
||||
data: pages.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
},
|
||||
actionProps: { to: '/raw_visits' }
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number, avgSeconds: number }[]>('/api/data/pages_durations', {
|
||||
headers: { 'x-limit': '1000' }
|
||||
});
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
75
dashboard/components/complex/line-data/Referrers.vue
Normal file
75
dashboard/components/complex/line-data/Referrers.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Link } from 'lucide-vue-next';
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
import SelectRefer from './selectors/SelectRefer.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
const SELF_INFO_TOOLTIP_TEXT = '"Self" means the visitor came to your site directly, without any referrer (like from typing the URL, a bookmark, or a QR code).';
|
||||
|
||||
const { data: referrers, status, refresh } = useAuthFetch<{ _id: string, count: number }[]>('/api/data/referrers', {
|
||||
headers: {
|
||||
'x-limit': '10'
|
||||
}, lazy: true,
|
||||
transform: (data) => {
|
||||
return data.map(e => e._id === 'self' ? { ...e, info: SELF_INFO_TOOLTIP_TEXT } : e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
|
||||
if (e._id === 'self') return ['component', Link];
|
||||
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${e._id}&sz=64`]
|
||||
}
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Referrers',
|
||||
sub: 'Where users find your website.',
|
||||
data: referrers.value ?? [],
|
||||
//action:SelectRefer,
|
||||
iconProvider,
|
||||
iconStyle: 'width: 1.3rem; height: auto;',
|
||||
hasLink: true,
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: string, count: number }[]>('/api/data/referrers', {
|
||||
headers: {
|
||||
'x-limit': '1000'
|
||||
}
|
||||
});
|
||||
showMoreDataItems.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
65
dashboard/components/complex/line-data/Regions.vue
Normal file
65
dashboard/components/complex/line-data/Regions.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { CircleHelp } from 'lucide-vue-next';
|
||||
import LineDataTemplate, { type IconProvider, type LineDataProps } from './LineDataTemplate.vue';
|
||||
|
||||
const props = defineProps<{ refreshToken: number, sharedLink?: string }>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
onMounted(() => {
|
||||
emits('init', data.value);
|
||||
})
|
||||
|
||||
|
||||
const { data: regions, status, refresh } = useAuthFetch<{ _id: any, count: number }[]>('/api/data/regions', {
|
||||
headers: { 'x-limit': '10' }, lazy: true,
|
||||
transform: (data) => {
|
||||
return data.filter(e => e._id !== '??' && getRegionFromISO(e._id.region, e._id.country)).map(e => ({
|
||||
...e, flag: e._id,
|
||||
_id: e._id ? (getRegionFromISO(e._id.region, e._id.country) ?? "NO_REGION") : 'NO_REGION'
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const data = computed<LineDataProps>(() => {
|
||||
return {
|
||||
loading: status.value !== 'success',
|
||||
title: 'Regions',
|
||||
sub: 'Lists the regions where users access your website.',
|
||||
data: regions.value ?? [],
|
||||
iconStyle: 'width: 1.8rem; padding: 1px;',
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const data = await useAuthFetchSync<{ _id: any, count: number }[]>('/api/data/regions', { headers: { 'x-limit': '1000' } });
|
||||
showMoreDataItems.value = data.filter(e => e._id !== '??' && getRegionFromISO(e._id.region, e._id.country)).map(e => ({
|
||||
...e, flag: e._id,
|
||||
_id: e._id ? (getRegionFromISO(e._id.region, e._id.country) ?? "NO_REGION") : 'NO_REGION'
|
||||
}));
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data"> </LineDataTemplate>
|
||||
</template>
|
||||
84
dashboard/components/complex/line-data/ShowMoreDialog.vue
Normal file
84
dashboard/components/complex/line-data/ShowMoreDialog.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import type { IconProvider } from './LineDataTemplate.vue'
|
||||
|
||||
export type ShowMoreDialogProps = {
|
||||
title: string,
|
||||
sub?: string,
|
||||
items: { _id: string, count: number, avgSeconds?: number }[],
|
||||
total: any,
|
||||
iconProvider?: IconProvider,
|
||||
iconStyle?: string,
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: ShowMoreDialogProps }>();
|
||||
const total = computed(() => props.data.total);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<slot></slot>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="max-w-[90dvw] min-w-[40dvw] max-h-[90dvh] overflow-y-hidden poppins">
|
||||
<!-- sm:max-w-[425px] min-w-[40rem] -->
|
||||
<DialogHeader>
|
||||
<DialogTitle> {{ data.title }} </DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ data.sub }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="overflow-y-auto h-100">
|
||||
<Table v-if="!data.loading">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[90%]"> Item </TableHead>
|
||||
<TableHead> Count </TableHead>
|
||||
<TableHead v-if="data.items[0].avgSeconds"> Duration </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="w-full">
|
||||
<TableRow v-for="item in data.items" :key="item._id">
|
||||
<TableCell class="font-medium">
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="data.iconProvider">
|
||||
<img v-if="data.iconProvider(item)?.[0] === 'img'" class="h-full"
|
||||
:style="data.iconStyle" :src="(data.iconProvider(item)?.[1] as string)">
|
||||
|
||||
<component v-if="data.iconProvider(item)?.[0] == 'component'" class="size-4"
|
||||
:is="data.iconProvider(item)?.[1]">
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<span class="max-w-96 overflow-auto">{{ item._id }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="group">
|
||||
<span class="group-hover:hidden flex">
|
||||
{{ formatNumberK(item.count) }}
|
||||
|
||||
</span>
|
||||
<span class="hidden group-hover:flex "> {{ (100 / total * item.count).toFixed(1) }}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-center w-[6rem]" v-if="item.avgSeconds">
|
||||
{{ formatTime(item.avgSeconds * 1000, false) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div v-else class="flex justify-center items-center h-full w-full">
|
||||
<Loader />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
146
dashboard/components/complex/line-data/UtmGeneric.vue
Normal file
146
dashboard/components/complex/line-data/UtmGeneric.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import LineDataTemplate, { type LineDataProps } from './LineDataTemplate.vue';
|
||||
import type { UtmKey } from './selectors/SelectRefer.vue';
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
advanced_data: {
|
||||
raw_selected: string
|
||||
},
|
||||
refreshToken: number,
|
||||
sharedLink?: string
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'init', data: any): void
|
||||
}>()
|
||||
|
||||
const { data: utms, status: utms_status, refresh } = useAuthFetch<{ _id: string, count: number }[]>(() => `/api/data/utm?utm_type=${props.advanced_data.raw_selected.split('_')[1]}`,
|
||||
{
|
||||
headers: { 'x-limit': '10' },
|
||||
lazy: true
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => props.refreshToken, async () => {
|
||||
loading.value = true;
|
||||
await refresh(); // rifà il fetch
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const showMoreDataItems = ref<{ _id: string, count: number }[]>([]);
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
|
||||
const utmDataMap: Record<UtmKey, LineDataProps> = {
|
||||
'utm_term': {
|
||||
loading: false,
|
||||
title: 'UTM Term',
|
||||
sub: 'Term breakdown by usage',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
},
|
||||
'utm_campaign': {
|
||||
loading: false,
|
||||
title: 'UTM Campaign',
|
||||
sub: 'Campaign breakdown by usage',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
},
|
||||
'utm_medium': {
|
||||
loading: false,
|
||||
title: 'UTM Medium',
|
||||
sub: 'Medium breakdown by usage',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
},
|
||||
'utm_source': {
|
||||
loading: false,
|
||||
title: 'UTM Source',
|
||||
sub: 'Source breakdown by usage',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
},
|
||||
'utm_content': {
|
||||
loading: false,
|
||||
title: 'UTM Content',
|
||||
sub: 'Content breakdown by usage',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function buildLineDataProps(): LineDataProps {
|
||||
const target = utmDataMap[props.advanced_data.raw_selected as UtmKey];
|
||||
if (target) return {
|
||||
...target,
|
||||
loading: utms_status.value !== 'success',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
return {
|
||||
loading: utms_status.value !== 'success',
|
||||
title: props.advanced_data.raw_selected,
|
||||
sub: 'Custom utm parameter',
|
||||
data: utms.value ?? [],
|
||||
showMoreData: {
|
||||
items: showMoreDataItems.value,
|
||||
loading: loading.value
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const data = ref<LineDataProps>(buildLineDataProps());
|
||||
|
||||
function updateData() {
|
||||
data.value = buildLineDataProps();
|
||||
emits('init', data.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateData()
|
||||
})
|
||||
|
||||
watch(props, () => {
|
||||
updateData()
|
||||
});
|
||||
|
||||
watch(utms_status, () => {
|
||||
updateData()
|
||||
})
|
||||
|
||||
async function showMore() {
|
||||
loading.value = true;
|
||||
const raw = await useAuthFetchSync<{ _id: string; count: number }[]>(`/api/data/utm?utm_type=${props.advanced_data.raw_selected.split('_')[1]}`, {
|
||||
headers: { 'x-limit': '1000' }
|
||||
});
|
||||
showMoreDataItems.value = raw;
|
||||
loading.value = false;
|
||||
updateData();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineDataTemplate @show-more="showMore()" :data="data" />
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Lock } from 'lucide-vue-next'
|
||||
|
||||
// Props e emits
|
||||
const props = defineProps<{ modelValue?: string }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// Stato locale del valore selezionato
|
||||
const selectedCountryOption = ref(props.modelValue || '')
|
||||
|
||||
// Sync locale ↔ parent
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val !== selectedCountryOption.value) {
|
||||
selectedCountryOption.value = val || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedCountryOption, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="selectedCountryOption">
|
||||
<SelectTrigger class="bg-gray-100 dark:bg-black">
|
||||
<SelectValue placeholder="Select a Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="continents">
|
||||
Continents
|
||||
</SelectItem>
|
||||
<SelectItem value="countries">
|
||||
Country
|
||||
</SelectItem>
|
||||
<SelectItem value="regions">
|
||||
Regions
|
||||
</SelectItem>
|
||||
<SelectItem value="cities">
|
||||
City
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// Props e emits
|
||||
const props = defineProps<{ modelValue?: string }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// Stato locale del valore selezionato
|
||||
const selectedDeviceOption = ref(props.modelValue || '')
|
||||
|
||||
// Sync locale ↔ parent
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val !== selectedDeviceOption.value) {
|
||||
selectedDeviceOption.value = val || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedDeviceOption, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="selectedDeviceOption">
|
||||
<SelectTrigger class="bg-gray-100 dark:bg-black">
|
||||
<SelectValue placeholder="Select a Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="devices">
|
||||
Devices
|
||||
</SelectItem>
|
||||
<SelectItem value="oss">
|
||||
OS
|
||||
</SelectItem>
|
||||
<SelectItem value="browsers">
|
||||
Browsers
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Lock } from 'lucide-vue-next'
|
||||
|
||||
// Props e emits
|
||||
const props = defineProps<{ modelValue?: string }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// Stato locale del valore selezionato
|
||||
const selectPageOption = ref(props.modelValue || '')
|
||||
|
||||
// Sync locale ↔ parent
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val !== selectPageOption.value) {
|
||||
selectPageOption.value = val || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectPageOption, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="selectPageOption">
|
||||
<SelectTrigger class="bg-gray-100 dark:bg-black">
|
||||
<SelectValue placeholder="Select a Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="pages">
|
||||
Top Pages
|
||||
</SelectItem>
|
||||
|
||||
|
||||
<SelectItem value="pages_entries">
|
||||
Entry Pages
|
||||
</SelectItem>
|
||||
<SelectItem value="pages_exits">
|
||||
Exit Pages
|
||||
</SelectItem>
|
||||
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Lock } from 'lucide-vue-next'
|
||||
|
||||
// Props e emits
|
||||
const props = defineProps<{ modelValue?: string }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// Stato locale del valore selezionato
|
||||
const selectedTrafficOption = ref(props.modelValue || '')
|
||||
|
||||
export type UtmKey = keyof typeof utmKeysMap;
|
||||
|
||||
const utmKeysMap = {
|
||||
'utm_campaign': 'Campaign',
|
||||
'utm_source': 'Source',
|
||||
'utm_medium': 'Medium',
|
||||
'utm_term': 'Term',
|
||||
'utm_content': 'Content'
|
||||
}
|
||||
|
||||
// Sync locale ↔ parent
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val !== selectedTrafficOption.value) {
|
||||
selectedTrafficOption.value = val || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedTrafficOption, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="selectedTrafficOption">
|
||||
<SelectTrigger class="bg-gray-100 dark:bg-black">
|
||||
<SelectValue placeholder="Select a Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Sources</SelectLabel>
|
||||
<SelectItem value="referrers">
|
||||
Referrer
|
||||
</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectLabel>UTM</SelectLabel>
|
||||
<SelectItem v-for="(value, key) in utmKeysMap" :value="key">
|
||||
{{ value }}
|
||||
</SelectItem>
|
||||
<!-- <SelectSeparator />
|
||||
<SelectLabel>Custom</SelectLabel>
|
||||
<SelectItem v-if="utm_keys" v-for="key of utm_keys" :value="key._id">
|
||||
{{ key._id.split('_').slice(1).join(' ') }}
|
||||
</SelectItem> -->
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
Reference in New Issue
Block a user