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

@@ -1,109 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useBarChart, BarChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
data: any[],
labels: string[]
color: string,
}>();
const chartOptions = ref<ChartOptions<'bar'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: false,
drawBorder: false,
color: '#CCCCCC22',
},
},
x: {
ticks: { display: true },
grid: {
display: false,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: {
display: false,
position: 'right',
},
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',
}
},
});
const chartData = ref<ChartData<'bar'>>({
labels: props.labels,
datasets: [
{
data: props.data,
backgroundColor: [props.color + '77'],
borderColor: props.color,
borderWidth: 4,
hoverBackgroundColor: props.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
],
});
const { barChartProps, barChartRef } = useBarChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
// const c = document.createElement('canvas');
// const ctx = c.getContext("2d");
// let gradient: any = `${props.color}22`;
// if (ctx) {
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
// gradient.addColorStop(0, `${props.color}99`);
// gradient.addColorStop(0.35, `${props.color}66`);
// gradient.addColorStop(1, `${props.color}22`);
// } else {
// console.warn('Cannot get context for gradient');
// }
// chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
console.log('UPDATE')
chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data;
});
});
</script>
<template>
<BarChart v-bind="barChartProps"> </BarChart>
</template>

View File

@@ -1,110 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
data: any[],
labels: string[]
color: string,
}>();
const chartOptions = ref<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]
},
},
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',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels,
datasets: [
{
data: props.data,
backgroundColor: [props.color + '77'],
borderColor: props.color,
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: props.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
],
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${props.color}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${props.color}99`);
gradient.addColorStop(0.35, `${props.color}66`);
gradient.addColorStop(1, `${props.color}22`);
} else {
console.warn('Cannot get context for gradient');
}
chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data;
});
});
</script>
<template>
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -1,116 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useBarChart, BarChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
datasets: any[],
labels: string[],
}>();
const chartOptions = ref<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: true,
position: 'right',
},
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',
}
},
});
const chartData = ref<ChartData<'bar'>>({
labels: props.labels,
datasets: props.datasets.map(e => {
return {
data: e.data,
label: e.label || '?',
backgroundColor: [e.color],
borderWidth: 0,
borderRadius: 0
}
})
});
const { barChartProps, barChartRef } = useBarChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
// const c = document.createElement('canvas');
// const ctx = c.getContext("2d");
// let gradient: any = `${props.color}22`;
// if (ctx) {
// gradient = ctx.createLinearGradient(0, 25, 0, 300);
// gradient.addColorStop(0, `${props.color}99`);
// gradient.addColorStop(0.35, `${props.color}66`);
// gradient.addColorStop(1, `${props.color}22`);
// } else {
// console.warn('Cannot get context for gradient');
// }
// chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
console.log('UPDATE')
chartData.value.labels = props.labels;
chartData.value.datasets.length = 0;
chartData.value.datasets = props.datasets.map(e => {
return {
data: e.data,
label: e.label || '?',
backgroundColor: [e.color],
borderWidth: 0,
borderRadius: 8
}
})
});
});
</script>
<template>
<BarChart v-bind="barChartProps"> </BarChart>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import type { SidebarProps } from '@/components/ui/sidebar'
import NavMain from './NavMain.vue'
import NavProjects from './NavProjects.vue'
import NavUser from './NavUser.vue'
import TeamSwitcher from './ProjectSwitcher.vue'
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from '@/components/ui/sidebar'
import SidebarData from './SidebarData.vue'
import { Box } from 'lucide-vue-next'
const { plan } = usePremiumStore();
const props = withDefaults(defineProps<SidebarProps>(), { collapsible: 'icon' });
const { user } = useUserSession();
const userLogo = true;
const userData = computed(() => {
return {
name: 'noname',
avatar: '',
email: user.value?.email || 'nomail'
}
})
const debugMode = false;//process.dev;
const projectStore = useProjectStore();
const colorMode = useColorMode()
async function leaveProject() {
await useAuthFetchSync('/api/members/leave');
await projectStore.fetchProjects();
}
async function acceptInvite(project_id: string) {
await useCatch({
toast: true,
toastTitle: 'Error accepting invite',
async action() {
await useAuthFetchSync('/api/members/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: { project_id }
})
},
async onSuccess() {
await projectStore.fetchProjects();
await projectStore.fetchPendingInvites();
const newActive = projectStore.projects.at(-1)?._id.toString();
if (newActive) await projectStore.setActive(newActive);
},
});
}
async function declineInvite(project_id: string) {
await useCatch({
toast: true,
toastTitle: 'Error declining invite',
async action() {
await useAuthFetchSync('/api/members/decline', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: { project_id }
})
},
onSuccess() {
projectStore.fetchPendingInvites();
},
});
}
</script>
<template>
<Sidebar v-bind="props" variant="sidebar">
<SidebarHeader class="px-0">
<div class="border-b-2 ">
<div class="px-2 flex items-center justify-center my-4 gap-4">
<NuxtLink to="/"><img class="h-6" :src="colorMode.value === 'dark' ? '/logo-white.svg' : '/logo-black.svg'"></NuxtLink>
</div>
</div>
<!-- <ProjectSwitcher /> -->
</SidebarHeader>
<SidebarContent>
<SidebarData></SidebarData>
<div v-if="debugMode"
class="bg-red-500/70 text-white text-[.8rem] flex font-bold mx-4 p-2 px-4 rounded-md z-[100]">
<div class="poppins mr-4"> DEV </div>
<div class="poppins flex sm:hidden"> XS </div>
<div class="poppins hidden sm:max-md:flex"> SM - MOBILE </div>
<div class="poppins hidden md:max-lg:flex"> MD - TABLET </div>
<div class="poppins hidden lg:max-xl:flex"> LG - LARGE </div>
<div class="poppins hidden xl:max-2xl:flex"> XL - EXTRA LARGE </div>
<div class="poppins hidden 2xl:flex"> 2XL - WIDE SCREEN </div>
</div>
</SidebarContent>
<SidebarFooter>
<SidebarBanner
v-if="plan && ((plan.premium_type === 7006 || plan.premium_type === 0) || plan.payment_failed || plan.canceled) && projectStore.isOwner"
class="w-full">
</SidebarBanner>
<div class="border border-violet-500/50 dark:bg-violet-500/10 rounded-lg py-2 px-4 flex flex-col gap-4 mt-4"
v-if="projectStore.pendingInvites.length > 0">
<div class="text-[.9rem]">
You have been invited to
<b>{{ projectStore.pendingInvites[0].project_name }} </b>
</div>
<div class="flex gap-2 justify-between">
<Button @click="declineInvite(projectStore.pendingInvites[0].project_id)" size="sm" variant="ghost">
Decline </Button>
<Button @click="acceptInvite(projectStore.pendingInvites[0].project_id)" size="sm"> Accept </Button>
</div>
</div>
<Button @click="leaveProject()" class="my-4" v-if="!projectStore.isOwner" variant="outline">
Leave project
</Button>
<NavUser :user="userData" />
</SidebarFooter>
</Sidebar>
</template>

View File

@@ -1,175 +0,0 @@
<script lang="ts" setup>
export type IconProvider = (e: { _id: string, count: string } & any) => ['img' | 'icon', string] | undefined;
type Props = {
data: { _id: string, count: number }[],
iconProvider?: IconProvider,
elementTextTransformer?: (text: string) => string,
label: string,
subLabel: string,
desc: string,
loading?: boolean,
interactive?: boolean,
isDetailView?: boolean,
rawButton?: boolean,
hideShowMore?: boolean,
customIconStyle?: string,
showLink?: boolean
}
const props = defineProps<Props>();
const emits = defineEmits<{
(e: 'dataReload'): void,
(e: 'showDetails', id: string): void,
(e: 'showRawData'): void,
(e: 'showGeneral'): void,
(e: 'showMore'): void,
}>();
const maxData = computed(() => {
const counts = props.data.map(e => e.count);
return Math.max(...counts);
});
function reloadData() {
emits('dataReload');
}
function showDetails(id: string) {
emits('showDetails', id);
}
function openExternalLink(link: string) {
if (link === 'self') return;
return window.open('https://' + link, '_blank');
}
</script>
<template>
<LyxUiCard class="w-full h-full p-4 flex flex-col gap-8 relative">
<div class="flex justify-between mb-3">
<div class="flex flex-col gap-1">
<div class="flex gap-4 items-center">
<div class="poppins font-semibold text-[1.4rem] text-lyx-lightmode-text dark:text-lyx-text">
{{ label }}
</div>
<div class="flex items-center">
<i @click="reloadData()"
class="hover:rotate-[50deg] transition-all duration-100 fas fa-refresh text-[1.2rem] cursor-pointer"></i>
</div>
</div>
<div class="poppins text-[1rem] text-lyx-ligtmode-text-darker dark:text-text-sub/90">
{{ desc }}
</div>
</div>
<div v-if="rawButton" class="hidden lg:flex">
<LyxUiButton @click="$emit('showRawData')" type="primary" class="h-fit">
<div class="flex gap-1 items-center justify-center ">
<div> Show raw data </div>
<div class="flex items-center"> <i class="fas fa-arrow-up-right"></i> </div>
</div>
</LyxUiButton>
</div>
</div>
<div class="h-full flex flex-col">
<div
class="flex justify-between font-bold lyx-text-lightmode-text-dark dark:text-text-sub/80 text-[1.1rem] mb-4">
<div class="flex items-center gap-2">
<div v-if="isDetailView" class="flex items-center justify-center">
<i @click="$emit('showGeneral')"
class="fas fa-arrow-left text-[.9rem] hover:text-text cursor-pointer"></i>
</div>
<div> {{ subLabel }} </div>
</div>
<div> Count </div>
</div>
<div class="flex flex-col gap-1">
<div v-if="props.data.length > 0" class="flex justify-between items-center"
v-for="element of props.data">
<div class="flex items-center gap-2 w-10/12 relative">
<div v-if="showLink">
<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" @click="showDetails(element._id)"
:class="{ 'cursor-pointer line-active': interactive }">
<div class="absolute rounded-sm w-full h-full bg-[#6f829c38] dark:bg-[#92abcf38]"
:style="'width:' + 100 / maxData * element.count + '%;'"></div>
<div class="flex px-2 py-1 relative items-center gap-4">
<div v-if="iconProvider && iconProvider(element) != undefined"
class="flex items-center h-[1.3rem]">
<img v-if="iconProvider(element)?.[0] == 'img'" class="h-full"
:style="customIconStyle" :src="iconProvider(element)?.[1]">
<i v-else :class="iconProvider(element)?.[1]"></i>
</div>
<span
class="text-ellipsis line-clamp-1 ui-font z-[19] text-[.95rem] text-lyx-lightmode-text-dark dark:text-text/70">
{{ elementTextTransformer?.(element._id) || element._id }}
</span>
</div>
</div>
</div>
<div
class="text-lyx-lightmode-text dark:text-lyx-text font-semibold text-[.9rem] md:text-[1rem] manrope">
{{
formatNumberK(element.count) }} </div>
</div>
<div v-if="props.data.length == 0" class="flex justify-center text-text-sub font-light text-[1.1rem]">
No data yet
</div>
</div>
<div v-if="!hideShowMore" class="flex justify-center mt-4 text-text-sub/90 items-end grow">
<LyxUiButton type="outline" @click="$emit('showMore')">
Show more
</LyxUiButton>
</div>
</div>
<div v-if="loading"
class="backdrop-blur-[1px] z-[20] left-0 top-0 w-full h-full flex items-center justify-center font-bold rockmann absolute">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
</LyxUiCard>
</template>
<style scoped lang="scss">
.line-active:hover {
.absolute {
@apply bg-accent/20
}
}
.ui-font {
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
font-variation-settings: normal;
font-weight: 600;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
</style>

View File

@@ -1,66 +0,0 @@
<script lang="ts" setup>
import type { IconProvider } from './Base.vue';
function iconProvider(e: { _id: string, flag: 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'
if (name === 'duckduckgo') return ['icon', 'far fa-duck']
if (name === 'avast-secure-browser') return ['icon', 'far fa-bug']
if (name === 'avg-secure-browser') return ['icon', 'far fa-bug']
if (name === 'no_browser') return ['icon', 'far fa-question']
if (name === 'gsa') return ['icon', 'far fa-question']
if (name === 'miui-browser') return ['icon', 'far fa-question']
if (name === 'vivo-browser') return ['icon', 'far fa-question']
if (name === 'whale') return ['icon', 'far fa-question']
if (name === 'twitter') return ['icon', 'fab fa-twitter']
if (name === 'linkedin') return ['icon', 'fab fa-linkedin']
if (name === 'facebook') return ['icon', 'fab fa-facebook']
return [
'img',
`https://github.com/alrra/browser-logos/blob/main/src/${name}/${name}_256x256.png?raw=true`
]
}
const browsersData = useFetch('/api/data/browsers', {
headers: useComputedHeaders({ limit: 10, }), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/browsers', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = res?.map(e => {
return { ...e, icon: iconProvider(e as any) }
}) || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="browsersData.refresh()" :data="browsersData.data.value || []"
desc="The browsers most used to search your website." :dataIcons="true" :iconProvider="iconProvider"
:loading="browsersData.pending.value" label="Browsers" sub-label="Browsers">
</BarCardBase>
</div>
</template>

View File

@@ -1,54 +0,0 @@
<script lang="ts" setup>
import type { IconProvider } from './Base.vue';
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
if (e._id === 'desktop') return ['icon','far fa-desktop'];
if (e._id === 'tablet') return ['icon','far fa-tablet ml-1'];
if (e._id === 'mobile') return ['icon','far fa-mobile ml-1'];
if (e._id === 'smarttv') return ['icon','far fa-tv'];
if (e._id === 'console') return ['icon','far fa-game-console-handheld'];
return ['icon', 'far fa-question ml-1 mr-1']
}
function transform(data: { _id: string, count: number }[]) {
console.log(data);
return data.map(e => ({ ...e, _id: e._id == null ? 'others' : e._id }))
}
const devicesData = useFetch('/api/data/devices', {
headers: useComputedHeaders({ limit: 10, }), lazy: true,
transform
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/devices', {
headers: useComputedHeaders({ limit: 1000 }).value,
});
dialogBarData.value = transform(res || []);
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="devicesData.refresh()" :data="devicesData.data.value || []"
:iconProvider="iconProvider" :dataIcons="true" desc="The devices most used to access your website."
:loading="devicesData.pending.value" label="Devices" sub-label="Devices"></BarCardBase>
</div>
</template>

View File

