new selfhosted version

This commit is contained in:
antonio
2025-11-28 14:11:51 +01:00
parent afda29997d
commit 951860f67e
1046 changed files with 72586 additions and 574750 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>&lt;head&gt;</span>
</strong>
or at the end of <strong><span v-pre>&lt;/body&gt;</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>

View 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>

View 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>

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 its scrappy.</AlertTitle>
<AlertDescription>
Using it helps us learn what you really need. Got feedback? Wed 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>

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 isnt 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

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

View File

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

View File

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

View File

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