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