@@ -1,42 +0,0 @@
<script lang="ts" setup>
const router = useRouter();
function goToView() {
router.push('/dashboard/events');
}
const eventsData = useFetch('/api/data/events', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value=[];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/events', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = res || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @showRawData="goToView()"
desc="Most frequent user events triggered in this project" @dataReload="eventsData.refresh()"
:data="eventsData.data.value || []" :loading="eventsData.pending.value" label="Top Events"
sub-label="Events" :rawButton="!isLiveDemo"></BarCardBase>
</div>
</template>

View File

@@ -1,59 +0,0 @@
<script lang="ts" setup>
import type { IconProvider } from '../BarCard/Base.vue';
function iconProvider(e: { _id: string, flag: string, count: number }): ReturnType<IconProvider> {
if (!e.flag) return ['icon', 'far fa-question']
return [
'img',
`https://raw.githubusercontent.com/hampusborgos/country-flags/refs/heads/main/svg/${e.flag.toLowerCase()}.svg`
// `https://raw.githubusercontent.com/hampusborgos/country-flags/main/png250px/${e.flag.toLowerCase()}.png`
]
}
const customIconStyle = `width: 2rem; padding: 1px;`
const geolocationData = useFetch('/api/data/countries', {
headers: useComputedHeaders({ limit: 10, }), lazy: true,
transform: (e) => {
if (!e) return e;
return e.map(k => {
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
})
}
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/countries', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = res?.map(k => {
return { ...k, flag: k._id, _id: getCountryName(k._id) ?? k._id }
}).map(e => {
return { ...e, icon: iconProvider(e) }
}) || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="geolocationData.refresh()"
:data="geolocationData.data.value || []" :dataIcons="false" :loading="geolocationData.pending.value"
label="Countries" sub-label="Countries" :iconProvider="iconProvider" :customIconStyle="customIconStyle"
desc=" Lists the countries where users access your website.">
</BarCardBase>
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script lang="ts" setup>
const ossData = useFetch('/api/data/oss', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value=[];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/oss', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = res || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" @dataReload="ossData.refresh()" :data="ossData.data.value || []"
desc="The operating systems most commonly used by your website's visitors." :dataIcons="false"
:loading="ossData.pending.value" label="OS" sub-label="OSs"></BarCardBase>
</div>
</template>

View File

@@ -1,44 +0,0 @@
<script lang="ts" setup>
const router = useRouter();
const pagesData = useFetch('/api/data/pages', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/pages', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = (res || []);
isDataLoading.value = false;
}
function goToView() {
router.push('/dashboard/visits');
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showRawData="goToView()" @showMore="showMore()" @dataReload="pagesData.refresh()" :showLink=true
:data="pagesData.data.value || []" :interactive="false" desc="Most visited pages."
:rawButton="!isLiveDemo"
:dataIcons="true" :loading="pagesData.pending.value" label="Top Pages" sub-label="Referrers">
</BarCardBase>
</div>
</template>

View File

@@ -1,53 +0,0 @@
<script lang="ts" setup>
import type { IconProvider } from './Base.vue';
function iconProvider(e: { _id: string, count: number }): ReturnType<IconProvider> {
if (e._id === 'self') return ['icon', 'fas fa-link'];
return ['img', `https://s2.googleusercontent.com/s2/favicons?domain=${e._id}&sz=64`]
}
function elementTextTransformer(element: string) {
if (element === 'self') return 'Direct Link';
return element;
}
const referrersData = useFetch('/api/data/referrers', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const { showDialog, dialogBarData, isDataLoading } = useBarCardDialog();
async function showMore() {
dialogBarData.value = [];
showDialog.value = true;
isDataLoading.value = true;
const res = await $fetch('/api/data/referrers', {
headers: useComputedHeaders({ limit: 1000 }).value
});
dialogBarData.value = res?.map(e => {
return { ...e, icon: iconProvider(e as any) }
}) || [];
isDataLoading.value = false;
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase @showMore="showMore()" :elementTextTransformer="elementTextTransformer"
:iconProvider="iconProvider" @dataReload="referrersData.refresh()" :showLink=true
:data="referrersData.data.value || []" :interactive="false" desc="Where users find your website."
:dataIcons="true" :loading="referrersData.pending.value" label="Top Sources" sub-label="Referrers">
</BarCardBase>
</div>
</template>

View File

@@ -1,57 +0,0 @@
<script lang="ts" setup>
const currentWebsite = ref<string>("");
const websitesData = useFetch('/api/data/websites', {
headers: useComputedHeaders({
limit: 10,
}), lazy: true
});
const pagesData = useFetch('/api/data/websites_pages', {
headers: useComputedHeaders({
limit: 10,
custom: {
'x-website-name': currentWebsite
}
}), lazy: true
});
const isPagesView = ref<boolean>(false);
const currentData = computed(() => {
return isPagesView.value ? pagesData : websitesData
})
async function showDetails(website: string) {
currentWebsite.value = website;
isPagesView.value = true;
}
async function showGeneral() {
websitesData.execute();
isPagesView.value = false;
}
const router = useRouter();
function goToView() {
router.push('/dashboard/visits');
}
</script>
<template>
<div class="flex flex-col gap-2 h-full">
<BarCardBase :hideShowMore="true" @showGeneral="showGeneral()" @showRawData="goToView()"
@dataReload="currentData.refresh()" @showDetails="showDetails" :data="currentData.data.value || []"
:loading="currentData.pending.value" :label="isPagesView ? 'Top pages' : 'Top Domains'"
:sub-label="isPagesView ? 'Page' : 'Domains'"
:desc="isPagesView ? 'Most visited pages' : 'Most visited domains in this project'"
:interactive="!isPagesView" :rawButton="!isLiveDemo" :isDetailView="isPagesView">
</BarCardBase>
</div>
</template>

View File

@@ -1,14 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ label: string, disabled?: boolean, loading?: boolean }>();
</script>
<template>
<div :class="{ '!bg-[#354a87] !text-text/50 !cursor-not-allowed': (disabled || loading) }"
class="bg-accent text-text px-4 py-2 text-center cursor-pointer hover:bg-[#5075e2] hover:text-text-sub">
<span v-show="!loading">{{ label }}</span>
<i v-if="loading" class="fas fa-loader animate-[spin_2s_linear_infinite]"></i>
</div>
</template>

View File

@@ -1,31 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ modelValue: string, placeholder?: string, readonly?: boolean }>();
const emits = defineEmits(['update:modelValue', 'change']);
function updateText(e: any) {
emits('update:modelValue', e.target.value);
}
function emitChange(e: any) {
emits('change', e.target.value);
}
</script>
<template>
<div>
<input @change="emitChange" :readonly="readonly" :value="modelValue" @input="updateText"
class="placeholder:text-text-sub/70 w-full read-only:bg-white/10 read-only:text-text-sub/60 placeholder:text-text-sub border-gray-400 bg-bg border-[1px] text-text rounded-md px-4 py-2"
:placeholder="placeholder" type="text">
</div>
</template>
<style lang="scss" scoped>
input:focus {
outline: none;
}
</style>

View File

@@ -1,9 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div class="bg-card card-shadow rounded-xl">
<slot></slot>
</div>
</template>

View File

@@ -1,26 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ title: string, sub?: string }>();
</script>
<template>
<LyxUiCard>
<div class="flex flex-col gap-4 h-full">
<div class="flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-[1rem] md:text-[1.3rem] text-lyx-lightmode-text-dark dark:text-text">
{{ props.title }}
</div>
<div v-if="props.sub" class="poppins text-[.7rem] md:text-[1rem] text-lyx-lightmode-text-darker dark:text-text-sub">
{{ props.sub }}
</div>
</div>
<slot name="header"></slot>
</div>
<div class="h-full">
<slot></slot>
</div>
</div>
</LyxUiCard>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { EyeIcon, EyeOffIcon } from 'lucide-vue-next';
const props = defineProps<{ modelValue: string, readonly?: boolean }>();
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>();
const show = ref<boolean>(false);
</script>
<template>
<div class="flex items-center gap-2 relative">
<Input class="pr-8" :type="show ? 'text' : 'password'" :readonly="readonly ?? false"
:model-value="props.modelValue" @update:model-value="emit('update:modelValue', $event as string)" />
<EyeIcon v-if="!show" @click="show = true" class="size-4 absolute right-2 cursor-pointer"></EyeIcon>
<EyeOffIcon v-else @click="show = false" class="size-4 absolute right-2 cursor-pointer"></EyeOffIcon>
</div>
</template>

View File

@@ -1,73 +0,0 @@
<script lang="ts" setup>
export type CItem = { label: string, slot: string, tab?: string }
const props = defineProps<{
items: CItem[],
manualScroll?: boolean,
route?: boolean
}>();
const router = useRouter();
const route = useRoute();
const activeTabIndex = ref<number>(0);
function updateTab() {
const target = props.items.findIndex(e => e.tab == route.query.tab);
if (target == -1) {
activeTabIndex.value = 0;
} else {
activeTabIndex.value = target;
}
}
function onChangeTab(newIndex: number) {
activeTabIndex.value = newIndex;
const target = props.items[newIndex];
if (!target) return;
router.push({ query: { tab: target.tab } });
}
onMounted(() => {
if (props.route !== true) return;
updateTab();
watch(route, () => {
updateTab();
})
})
</script>
<template>
<div class="h-full flex flex-col">
<div class="flex overflow-x-auto hide-scrollbars">
<div class="flex">
<div v-for="(tab, index) of items" @click="onChangeTab(index)"
class="px-6 pb-3 poppins font-medium text-lyx-lightmode-text dark:text-lyx-text-darker border-b-[1px] border-lyx-text-darker"
:class="{
'dark:!border-[#FFFFFF] dark:!text-[#FFFFFF] !border-lyx-primary !text-lyx-primary': activeTabIndex === index,
'hover:border-lyx-lightmode-text-dark hover:text-lyx-lightmode-text-dark/60 dark:hover:border-lyx-text-dark dark:hover:text-lyx-text-dark cursor-pointer': activeTabIndex !== index
}">
{{ tab.label }}
</div>
</div>
<div class="border-b-[1px] border-lyx-text-darker w-full">
</div>
</div>
<div :class="{ 'overflow-y-hidden': manualScroll }" class="overflow-y-auto h-full">
<slot :name="props.items[activeTabIndex].slot"></slot>
</div>
</div>
</template>

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
import 'v-calendar/dist/style.css'
const props = defineProps({
modelValue: {
type: [Date, Object] as PropType<DatePickerDate | DatePickerRangeObject | null>,
default: null
}
})
const emit = defineEmits(['update:model-value', 'close'])
const date = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:model-value', value)
emit('close')
}
})
const attrs = {
transparent: true,
borderless: true,
color: 'primary',
'is-dark': { selector: 'html', darkClass: 'dark' },
'first-day-of-week': 2,
}
</script>
<template>
<VCalendarDatePicker v-if="date && (typeof date === 'object')" v-model.range="date" :columns="2" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
</template>
<style>
:root {
--vc-gray-50: rgb(var(--color-gray-50));
--vc-gray-100: rgb(var(--color-gray-100));
--vc-gray-200: rgb(var(--color-gray-200));
--vc-gray-300: rgb(var(--color-gray-300));
--vc-gray-400: rgb(var(--color-gray-400));
--vc-gray-500: rgb(var(--color-gray-500));
--vc-gray-600: rgb(var(--color-gray-600));
--vc-gray-700: rgb(var(--color-gray-700));
--vc-gray-800: rgb(var(--color-gray-800));
--vc-gray-900: rgb(var(--color-gray-900));
}
.vc-primary {
--vc-accent-50: rgb(var(--color-primary-50));
--vc-accent-100: rgb(var(--color-primary-100));
--vc-accent-200: rgb(var(--color-primary-200));
--vc-accent-300: rgb(var(--color-primary-300));
--vc-accent-400: rgb(var(--color-primary-400));
--vc-accent-500: rgb(var(--color-primary-500));
--vc-accent-600: rgb(var(--color-primary-600));
--vc-accent-700: rgb(var(--color-primary-700));
--vc-accent-800: rgb(var(--color-primary-800));
--vc-accent-900: rgb(var(--color-primary-900));
}
</style>

View File

@@ -1,234 +0,0 @@
<script lang="ts" setup>
const { project } = useProject();
const { createAlert } = useAlert();
import 'highlight.js/styles/stackoverflow-dark.css';
import hljs from 'highlight.js';
import CardTitled from './CardTitled.vue';
import { Lit } from 'litlyx-js';
const props = defineProps<{
firstInteraction: boolean,
refreshInteraction: () => any
}>()
onMounted(() => {
hljs.highlightAll();
})
function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(project.value?._id?.toString() || '');
Lit.event('no_visit_copy_id');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
const createScriptText = () => {
return [
'<script defer ',
`data-project="${project.value?._id}" `,
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
'script>'
].join('')
}
Lit.event('no_visit_copy_script');
navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
}
const scriptText = computed(() => {
return [
`<script defer data-project="${project.value?._id.toString()}"`,
`\nsrc="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js">\n<`,
`/script>`
].join('');
})
function reloadPage() {
location.reload();
}
</script>
<template>
<div v-if="!firstInteraction && project" class="mt-[5vh] flex flex-col">
<div class="flex items-center justify-center">
<div class="mr-4 animate-pulse w-[1rem] h-[1rem] bg-accent rounded-full"> </div>
<div class="text-lyx-lightmode-text dark:text-text/90 poppins text-[1.1rem] font-medium">
Waiting for your first visit
</div>
<LyxUiButton class="ml-6" type="secondary" @click="reloadPage()">
<div class="flex items-center gap-2">
<i class="far fa-refresh"></i>
<div> Refresh </div>
</div>
</LyxUiButton>
</div>
<div class="flex items-center justify-center mt-10 w-full px-10">
<div class="flex flex-col gap-6">
<div class="flex gap-6 xl:flex-row flex-col">
<div class="h-full w-full">
<CardTitled class="h-full w-full xl:min-w-[400px] xl:h-[35rem]" title="Quick setup tutorial"
sub="Quickly Set Up Litlyx in 30 Seconds!">
<div class="flex items-center justify-center h-full w-full">
<iframe class="w-full h-full min-h-[400px]"
src="https://www.youtube.com/embed/LInFoNLJ-CI?si=a97HVXpXFDgFg2Yp" title="Litlyx"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
</CardTitled>
</div>
<div class="flex flex-col gap-6">
<div class="w-full">
<CardTitled title="Quick Integration"
sub="Start tracking web analytics in one line. (works everywhere js is supported)">
<div class="flex flex-col items-end gap-4">
<div class="w-full xl:text-[1rem] text-[.8rem]">
<pre>
<code class="language-html rounded-md">{{ scriptText }}</code>
</pre>
</div>
<LyxUiButton type="secondary" @click="copyScript()">
Copy
</LyxUiButton>
</div>
</CardTitled>
</div>
<div class="h-full w-full">
<CardTitled class="h-full w-full" title="Project id"
sub="This is the identifier for this project, used to forward data">
<div class="flex items-center justify-between gap-4 mt-6">
<div class="p-2 bg-lyx-lightmode-widget dark:bg-[#1c1b1b] rounded-md w-full">
<div class="w-full text-[.9rem] dark:text-[#acacac]"> {{ project?._id }} </div>
</div>
<LyxUiButton type="secondary" @click="copyProjectId()"> Copy </LyxUiButton>
</div>
</CardTitled>
</div>
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="Wordpress + Elementor"
sub="Our WordPress plugin is coming soon!.">
<template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com">
Visit documentation
</LyxUiButton>
</template>
<div class="flex flex-col items-end">
<div class="justify-center w-full hidden xl:flex gap-3">
<a href="#">
<img class="cursor-pointer" :src="'tech-icons/wpel.png'" alt="Litlyx-Wordpress-Elementor">
</a>
</div>
</div>
</CardTitled>
</div>
</div>
<div>
<div>
<CardTitled class="w-full h-full" title="Modules"
sub="Get started with your favorite framework.">
<template #header>
<LyxUiButton @click="Lit.event('no_visit_goto_docs')" type="secondary"
to="https://docs.litlyx.com">
Visit documentation
</LyxUiButton>
</template>
<div class="flex flex-col items-end">
<div class="justify-center w-full hidden xl:flex gap-3">
<a href="https://docs.litlyx.com/techs/js" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/js.png'" alt="Litlyx-Javascript-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/nuxt" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/nuxt.png'" alt="Litlyx-Nuxt-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/next" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/next.png'" alt="Litlyx-Next-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/react" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/react.png'" alt="Litlyx-React-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/vue" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/vue.png'" alt="Litlyx-Vue-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/angular" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/angular.png'" alt="Litlyx-Angular-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/python" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/py.png'" alt="Litlyx-Python-Analytics">
</a>
<a href="https://docs.litlyx.com/techs/serverless" target="_blank">
<img class="cursor-pointer" :src="'tech-icons/serverless.png'" alt="Litlyx-Serverless-Analytics">
</a>
</div>
</div>
</CardTitled>
</div>
</div>
</div>
</div>
<!-- <div class="flex justify-center gap-10 flex-col xl:flex-row items-center xl:items-stretch px-10">
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full">
<div class="poppins font-semibold"> Copy your project_id: </div>
<div class="flex items-center gap-2">
<div> <i @click="copyProjectId()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
<div class="text-[.9rem] text-[#acacac]"> {{ activeProject?._id }} </div>
</div>
</div>
<div class="bg-menu p-6 rounded-xl flex flex-col gap-2 w-full xl:max-w-[40vw]">
<div class="poppins font-semibold">
Start logging visits in 1 click | Plug anywhere !
</div>
<div class="flex items-center gap-4">
<div> <i @click="copyScript()" class="cursor-pointer hover:text-text far fa-copy"></i> </div>
<pre><code class="language-html">{{ scriptText }}</code></pre>
</div>
</div>
</div> -->
</div>
</template>

