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