View File

@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { DialogDeleteAccount } from '#components';
import { LockIcon, LogOut, MoonIcon, SunIcon, TrashIcon } from 'lucide-vue-next';
const router = useRouter();
const { user, clear } = useUserSession();
const colorMode = useColorMode()
const isDark = computed({
get() {
return colorMode.value === 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
const showPlans = ref<boolean>(false);
async function logout() {
await clear();
router.push('/login');
}
const dialog = useDialog();
async function showDeleteAccountDialog() {
dialog.open({
body: DialogDeleteAccount,
title: 'Delete account',
async onSuccess() {
deleteAccount();
},
})
}
async function deleteAccount() {
await useCatch({
toast: true,
toastTitle: 'Error deleting account data',
async action() {
await useAuthFetchSync('/api/user/delete', { method: 'DELETE' })
},
async onSuccess(_, showToast) {
showToast('Deleting scheduled', { description: 'Account deleted successfully.', position: 'top-right' })
dialog.close();
await clear();
router.push('/');
},
})
}
const feedbackText = ref<string>('');
const feedbackOpen = ref<boolean>(false);
function sendFeedback() {
useCatch({
toast: true,
toastTitle: 'Error sending feedback',
async action() {
await useAuthFetchSync('/api/feedback/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { text: feedbackText.value }
});
},
onSuccess(_, showToast) {
feedbackOpen.value = false;
showToast('Feedback sent', { description: 'Feedback sent successfully', position: 'top-right' });
},
})
}
</script>
<template>
<div class="w-full h-dvh flex flex-col items-center p-8 overflow-auto poppins">
<!-- <div class="flex w-full flex-col items-center gap-4 md:flex-row md:justify-between mb-8 ">
<img class="h-[5dvh]" :src="isDark ? 'logo-white.svg' : 'logo-black.svg'">
<div>
<div class="flex gap-2">
<Button @click="isDark = !isDark" variant="outline" v-if="isDark">
<SunIcon></SunIcon>
</Button>
<Button @click="isDark = !isDark" variant="outline" v-if="!isDark">
<MoonIcon></MoonIcon>
</Button>
<Popover v-model:open="feedbackOpen">
<PopoverTrigger>
<Button variant="outline"> Feedback </Button>
</PopoverTrigger>
<PopoverContent>
<div class="flex flex-col gap-4">
<Label> Share everything with us. </Label>
<Textarea v-model="feedbackText" placeholder="Leave your feedback here"
class="resize-none h-24"></Textarea>
<Button @click="sendFeedback()"> Send </Button>
</div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="secondary">
Manage account
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent :side-offset="10" class="w-56">
<DropdownMenuLabel class="truncate px-2">
{{ user?.email }}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click="showDeleteAccountDialog()">
<TrashIcon></TrashIcon>
<span> Delete Account </span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click="logout()">
<LogOut></LogOut>
<span> Log out </span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div> -->
<Card v-if="!showPlans" class="mt-[15dvh] min-w-[60dvw]">
<CardContent class="p-4">
<div class="flex items-center text-center flex-col gap-4">
<LockIcon class="size-8"></LockIcon>
<PageHeader title="Dashboard Locked"
description="Your free trial has ended. Subscribe below to unlock your dashboard and access your stats." />
<div class="flex items-center gap-4">
<Button @click="showPlans = true"> Manage my Plan</Button>
</div>
</div>
</CardContent>
</Card>
<div v-else class="mt-[5dvh]">
<ManagePlans></ManagePlans>
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
const { isOpen, data, close } = useDialog();
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent v-if="data" class="sm:max-w-[425px] z-[60]">
<DialogHeader v-if="data.title || data.description">
<DialogTitle v-if="data.title">
{{ data.title }}
</DialogTitle>
<DialogDescription v-if="data.description">
{{ data.description }}
</DialogDescription>
</DialogHeader>
<component :data="data.props" @confirm="data.onSuccess?.($event, close)" :is="data.body"></component>
<DialogFooter v-if="data.footer">
<component :is="data.footer"></component>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
<script lang="ts" setup>
import { LoaderCircle } from 'lucide-vue-next';
</script>
<template>
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
</LoaderCircle>
</template>

View File

@@ -1,26 +0,0 @@
<script lang="ts" setup>
export type ButtonType = 'primary' | 'secondary' | 'outline' | 'outlined' | 'danger';
const props = defineProps<{ type: ButtonType, link?: string, target?: string, disabled?: boolean }>();
</script>
<template>
<NuxtLink tag="div" :to="disabled ? '' : link" :target="target"
class="poppins w-fit cursor-pointer px-4 py-1 rounded-md outline outline-[1px] text-lyx-lightmode-text dark:text-lyx-text"
:class="{
'bg-[#85a3ff] hover:bg-[#9db5fc] outline-lyx-lightmode-widget-light dark:bg-lyx-primary-dark dark:outline-lyx-primary dark:hover:bg-lyx-primary-hover': type === 'primary',
'bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget dark:bg-lyx-widget-lighter hover:bg-lyx-lightmode-widget dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': type === 'secondary',
'bg-lyx-transparent outline-lyx-lightmode-widget hover:bg-lyx-lightmode-widget-light dark:outline-lyx-widget-lighter dark:hover:bg-lyx-widget-light': (type === 'outline' || type === 'outlined'),
'bg-[#fcd1cb] hover:bg-[#f8c5be] dark:bg-lyx-danger-dark outline-lyx-danger dark:hover:bg-lyx-danger': type === 'danger',
'text-lyx-text !bg-lyx-widget !outline-lyx-widget-lighter !cursor-not-allowed': disabled === true,
}">
<slot></slot>
</NuxtLink>
</template>

View File

@@ -1,10 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div class="w-fit h-fit rounded-md bg-lyx-lightmode-background outline-lyx-lightmode-widget dark:bg-lyx-widget dark:outline-lyx-background-lighter p-4 outline outline-[1px] ">
<slot></slot>
</div>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ icon: string }>();
</script>
<template>
<span class="material-symbols-outlined">
{{ props.icon }}
</span>
</template>

View File

@@ -1,24 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ placeholder?: string, modelValue: string, type?: string }>();
const emits = defineEmits<{
(e: "update:modelValue", value: string): void
}>();
const handleChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emits('update:modelValue', target.value);
};
//TODO: FUNCTIONALITY + PLACEHOLDER DARK
</script>
<template>
<input
class="bg-lyx-lightmode-widget-light outline-lyx-lightmode-widget text-lyx-lightmode-text dark:bg-lyx-widget-light dark:text-lyx-text-dark poppins rounded-md outline outline-[1px] dark:outline-lyx-widget-lighter"
:type="props.type ?? 'text'" :placeholder="props.placeholder" :value="props.modelValue" @input="handleChange">
</template>

View File

@@ -1,15 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ size?: string }>();
const widgetStyle = computed(() => {
return `height: ${props.size ?? '1px'}`;
})
</script>
<template>
<div :style="widgetStyle" class="bg-lyx-widget-light"></div>
</template>

View File

@@ -0,0 +1,229 @@
<script lang="ts" setup>
import type { PlanCardPropData } from '~/components/plans/PlanCard.vue';
import { getPlanFromTag, type PLAN_TAG } from '~/shared/data/PLANS';
const { data: plan } = useAuthFetch('/api/user/plan');
function getPlanButtonType(premium_type: number): PlanCardPropData['button'][] {
const CURRENT_PLAN = premium_type;
if (CURRENT_PLAN === 101) return ['over_limits', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 102) return ['over_limits', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 103) return ['over_limits', 'over_limits', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 104) return ['over_limits', 'over_limits', 'over_limits', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 105) return ['over_limits', 'over_limits', 'over_limits', 'over_limits', 'upgrade'];
if (CURRENT_PLAN === 106) return ['over_limits', 'over_limits', 'over_limits', 'over_limits', 'over_limits'];
if (CURRENT_PLAN === 2001) return ['upgrade', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 5001) return ['upgrade', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 6001) return ['over_limits', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 6002) return ['over_limits', 'over_limits', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 6003) return ['over_limits', 'over_limits', 'over_limits', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 6006) return ['over_limits', 'over_limits', 'over_limits', 'over_limits', 'upgrade'];
if (CURRENT_PLAN === 7006) return ['upgrade', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 7999) return ['upgrade', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 8001 || CURRENT_PLAN === 8002) return ['current', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 8003 || CURRENT_PLAN === 8004) return ['over_limits', 'current', 'upgrade', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 8005 || CURRENT_PLAN === 8006) return ['over_limits', 'over_limits', 'current', 'upgrade', 'upgrade'];
if (CURRENT_PLAN === 8007 || CURRENT_PLAN === 8008) return ['over_limits', 'over_limits', 'over_limits', 'current', 'upgrade'];
if (CURRENT_PLAN === 8009 || CURRENT_PLAN === 8010) return ['over_limits', 'over_limits', 'over_limits', 'over_limits', 'current'];
return ['upgrade', 'upgrade', 'upgrade', 'upgrade', 'upgrade'];
}
function getHoloType(plan_tags: PLAN_TAG[]): PlanCardPropData['holo'] {
const ids = plan_tags.map(e => getPlanFromTag(e)?.ID);
if (ids.includes(plan.value?.premium_type)) return 'current_plan';
return 'normal_plan'
}
const plansArray = shallowRef<PlanCardPropData[]>([]);
watch(plan, () => {
if (!plan.value) return;
plansArray.value = getPlansArray(plan.value.premium_type);
})
function getPlansArray(current_premium_type: number): PlanCardPropData[] {
return [
{
title: 'Mini',
plan_tag: ['MINI_MONTHLY', 'MINI_ANNUAL'],
price_month: '5,99',
price_year: '4,99',
description: 'Up to 10K pageviews per month',
features: [
"Up to 1 workspace",
"No members allowed",
"Up to 2 Years Data retention",
"Unlimited domains per workspace",
"Advanced Analytics",
"Easy report"
],
info: [
{ index: 4, value: 'Advanced Analytics include: time on page, entry/exit pages, UTM and campaign parameters, traffic medium, and advanced user location (country/region/city).' }
],
holo: getHoloType(['MINI_MONTHLY', 'MINI_ANNUAL']),
button: getPlanButtonType(current_premium_type)[0]
},
{
title: 'Basic',
plan_tag: ['BASIC_MONTHLY', 'BASIC_ANNUAL'],
price_month: '17,99',
price_year: '14,99',
description: 'Up to 150K pageviews per month',
previousText: 'Everything in Mini plus:',
most_buy: true,
features: [
"Up to 2 workspaces",
"No members allowed",
"Up to 3 Years Data retention",
"Public shareable links",
"Unlimited AI messages"
],
holo: getHoloType(['BASIC_MONTHLY', 'BASIC_ANNUAL']),
button: getPlanButtonType(current_premium_type)[1]
},
{
title: 'Pro',
plan_tag: ['PRO_MONTHLY', 'PRO_ANNUAL'],
price_month: '37,99',
price_year: '29,99',
description: 'Up to 500K pageviews per month',
previousText: 'Everything in Basic plus:',
features: [
"Up to 3 workspaces",
"No members allowed",
"Up to 5 Years Data retention",
"Private shareable links"
],
holo: getHoloType(['PRO_MONTHLY', 'PRO_ANNUAL']),
button: getPlanButtonType(current_premium_type)[2]
},
{
title: 'Launch',
plan_tag: ['LAUNCH_MONTHLY', 'LAUNCH_ANNUAL'],
price_month: '67,99',
price_year: '59,99',
description: 'Up to 2M pageviews per month',
most_buy: true,
features: [
"Up to 10 workspaces",
"Up to 3 members per workspace",
"Up to 6 Years Data retention",
"Unlimited AI messages",
"Shareable links",
"Advanced reports"
],
holo: getHoloType(['LAUNCH_MONTHLY', 'LAUNCH_ANNUAL']),
button: getPlanButtonType(current_premium_type)[3]
},
{
title: 'Scale',
plan_tag: ['SCALE_MONTHLY', 'SCALE_ANNUAL'],
price_month: '97,99',
price_year: '89,99',
description: 'Up to 5M pageviews per month',
previousText: 'Everything in Launch plus:',
features: [
"Up to 25 workspaces",
"Unlimited members per workspace",
"Up to 10 Years Data retention",
"Dedicated server",
"Priority support"
],
holo: getHoloType(['SCALE_MONTHLY', 'SCALE_ANNUAL']),
button: getPlanButtonType(current_premium_type)[4]
},
{
title: 'Enterprise',
plan_tag: ['MINI_MONTHLY', 'MINI_ANNUAL'],
price_month: '',
price_year: '',
description: 'Our software scale at each stage.',
features: [
"+5M pageviews per month",
"+25 workspaces",
"Custom features",
"Dedicated server",
"On premise support",
"Personal success manager"
],
holo: 'normal_plan',
button: 'custom'
}
]
}
const yearly = ref<boolean>(true);
</script>
<template>
<div>
<PageHeader title="Plans"
description="Try Litlyx for free for 30 days. Upgrade to gain additional features, and increase pageviews limits." />
<p class="text-gray-500 text-sm dark:text-gray-400 poppins ">
<span>To view your plan and invoices check your billing overview
<NuxtLink to="/billing" class="text-[#9f7be7]"> here </NuxtLink>
</span>
</p>
<Tabs class="mt-8" default-value="personal" v-if="plansArray.length > 0">
<div class="flex gap-4 items-center">
<TabsList>
<TabsTrigger value="personal">
<div class="poppins px-2"> Personal </div>
</TabsTrigger>
<TabsTrigger value="business">
<div class="poppins px-2"> Business </div>
</TabsTrigger>
</TabsList>
<div class="flex items-center gap-2">
<Switch v-model="yearly"></Switch>
<Label class="text-sm text-muted-foreground">{{ yearly ? 'Yearly' : 'Monthly' }}</Label>
</div>
</div>
<Separator class="my-4"></Separator>
<TabsContent value="personal">
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
<PlansPlanCard @yearly-change="yearly = $event" :yearly="yearly" :data="plansArray[0]">
</PlansPlanCard>
<PlansPlanCard @yearly-change="yearly = $event" :yearly="yearly" :data="plansArray[1]">
</PlansPlanCard>
<PlansPlanCard @yearly-change="yearly = $event" :yearly="yearly" :data="plansArray[2]">
</PlansPlanCard>
</div>
</TabsContent>
<TabsContent value="business">
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
<PlansPlanCard @yearly-change="yearly = $event" :yearly="yearly" :data="plansArray[3]">
</PlansPlanCard>
<PlansPlanCard @yearly-change="yearly = $event" :yearly="yearly" :data="plansArray[4]">
</PlansPlanCard>
<PlansPlanCard @yearly-change="yearly = $event" :yearly="yearly" :data="plansArray[5]">
</PlansPlanCard>
</div>
</TabsContent>
</Tabs>
</div>
</template>

View File

@@ -1,15 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div>
<div class="lg:hidden">
<slot></slot>
</div>
<div class="hidden lg:flex">
<slot name="desktop"></slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/sidebar'
import { ChevronRight, type LucideIcon } from 'lucide-vue-next'
defineProps<{
items: {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
items?: {
title: string
url: string,
active?: boolean | Ref<boolean>
}[]
}[]
}>()
</script>
<template>
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<Collapsible v-for="item in items" :key="item.title" as-child :default-open="item.isActive"
class="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<NuxtLink :to="subItem.url">
<span :class="{ 'text-blue-300': unref(subItem.active) }">{{ subItem.title }}</span>
</NuxtLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import {
Folder,
Forward,
type LucideIcon,
MoreHorizontal,
Trash2,
} from 'lucide-vue-next'
defineProps<{
projects: {
name: string
url: string
icon: LucideIcon
}[]
}>()
const { isMobile } = useSidebar()
</script>
<template>
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Workspaces</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in projects" :key="item.name">
<SidebarMenuButton as-child>
<a :href="item.url">
<component :is="item.icon" />
<span>{{ item.name }}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuAction show-on-hover>
<MoreHorizontal />
<span class="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48 rounded-lg" :side="isMobile ? 'bottom' : 'right'"
:align="isMobile ? 'end' : 'start'">
<DropdownMenuItem>
<Folder class="text-gray-500 dark:text-gray-400" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward class="text-gray-500 dark:text-gray-400" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 class="text-gray-500 dark:text-gray-400" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { LucideIcon } from 'lucide-vue-next'
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
const props = defineProps<{
items: {
title: string
url: string
icon: LucideIcon
}[]
}>()
</script>
<template>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton as-child size="sm">
<a :href="item.url">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</template>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
import { BadgeCheck, CopyIcon, HelpCircle, Bell, MessageCircleMoreIcon, CatIcon, ChevronsUpDown, CreditCard, LogOut, MoonIcon, Sparkles, SunIcon, User, UserIcon, Wallet, } from 'lucide-vue-next';
import { toast } from 'vue-sonner';
const props = defineProps<{
user: {
name: string,
email: string,
avatar: string,
}
}>()
const { planInfo } = usePremiumStore();
const projectStore = useProjectStore();
const colorMode = useColorMode()
const isDark = computed({
get() {
return colorMode.value === 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
const { isMobile } = useSidebar()
const { clear } = useUserSession()
const router = useRouter();
async function logout() {
await clear();
router.push('/login');
}
const helpOpen = ref<boolean>(false);
function copyEmail() {
if (!navigator.clipboard) return toast.error('Error', { position: 'top-right', description: 'Error copying' });
navigator.clipboard.writeText("help@litlyx.com");
toast.info('Email copied', { description: 'Email is now in your clipboard', position: 'top-right' });
}
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar" :alt="user.email" />
<AvatarFallback class="rounded-lg">
{{ user.email.substring(0, 1) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.email }}</span>
<span class="truncate text-xs" v-if="planInfo">
{{ (planInfo.NAME ?? '???') }}
</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-56 max-w-[240px] rounded-lg"
:side="isMobile ? 'bottom' : 'top'" align="center" :side-offset="12">
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar" :alt="user.email" />
<AvatarFallback class="rounded-lg">
{{ user.email.substring(0, 1) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.email }}</span>
<span class="truncate text-xs" v-if="planInfo">
{{ planInfo.NAME ?? '???' }}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<!-- <DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup> -->
<!-- <DropdownMenuSeparator /> -->
<DropdownMenuGroup>
<NuxtLink to="/account">
<DropdownMenuItem>
<div class="flex items-center gap-2">
<UserIcon />
Account
</div>
</DropdownMenuItem>
</NuxtLink>
</DropdownMenuGroup>
<DropdownMenuGroup v-if="projectStore.isOwner">
<DropdownMenuSeparator />
<NuxtLink v-if="!isSelfhosted()" to="/plans">
<DropdownMenuItem>
<div class="flex items-center gap-2">
<CreditCard />
Plans
</div>
</DropdownMenuItem>
</NuxtLink>
<NuxtLink to="/billing">
<DropdownMenuItem>
<div class="flex items-center gap-2">
<Wallet />
Billing
</div>
</DropdownMenuItem>
</NuxtLink>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem as-child>
<Popover v-model:open="helpOpen">
<PopoverTrigger as-child>
<Button @click.prevent.stop variant="ghost" size="sm"
class="hover:!bg-sidebar-accent w-full flex justify-start font-normal">
<HelpCircle class="size-4 text-muted-foreground" />
Help
</Button>
</PopoverTrigger>
<PopoverContent side="right" :side-offset='16'>
<div class="flex flex-col gap-4">
<Label> Contact support </Label>
<Label class="text-muted-foreground">
If you have any question or issue we are here to help you
</Label>
<div>
<div class="border-solid border-[1px] rounded-md px-2 py-1 relative">
<CopyIcon @click="copyEmail()" class="size-4 absolute right-2 top-2 cursor-pointer"></CopyIcon>
<div class="poppins text-[.9rem]"> help@litlyx.com </div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</DropdownMenuItem>
<NuxtLink to="https://discord.gg/tg7FHkffR7" target="_blank">
<DropdownMenuItem>
<div class="flex items-center gap-2">
<Icon class="text-xl text-gray-400" name="ic:baseline-discord"></Icon>
Discord support
</div>
</DropdownMenuItem>
</NuxtLink>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click="isDark = !isDark">
<div class="flex items-center gap-2">
<SunIcon v-if="isDark" />
<MoonIcon v-if="!isDark" />
Switch theme
</div>
</DropdownMenuItem>
<NuxtLink to="/admin">
<DropdownMenuItem v-if="user.email === 'helplitlyx@gmail.com'">
<div class="flex items-center gap-2">
<CatIcon></CatIcon>
Admin panel
</div>
</DropdownMenuItem>
</NuxtLink>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout()">
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>

View File

@@ -1,176 +0,0 @@
<script lang="ts" setup>
const { data: needsOnboarding } = useFetch("/api/onboarding/exist", {
headers: useComputedHeaders({ useSnapshotDates: false, useTimeOffset: false })
});
const route = useRoute();
const analyticsList = [
"I have no prior analytics tool",
"Google Analytics 4",
"Plausible",
"Umami",
"MixPanel",
"Simple Analytics",
"Matomo",
"Fathom",
"Adobe Analytics",
"Other"
]
const jobsList = [
"Developer",
"Marketing",
"Product",
"Startup founder",
"Indie hacker",
"Other",
]
const selectedIndex = ref<number>(-1);
const otherFieldVisisble = ref<boolean>(false);
const otherText = ref<string>('');
function selectIndex(index: number) {
selectedIndex.value = index;
otherFieldVisisble.value = index == analyticsList.length - 1;
}
const selectedIndex2 = ref<number>(-1);
const otherFieldVisisble2 = ref<boolean>(false);
const otherText2 = ref<string>('');
function selectIndex2(index: number) {
selectedIndex2.value = index;
otherFieldVisisble2.value = index == jobsList.length - 1;
}
const page = ref<number>(0);
function onNextPage() {
if (selectedIndex.value == -1) return;
saveAnalyticsType();
page.value = 1;
}
function onFinish(skipped?: boolean) {
if (skipped) return location.reload();
if (selectedIndex2.value == -1) return;
saveJobTitle();
page.value = 2;
location.reload();
}
async function saveAnalyticsType() {
await $fetch('/api/onboarding/add', {
headers: useComputedHeaders({
useSnapshotDates: false, useTimeOffset: false,
custom: { 'Content-Type': 'application/json' }
}).value,
method: 'POST',
body: JSON.stringify({
analytics:
selectedIndex.value == analyticsList.length - 1 ?
otherText.value :
analyticsList[selectedIndex.value]
})
})
}
async function saveJobTitle() {
await $fetch('/api/onboarding/add', {
headers: useComputedHeaders({
useSnapshotDates: false, useTimeOffset: false,
custom: { 'Content-Type': 'application/json' }
}).value,
method: 'POST',
body: JSON.stringify({
job:
selectedIndex2.value == jobsList.length - 1 ?
otherText2.value :
jobsList[selectedIndex2.value]
})
})
}
const showOnboarding = computed(() => {
if (route.path === '/login') return false;
if (route.path === '/register') return false;
if ((needsOnboarding.value as any)?.exist === false) return true;
if ((needsOnboarding.value as any)?.exists === false) return true;
})
</script>
<template>
<div v-if="showOnboarding" class="absolute top-0 left-0 w-full h-full z-[30] bg-black/80 flex justify-center">
<div v-if="page == 0" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
For the current project do you already have other Analytics tools implemented (e.g. GA4) or Litlyx is
going to be your first/main analytics?
</div>
<div class="grid grid-cols-2 gap-3 mt-8">
<div v-for="(e, i) of analyticsList">
<div @click="selectIndex(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex == i }"
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }}
</div>
</div>
</div>
<div class="mt-8">
<LyxUiInput v-if="otherFieldVisisble" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
v-model="otherText"></LyxUiInput>
</div>
<div class="mt-6 flex justify-center flex-col items-center">
<LyxUiButton @click="onNextPage()" class="px-[8rem] py-2" :disabled="selectedIndex == -1"
type="primary"> Next </LyxUiButton>
<!-- <div class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div> -->
</div>
</div>
<div v-if="page == 1" class="bg-lyx-lightmode-background-light dark:bg-lyx-background-light mt-[10vh] w-[50vw] min-w-[400px] h-fit p-8 rounded-md">
<div class="text-lyx-lightmode-text dark:text-lyx-text text-[1.4rem] text-center font-medium"> Getting Started </div>
<div class="text-lyx-lightmode-text dark:text-lyx-text mt-4">
What is your job title ?
</div>
<div class="grid grid-cols-2 gap-3 mt-8">
<div v-for="(e, i) of jobsList">
<div @click="selectIndex2(i)"
:class="{ 'outline outline-[1px] outline-[#5680f8]': selectedIndex2 == i }"
class="bg-lyx-lightmode-widget-light dark:bg-lyx-widget-light text-center p-2 rounded-md cursor-pointer">
{{ e }}
</div>
</div>
</div>
<div class="mt-8">
<LyxUiInput v-if="otherFieldVisisble2" class="w-full !rounded-md py-2 px-2" placeholder="Please specify"
v-model="otherText2"></LyxUiInput>
</div>
<div class="mt-6 flex justify-center flex-col items-center">
<LyxUiButton @click="onFinish()" class="px-[8rem] py-2" :disabled="selectedIndex2 == -1" type="primary">
Finish </LyxUiButton>
<div @click="onFinish(true)" class="mt-2 text-lyx-text-darker cursor-pointer"> Skip </div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
defineProps<{ title?: string, description?: string }>();
</script>
<template>
<div class="space-y-1 poppins">
<h1 class="text-[16px] font-semibold lg:text-lg"> {{ title }} </h1>
<p class="text-gray-500 text-sm lg:text-md dark:text-gray-400"> {{ description }} </p>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { ChevronsUpDown, Layers2, Package, Plus } from 'lucide-vue-next'
const projectStore = useProjectStore();
const premiumStore = usePremiumStore();
const router = useRouter();
const { isMobile } = useSidebar()
function gotoAddProject() {
router.push('/create_project')
}
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<Skeleton v-if="!projectStore.activeProject" class="w-full h-12 p-2"></Skeleton>
<DropdownMenu v-if="projectStore.activeProject">
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="cursor-pointer data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar">
<Package v-if="projectStore.activeProject.guest"></Package>
<Layers2 v-if="!projectStore.activeProject.guest"></Layers2>
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span v-if="projectStore.pid" class="truncate font-medium">
{{ projectStore.activeProject.name }}
</span>
<span class="truncate text-xs text-gray-400">
{{ projectStore.activeProject.guest ? 'Guest' : 'Owned' }}
</span>
</div>
<ChevronsUpDown class="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent v-if="premiumStore.planInfo" class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg" align="start"
:side="isMobile ? 'bottom' : 'right'" :side-offset="4">
<DropdownMenuLabel class="text-xs text-gray-500 dark:text-gray-400">
Workspaces <span>{{ projectStore.projects.length }}/{{premiumStore.planInfo?.features.workspaces === 999 ? 'Unlimited' : (premiumStore.planInfo?.features.workspaces ?? 0)}}</span>
</DropdownMenuLabel>
<DropdownMenuItem v-for="item in projectStore.projects" :key="item.name" class="gap-2 p-2"
@click="projectStore.setActive(item._id.toString())">
<div class="flex size-6 items-center justify-center rounded-sm border">
<Package v-if="item.guest"></Package>
<Layers2 v-if="!item.guest"></Layers2>
</div>
{{ item.name }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem v-if="projectStore.projects.length >= premiumStore.planInfo.features.workspaces" class="gap-2 p-2" asChild>
<ProDropdown/>
</DropdownMenuItem>
<DropdownMenuItem v-else @click="gotoAddProject()" class="gap-2 p-2">
<div
class="flex size-6 items-center justify-center rounded-md border border-gray-200 bg-transparent dark:border-gray-800">
<Plus class="size-4" />
</div>
<div class="font-medium">
Add workspace
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { ChevronsUpDown, Layers2, Layers3, Package, Plus } from 'lucide-vue-next'
const projectStore = useProjectStore();
const premiumStore = usePremiumStore();
const router = useRouter();
const { isMobile } = useSidebar()
function gotoAddProject() {
router.push('/create_project')
}
</script>
<template>
<SidebarMenu class="w-full px-2">
<SidebarMenuItem>
<Skeleton v-if="!projectStore.activeProject" class="w-full h-12 p-2"></Skeleton>
<DropdownMenu v-if="projectStore.activeProject">
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="flex justify-between cursor-pointer data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div class="flex aspect-square size-8 justify-center bg-violet-500/40 items-center rounded-lg gap-4">
<Package v-if="projectStore.activeProject.guest" class="size-5"></Package>
<Layers2 v-if="!projectStore.activeProject.guest" class="size-5"></Layers2>
</div>
<span class="text-muted-foreground poppins ">Workspaces</span>
<ChevronsUpDown />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent v-if="premiumStore.planInfo"
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg" align="start"
:side="isMobile ? 'bottom' : 'right'" :side-offset="4">
<DropdownMenuLabel class="text-xs text-gray-500 dark:text-gray-400">
Workspaces <span>{{projectStore.projects.filter(e =>
!e.guest).length}}/{{ premiumStore.planInfo?.features.workspaces === 999 ? 'Unlimited' :
(premiumStore.planInfo?.features.workspaces ?? 0)}}</span>
</DropdownMenuLabel>
<DropdownMenuItem class="gap-2 p-2 " @click="router.push('/workspaces')">
<div class="flex size-6 items-center justify-center rounded-sm border">
<Layers3 />
</div>
All Workspaces
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem v-for="item in projectStore.projects" :key="item.name" class="gap-2 my-1"
@click="() => { projectStore.setActive(item._id.toString()); }"
:class="{ 'bg-sidebar-accent/50': item.name === projectStore.activeProject.name }">
<div class="flex size-6 items-center justify-center rounded-sm border">
<Package v-if="item.guest"></Package>
<Layers2 v-if="!item.guest"></Layers2>
</div>
{{ item.name }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem v-if="projectStore.projects.length >= premiumStore.planInfo.features.workspaces"
class="gap-2 p-2" asChild>
<ProDropdown />
</DropdownMenuItem>
<DropdownMenuItem v-else @click="gotoAddProject()" class="gap-2 p-2">
<div
class="flex size-6 items-center justify-center rounded-md border border-gray-200 bg-transparent dark:border-gray-800">
<Plus class="size-4" />
</div>
<div class="font-medium">
Add workspace
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const email = ref<string>('');
const password = ref<string>('');
const emits = defineEmits<{ (event: 'submit', data: { email: string, password: string }): void }>();
</script>
<template>
<div :class="cn('flex flex-col gap-6', props.class)">
<Card class="overflow-hidden p-0">
<CardContent class="grid p-0 md:grid-cols-2">
<form @submit.prevent="emits('submit', { email, password })" class="p-6 md:p-8">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-center text-center">
<h1 class="text-2xl font-bold">
Ayaya Images
</h1>
<p class="text-gray-500 text-balance dark:text-gray-400">
Start create images now
</p>
<p class="text-gray-500 text-balance dark:text-gray-400 mt-2">
Creating an account will allow you to use all the features of the ayaya-generator
</p>
</div>
<div class="grid gap-3">
<Label for="email">Email</Label>
<Input id="email" v-model="email" type="email" placeholder="email@example.com" required />
</div>
<div class="grid gap-3">
<div class="flex items-center">
<Label for="password">Password</Label>
</div>
<Input id="password" v-model="password" type="password" minlength="4" required />
</div>
<Button type="submit" class="w-full">
Register
</Button>
<div class="text-center text-sm">
Already have an account?
<NuxtLink to="/login" class="underline underline-offset-4">
Sign In
</NuxtLink>
</div>
</div>
</form>
<div class="bg-gray-100 relative hidden md:block dark:bg-gray-800">
<img src="/bg.avif" alt="Image"
class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
</div>
</CardContent>
</Card>
</div>
</template>

View File

@@ -1,37 +0,0 @@
<script lang="ts" setup>
type Props = {
options: { label: string, disabled?: boolean }[],
currentIndex: number
}
const props = defineProps<Props>();
const emits = defineEmits<{
(evt: 'changeIndex', newIndex: number): void;
}>();
</script>
<template>
<div class="flex gap-2 border-[1px] p-1 md:p-2 rounded-xl bg-lyx-lightmode-widget-light border-lyx-lightmode-widget dark:bg-lyx-widget dark:border-lyx-widget-lighter">
<div @click="opt.disabled ? ()=>{}: $emit('changeIndex', index)" v-for="(opt, index) of options"
class="hover:bg-lyx-lightmode-widget dark:hover:bg-lyx-widget-lighter/60 select-btn-animated cursor-pointer rounded-lg poppins font-regular px-2 md:px-3 py-1 text-[.8rem] md:text-[1rem]"
:class="{
'bg-lyx-lightmode-widget hover:!bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter dark:hover:!bg-lyx-widget-lighter': currentIndex == index && !opt.disabled,
'hover:!bg-lyx-lightmode-widget-light text-lyx-lightmode-widget dark:hover:!bg-lyx-widget !cursor-not-allowed dark:!text-lyx-widget-lighter': opt.disabled
}">
{{ opt.label }}
</div>
</div>
</template>
<style scoped lang="scss">
.select-btn-animated {
transition: all .4s linear;
}
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts" setup>
import GradientBorder from '~/components/complex/GradientBorder.vue';
const { billingPeriodPercent, plan } = usePremiumStore()
const colorMode = useColorMode()
const isDark = computed({
get() {
return colorMode.value === 'dark'
},
set() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
})
</script>
<template>
<GradientBorder v-if="plan">
<div class="flex items-center flex-col gap-2" v-if="plan.premium_type === 7006">
<img :src="isDark ? '/flamy-white.png' : '/flamy-black.png'" class="w-[15%]">
<div class="poppins font-semibold text-lg"> Free trial </div>
<div class="flex flex-col gap-1">
<Label class="poppins dark:text-white/80"> Your free trial ends in </Label>
<div> <Progress :model-value="billingPeriodPercent"></Progress> </div>
<div class="poppins text-sm dark:text-white/80 text-center">
{{ Math.floor((plan.end_at - Date.now()) / (1000 * 60 * 60 * 24)) }}
days </div>
</div>
<NuxtLink to="/plans" class="w-full mt-2">
<Button size="sm" class="w-full"> Upgrade now </Button>
</NuxtLink>
</div>
<div class="flex items-center flex-col gap-2" v-if="plan.premium_type === 0">
<img :src="isDark ? '/flamy-white.png' : '/flamy-black.png'" class="w-[15%]">
<div class="poppins font-semibold text-lg"> Free plan </div>
<Label class="poppins dark:text-white/80"> Your are on a free plan </Label>
<NuxtLink to="/plans" class="w-full mt-2">
<Button size="sm" class="w-full"> Upgrade now </Button>
</NuxtLink>
</div>
<div class="flex items-center flex-col gap-2" v-if="plan.payment_failed">
<img :src="isDark ? '/flamy-white.png' : '/flamy-black.png'" class="w-[15%]">
<div class="poppins font-semibold text-lg"> Payment Failed </div>
<Label class="poppins dark:text-white/80 text-center">
Please update your billing details to avoid service interruption.
</Label>
<NuxtLink to="/plans" class="w-full mt-2">
<Button size="sm" class="w-full"> Update now </Button>
</NuxtLink>
</div>
<div class="flex items-center flex-col gap-2" v-if="plan.canceled">
<img :src="isDark ? '/flamy-white.png' : '/flamy-black.png'" class="w-[15%]">
<div class="poppins font-semibold text-lg"> Plan canceled </div>
<div class="flex flex-col gap-1">
<Label class="poppins dark:text-white/80"> Your plan is still active for </Label>
<div> <Progress :model-value="billingPeriodPercent"></Progress> </div>
<div class="poppins text-sm dark:text-white/80 text-center">
{{ Math.floor((plan.end_at - Date.now()) / (1000 * 60 * 60 * 24)) }}
days </div>
</div>
</div>
</GradientBorder>
</template>

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
import { ChartSpline, LayoutPanelLeft, Layers2, Download, ChartColumnIncreasing, Sparkles, UsersRound, FileChartLine, Settings, Shield, ChevronRight, Lock } from 'lucide-vue-next'
import LiveUsers from '~/components/dashboard/LiveUsers.vue';
const { toggleSidebar, isMobile } = useSidebar();
const route = useRoute();
const projectStore = useProjectStore();
const premiumStore = usePremiumStore();
const openIndex = ref<number | null>(null) // nessuna sezione aperta allinizio
const toggle = (index: number) => {
openIndex.value = openIndex.value === index ? null : index
}
// childs:[]
const items = ref([
{
url: '/',
text: 'Web Analytics',
icon: ChartSpline,
disable_action: false,
disabled: computed(() => !projectStore.permissions?.webAnalytics)
},
{
url: '/events',
text: 'Custom Events',
icon: ChartColumnIncreasing,
disable_action: false,
disabled: computed(() => !projectStore.permissions?.events)
},
{
url: '/reports',
text: 'Reports',
icon: Download,
disable_action: false,
disabled: computed(() => !projectStore.isOwner || !projectStore.firstInteraction)
},
{
url: '/members',
text: 'Members',
icon: UsersRound,
disable_action: [0, 7006, 8001, 8002].includes(premiumStore.planInfo?.ID ?? -1),
disabled: computed(() => !projectStore.isOwner || [0, 7006, 8001, 8002].includes(premiumStore.planInfo?.ID ?? -1))
},
{
url: '/shields',
text: 'Shields',
icon: Shield,
disable_action: false,
disabled: computed(() => !projectStore.isOwner)
},
{
url: '/settings',
text: 'Settings',
icon: Settings,
disable_action: false,
disabled: computed(() => !projectStore.isOwner)
},
{
url: '/ai',
text: 'AI Assistant',
icon: Sparkles,
color: 'text-yellow-500',
disable_action: [0].includes(premiumStore.planInfo?.ID ?? -1),
disabled: computed(() => !projectStore.permissions?.ai || [0].includes(premiumStore.planInfo?.ID ?? -1))
}
])
onMounted(() => {
if (!isAiEnabled()) {
items.value.splice(-1);
items.value.splice(2, 1);
}
})
</script>
<template>
<ProjectSwitcherMini />
<SidebarGroup class="group-data-[collapsible=icon]:hidden poppins">
<!-- <SidebarGroupLabel>Cose</SidebarGroupLabel> -->
<SidebarMenu>
<SidebarGroupLabel class="flex justify-between gap-4 px-0">
<Badge variant="outline" class="truncate max-w-40 font-medium">
{{ projectStore?.activeProject?.name || 'Project' }}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<LiveUsers v-if="projectStore?.firstInteraction"></LiveUsers>
</TooltipTrigger>
<TooltipContent side="right">
<p>Online users at current time</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SidebarGroupLabel>
<SidebarMenuItem v-for="item in items" :key="item.text">
<SidebarMenuButton asChild @click="isMobile && toggleSidebar()" :disabled="item.disabled === true">
<NuxtLink :to="item.disabled === true ? '' : item.url" :class="{
'bg-sidebar-accent': route.path === item.url,
'!cursor-default !pointer-events-none !opacity-30 text-sidebar-accent-foreground font-light': item.disabled === true
}" class="flex justify-between" as-child>
<div class="flex flex-row gap-2">
<component :is="item.icon" class="size-4" :class="item.color ?? item.color" />
<span>{{ item.text }}</span>
</div>
<SidebarMenuBadge v-if="item.disable_action">
<Lock class="size-4 text-yellow-500" />
</SidebarMenuBadge>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
<Card v-if="isSelfhosted() && !isAiEnabled()" class="p-2! mt-4">
<div class="flex flex-col gap-2">
<div class="text-center text-xs">
To unlock AI features, make sure youve added a valid AI_KEY, AI_ORG, and AI_PROJECT inside your
docker-compose.yml.
</div>
<NuxtLink to="https://docs.litlyx.com" target="_blank" class="text-xs text-center text-blue-400">
View Documentation
</NuxtLink>
</div>
</Card>
</SidebarMenu>
<!-- <SidebarMenu>
<SidebarGroupLabel class="flex justify-between gap-4 px-0">
<span class="truncate">{{ projectStore?.activeProject?.name || 'Project' }}</span>
<LiveUsers v-if="projectStore?.firstInteraction" class="hidden lg:flex"></LiveUsers>
</SidebarGroupLabel>
<SidebarMenuItem v-for="(item, index) in items" :key="index">
<Collapsible v-if="item.url === ''" :open="openIndex === index">
<CollapsibleTrigger as-child :disabled="item.disabled === true" :class="{
'bg-sidebar-accent/50': item.childs && item.childs.some(child => route.path === child.url),
'!cursor-default !text-muted-foreground hover:!bg-sidebar': item.disabled === true
}" @click="toggle(index)">
<SidebarMenuButton>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<component :is="item.icon" class="size-4" />
<span>{{ item.text }}</span>
</div>
<ChevronRight class="h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-90': openIndex === index }" />
</div>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="child in item.childs" :key="child.url">
<SidebarMenuSubButton as-child @click="isMobile && toggleSidebar()">
<NuxtLink :to="child.disabled === true ? '' : child.url" :class="{
'bg-sidebar-accent': route.path === child.url,
'!cursor-default !text-muted-foreground hover:!bg-sidebar': child.disabled === true
}" class="flex justify-between">
<div class="flex flex-row gap-2">
<component :is="child.icon" class="size-4" />
<span>{{ child.text }}</span>
</div>
</NuxtLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
<template v-else>
<SidebarMenuButton asChild @click="isMobile && toggleSidebar()" :disabled="item.disabled === true">
<NuxtLink :to="item.disabled === true ? '' : item.url" :class="{
'bg-sidebar-accent': route.path === item.url,
'!cursor-default !pointer-events-none !opacity-30 text-sidebar-accent-foreground font-light': item.disabled === true
}" class="flex justify-between" as-child>
<div class="flex flex-row gap-2">
<component :is="item.icon" class="size-4" :class="item.color ?? item.color" />
<span>{{ item.text }}</span>
</div>
</NuxtLink>
</SidebarMenuButton>
</template>
</SidebarMenuItem>
</SidebarMenu> -->
</SidebarGroup>
</template>

View File

@@ -1,15 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{ text: string }>();
const currentText = ref<string>("");
onMounted(()=>{
});
</script>
<template>
<div>{{ text }}</div>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
const props = defineProps<{
authorization: string
}>()
</script>
<template>
<div class="flex flex-col items-center gap-4 mt-[10vh]">
<div class="w-[10rem]">
<img class="w-full" :src="'/sticker_sad.png'" alt="Litlyx Sticker Sad">
</div>
<div class="poppins text-[1.2rem]"> Contact the project owner to get access to this section. </div>
<div class="poppins text-[.9rem] text-muted-foreground"> Missing authorization: {{ props.authorization }} </div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
import { TrashIcon } from 'lucide-vue-next';
import type { TAiNewChatSchema } from '~/shared/schema/ai/AiNewChatSchema';
const { data: chats } = useAuthFetch<TAiNewChatSchema[]>('/api/admin/aichats');
</script>
<template>
<div class="flex flex-col gap-4 h-full overflow-y-auto">
<Card v-for="chat of chats.toReversed()">
<CardHeader>
<div class="flex gap-4 justify-center text-muted-foreground">
<div class="font-semibold text-white"> {{ chat.title }} </div>
<div> {{ chat.status }} </div>
<div> {{ new Date(chat.created_at).toLocaleString('it-IT') }} </div>
</div>
</CardHeader>
<CardContent>
<div class="flex flex-col gap-4">
<div v-for="e of chat.messages">
<div class="flex gap-2 items-center" v-if="e.role === 'user'">
<div class="text-white/40 shrink-0">
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
</div>
<div> {{ e.name }}: </div>
<div> {{ e.content }} </div>
</div>
<div class="flex gap-2 items-center"
v-else-if="e.role === 'assistant' && e.tool_calls && e.tool_calls.length > 0">
<div class="text-white/40 shrink-0">
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
</div>
<div> {{ e.name }}: </div>
<div> Function call <span class="font-semibold">{{e.tool_calls.map((e: any) =>
e.function.name).join(' ') }} </span></div>
</div>
<div class="flex gap-2 items-center" v-else-if="e.role === 'assistant' && !e.tool_calls">
<div class="text-white/40 shrink-0">
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
</div>
<div> {{ e.name }}: </div>
<div> {{ e.content }} </div>
</div>
<div class="flex gap-2 items-center" v-else-if="e.role === 'tool'">
<div class="text-white/40 shrink-0">
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
</div>
<div> TOOL CALL </div>
</div>
<div v-else>
{{ e.role }}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</template>

View File

@@ -1,72 +1,143 @@
<script lang="ts" setup>
const { data: result, refresh, status } = useAuthFetch<{
aggregations: { info: any, advanced: any, chunks: any[] }[],
operations: any[]
}>('/api/admin/shard/info');
function getLastModified(e: any) {
return new Date(new Date(e.info.lastmod).getTime() + 1000 * 60 * 60).toLocaleString('it-IT')
}
function getKeys(e: any) {
return Object.keys(e.info.key);
}
const replSets = ['shard1ReplSet', 'shard2ReplSet', 'shard3ReplSet'];
const colors = ['#d0f4de', '#ffadad', '#e4c1f9', '#fcf6bd', '#ff99c8'];
const chunkColors = ['#808080', '#dddddd', '#ccaa00'];
// const collections = computed(() => {
// if (!result.value) return;
// const returnData: {
// shards: { data: any, stats: any, doc_percent: number, color: string }[],
// info: any,
// advanced: any
// }[] = [];
// for (const collection of result.value.aggregations) {
// const info = collection.info;
// const advanced = collection.advanced;
// const totalDocs = replSets.reduce((a, repl) => {
// return a + ((collection.stats.find((e: any) => e.shard === repl)?.count ?? 0));
// }, 0);
// const shards = replSets.map((repl, index) => {
// const data = collection.data.find((e: any) => e.shard === repl);
// const stats = collection.stats.find((e: any) => e.shard === repl);
// const color = colors[index];
// if (!data || !stats) return {
// data: {
// chunkCount: 0,
// percent: 0
// },
// stats: {
// count: 0
// },
// doc_percent: 0,
// color
// };
// const percent = 100 / totalDocs * (stats.count);
// return { data, stats, doc_percent: percent, color };
// });
// returnData.push({ shards, info, advanced });
// }
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
// return returnData;
// });
const avgDuration = computed(() => {
if (!backendData?.value?.durations) return -1;
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
})
const labels = new Array(650).fill('-');
const durationsDatasets = computed(() => {
if (!backendData?.value?.durations) return [];
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
const datasets = [];
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
for (let i = 0; i < uniqueConsumers.length; i++) {
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
datasets.push({
points: consumerDurations.map((e: any) => {
return 1000 / parseInt(e[1])
}),
color: colors[i],
chartType: 'line',
name: uniqueConsumers[i]
})
function getShardsOrdered(coll: any) {
const shards: Record<string, any> = {}
for (const replSet of replSets) {
shards[replSet] = coll.advanced.shards[replSet] ?? { count: 0, totalSize: 0, totalIndexSize: 0 }
shards[replSet] = { ...shards[replSet], chunks: coll.chunks.find((e: any) => e.shard === replSet)?.chunkCount ?? 0 }
}
return datasets;
})
return shards;
}
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col gap-4 h-full overflow-y-auto">
<div class="cursor-default flex justify-center w-full">
<div v-if="result && result.operations.length > 0" class="flex flex-col gap-2 mt-4">
<AdminBackendOperation :operation="op" v-for="op of result.operations"> </AdminBackendOperation>
</div>
<div v-if="backendData" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
<div class="flex gap-8">
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
</div>
<div class="w-full">
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
</AdminBackendLineChart>
</div>
<div @click="refreshBackend()"> Refresh </div>
<div v-if="result">
<div class="flex items-center gap-2">
<Button @click="refresh()" size="sm"> Refresh </Button>
<Label> Status: {{ status }} </Label>
</div>
<div class="flex flex-col gap-2 mt-4">
<Card v-for="coll of result.aggregations" class="gap-2">
<CardContent class="flex flex-col gap-8">
<div class="flex gap-2 items-center">
<div v-if="backendPending">
Loading...
<div :class="{
'bg-green-200': !coll.info.noBalance,
'bg-red-200': coll.info.noBalance,
}" class="rounded-full size-3"></div>
<div class="w-[15rem]">
<div> {{ coll.info._id.split('.')[1].toString() }} </div>
</div>
<div class="flex items-center gap-2">
<div v-for="k of getKeys(coll)" class="flex items-center">
<Icon name="material-symbols:key-vertical" :size="16"></Icon>
<div> {{ k }} </div>
</div>
</div>
</div>
<div class="grid grid-cols-[auto_auto_auto]">
<div v-for="(value, indexName) in coll.advanced.indexSizes" class="flex items-center gap-2">
<div class="w-[5.5rem] text-right"> {{ formatBytes(value, 2) }} </div>
<Icon name="material-symbols:key-vertical" :size="16"></Icon>
<div> {{ indexName }} </div>
</div>
</div>
<Separator></Separator>
<div class="flex flex-col gap-2">
<AdminBackendShardData v-for="(shard, shardName) of getShardsOrdered(coll)"
:shardName="(shardName as any)" :count="shard.count" :totalSize="shard.totalSize"
:totalIndexSize="shard.totalIndexSize" :chunks="shard.chunks">
</AdminBackendShardData>
<AdminBackendShardData shardName="Total" :count="coll.advanced.count"
:totalSize="coll.advanced.totalSize" :totalIndexSize="coll.advanced.totalIndexSize">
</AdminBackendShardData>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>
</div>
</template>

View File

@@ -1,31 +1,37 @@
<script lang="ts" setup>
import { TrashIcon } from 'lucide-vue-next';
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
const { data: feedbacks, refresh } = useAuthFetch('/api/admin/feedbacks');
async function deleteFeedback(feedback_id: string) {
const sure = confirm('Are you sure to delete the feedback ?');
if (!sure) return;
await useAuthFetch(`/api/admin/feedbacks_delete?id=${feedback_id}`);
refresh();
}
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col gap-4 h-full overflow-y-auto">
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
v-for="feedback of feedbacks">
<div class="flex flex-col gap-1">
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
</div>
<Card v-for="feedback of feedbacks?.toReversed()">
<CardHeader>
<div class="flex gap-4 justify-center text-muted-foreground">
<div> {{ feedback.user_id?.email ?? 'USER_DELETED' }} </div>
<div> Project: {{ feedback.project_id }} </div>
</div>
<CardAction>
<TrashIcon @click="deleteFeedback((feedback as any)._id.toString())" class="size-5"></TrashIcon>
</CardAction>
</CardHeader>
<CardContent>
<div class="whitespace-pre-wrap">
{{ feedback.text }}
</div>
</div>
</CardContent>
</Card>
<div v-if="pendingFeedbacks"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>
</template>

View File

@@ -1,271 +0,0 @@
<script lang="ts" setup>
import DateService, { type Slice } from '@services/DateService';
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as fns from 'date-fns';
const props = defineProps<{ pid: string }>();
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
const chartOptions = ref<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: (false as any),
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 }
},
});
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
labels: [],
datasets: [
{
label: 'Visits',
data: [],
backgroundColor: ['#5655d7'],
borderColor: '#5655d7',
borderWidth: 4,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: '#5655d7',
hoverBorderColor: 'white',
hoverBorderWidth: 2,
segment: {
borderColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return '#5655d7';
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return '#5655d7'
},
borderDash(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return undefined;
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
return undefined;
},
backgroundColor(ctx, options) {
const todayIndex = visitsData.data.value?.todayIndex;
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
return createGradient('#5655d7');
},
},
},
{
label: 'Unique visitors',
data: [],
backgroundColor: ['#4abde8'],
borderColor: '#4abde8',
borderWidth: 2,
hoverBackgroundColor: '#4abde8',
hoverBorderColor: '#4abde8',
hoverBorderWidth: 2,
type: 'bar',
// barThickness: 20,
borderSkipped: ['bottom'],
},
{
label: 'Events',
data: [],
backgroundColor: ['#fbbf24'],
borderWidth: 2,
hoverBackgroundColor: '#fbbf24',
hoverBorderColor: '#fbbf24',
hoverBorderWidth: 2,
type: 'bubble',
stack: 'combined',
borderColor: ["#fbbf24"]
},
],
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
const selectedSlice: Slice = 'day'
const allDatesFull = ref<string[]>([]);
function transformResponse(input: { _id: string, count: number }[]) {
const data = input.map(e => e.count);
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
return { data, labels, todayIndex }
}
function onResponseError(e: any) {
let message = e.response._data.message ?? 'Generic error';
if (message == 'internal server error') message = 'Please change slice';
errorData.value = { errored: true, text: message }
}
function onResponse(e: any) {
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
}
const headers = computed(() => {
return {
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
'x-pid': props.pid
}
});
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}),
lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
headers: useComputedHeaders({
slice: selectedSlice,
custom: { ...headers.value },
useActivePid: false,
useActiveDomain: false
}), lazy: true,
transform: transformResponse, onResponseError, onResponse
});
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
watch(readyToDisplay, () => {
if (readyToDisplay.value === true) onDataReady();
})
function onDataReady() {
if (!visitsData.data.value) return;
if (!eventsData.data.value) return;
if (!sessionsData.data.value) return;
chartData.value.labels = visitsData.data.value.labels;
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
const maxEventSize = Math.max(...eventsData.data.value.data)
chartData.value.datasets[0].data = visitsData.data.value.data;
chartData.value.datasets[1].data = sessionsData.data.value.data;
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
const rValue = 20 / maxEventSize * e;
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
});
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return true;
return 'bottom';
});
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
const todayIndex = eventsData.data.value?.todayIndex || 0;
if (i == todayIndex - 1) return '#fbbf2400';
return '#fbbf24';
});
updateChart();
}
</script>
<template>
<div class="h-[10rem] w-full flex">
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</div>
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
{{ errorData.text }}
</div>
</div>
</template>
<style lang="scss" scoped>
#external-tooltip {
border-radius: 3px;
color: white;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate(-50%, 0);
transition: all .1s ease;
}
</style>

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
const props = defineProps<{ items: { value: number, color: string }[] }>();
function getPercent(index: number) {
const total = props.items.reduce((a, e) => a + e.value, 0);
const percent = 100 / total * props.items[index].value;
return Math.ceil(percent);
}
</script>
<template>
<div class="flex rounded-md overflow-hidden">
<div :style="`width: ${getPercent(index)}%; background-color: ${props.items[index].color};`"
v-for="(item, index) of props.items">
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
const { data: onboarding } = useAuthFetch('/api/admin/onboarding');
</script>
<template>
<div class="flex flex-col gap-4 h-full overflow-y-auto">
<div class="mt-6 h-full">
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 h-full pt-6 pb-[8rem]">
<div v-if="onboarding" class="flex gap-40 px-20">
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Anaytics </div>
<div class="flex items-center gap-2"
v-for="e of onboarding.analytics.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Jobs </div>
<div class="flex items-center gap-2"
v-for="e of onboarding.jobs.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div v-if="onboarding" class="flex flex-col gap-8">
<AdminOnboardingPieChart :data="onboarding.analytics" title="Analytics">
</AdminOnboardingPieChart>
<AdminOnboardingPieChart :data="onboarding.jobs" title="Jobs"></AdminOnboardingPieChart>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,45 +0,0 @@
<script lang="ts" setup>
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
</script>
<template>
<div class="mt-6 h-full">
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<div v-if="onboardings" class="flex gap-40 px-20">
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Anaytics </div>
<div class="flex items-center gap-2"
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="text-lyx-primary"> Jobs </div>
<div class="flex items-center gap-2"
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
<div>{{ e._id }}</div>
<div>{{ e.count }}</div>
</div>
</div>
<div v-if="onboardings" class="flex flex-col gap-8">
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
</div>
</div>
<div v-if="pendingOnboardings"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,204 +1,10 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
{ label: 'visits -->', id: '{ "visits": 1 }' },
{ label: 'visits <--', id: '{ "visits": -1 }' },
{ label: 'events -->', id: '{ "events": 1 }' },
{ label: 'events <--', id: '{ "events": -1 }' },
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const filterList = [
{ label: 'ALL', id: '{}' },
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
]
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
onMounted(() => {
for (const key in PREMIUM_PLAN) {
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
}
})
const filter = ref<string>('{}');
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { data: metrics, pending: pendingMetrics } = useFetch(
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col items-center gap-8">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Filter:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
value-attribute="id" option-attribute="label" v-model="filter">
</USelectMenu>
</div>
</div>
<div class="flex items-center gap-10 justify-center px-10 w-full">
<div class="flex gap-2 items-center shrink-0">
<div>Page {{ page }} </div>
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
}}</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
<div class="w-[80%]">
<div v-if="pendingMetrics"> Loading... </div>
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
<div>
Total visits: {{ formatNumberK(metrics.totalVisits) }}
</div>
<div>
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
Dead: {{ metrics.deadProjects }}
</div>
<div>
Total events: {{ formatNumberK(metrics.totalEvents) }}
</div>
</div>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
<div v-if="pendingProjects"> Loading...</div>
</div>
<div class="flex flex-col gap-10 h-full overflow-hidden">
<AdminOverviewCounts></AdminOverviewCounts>
<AdminOverviewUsers></AdminOverviewUsers>
</div>
</template>
<style scoped lang="scss"></style>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
const { data: counts } = useAuthFetch('/api/admin/counts');
</script>
<template>
<div class="w-full">
<div v-if="counts" class="flex justify-center gap-20">
<div class="flex gap-4">
<Label> Projects: {{ counts.projects }} </Label>
<Label> Active: {{ counts.active }} </Label>
<Label> Dead: {{ counts.dead }} </Label>
</div>
<div class="flex gap-4">
<Label> Users: {{ counts.users }} </Label>
<Label> Paid: {{ counts.paid }} </Label>
<Label> Appsumo: {{ counts.appsumo }} </Label>
<Label> Free: {{ counts.free_trial }} </Label>
<Label> FreeEnd: {{ counts.free_trial_ended }} </Label>
</div>
<div class="flex gap-4">
<Label> Visits: {{ formatNumberK(counts.visits, 2) }} </Label>
<Label> Events: {{ formatNumberK(counts.events, 2) }} </Label>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
const props = defineProps<{ project: any }>();
const loading = ref<boolean>(true);
const domains = ref<string[]>([]);
const { list, containerProps, wrapperProps } = useVirtualList(domains, { itemHeight: 40 });
async function loadData() {
domains.value.length = 0;
loading.value = true;
await useCatch({
async action() {
const res = await useAuthFetchSync<string[]>(`/api/admin/domains?pid=${props.project._id.toString()}`);
return res;
},
async onSuccess(data) {
domains.value = data;
},
})
loading.value = false;
}
const projectStore = useProjectStore();
function stealProject() {
projectStore.projects.push(props.project);
}
</script>
<template>
<Popover @update:open="loadData()">
<PopoverTrigger as-child>
<Button variant="link">
{{ props.project.name }} -
{{ props.project.counts[0].visits }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-full h-full">
<div class="flex flex-col w-full h-full">
<div class="flex justify-center">
<Loader v-if="loading"></Loader>
<div v-if="!loading && domains.length == 0">No domains</div>
</div>
<div v-if="!loading && domains.length > 0" class="flex flex-col">
<div class="flex justify-center pb-2 gap-2">
<Button @click="stealProject()" size="sm">Steal</Button>
<Label> {{ domains.length }} domains</Label>
</div>
<div v-bind="containerProps" class="h-[18rem] w-[25rem]">
<div v-bind="wrapperProps" class="flex flex-col">
<div v-for="(domain, index) of list" class="!h-[40px]" :key="index">
<Separator v-if="index < domains.length - 1" class="my-2"></Separator>
<div>{{ domain.data }}</div>
</div>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -0,0 +1,201 @@
<script lang="ts" setup>
import type { DateRange } from 'reka-ui'
import { RangeCalendar } from '@/components/ui/range-calendar'
import { CalendarIcon, LucideSearch, X } from 'lucide-vue-next'
import { CalendarDate, DateFormatter, getLocalTimeZone } from '@internationalized/date'
const currentPage = ref<number>(1);
const currentSorting = ref<string>('usage-more')
const popoverOpen = ref<boolean>(false);
const search = ref<string>('');
const searchRequest = ref<string>('');
function clearSearchData() {
searchRequest.value = '';
search.value = '';
}
function searchData() {
searchRequest.value = search.value;
}
const value = ref<DateRange>({
start: new CalendarDate(new Date().getFullYear(), new Date().getUTCMonth() + 1, 1),
end: new CalendarDate(new Date().getFullYear(), new Date().getUTCMonth() + 1, new Date().getDate())
}) as Ref<DateRange>;
const df = new DateFormatter('en-US', { dateStyle: 'medium' })
const { data: info } = useAuthFetch(() => `/api/admin/users?page=${currentPage.value}&sort=${currentSorting.value}&from=${value.value.start}&to=${value.value.end}&search=${searchRequest.value}`);
function onPageChange(page: number) {
currentPage.value = page;
}
function isActive(u: any) {
const updates: Date[] = u.projects.map((e: any) => new Date(e.counts[0].updated_at));
const lastUpdates = updates.toSorted((a, b) => b.getTime() - a.getTime());
if (lastUpdates.length == 0) return false;
const lastUpdate = lastUpdates[0];
if (lastUpdate.getTime() < Date.now() - 1000 * 60 * 60 * 24 * 3) return false;
return true;
}
function setDate(hours: number) {
const start = new Date(Date.now() - hours * 1000 * 60 * 60);
value.value.start = new CalendarDate(start.getFullYear(), start.getUTCMonth() + 1, start.getDate());
value.value.end = new CalendarDate(new Date().getFullYear(), new Date().getUTCMonth() + 1, new Date().getDate());
}
// function getLastUpdate(u: any) {
// const updates: Date[] = u.projects.map((e: any) => new Date(e.counts[0].updated_at));
// const lastUpdates = updates.toSorted((a, b) => b.getTime() - a.getTime());
// if (lastUpdates.length == 0) return '-';
// const lastUpdate = lastUpdates[0];
// return lastUpdate.toLocaleDateString('it-IT');
// }
</script>
<template>
<div class="flex flex-col gap-6 h-full overflow-hidden">
<div class="flex justify-center gap-8">
<Select v-model="currentSorting">
<SelectTrigger>
<SelectValue class="w-[8rem]">
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="newer">
Newer
</SelectItem>
<SelectItem value="older">
Older
</SelectItem>
<SelectItem value="usage-more">
More usage %
</SelectItem>
<SelectItem value="usage-less">
Less usage %
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Popover v-model:open="popoverOpen">
<PopoverTrigger as-child>
<Button variant="outline">
<CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="value.start">
<template v-if="value.end">
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{
df.format(value.end.toDate(getLocalTimeZone())) }}
</template>
<template v-else>
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
</template>
</template>
<template v-else>
Pick a date
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-4 flex flex-col items-end relative z-[90]">
<RangeCalendar v-model="value" initial-focus :number-of-months="2"
@update:start-value="(startDate) => value.start = startDate" />
<Button @click="popoverOpen = false;"> Confirm </Button>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger>
<Button size="sm"> Timeframe </Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem @click="setDate(365 * 10 * 24)">
All Time
</DropdownMenuItem>
<DropdownMenuItem @click="setDate(48)">
Last day
</DropdownMenuItem>
<DropdownMenuItem @click="setDate(30 * 24)">
Last 30 days
</DropdownMenuItem>
<DropdownMenuItem @click="setDate(60 * 24)">
Last 60 days
</DropdownMenuItem>
<DropdownMenuItem @click="setDate(90 * 24)">
Last 90 days
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<div class="flex items-center gap-2">
<Input class="w-[20rem]" v-model="search" />
<Button :disabled="search == searchRequest" @click="searchData()" size="icon">
<LucideSearch></LucideSearch>
</Button>
<Button v-if="searchRequest.length > 0" @click="clearSearchData()" size="icon">
<X></X>
</Button>
</div>
</div>
<Pagination v-if="info" @update:page="onPageChange" v-slot="{ page }" :items-per-page="20" :total="info.count"
:default-page="currentPage">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value" :is-active="item.value === page">
{{ item.value }}
</PaginationItem>
</template>
<PaginationEllipsis v-if="info.count > 20 * 4" :index="4" />
<PaginationNext />
</PaginationContent>
</Pagination>
<div class="overflow-y-auto pb-10">
<div class="grid grid-cols-2 gap-4" v-if="info">
<Card v-for="user of info.users">
<CardContent>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<div class="size-3 rounded-full mt-[1px] bg-red-200" :class="{
'!bg-green-200': isActive(user)
}"></div>
<!-- <Label> {{ getLastUpdate(user) }} </Label> -->
<Label> {{ user.email }} </Label>
<Label class="text-muted-foreground">
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
</Label>
<Label class="text-muted-foreground ml-2">
{{ user.visits + user.events }} / {{ user.limit }}
({{ Math.floor(100 / user.limit * (user.visits + user.events)) }}%)
</Label>
</div>
<div>
<Progress
:model-value="Math.min(Math.floor(100 / user.limit * (user.visits + user.events)), 100)"></Progress>
</div>
<div class="flex gap-8 flex-wrap">
<div v-for="p of user.projects">
<AdminOverviewPopoverProject :project="p">
</AdminOverviewPopoverProject>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</template>

View File

@@ -1,151 +0,0 @@
<script lang="ts" setup>
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
import type { TAdminUser } from '~/server/api/admin/users';
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
const filterText = ref<string>('');
watch(filterText, () => {
page.value = 1;
})
function isRangeSelected(duration: Duration) {
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
}
function selectRange(duration: Duration) {
selected.value = { start: sub(new Date(), duration), end: new Date() }
}
const ranges = [
{ label: 'Last 7 days', duration: { days: 7 } },
{ label: 'Last 14 days', duration: { days: 14 } },
{ label: 'Last 30 days', duration: { days: 30 } },
{ label: 'Last 3 months', duration: { months: 3 } },
{ label: 'Last 6 months', duration: { months: 6 } },
{ label: 'Last year', duration: { years: 1 } }
]
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
const filter = computed(() => {
return JSON.stringify({
$or: [
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
]
})
})
const page = ref<number>(1);
const ordersList = [
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
]
const order = ref<string>('{ "created_at": -1 }');
const limitList = [
{ label: '10', id: 10 },
{ label: '20', id: 20 },
{ label: '50', id: 50 },
{ label: '100', id: 100 },
]
const limit = ref<number>(20);
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
signHeaders()
);
const { uiMenu } = useSelectMenuStyle();
</script>
<template>
<div class="mt-6 h-full">
<div class="flex flex-col items-center gap-6">
<div class="flex items-center gap-10 px-10">
<div class="flex gap-2 items-center">
<div>Order:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
value-attribute="id" option-attribute="label" v-model="order">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<div>Limit:</div>
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
value-attribute="id" option-attribute="label" v-model="limit">
</USelectMenu>
</div>
<div class="flex gap-2 items-center">
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
</div>
</div>
<div class="flex items-centet gap-10">
<div class="flex gap-2 items-center">
<div>Page {{ page }} </div>
<div>
{{ Math.min(limit, usersInfo?.count || 0) }}
of
{{ usersInfo?.count || 0 }}
</div>
</div>
<div>
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
</div>
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
<UButton class="w-full" color="primary" variant="solid">
<div class="flex items-center justify-center w-full gap-2">
<i class="i-heroicons-calendar-days-20-solid"></i>
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
</div>
</UButton>
<template #panel="{ close }">
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
<div class="hidden sm:flex flex-col py-4">
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
variant="ghost" class="rounded-none px-6"
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
truncate @click="selectRange(range.duration)" />
</div>
<DatePicker v-model="selected" @close="close" />
</div>
</template>
</UPopover>
</div>
</div>
<div
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
v-for="user of usersInfo?.users" />
<div v-if="pendingUsers"> Loading...</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,132 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as datefns from 'date-fns';
const errored = ref<boolean>(false);
const props = defineProps<{
labels: string[],
title: string,
datasets: {
points: number[],
color: string,
name: string
}[]
}>();
const chartOptions = ref<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]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: true },
title: {
display: true,
text: props.title
},
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',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels.map(e => {
try {
return datefns.format(new Date(e), 'dd/MM');
} catch (ex) {
return e;
}
}),
datasets: props.datasets.map(e => ({
data: e.points,
label: e.name,
backgroundColor: [e.color + '00'],
borderColor: e.color,
borderWidth: 2,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
type: 'line'
} as any))
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
onMounted(async () => {
try {
// chartData.value.datasets.forEach(dataset => {
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
// } else {
// dataset.backgroundColor = [createGradient('#3d59a4')]
// }
// });
} catch (ex) {
errored.value = true;
console.error(ex);
}
});
</script>
<template>
<div>
<div v-if="errored"> ERROR CREATING CHART </div>
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
defineProps<{ operation: any }>();
</script>
<template>
<Card>
<CardContent class="flex items-center gap-2">
<div class="size-3 bg-gray-300 rounded-full"></div>
<div class="w-[20rem]"> {{ operation.shard }} </div>
<div class="w-[20rem]"> {{ operation.ns }} </div>
<div class="w-[10rem]"> {{ formatTime(operation.totalOperationTimeElapsedSecs * 1000) }} </div>
<div class> {{ operation.donorState ?? 'NO_STATE' }} </div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
const props = defineProps<{ shardName: string, count: number, totalSize: number, totalIndexSize: number, chunks?: number }>();
</script>
<template>
<div class="flex gap-2 items-center">
<Icon name="uil:puzzle-piece" :size="20"></Icon>
<div class="w-[8rem]">{{ shardName }}</div>
<div class="w-[9rem] flex gap-2 items-center">
<Icon :size="20" name="ph:files"></Icon>
<div> {{ formatNumberK(count, 2) }} </div>
</div>
<div class="w-[9rem] flex gap-2 items-center">
<Icon :size="20" name="lucide:weight"></Icon>
<div> {{ formatBytes(totalSize, 2) }} </div>
</div>
<div class="w-[9rem] flex gap-2 items-center">
<Icon :size="20" name="material-symbols:key-vertical"></Icon>
<div> {{ formatBytes(totalIndexSize, 2) }} </div>
</div>
<div class="w-[9rem] flex gap-2 items-center" v-if="chunks">
<Icon :size="20" name="fluent:puzzle-cube-piece-20-filled"></Icon>
<div> {{ chunks }} </div>
</div>
</div>
</template>

View File

@@ -1,48 +0,0 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
const props = defineProps<{ pid: string }>();
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
() => `/api/admin/project_info?pid=${props.pid}`,
signHeaders(),
);
</script>
<template>
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
<div>
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
</div>
<div class="flex justify-center gap-10" v-if="projectInfo">
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
</div>
<div v-if="projectInfo" class="flex flex-col">
<div>Domains:</div>
<div class="flex flex-wrap gap-8 mt-8">
<div v-for="domain of projectInfo.domains">
{{ domain._id }}
</div>
</div>
</div>
</div>
<div v-if="pending">
Loading...
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,134 +0,0 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import { getPlanFromId } from '~/shared/data/PREMIUM';
import { AdminDialogProjectDetails } from '#components';
const { openDialogEx } = useCustomDialog();
function showProjectDetails(pid: string) {
openDialogEx(AdminDialogProjectDetails, {
params: { pid }
})
}
const props = defineProps<{ project: TAdminProject }>();
const logBg = computed(() => {
const day = 1000 * 60 * 60 * 24;
const week = 1000 * 60 * 60 * 24 * 7;
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
if (lastLoggedAtDate.getTime() > Date.now() - day) {
return 'bg-green-500'
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
return 'bg-yellow-500'
} else {
return 'bg-red-500'
}
});
const dateDiffDays = computed(() => {
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
if (res > -1 && res < 1) return 0;
return res;
});
const usageLabel = computed(() => {
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
});
const usagePercentLabel = computed(() => {
const percent = 100 / props.project.limit_max * props.project.limit_total;
return `~ ${percent.toFixed(1)}%`;
});
const usageAiLabel = computed(() => {
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
}
); const usageAiPercentLabel = computed(() => {
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
return `~ ${percent.toFixed(1)}%`
});
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
</div>
<div class="flex gap-4 justify-center text-[.9rem]">
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
</div>
</UTooltip>
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
{{ project.name }}
</div>
</div>
<div class="flex flex-col items-center mt-2">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,135 +0,0 @@
<script lang="ts" setup>
import type { TAdminProject } from '~/server/api/admin/projects';
import type { TAdminUser } from '~/server/api/admin/users';
import { getPlanFromId } from '~/shared/data/PREMIUM';
import { AdminDialogProjectDetails } from '#components';
const { openDialogEx } = useCustomDialog();
function showProjectDetails(pid: string) {
openDialogEx(AdminDialogProjectDetails, {
params: { pid }
})
}
const props = defineProps<{ user: TAdminUser }>();
</script>
<template>
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
<div class="flex gap-4 justify-center text-[.9rem]">
<div class="font-medium text-lyx-text-dark">
{{ user.name ?? user.given_name }}
</div>
<div class="text-lyx-text-darker">
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium">
{{ user.email }}
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="flex flex-col text-[.9rem]">
<div class="flex gap-2" v-for="project of user.projects">
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
</div>
</UTooltip>
<div @click="showProjectDetails(project._id.toString())"
class="ml-1 hover:text-lyx-primary cursor-pointer">
{{ project.name }}
</div>
</div>
</div>
</div>
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
</div>
<div class="flex gap-4 justify-center text-[.9rem]">
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
<div class="font-medium text-lyx-text-dark">
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
</div>
</UTooltip>
<div class="text-lyx-text-darker">
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
</div>
</div>
<div class="flex gap-5 justify-center">
<div class="font-medium">
{{ project.name }}
</div>
</div>
<div class="flex flex-col items-center mt-2">
<div class="flex gap-4">
<div class="flex gap-2">
<div class="text-right"> Visits:</div>
<div>{{ formatNumberK(project.visits || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Events:</div>
<div>{{ formatNumberK(project.events || 0) }}</div>
</div>
<div class="flex gap-2">
<div class="text-right"> Sessions:</div>
<div>{{ formatNumberK(project.sessions || 0) }}</div>
</div>
</div>
</div>
<LyxUiSeparator class="my-2" />
<div class="mb-2">
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
</div>
<div class="flex gap-6 justify-around">
<div class="flex gap-1">
<div>
{{ usageLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usagePercentLabel }}
</div>
</div>
<div class="flex gap-2">
<div>
{{ usageAiLabel }}
</div>
<div class="text-lyx-text-dark">
{{ usageAiPercentLabel }}
</div>
</div>
</div>
</div> -->
</template>
<style scoped lang="scss"></style>

View File

@@ -1,138 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
import * as datefns from 'date-fns';
registerChartComponents();
const errored = ref<boolean>(false);
const props = defineProps<{
labels: string[],
title: string,
datasets: {
points: number[],
color: string,
chartType: string,
name: string
}[]
}>();
const chartOptions = ref<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]
},
},
x: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: true },
title: {
display: true,
text: props.title
},
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',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels.map(e => {
try {
return datefns.format(new Date(e), 'dd/MM');
} catch (ex) {
return e;
}
}),
datasets: props.datasets.map(e => ({
data: e.points,
label: e.name,
backgroundColor: [e.color + '77'],
borderColor: e.color,
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: e.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
type: e.chartType
} as any))
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
function createGradient(startColor: string) {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${startColor}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${startColor}99`);
gradient.addColorStop(0.35, `${startColor}66`);
gradient.addColorStop(1, `${startColor}22`);
} else {
console.warn('Cannot get context for gradient');
}
return gradient;
}
onMounted(async () => {
try {
chartData.value.datasets.forEach(dataset => {
if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
} else {
dataset.backgroundColor = [createGradient('#3d59a4')]
}
});
} catch (ex) {
errored.value = true;
console.error(ex);
}
});
</script>
<template>
<div>
<div v-if="errored"> ERROR CREATING CHART </div>
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</div>
</template>

View File

@@ -1,110 +0,0 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
registerChartComponents();
const props = defineProps<{
data: any[],
labels: string[]
color: string,
}>();
const chartOptions = ref<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]
},
},
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',
}
},
});
const chartData = ref<ChartData<'line'>>({
labels: props.labels,
datasets: [
{
data: props.data,
backgroundColor: [props.color + '77'],
borderColor: props.color,
borderWidth: 4,
fill: true,
tension: 0.45,
pointRadius: 0,
pointHoverRadius: 10,
hoverBackgroundColor: props.color,
hoverBorderColor: 'white',
hoverBorderWidth: 2,
},
],
});
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
onMounted(async () => {
const c = document.createElement('canvas');
const ctx = c.getContext("2d");
let gradient: any = `${props.color}22`;
if (ctx) {
gradient = ctx.createLinearGradient(0, 25, 0, 300);
gradient.addColorStop(0, `${props.color}99`);
gradient.addColorStop(0.35, `${props.color}66`);
gradient.addColorStop(1, `${props.color}22`);
} else {
console.warn('Cannot get context for gradient');
}
chartData.value.datasets[0].backgroundColor = [gradient];
watch(props, () => {
chartData.value.labels = props.labels;
chartData.value.datasets[0].data = props.data;
});
});
</script>
<template>
<LineChart ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const props = defineProps<{
class?: HTMLAttributes['class'],
loading: boolean
}>()
const email = ref<string>('');
const password = ref<string>('');
const emits = defineEmits<{
(event: 'submit', data: { email: string, password: string }): void,
(event: 'oauth', provider: 'google'): void,
}>();
const checkInputs = computed(() => {
const isEmailValid = email.value.trim() !== '' && email.value.includes('@');
const isPasswordFilled = password.value.trim() !== '';
return isEmailValid && isPasswordFilled;
});
</script>
<template>
<div :class="cn('flex flex-col gap-6', props.class)" class="dark">
<form @submit.prevent="emits('submit', { email, password })">
<div class="flex flex-col gap-8">
<div class="flex flex-col items-center gap-4">
<div class="flex items-center gap-2 font-medium">
<img src="/logo-white.svg" class="h-16">
</div>
<div v-if="!isSelfhosted()" class="text-center text-sm text-gray-200">
Don't have an account?
<NuxtLink to="/register"
class="underline underline-offset-2 hover:underline-offset-4 transition-all text-white font-medium">
Sign Up </NuxtLink>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Label for="email" class="text-gray-200">Email</Label>
<Input v-model="email" id="email" type="email" placeholder="insert@email.com"
class="!bg-white/10 !border-white/40 !text-white/80 !border-0 !h-12" required />
</div>
<div class="space-y-2">
<div class="flex items-center">
<Label for="password" class="text-gray-200">Password</Label>
<NuxtLink v-if="!isSelfhosted()" to="/forgot_password" class="ml-auto text-sm underline-offset-4 hover:underline text-white">
Forgot password?
</NuxtLink>
</div>
<InputPassword id="password" v-model="password" required
class="!bg-white/10 !border-white/40 !text-white/80 !border-0 !h-12" />
</div>
<Button type="submit" class="w-full cursor-pointer h-12" :disabled="loading || !checkInputs">
<Loader v-if="loading" class="!size-4"></Loader>
<span v-if="!loading"> Login </span>
</Button>
<fieldset v-if="!isSelfhosted()" class="border-t border-gray-200 text-center">
<legend class="px-2 text-sm text-white">Or</legend>
</fieldset>
<div v-if="!isSelfhosted()" class="flex flex-col gap-4">
<Button @click="emits('oauth', 'google')" type="button" variant="outline"
class="w-full text-white !border-0 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor" />
</svg>
Continue with Google
</Button>
</div>
</div>
</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { GalleryVerticalEnd } from 'lucide-vue-next'
const props = defineProps<{
class?: HTMLAttributes['class'],
loading: boolean
}>()
const email = ref<string>('');
const password = ref<string>('');
const confirmPassword = ref<string>('');
const emits = defineEmits<{
(event: 'submit', data: { email: string, password: string }): void,
(event: 'oauth', provider: 'google'): void,
}>();
const canRegister = computed(() => {
const isEmailValid = email.value.includes('@');
const isPasswordValid = password.value.length >= 6;
const isPasswordConfirmed = password.value === confirmPassword.value;
return isEmailValid && isPasswordValid && isPasswordConfirmed;
});
const passwordStrength = computed(() => {
const val = password.value
let score = 0
if (val.length >= 8) score++
if (/[A-Z]/.test(val)) score++
if (/[0-9]/.test(val)) score++
if (/[\W_]/.test(val)) score++
if (score <= 1) return { percent: 25, label: 'Weak', class: 'bg-red-400/80' }
if (score === 2) return { percent: 50, label: 'Moderate', class: 'bg-yellow-400/80' }
if (score === 3) return { percent: 75, label: 'Strong', class: 'bg-blue-400/80' }
return { percent: 100, label: 'Very Strong', class: 'bg-green-400/80' }
})
</script>
<template>
<div :class="cn('flex flex-col gap-6', props.class)" class="dark">
<form @submit.prevent="emits('submit', { email, password })">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-center gap-4">
<div class="flex items-center gap-2 font-medium">
<img src="/logo-white.svg" class="h-16">
</div>
<div class="text-center text-sm text-gray-200">
Already have an account?
<NuxtLink to="/login"
class="underline underline-offset-2 hover:underline-offset-4 transition-all text-white font-medium">
Sign in
</NuxtLink>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="space-y-2">
<Label for="email" class="text-gray-200">Email</Label>
<Input v-model="email" id="email" type="email" placeholder="insert@email.com" required
class="!bg-white/10 !border-white/40 !text-white/80 !border-0 !h-12" />
</div>
<div class="space-y-2">
<div class="flex justify-between items-center">
<Label for="password" class="text-gray-200">Password</Label>
<div v-if="password.length >= 1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div class="w-12 h-2 rounded bg-white/10">
<div :class="['h-2 rounded transition-all', passwordStrength.class]"
:style="{ width: passwordStrength.percent + '%' }"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<span>{{ passwordStrength.label }} password</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<InputPassword id="password" v-model="password" required
class="!bg-white/10 !border-white/40 !text-white/80 !border-0 !h-12" />
</div>
<div class="space-y-2">
<Label for="password" class="text-gray-200">Confirm password</Label>
<InputPassword id="password" v-model="confirmPassword" required
class="!bg-white/10 !border-white/40 !text-white/80 !border-0 !h-12"
:class="confirmPassword === password ? '!ring-green-400/80' : '!ring-red-400/80'" />
</div>
<Button :disabled="!canRegister || loading" type="submit" class="w-full cursor-pointer h-12">
<Loader v-if="loading" class="!size-6"></Loader>
<span v-if="!loading"> Register </span>
</Button>
<fieldset class="border-t border-gray-200 text-center">
<legend class="px-2 text-sm text-white">Or</legend>
</fieldset>
<div class="flex flex-col gap-4">
<Button @click="emits('oauth', 'google')" variant="outline" type="button"
class="w-full text-white !border-0 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor" />
</svg>
Continue with Google
</Button>
</div>
</div>
</div>
</form>
</div>
</template>

View File

@@ -1,33 +0,0 @@
<script lang="ts" setup>
const limitsInfo = await useFetch("/api/project/limits_info", {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
const { showDrawer } = useDrawer();
function goToUpgrade() {
showDrawer('PRICING');
}
</script>
<template>
<div v-if="limitsInfo.data.value && limitsInfo.data.value.limited"
class="w-full bg-[#fbbf2422] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-[#fbbf24]">
Limit reached
</div>
<div class="poppins text-[#fbbf24]">
Litlyx cannot receive new data as you reached your plan's limit. Resume all the great
features and collect even more data with a higher plan.
</div>
</div>
<div>
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
</div>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script lang="ts" setup>
const { showDrawer } = useDrawer();
function goToUpgrade() {
showDrawer('PRICING');
}
const { project } = useProject()
const isPremium = computed(() => {
return project.value?.premium ?? false;
});
</script>
<template>
<div v-if="!isPremium" class="w-full bg-[#5680f822] p-4 rounded-lg text-[.9rem] flex items-center">
<div class="flex flex-col grow">
<div class="poppins font-semibold text-lyx-primary">
Launch offer: 25% off forever with code <span class="text-white font-bold text-[1rem]">LIT25</span> at
checkout
from Acceleration Plan and beyond.
</div>
<!-- <div class="poppins text-lyx-primary">
We're offering an exclusive 25% discount forever on all plans starting from the Acceleration
Plan for our first 100 users who believe in our project.
<br>
Redeem Code: <span class="text-white font-bold text-[1rem]">LIT25</span> at checkout to
claim your discount.
</div> -->
</div>
<div>
<LyxUiButton type="outline" @click="goToUpgrade()"> Upgrade </LyxUiButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
const { data: billingAddress, status: billingAddressStatus, refresh: refreshBillingAddress } = useAuthFetch('/api/user/customer');
const currentBillingAddress = ref({
line1: '',
line2: '',
country: '',
postal_code: '',
city: '',
state: ''
})
const canSave = computed(() => {
if (!billingAddress.value) return false;
if (currentBillingAddress.value.line1 !== billingAddress.value.line1) return true;
if (currentBillingAddress.value.line2 !== billingAddress.value.line2) return true;
if (currentBillingAddress.value.country !== billingAddress.value.country) return true;
if (currentBillingAddress.value.postal_code !== billingAddress.value.postal_code) return true;
if (currentBillingAddress.value.city !== billingAddress.value.city) return true;
if (currentBillingAddress.value.state !== billingAddress.value.state) return true;
return false;
})
watch(billingAddress, () => {
if (!billingAddress.value) return;
currentBillingAddress.value.line1 = billingAddress.value.line1;
currentBillingAddress.value.line2 = billingAddress.value.line2;
currentBillingAddress.value.country = billingAddress.value.country;
currentBillingAddress.value.postal_code = billingAddress.value.postal_code;
currentBillingAddress.value.city = billingAddress.value.city;
currentBillingAddress.value.state = billingAddress.value.state;
});
async function updateCustomer() {
await useCatch({
toast: true,
toastTitle: 'Error updating customer',
async action() {
await useAuthFetchSync('/api/user/update_customer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: currentBillingAddress.value
});
await refreshBillingAddress();
},
onSuccess(_, showToast) {
showToast('Update success', { description: 'Customer updated successfully', position: 'top-right' })
},
})
}
</script>
<template>
<div v-if="billingAddressStatus === 'success'" class="flex justify-center flex-col gap-4">
<div class="w-full flex flex-col gap-2">
<Input v-model="currentBillingAddress.line1" placeholder="Address line 1"></Input>
<Input v-model="currentBillingAddress.line2" placeholder="Address line 2"></Input>
<div class="flex gap-2">
<Input v-model="currentBillingAddress.country" placeholder="Country"></Input>
<Input v-model="currentBillingAddress.postal_code" placeholder="Postal code"></Input>
</div>
<div class="flex gap-2">
<Input v-model="currentBillingAddress.city" placeholder="City"></Input>
<Input v-model="currentBillingAddress.state" placeholder="State"></Input>
</div>
</div>
<div class="flex justify-end">
<Button :disabled="!canSave" @click="updateCustomer()" class="w-fit px-10"> Save </Button>
</div>
</div>
<div v-else class="flex justify-center">
<Loader></Loader>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import { FileIcon, FileCheck } from 'lucide-vue-next';
const { data: invoices, status: invoicesStatus } = useAuthFetch('/api/user/invoices');
</script>
<template>
<div class="flex justify-center">
<Card class="w-full" v-if="invoicesStatus === 'success' && invoices">
<CardContent class="flex flex-col gap-4">
<Table>
<TableHeader>
<TableRow class="*:text-center">
<TableHead class="w-[5%]"></TableHead>
<TableHead class="w-fit"> Date </TableHead>
<TableHead class="w-fit"> Price </TableHead>
<TableHead class="w-fit"> Number </TableHead>
<TableHead class="w-fit"> Status </TableHead>
<TableHead class="w-fit"> Actions </TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice of invoices.data" class="h-[2rem]">
<TableCell>
<FileCheck class="size-4"></FileCheck>
</TableCell>
<TableCell>
{{ new Date(invoice.created * 1000).toLocaleString() }}
</TableCell>
<TableCell>
{{ invoice.amount_due / 100 }}
</TableCell>
<TableCell>
{{ invoice.number }}
</TableCell>
<TableCell>
<Badge :class="{
'bg-red-300': invoice.status === 'open'
}">
{{ invoice.status }}
</Badge>
</TableCell>
<TableCell>
<NuxtLink target="_blank" :to="invoice.hosted_invoice_url ?? '#'">
<Button variant="ghost">
<FileIcon></FileIcon>
Manage
</Button>
</NuxtLink>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<div v-else class="flex justify-center">
<Loader></Loader>
</div>
</div>
</template>

View File

@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { LoaderCircle, TriangleAlert } from 'lucide-vue-next';
import type { TUserPlanInfo } from '~/server/api/user/plan';
import { getPlanFromId, PREMIUM_PLAN, type PLAN_TAG } from '@data/PLANS';
const { data: planInfo, status: planInfoStatus } = useAuthFetch<TUserPlanInfo>('/api/user/plan', {
key: 'current_plan'
});
const premiumStore = usePremiumStore();
function getPrice(type: number) {
const plan = getPlanFromId(type);
if (!plan) return 'ERROR';
return (plan.COST / 100).toFixed(2).replace('.', ',');
}
const billingPeriodPercent = computed(() => {
if (!planInfo.value) return 0;
const start = planInfo.value.start_at;
const end = planInfo.value.end_at;
const duration = end - start;
const remaining = end - Date.now();
const percent = 100 - Math.floor(100 / duration * remaining);
return percent;
});
const billingDaysRemaining = computed(() => {
if (!planInfo.value) return 0;
const end = planInfo.value.end_at;
const remaining = end - Date.now();
return Math.floor(remaining / (1000 * 60 * 60 * 24))
})
</script>
<template>
<div class="flex justify-center">
<Card class="w-full">
<CardContent>
<div v-if="planInfo && planInfoStatus === 'success'" class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<div class="font-semibold shrink-0">
{{ planInfo.premium ? 'Premium' : 'Free' }} plan
</div>
<Badge variant="outline">
{{ premiumStore.planInfo?.NAME ?? '???' }}
</Badge>
<Tooltip v-if="planInfo.payment_failed">
<TooltipTrigger as-child>
<TriangleAlert class="size-5 text-red-400">
</TriangleAlert>
</TooltipTrigger>
<TooltipContent side="right" align="center">
Please update your billing details to avoid service interruption.
</TooltipContent>
</Tooltip>
<div class="grow"></div>
<div v-if="!isSelfhosted()" class="shrink-0">
<span class="text-[1.3rem] font-semibold"> {{ getPrice(planInfo.premium_type) }}</span>
<span class="text-muted-foreground text-[1.1rem]">
{{ premiumStore.isAnnual ? ' per year' : ' per month' }}
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<div>
Billing period:
</div>
<div class="flex gap-8 items-center">
<Progress class="mt-[1px]" :model-value="billingPeriodPercent"> </Progress>
<div class="shrink-0 font-medium">
{{ billingDaysRemaining }} days left
</div>
</div>
</div>
<Separator></Separator>
<div class="flex items-center justify-between">
<div class="text-muted-foreground">
Expire date: {{ new Date(planInfo.end_at).toLocaleDateString() }}
</div>
<div>
<NuxtLink :to="isSelfhosted() ? 'https://litlyx.com/pricing-selfhosted': '/plans'">
<Button> Upgrade plan </Button>
</NuxtLink>
</div>
</div>
</div>
<div class="flex items-center justify-center" v-else>
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
</LoaderCircle>
</div>
</CardContent>
</Card>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script lang="ts" setup>
import { LoaderCircle } from 'lucide-vue-next';
import type { TUserPlanInfo } from '~/server/api/user/plan';
import { getPlanFromId, PREMIUM_PLAN, type PLAN_TAG } from '@data/PLANS';
const { data: planInfo, status: planInfoStatus } = useAuthFetch<TUserPlanInfo>('/api/user/plan', {
key: 'current_plan'
});
const usagePercent = computed(() => {
if (!planInfo.value) return 0;
return 100 / planInfo.value.limit * planInfo.value.count;
})
</script>
<template>
<div class="flex justify-center">
<Card class="w-full">
<CardContent>
<div v-if="planInfo && planInfoStatus === 'success'" class="flex flex-col gap-4">
<div class="flex flex-col">
<div class="font-semibold shrink-0">
Usage
</div>
<div class="text-muted-foreground">
Check the usage limits of your project.
</div>
</div>
<div class="flex flex-col gap-2">
<div>
Usage:
</div>
<div class="flex gap-8 items-center">
<Progress class="mt-[1px]" :model-value="Math.floor(usagePercent)"> </Progress>
<div class="shrink-0 font-medium">
{{ usagePercent.toFixed(2) }}%
</div>
</div>
<div class="text-center">
{{ formatNumberK(planInfo.count) }} / {{ formatNumberK(planInfo.limit) }}
</div>
</div>
</div>
<div class="flex items-center justify-center" v-else>
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
</LoaderCircle>
</div>
</CardContent>
</Card>
</div>
</template>

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

@@ -1,9 +1,28 @@
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js';
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,
@@ -46,6 +65,16 @@ const chartOptions = ref<ChartOptions<'funnel'>>({
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) + '%';
},
}
},
});
@@ -110,11 +139,7 @@ onMounted(async () => {
});
const eventsData = useFetch(`/api/data/events`, {
headers: useComputedHeaders(), lazy: true
});
const enabledEvents = ref<string[]>([]);
async function onEventCheck(eventName: string) {
const index = enabledEvents.value.indexOf(eventName);
@@ -138,24 +163,36 @@ async function onEventCheck(eventName: string) {
<template>
<CardTitled title="Funnel"
sub="Monitor and analyze the actions your users are performing on your platform to gain insights into their behavior and optimize the user experience">
<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>
<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 v-for="event of eventsData.data.value">
<UCheckbox color="secondary" @change="onEventCheck(event._id)"
:value="enabledEvents.includes(event._id)" :label="event._id">
</UCheckbox>
<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>
<div class="grow">
<FunnelChart :chart-data="chartData" :options="chartOptions"> </FunnelChart>
</div>
</div>
</CardTitled>
</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>

Some files were not shown because too many files have changed in this diff Show More